User finding 3 (retail screenshot): hovering a town on the Map tab turns its marker GREEN and shows the name on a special-font tooltip — clearly not our generic 0x10000395 popup skin, and we had no hover highlight at all. Re-derivation (live-DAT probe + raw ElementDesc dump + surface byte-decode; MapNoteLiveDatTests pins all of it): - m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the note CONSTRUCTION template (AddMapNote @0x004a1bb0's CreateChildElement args) — that part we had right. - The TEMPLATE's own DirectState authors the note's tooltip popup locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin, whose incorporated text child 0x10000396 fonts 0x40000015 where the other three skins font 0x40000002 (the user's "special font") — plus P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn, and P0x13 RolloverEnabled. Batch C's "the template authors no locator of its own" claim was WRONG, and BuildTownMarkers' hardcoded shared-skin override was clobbering the authored values — removed. - The hover highlight: the template's Normal/Normal_rollover states are PassToChildren descriptors driving the swallowed highlight child 0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via per-state P0x3B (Invisible): hidden at rest, green on rollover. Port: - UiButton.CascadeStateToChildren — retail UIElement::SetState @0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id (properties commit unconditionally; only the sprite draw is art-gated, the existing #382/AP-222 distinction). - UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states (OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The unnamed-DirectState case is explicitly excluded — honoring it would un-gate ISSUES #408 (1,083 authored-invisible elements) through BuildWidget's post-children state reapply; measured breaking the spell-favorite drag tests before the scoping (note added to #408). - MapPageController.BuildTownMarkers rebuilds the button-swallowed highlight child per marker through the AD-108 IconBuilder seam (Bindings.TemplateInfoResolver, backed by RowTemplateResolver.ResolveInfo — same cache) and arms it with the initial Normal cascade. Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's F10 shared-skin remark updated (MapPageController no longer a consumer). Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B + green frame; the four-skin font sweep), UiButton cascade + UiDatElement P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture. App suite 5487 passed / 3 skips (5490 total, +11 over baseline); Runtime 1744/1744. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
17 KiB
C#
364 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Controller-level tests for <see cref="MapHousePanelController"/> against
|
|
/// the committed <c>map_house_2100006E_1000018C.json</c> fixture (Batch C,
|
|
/// overnight hover/UI round) — the SAME <c>LayoutImporter.ImportInfos(dats,
|
|
/// 0x2100006Eu, 0x1000018Cu)</c> catalog import
|
|
/// <see cref="RetailUiRuntime.MountMapHousePanel"/> performs against real
|
|
/// DATs. No DAT access, no live runtime — dat-free per the established
|
|
/// <see cref="FixtureLoader"/> pattern.
|
|
/// </summary>
|
|
public sealed class MapHousePanelControllerTests
|
|
{
|
|
/// <summary>The live hotspot template's own authored popup locator
|
|
/// (<c>MapHousePanelSlotProbeTests</c>, 2026-08-17 morning gate
|
|
/// finding 3: DirectState <c>P0x47=0x10000398</c>/<c>P0x48=0x21000041</c>
|
|
/// — the map-note skin whose text child fonts <c>0x40000015</c>).</summary>
|
|
private const uint MapNoteSkinRootId = 0x10000398u;
|
|
private const uint MapNoteSkinLayoutId = 0x21000041u;
|
|
|
|
/// <summary>Serves the town-hotspot template. Returns a
|
|
/// <see cref="UiButton"/> — matching the live template's own authored
|
|
/// Type 1 (<c>MapHousePanelSlotProbeTests</c>: "hotspot template
|
|
/// type=1") — so <see cref="MapPageController.BuildTownMarkers"/>'s
|
|
/// <c>marker is UiButton</c> tooltip-text branch is actually exercised
|
|
/// by these tests. A real <see cref="RowTemplateResolver"/> would set
|
|
/// <c>DatElementId</c> the same way <see cref="LayoutImporter.Build"/>
|
|
/// 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 <see cref="LayoutImporter"/> 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.</summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>The template's authored state shape (raw-DAT-dumped):
|
|
/// media-less <c>Normal</c>/<c>Normal_rollover</c> descriptors, both
|
|
/// <c>PassToChildren=true</c>, plus <c>P0x13</c> RolloverEnabled.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>The template's highlight child info (live: <c>0x100001F1</c>,
|
|
/// base <c>0x100002B7</c>@<c>0x21000042</c> — the green frame), with the
|
|
/// authored per-state <c>P0x3B</c> flip.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Template-info resolver standing in for
|
|
/// <c>RowTemplateResolver.ResolveInfo</c>: the template with its
|
|
/// button-swallowed highlight child attached.</summary>
|
|
private static ElementInfo? FakeHotspotTemplateInfo(uint layoutId, uint elementId)
|
|
{
|
|
ElementInfo info = MapNoteTemplateInfo();
|
|
info.Children.Add(HighlightChildInfo());
|
|
return info;
|
|
}
|
|
|
|
/// <summary>The <see cref="MapPageController.Bindings.IconBuilder"/>
|
|
/// seam: builds m_pMap's two button-swallowed icon children from their
|
|
/// OWN <see cref="ElementInfo"/>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
|
|
/// <c>pageInfo</c> and built through this seam instead). Mirrors
|
|
/// production's <c>LayoutImporter.Build(info, ...).Root</c>, which sets
|
|
/// <c>DatElementId</c> from the info's own id — and, like production's
|
|
/// type-driven factory, builds a Type-3 info as a stateful
|
|
/// <see cref="UiDatElement"/> 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).</summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>The House ListBox's own row template resolves to a
|
|
/// <see cref="UiText"/> in the live DAT (<c>MapHousePanelSlotProbeTests</c>:
|
|
/// "row template type=12" — <c>UIElement_Text</c>), unlike the Map tab's
|
|
/// UiButton hotspots above — a fresh instance per call, matching
|
|
/// production's real resolver.</summary>
|
|
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
|
|
=> new UiText { Width = 280f, Height = 28f };
|
|
|
|
private static MapHousePanelController.Callbacks MakeCallbacks(
|
|
List<string>? calls = null,
|
|
Func<DerethDateTime.Calendar>? currentCalendar = null,
|
|
Func<uint>? playerCellId = null,
|
|
Func<CreateObject.ServerPosition?>? housePosition = null,
|
|
Func<IReadOnlyList<string>>? houseLines = null,
|
|
Func<uint, uint, ElementInfo?>? templateInfoResolver = null)
|
|
{
|
|
calls ??= new List<string>();
|
|
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<string>()),
|
|
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<UiTabPanel>(controller!.Root);
|
|
}
|
|
|
|
/// <summary>Pins the authored tab table exactly as read from the live
|
|
/// DATs (<c>MapHousePanelSlotProbeTests</c>): two entries, Map is the
|
|
/// sole default.</summary>
|
|
[Fact]
|
|
public void TabTable_MatchesLiveDatPairing_MapIsDefault()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
|
|
var tabs = Assert.IsType<UiTabPanel>(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<string>();
|
|
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<string>();
|
|
MapHousePanelController? controller =
|
|
MapHousePanelController.Bind(rootInfo, layout, MakeCallbacks(calls));
|
|
Assert.NotNull(controller);
|
|
|
|
UiElement? close = layout.FindElement(0x100001F5u);
|
|
Assert.IsType<UiButton>(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<UiButton>(c));
|
|
Assert.All(townMarkers, c => Assert.False(string.IsNullOrEmpty(((UiButton)c).TooltipText)));
|
|
Assert.Contains(townMarkers, c => ((UiButton)c).TooltipText == "Holtburg");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>P0x3B</c>); hovering the marker
|
|
/// swaps it to <c>Normal_rollover</c> (shown — the green
|
|
/// <c>0x06004CC9</c> frame in the live DAT).
|
|
/// </summary>
|
|
[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<UiButton>()
|
|
.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<UiTemplateListBox>(box);
|
|
Assert.Equal(0, listBox.ContentHeight);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Batch C House-ownership-text closer (2026-08-17): the ONE
|
|
/// decomp-verified <c>gmHouseUI::DisplayPurchaseTimeText @0x004a3110</c>
|
|
/// line a houseless character's HouseQuery response renders — proves
|
|
/// <see cref="HousePageController"/>'s revision-gated
|
|
/// <see cref="MapHousePanelController.Tick"/> poll actually rebuilds the
|
|
/// authored row template with real text end-to-end, the same way
|
|
/// <see cref="RuntimeHouseStateTests"/> proves the text composition in
|
|
/// isolation.
|
|
/// </summary>
|
|
[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<UiTemplateListBox>(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<UiScrollablePanel>(
|
|
listBox.ViewportForTest);
|
|
Assert.Single(viewport.Children);
|
|
var row = Assert.IsType<UiText>(viewport.Children[0]);
|
|
Assert.Equal(
|
|
"You may buy another house immediately.",
|
|
Assert.Single(row.LinesProvider()).Text);
|
|
}
|
|
}
|