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 analogue /// (acclient_2013_pseudo_c.txt:198635). /// /// /// Layout construction mirrors : build a minimal /// from a root + a /// keyed by element id. Elements are constructed /// directly (no importer / no dat / no GL) so tests are pure in-process. /// /// public class SelectedObjectControllerTests { // ── Shared layout ──────────────────────────────────────────────────────── /// /// Build a minimal toolbar layout containing the three selected-object elements: /// /// 0x1000019F → a name container (100×20). /// 0x100001A0 → a overlay with "ObjectSelected" /// and "StackedItemSelected" states wired to distinct file ids. /// 0x100001A1 → a health meter. /// /// Additional element ids can be added by the caller for edge-case tests. /// private static ( ImportedLayout layout, UiPanel nameEl, UiDatElement overlayEl, UiMeter healthMeterEl) FakeLayout() { var dict = new Dictionary(); var root = new UiPanel(); // Name element: a plain panel that will have a UiText child attached by the controller. var nameEl = new UiPanel { Width = 100, Height = 20 }; dict[SelectedObjectController.NameId] = nameEl; root.AddChild(nameEl); // Overlay element: a UiDatElement with the two named states the controller switches between. var overlayInfo = new ElementInfo { Id = SelectedObjectController.OverlayId, Type = 3, // Type 3 = container/chrome — the overlay field's dat type StateMedia = { [""] = (0x06000001u, 3), // DirectState (blank) ["ObjectSelected"] = (0x06001937u, 3), // ObjectSelected sprite id from toolbar dump ["StackedItemSelected"] = (0x06004CF4u, 3), // StackedItemSelected sprite id }, }; var overlayEl = new UiDatElement(overlayInfo, _ => (0u, 0, 0)); dict[SelectedObjectController.OverlayId] = overlayEl; root.AddChild(overlayEl); // Health meter element. 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 ────────────────────────────────────────────────── /// /// Build a recording set of delegates. Name, health, stack are keyed by guid; /// accumulates every guid passed to sendQueryHealth. /// private static ( Action> subscribe, Action fireSelection, Func isHealthTarget, Func name, Func healthPercent, Func stackSize, Action sendQueryHealth, List queryHealthCalls) MakeDelegates( Dictionary healthTargetMap, Dictionary nameMap, Dictionary healthMap, Dictionary stackMap) { Action? registeredHandler = null; var queryHealthCalls = new List(); Action> subscribe = h => registeredHandler = h; Action fireSelection = guid => registeredHandler?.Invoke(guid); Func isHealthTarget = g => healthTargetMap.TryGetValue(g, out var v) && v; Func name = g => nameMap.TryGetValue(g, out var v) ? v : null; Func healthPercent = g => healthMap.TryGetValue(g, out var v) ? v : 1f; Func stackSize = g => stackMap.TryGetValue(g, out var v) ? v : 0u; Action sendQueryHealth = g => queryHealthCalls.Add(g); return (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls); } // ── B1: Bind initialisation ────────────────────────────────────────────── /// /// After Bind: /// - the health meter is hidden (controller owns initial-hidden state). /// - the name element has exactly one UiText child (the name label). /// [Fact] public void Bind_healthMeterHidden_andNameTextChildAttached() { var (layout, nameEl, _, healthMeterEl) = FakeLayout(); var (subscribe, _, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) = MakeDelegates( healthTargetMap: new(), nameMap: new(), healthMap: new(), stackMap: new()); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); // Health meter must start hidden. Assert.False(healthMeterEl.Visible, "health meter must be Visible=false immediately after Bind"); // A UiText child should have been attached to the name element. 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 (non-interactive decoration)"); 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"); } /// /// After Bind, the attached UiText's LinesProvider yields no lines (nothing selected yet). /// [Fact] public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() { var (layout, nameEl, _, _) = FakeLayout(); var (subscribe, _, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) = MakeDelegates(new(), new(), new(), new()); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); var textChild = nameEl.Children.OfType().Single(); var lines = textChild.LinesProvider(); Assert.Empty(lines); } // ── H1: Select a health target (creature) ─────────────────────────────── /// /// Selecting a health target (stackSize=1, isHealthTarget=true): /// - overlay ActiveState == "ObjectSelected" /// - meter Visible == true /// - sendQueryHealth invoked exactly once with the guid /// - name LinesProvider yields a single white line with the expected name /// [Fact] public void SelectHealthTarget_meterVisible_overlayObjectSelected_queryHealthFired() { const uint Guid = 0xAA01u; const string ExpectedName = "Drudge Prowler"; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) = MakeDelegates( healthTargetMap: new() { [Guid] = true }, nameMap: new() { [Guid] = ExpectedName }, healthMap: new() { [Guid] = 0.75f }, stackMap: new() { [Guid] = 1u }); // stackSize=1 → ObjectSelected SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); // Fire the selection. fireSelection(Guid); Assert.True(healthMeterEl.Visible, "health meter must become Visible after selecting a health target"); Assert.Equal("ObjectSelected", overlayEl.ActiveState); Assert.Single(queryHealthCalls); Assert.Equal(Guid, queryHealthCalls[0]); // Name label: the LinesProvider should yield the creature's name as a white line. var textChild = nameEl.Children.OfType().Single(); var lines = textChild.LinesProvider(); Assert.Single(lines); Assert.Equal(ExpectedName, lines[0].Text); Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color); } // ── H2: Select a stacked item ──────────────────────────────────────────── /// /// Selecting a stacked item (stackSize > 1): overlay ActiveState == "StackedItemSelected". /// [Fact] public void SelectStackedItem_overlayStackedItemSelected() { const uint Guid = 0xBB02u; var (layout, _, overlayEl, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) = MakeDelegates( healthTargetMap: new() { [Guid] = false }, nameMap: new() { [Guid] = "Heal Kits" }, healthMap: new(), stackMap: new() { [Guid] = 5u }); // stackSize > 1 SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); fireSelection(Guid); Assert.Equal("StackedItemSelected", overlayEl.ActiveState); // Not a health target → meter stays hidden. Assert.False(healthMeterEl.Visible); } // ── H3: Select a non-health target (friendly NPC / scenery) ───────────── /// /// Selecting a non-health target (isHealthTarget=false): /// - meter stays hidden /// - sendQueryHealth NOT invoked /// - name and overlay are still set /// [Fact] public void SelectNonHealthTarget_meterHidden_noQueryHealth_nameSet() { const uint Guid = 0xCC03u; const string ExpectedName = "Town Crier"; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) = MakeDelegates( healthTargetMap: new() { [Guid] = false }, nameMap: new() { [Guid] = ExpectedName }, healthMap: new(), stackMap: new() { [Guid] = 0u }); // non-stack → ObjectSelected SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); fireSelection(Guid); Assert.False(healthMeterEl.Visible, "meter must stay hidden for a non-health target"); Assert.Empty(queryHealthCalls); // sendQueryHealth must NOT be invoked for a non-health target // Overlay and name are still populated. Assert.Equal("ObjectSelected", overlayEl.ActiveState); var textChild = nameEl.Children.OfType().Single(); var lines = textChild.LinesProvider(); Assert.Single(lines); Assert.Equal(ExpectedName, lines[0].Text); } // ── H4: Deselect (null) ────────────────────────────────────────────────── /// /// Selecting null clears the strip: /// - meter Visible == false /// - overlay ActiveState == "" /// - name LinesProvider yields empty /// [Fact] public void SelectNull_clearsStrip() { const uint Guid = 0xDD04u; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) = MakeDelegates( healthTargetMap: new() { [Guid] = true }, nameMap: new() { [Guid] = "Wolf" }, healthMap: new() { [Guid] = 0.5f }, stackMap: new() { [Guid] = 0u }); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); // First select something... fireSelection(Guid); Assert.True(healthMeterEl.Visible); // ...then deselect. fireSelection(null); Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect"); Assert.Equal("", overlayEl.ActiveState); var textChild = nameEl.Children.OfType().Single(); var lines = textChild.LinesProvider(); Assert.Empty(lines); } // ── H5: Clear → new selection (re-select) ──────────────────────────────── /// /// Selecting one target then another should clear the first and apply the second. /// [Fact] public void ReSelect_differentGuid_clearsFirstThenAppliesSecond() { const uint GuidA = 0xEE05u; const uint GuidB = 0xFF06u; var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, queryHealthCalls) = MakeDelegates( healthTargetMap: new() { [GuidA] = true, [GuidB] = false }, nameMap: new() { [GuidA] = "Bandit", [GuidB] = "Chest" }, healthMap: new() { [GuidA] = 1.0f }, stackMap: new() { [GuidA] = 0u, [GuidB] = 0u }); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); // Select A (health target). fireSelection(GuidA); Assert.True(healthMeterEl.Visible); Assert.Single(queryHealthCalls); // Select B (non-health target) — must clear A's state and apply B. fireSelection(GuidB); Assert.False(healthMeterEl.Visible, "health meter must be cleared when switching to non-health target"); Assert.Equal("ObjectSelected", overlayEl.ActiveState); // sendQueryHealth must NOT be called again (B is not a health target). Assert.Single(queryHealthCalls); // Name should reflect B. var textChild = nameEl.Children.OfType().Single(); var lines = textChild.LinesProvider(); Assert.Single(lines); Assert.Equal("Chest", lines[0].Text); } // ── H6: Partial layout (missing elements) ──────────────────────────────── /// /// When elements are absent (partial layout), Bind does not throw and /// OnSelectionChanged does not throw for any combination. /// [Fact] public void PartialLayout_noElements_doesNotThrow() { // Empty layout — none of the three ids are present. var root = new UiPanel(); var layout = new ImportedLayout(root, new Dictionary()); Action? registeredHandler = null; var queryHealthCalls = new List(); SelectedObjectController.Bind( layout, subscribeSelectionChanged: h => registeredHandler = h, isHealthTarget: _ => true, name: _ => "Something", healthPercent: _ => 1f, stackSize: _ => 0u, sendQueryHealth: g => queryHealthCalls.Add(g), datFont: null); Assert.NotNull(registeredHandler); // Firing selection / deselection on a partial layout must not throw. var ex = Record.Exception(() => registeredHandler!.Invoke(0x12345678u)); Assert.Null(ex); ex = Record.Exception(() => registeredHandler!.Invoke(null)); Assert.Null(ex); // QueryHealth must still be called (the delegate doesn't depend on the meter element). Assert.Single(queryHealthCalls); Assert.Equal(0x12345678u, queryHealthCalls[0]); } // ── H7: Fill closure reflects live healthPercent ───────────────────────── /// /// The meter's Fill closure reads the current guid's health percent from the /// healthPercent delegate on every poll — so if the server updates the /// health between polls the fill reflects the new value without re-selecting. /// [Fact] public void HealthMeterFill_reflectsLiveHealthPercent() { const uint Guid = 0xAA07u; float currentHealth = 0.5f; var (layout, _, _, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, _, stackSize, sendQueryHealth, _) = MakeDelegates( healthTargetMap: new() { [Guid] = true }, nameMap: new() { [Guid] = "Arwic Banderling" }, healthMap: new(), // not used here stackMap: new() { [Guid] = 0u }); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent: _ => currentHealth, // reads the captured variable stackSize, sendQueryHealth, datFont: null); fireSelection(Guid); // Fill should return the current health value. Assert.Equal(0.5f, healthMeterEl.Fill()); // Simulate server updating health (as if UpdateHealth 0x01C0 arrived). currentHealth = 0.25f; Assert.Equal(0.25f, healthMeterEl.Fill()); } // ── H8: Fill returns 0 when nothing is selected ────────────────────────── /// /// After deselect, the meter Fill returns 0f (empty bar) rather than /// the last selected target's health value. /// [Fact] public void HealthMeterFill_returnsZero_whenNothingSelected() { const uint Guid = 0xAA08u; var (layout, _, _, healthMeterEl) = FakeLayout(); var (subscribe, fireSelection, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, _) = MakeDelegates( healthTargetMap: new() { [Guid] = true }, nameMap: new() { [Guid] = "Spider" }, healthMap: new() { [Guid] = 0.8f }, stackMap: new() { [Guid] = 0u }); SelectedObjectController.Bind(layout, subscribe, isHealthTarget, name, healthPercent, stackSize, sendQueryHealth, datFont: null); fireSelection(Guid); Assert.Equal(0.8f, healthMeterEl.Fill()); // sanity check fireSelection(null); // After deselect, Fill() must return 0f (or null coerced to 0f). var fill = healthMeterEl.Fill(); Assert.Equal(0f, fill ?? 0f); } }