using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Net.Messages; using AcDream.Core.World; namespace AcDream.App.Tests.UI.Layout; /// /// Controller-level tests for against /// the committed map_house_2100006E_1000018C.json fixture (Batch C, /// overnight hover/UI round) — the SAME LayoutImporter.ImportInfos(dats, /// 0x2100006Eu, 0x1000018Cu) catalog import /// performs against real /// DATs. No DAT access, no live runtime — dat-free per the established /// pattern. /// public sealed class MapHousePanelControllerTests { /// The live hotspot template's own authored popup locator /// (MapHousePanelSlotProbeTests, 2026-08-17 morning gate /// finding 3: DirectState P0x47=0x10000398/P0x48=0x21000041 /// — the map-note skin whose text child fonts 0x40000015). private const uint MapNoteSkinRootId = 0x10000398u; private const uint MapNoteSkinLayoutId = 0x21000041u; /// Serves the town-hotspot template. Returns a /// — matching the live template's own authored /// Type 1 (MapHousePanelSlotProbeTests: "hotspot template /// type=1") — so 's /// marker is UiButton tooltip-text branch is actually exercised /// by these tests. A real would set /// DatElementId the same way /// does, so tests that need to find these markers back by id after the /// fact need it too. Carries the template's own authored tooltip /// locator (which production populates /// from the property bag) — finding 3 removed the shared-skin override /// that used to clobber it, so the fixture must author it the way the /// live DAT does. private static UiElement? FakeHotspotTemplate(uint layoutId, uint elementId) => new UiButton(MapNoteTemplateInfo(), static _ => (0u, 0, 0)) { Width = 10f, Height = 10f, DatElementId = elementId, AuthoredTooltipRootElementId = MapNoteSkinRootId, AuthoredTooltipLayoutDid = MapNoteSkinLayoutId, AuthoredTooltipDelaySeconds = 0f, // authored P0x50 = 0.0 AuthoredTooltipEnabled = true, // authored P0x4B }; /// The template's authored state shape (raw-DAT-dumped): /// media-less Normal/Normal_rollover descriptors, both /// PassToChildren=true, plus P0x13 RolloverEnabled. private static ElementInfo MapNoteTemplateInfo() { var info = new ElementInfo { Type = 1, Width = 10, Height = 10 }; var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; direct.Properties.Values[0x13u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true }; info.States[UiStateInfo.DirectStateId] = direct; info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", PassToChildren = true }; info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover", PassToChildren = true }; return info; } /// The template's highlight child info (live: 0x100001F1, /// base 0x100002B7@0x21000042 — the green frame), with the /// authored per-state P0x3B flip. private static ElementInfo HighlightChildInfo() { var info = new ElementInfo { Id = 0x100001F1u, Type = 3, Width = 10, Height = 10 }; var normal = new UiStateInfo { Id = 1u, Name = "Normal" }; normal.Properties.Values[0x3Bu] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true }; var rollover = new UiStateInfo { Id = 2u, Name = "Normal_rollover" }; rollover.Properties.Values[0x3Bu] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = false }; info.States[1u] = normal; info.States[2u] = rollover; return info; } /// Template-info resolver standing in for /// RowTemplateResolver.ResolveInfo: the template with its /// button-swallowed highlight child attached. private static ElementInfo? FakeHotspotTemplateInfo(uint layoutId, uint elementId) { ElementInfo info = MapNoteTemplateInfo(); info.Children.Add(HighlightChildInfo()); return info; } /// The /// seam: builds m_pMap's two button-swallowed icon children from their /// OWN s inside the panel-slot resolve tree /// (register row AD-108 — a standalone re-import cannot find them on /// the live DAT, so the icons are found under the already-resolved /// pageInfo and built through this seam instead). Mirrors /// production's LayoutImporter.Build(info, ...).Root, which sets /// DatElementId from the info's own id — and, like production's /// type-driven factory, builds a Type-3 info as a stateful /// FROM that info (the town markers' /// rollover-highlight child, morning gate finding 3, rides this same /// seam and needs its per-state property bags carried through). private static UiElement? FakeIconBuilder(ElementInfo info) => info.Type == 3 ? new UiDatElement(info, static _ => (0u, 0, 0)) { Width = 10f, Height = 10f, DatElementId = info.Id, } : new UiButton(new ElementInfo(), static _ => (0u, 0, 0)) { Width = 10f, Height = 10f, DatElementId = info.Id, }; /// The House ListBox's own row template resolves to a /// in the live DAT (MapHousePanelSlotProbeTests: /// "row template type=12" — UIElement_Text), unlike the Map tab's /// UiButton hotspots above — a fresh instance per call, matching /// production's real resolver. private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId) => new UiText { Width = 280f, Height = 28f }; private static MapHousePanelController.Callbacks MakeCallbacks( List? calls = null, Func? currentCalendar = null, Func? playerCellId = null, Func? housePosition = null, Func>? houseLines = null, Func? templateInfoResolver = null) { calls ??= new List(); return new MapHousePanelController.Callbacks( Toggle: () => calls.Add("toggle"), Map: new MapPageController.Bindings( CurrentCalendar: currentCalendar ?? (static () => default), PlayerCellId: playerCellId ?? (static () => 0u), HousePosition: housePosition ?? (static () => null), TemplateResolver: FakeHotspotTemplate, IconBuilder: FakeIconBuilder, TemplateInfoResolver: templateInfoResolver), House: new HousePageController.Bindings( Lines: houseLines ?? (static () => Array.Empty()), OnShown: () => calls.Add("house-shown"), TemplateResolver: FakeHouseRowTemplate)); } [Fact] public void Bind_RootBuildsAsUiTabPanel() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks()); Assert.NotNull(controller); Assert.IsType(controller!.Root); } /// Pins the authored tab table exactly as read from the live /// DATs (MapHousePanelSlotProbeTests): two entries, Map is the /// sole default. [Fact] public void TabTable_MatchesLiveDatPairing_MapIsDefault() { ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); var tabs = Assert.IsType(layout.Root); Assert.Equal(2, tabs.Tabs.Count); Assert.Contains(tabs.Tabs, e => e.ButtonElementId == 0x100001F3u && e.PageElementId == 0x100001F6u && e.IsDefault); Assert.Contains(tabs.Tabs, e => e.ButtonElementId == 0x100001F4u && e.PageElementId == 0x100001F7u && !e.IsDefault); Assert.Single(tabs.Tabs, e => e.IsDefault); } [Fact] public void Bind_Succeeds_AndActivateTabs_SelectsMapByDefault() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks()); Assert.NotNull(controller); controller!.ActivateTabs(); Assert.Empty(controller.TabPanel.UnresolvedEntries); Assert.False(controller.IsShowingHouse); } [Fact] public void SwitchToHouse_FiresOnShown_OnlyWhenPanelIsVisible() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); var calls = new List(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls)); Assert.NotNull(controller); controller!.ActivateTabs(); // Not visible yet: switching tabs must not fire OnShown. controller.TabPanel.SwitchTo(0x100001F7u); Assert.DoesNotContain("house-shown", calls); controller.OnShown(); Assert.Contains("house-shown", calls); } [Fact] public void CloseButton_InvokesToggle() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); var calls = new List(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls)); Assert.NotNull(controller); UiElement? close = layout.FindElement(0x100001F5u); Assert.IsType(close); ((UiButton)close!).OnClick?.Invoke(); Assert.Contains("toggle", calls); } [Fact] public void Bind_BuildsAll53TownHotspots_UnderTheMapWidget() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks()); Assert.NotNull(controller); UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId); Assert.NotNull(map); // m_pMap's own children are the player/house icons (found under the // panel-slot resolve tree and rebuilt via Bindings.IconBuilder — see // MapPageController.Bind's doc on why m_pMap being a Button swallows // its authored nested children) PLUS the 53 town hotspots. var townMarkers = map!.Children .Where(c => c.DatElementId != MapPageController.PlayerIconId && c.DatElementId != MapPageController.HouseIconId) .ToList(); Assert.Equal(55, map.Children.Count); Assert.Equal(53, townMarkers.Count); Assert.All(townMarkers, c => Assert.Contains( MapLocations.All, loc => loc.Width == c.Width && loc.Height == c.Height)); // Runtime tooltip text (UiButton.TooltipText, backing // GetTooltipText()) — the retail SetTooltip/m_TTText mechanism, NOT // the DAT-authored AuthoredTooltipText path. The popup-skin locator // + zero delay are the TEMPLATE's OWN authored values (2026-08-17 // morning gate finding 3: P0x47=0x10000398/P0x48=0x21000041/ // P0x50=0.0), which BuildTownMarkers must NOT clobber — the pre-fix // code overwrote them with the shared generic skin, losing the // map-note skin's 0x40000015 font. Assert.All(townMarkers, c => Assert.Equal(MapNoteSkinRootId, c.AuthoredTooltipRootElementId)); Assert.All(townMarkers, c => Assert.Equal(MapNoteSkinLayoutId, c.AuthoredTooltipLayoutDid)); Assert.All(townMarkers, c => Assert.Equal(0f, c.AuthoredTooltipDelaySeconds)); Assert.All(townMarkers, c => Assert.IsType(c)); Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText))); Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg"); } /// /// 2026-08-17 morning gate finding 3, the hover-highlight half: each /// marker rebuilds the template's button-swallowed highlight child /// through the IconBuilder seam and arms it via the initial Normal /// cascade (hidden at rest, per-state P0x3B); hovering the marker /// swaps it to Normal_rollover (shown — the green /// 0x06004CC9 frame in the live DAT). /// [Fact] public void TownMarkers_RolloverHighlight_StartsHidden_ShowsOnHover() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); MapHousePanelController? controller = MapHousePanelController.Bind( rootInfo, layout, MakeCallbacks(templateInfoResolver: FakeHotspotTemplateInfo)); Assert.NotNull(controller); UiElement? map = UiElement.FindDescendant(controller!.Root, MapPageController.MapWidgetId); Assert.NotNull(map); var markers = map!.Children .Where(c => c.DatElementId != MapPageController.PlayerIconId && c.DatElementId != MapPageController.HouseIconId) .OfType() .ToList(); Assert.Equal(53, markers.Count); foreach (UiButton marker in markers) { UiElement highlight = Assert.Single(marker.Children); Assert.False(highlight.Visible); // Normal: P0x3B=true } UiButton hovered = markers[0]; UiElement hoveredHighlight = hovered.Children[0]; hovered.OnEvent(new UiEvent(0, hovered, UiEventType.HoverEnter)); Assert.True(hoveredHighlight.Visible); // Normal_rollover: P0x3B=false hovered.OnEvent(new UiEvent(0, hovered, UiEventType.HoverLeave)); Assert.False(hoveredHighlight.Visible); } [Fact] public void Bind_HouseListBoxStartsEmpty_MatchingRetailPostInit() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks()); Assert.NotNull(controller); UiElement? box = UiElement.FindDescendant(controller!.Root, HousePageController.TextBoxId); var listBox = Assert.IsType(box); Assert.Equal(0, listBox.ContentHeight); } /// /// Batch C House-ownership-text closer (2026-08-17): the ONE /// decomp-verified gmHouseUI::DisplayPurchaseTimeText @0x004a3110 /// line a houseless character's HouseQuery response renders — proves /// 's revision-gated /// poll actually rebuilds the /// authored row template with real text end-to-end, the same way /// proves the text composition in /// isolation. /// [Fact] public void Tick_RendersHouseLinesIntoTheAuthoredRowTemplate() { ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); string[] lines = ["You may buy another house immediately."]; MapHousePanelController? controller = MapHousePanelController.Bind( rootInfo, layout, MakeCallbacks(houseLines: () => lines)); Assert.NotNull(controller); controller!.Tick(0.016); UiElement? box = UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId); var listBox = Assert.IsType(box); // Rows land in the ListBox's internal scrollable viewport (AddChild // there, not directly on the ListBox itself — UiTemplateListBox's // own #372/#412 dormancy machinery), exposed to tests via // ViewportForTest. UiScrollablePanel viewport = Assert.IsType( listBox.ViewportForTest); Assert.Single(viewport.Children); var row = Assert.IsType(viewport.Children[0]); Assert.Equal( "You may buy another house immediately.", Assert.Single(row.LinesProvider()).Text); } }