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);
}
}