using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using Xunit; namespace AcDream.App.Tests.UI.Layout; /// /// Unit tests for — the /// gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth /// analogue (acclient_2013_pseudo_c.txt:198635 / :196213). /// /// /// Key behavior under test: the Health meter is UpdateHealth-driven — it becomes /// visible only when real health is known for the selected guid (a HealthChanged /// fires for it, or it is already cached at select time via hasHealth). Selecting a /// target does NOT show the meter on its own. This matches retail: a friendly NPC you have /// not assessed shows name-only; a monster's bar appears after damage / assess. /// /// public class SelectedObjectControllerTests { // ── Shared layout ──────────────────────────────────────────────────────── private static ( ImportedLayout layout, UiPanel nameEl, UiDatElement overlayEl, UiMeter healthMeterEl) FakeLayout() { var dict = new Dictionary(); var root = new UiPanel(); var nameEl = new UiPanel { Width = 100, Height = 20 }; dict[SelectedObjectController.NameId] = nameEl; root.AddChild(nameEl); var overlayInfo = new ElementInfo { Id = SelectedObjectController.OverlayId, Type = 3, StateMedia = { [""] = (0x06000001u, 3), ["ObjectSelected"] = (0x06001937u, 3), ["StackedItemSelected"] = (0x06004CF4u, 3), }, }; var overlayEl = new UiDatElement(overlayInfo, _ => (0u, 0, 0)); dict[SelectedObjectController.OverlayId] = overlayEl; root.AddChild(overlayEl); var healthMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true }; dict[SelectedObjectController.HealthMeterId] = healthMeterEl; root.AddChild(healthMeterEl); return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl); } // ── Recording delegates ────────────────────────────────────────────────── private sealed class Harness { public Action? SelectionHandler; public Action? HealthHandler; public readonly List QueryHealthCalls = new(); public readonly Dictionary HealthTargetMap = new(); public readonly Dictionary NameMap = new(); public readonly Dictionary HealthMap = new(); public readonly Dictionary HasHealthMap = new(); public readonly Dictionary StackMap = new(); public void FireSelection(uint? g) => SelectionHandler?.Invoke(g); public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) => SelectedObjectController.Bind( layout, subscribeSelectionChanged: h => SelectionHandler = h, subscribeHealthChanged: h => HealthHandler = h, isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v, name: g => NameMap.TryGetValue(g, out var v) ? v : null, healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f, hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v, stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u, sendQueryHealth: g => QueryHealthCalls.Add(g), datFont: datFont); } // ── B1: Bind initialisation ────────────────────────────────────────────── [Fact] public void Bind_healthMeterHidden_nameTextChildAttached_nameFloatedOnTop() { var (layout, nameEl, _, healthMeterEl) = FakeLayout(); new Harness().Bind(layout); Assert.False(healthMeterEl.Visible, "health meter must be Visible=false immediately after Bind"); var textChild = nameEl.Children.OfType().SingleOrDefault(); Assert.NotNull(textChild); Assert.True(textChild!.Centered, "name UiText must be Centered"); Assert.True(textChild.ClickThrough, "name UiText must be ClickThrough"); Assert.False(textChild.AcceptsFocus, "AcceptsFocus must be false on name label"); Assert.False(textChild.IsEditControl, "IsEditControl must be false on name label"); Assert.False(textChild.CapturesPointerDrag, "CapturesPointerDrag must be false on name label"); // The name element must be floated to the top of the strip's z-order so it draws // OVER the overlay frame and the health bar (retail draws the name over the bar). Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order"); } [Fact] public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() { var (layout, nameEl, _, _) = FakeLayout(); new Harness().Bind(layout); var textChild = nameEl.Children.OfType().Single(); Assert.Empty(textChild.LinesProvider()); } // ── H1: Select a health target — meter does NOT show on select alone ───── [Fact] public void SelectHealthTarget_unknownHealth_meterStaysHidden_queryFired_nameAndOverlaySet() { const uint Guid = 0xAA01u; const string ExpectedName = "Drudge Prowler"; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.NameMap[Guid] = ExpectedName; h.StackMap[Guid] = 1u; // ObjectSelected // HasHealthMap[Guid] not set → false (no health known yet) h.Bind(layout); h.FireSelection(Guid); // Health not yet known → meter must stay hidden (retail: shows on UpdateHealth). Assert.False(healthMeterEl.Visible, "meter must stay hidden on select when no health is known yet"); // But QueryHealth is sent (retail Event_QueryHealth on select for a health target). Assert.Single(h.QueryHealthCalls); Assert.Equal(Guid, h.QueryHealthCalls[0]); Assert.Equal("ObjectSelected", overlayEl.ActiveState); var lines = nameEl.Children.OfType().Single().LinesProvider(); Assert.Single(lines); Assert.Equal(ExpectedName, lines[0].Text); Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color); } // ── H1b: Health arrives for the selected guid → meter appears ─────────── [Fact] public void HealthChanged_forSelectedGuid_showsMeter() { const uint Guid = 0xAA02u; var (layout, _, _, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.NameMap[Guid] = "Drudge Slinker"; h.Bind(layout); h.FireSelection(Guid); Assert.False(healthMeterEl.Visible, "hidden until health arrives"); // Simulate UpdateHealth (0x01C0) for the selected guid. h.FireHealth(Guid, 0.6f); Assert.True(healthMeterEl.Visible, "meter must appear when health arrives for the selected guid"); } [Fact] public void HealthChanged_forOtherGuid_doesNotShowMeter() { const uint Sel = 0xAA03u, Other = 0xBB03u; var (layout, _, _, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Sel] = true; h.HealthTargetMap[Other] = true; h.NameMap[Sel] = "Selected"; h.Bind(layout); h.FireSelection(Sel); h.FireHealth(Other, 0.5f); // health for a DIFFERENT entity Assert.False(healthMeterEl.Visible, "health for a non-selected guid must not show the meter"); } // ── H1c: Already-known health → meter shows immediately on select ─────── [Fact] public void SelectHealthTarget_alreadyKnownHealth_meterVisibleImmediately() { const uint Guid = 0xAA04u; var (layout, _, _, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.HasHealthMap[Guid] = true; // health already cached (e.g. previously assessed) h.HealthMap[Guid] = 0.9f; h.NameMap[Guid] = "Olthoi"; h.Bind(layout); h.FireSelection(Guid); Assert.True(healthMeterEl.Visible, "meter must show immediately when health is already known for the target"); } // ── H2: Stacked item ───────────────────────────────────────────────────── [Fact] public void SelectStackedItem_overlayStackedItemSelected_meterHidden() { const uint Guid = 0xBB02u; var (layout, _, overlayEl, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = false; h.NameMap[Guid] = "Heal Kits"; h.StackMap[Guid] = 5u; // stackSize > 1 h.Bind(layout); h.FireSelection(Guid); Assert.Equal("StackedItemSelected", overlayEl.ActiveState); Assert.False(healthMeterEl.Visible); } // ── H3: Non-health target (friendly NPC / scenery / Door) ─────────────── [Fact] public void SelectNonHealthTarget_meterHidden_noQuery_nameSet() { const uint Guid = 0xCC03u; const string ExpectedName = "Town Crier"; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = false; h.NameMap[Guid] = ExpectedName; h.Bind(layout); h.FireSelection(Guid); Assert.False(healthMeterEl.Visible, "meter must stay hidden for a non-health target"); Assert.Empty(h.QueryHealthCalls); Assert.Equal("ObjectSelected", overlayEl.ActiveState); var lines = nameEl.Children.OfType().Single().LinesProvider(); Assert.Single(lines); Assert.Equal(ExpectedName, lines[0].Text); } // ── H4: Deselect clears the strip ──────────────────────────────────────── [Fact] public void SelectNull_clearsStrip() { const uint Guid = 0xDD04u; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.HasHealthMap[Guid] = true; // so the meter is shown on select h.HealthMap[Guid] = 0.5f; h.NameMap[Guid] = "Wolf"; h.Bind(layout); h.FireSelection(Guid); Assert.True(healthMeterEl.Visible); h.FireSelection(null); Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect"); Assert.Equal("", overlayEl.ActiveState); Assert.Empty(nameEl.Children.OfType().Single().LinesProvider()); } // ── H5: Re-select a different guid ─────────────────────────────────────── [Fact] public void ReSelect_differentGuid_clearsFirstThenAppliesSecond() { const uint GuidA = 0xEE05u, GuidB = 0xFF06u; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[GuidA] = true; h.HealthTargetMap[GuidB] = false; h.HasHealthMap[GuidA] = true; // A shows its bar on select h.NameMap[GuidA] = "Bandit"; h.NameMap[GuidB] = "Chest"; h.HealthMap[GuidA] = 1.0f; h.Bind(layout); h.FireSelection(GuidA); Assert.True(healthMeterEl.Visible); Assert.Single(h.QueryHealthCalls); h.FireSelection(GuidB); Assert.False(healthMeterEl.Visible, "meter must clear when switching to a non-health target"); Assert.Equal("ObjectSelected", overlayEl.ActiveState); Assert.Single(h.QueryHealthCalls); // B is not a health target → no extra query var lines = nameEl.Children.OfType().Single().LinesProvider(); Assert.Single(lines); Assert.Equal("Chest", lines[0].Text); } // ── H6: Overlay flash reverts after the flash window (Tick) ───────────── [Fact] public void Tick_revertsOverlayFlash_afterDuration() { const uint Guid = 0xAB06u; var (layout, _, overlayEl, _) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = false; h.NameMap[Guid] = "Lever"; var c = h.Bind(layout); h.FireSelection(Guid); Assert.Equal("ObjectSelected", overlayEl.ActiveState); // A small tick before the window elapses → still flashing. c.Tick(0.1); Assert.Equal("ObjectSelected", overlayEl.ActiveState); // Tick past the 0.25s window → overlay reverts to blank. c.Tick(0.2); Assert.Equal("", overlayEl.ActiveState); } // ── H7: Partial layout (missing elements) ──────────────────────────────── [Fact] public void PartialLayout_noElements_doesNotThrow() { var root = new UiPanel(); var layout = new ImportedLayout(root, new Dictionary()); var h = new Harness(); h.HealthTargetMap[0x12345678u] = true; h.NameMap[0x12345678u] = "Something"; var c = h.Bind(layout); Assert.NotNull(h.SelectionHandler); Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u))); Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f))); Assert.Null(Record.Exception(() => c.Tick(0.5))); Assert.Null(Record.Exception(() => h.FireSelection(null))); Assert.Single(h.QueryHealthCalls); Assert.Equal(0x12345678u, h.QueryHealthCalls[0]); } // ── H8: Fill reflects live health; returns 0 when nothing selected ────── [Fact] public void HealthMeterFill_reflectsLiveHealthPercent() { const uint Guid = 0xAA07u; var (layout, _, _, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.NameMap[Guid] = "Arwic Banderling"; h.HealthMap[Guid] = 0.5f; h.Bind(layout); h.FireSelection(Guid); Assert.Equal(0.5f, healthMeterEl.Fill()); h.HealthMap[Guid] = 0.25f; // server updates health Assert.Equal(0.25f, healthMeterEl.Fill()); } [Fact] public void HealthMeterFill_returnsZero_whenNothingSelected() { const uint Guid = 0xAA08u; var (layout, _, _, healthMeterEl) = FakeLayout(); var h = new Harness(); h.HealthTargetMap[Guid] = true; h.NameMap[Guid] = "Spider"; h.HealthMap[Guid] = 0.8f; h.Bind(layout); h.FireSelection(Guid); Assert.Equal(0.8f, healthMeterEl.Fill()); h.FireSelection(null); Assert.Equal(0f, healthMeterEl.Fill() ?? 0f); } }