acdream/tests/AcDream.App.Tests/UI/Layout/MapPageControllerTests.cs
Erik 0b0c7aa485 fix(ui): night-round review — F11/F13/F14/F15 one-liners
F11: logs when a Map tab town-marker's template resolves to something
other than a UiButton — that path previously silently skipped the
TooltipText write with no diagnostic, leaving a mounted-but-empty
tooltip popup indistinguishable from "no template configured".

F13: RetailSkillFormula.FormatFormula now reads Attribute1Multiplier/
Attribute2Multiplier/AdditiveBonus/Divisor through the SAME unsigned
reinterpretation TryCalculate already uses (this class's own doc
comment already stated the invariant; FormatFormula just didn't follow
it). A high-bit-set value would previously both mis-gate hasAttr1/
hasAttr2 and print a negative number, out of sync with what
TryCalculate actually computes with for the same formula. Added
regression tests, empirically verified to fail without the fix.

F14: documented the RefreshHouseMarker gap rather than guessing at the
byte-decode — Position::get_outside_cell_id @0x004527b0 is itself
BN-mangled (its `(eax_2 - eax_2) & objcell_id` return is the same
decompiler-obscures-a-real-conditional artifact class this round hit
elsewhere) and depends on LandDefs::adjust_to_outside, a genuinely
larger port than this round's other findings. HousePosition is wired
() => null in production today (ISSUES #413's remaining scope), so
this method is currently unreachable; left a TODO citing the retail
call chain for whenever that lands.

F15: fixed RefreshCoordinatesAndPlayerMarker's gate to AND-on-both-
present, matching gmMapUI::Update @0x004a2078's exact
`if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` condition.
The prior `_coordinateText is null && _playerIcon is null` check only
skipped when BOTH were absent (proceeding whenever EITHER was
present), letting coordinate text and the player marker update
independently instead of as the single gated unit retail treats them
as. Added a regression test (player-icon template resolution failure
must also skip the coordinate-text write), empirically verified to
fail without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 05:06:17 +02:00

296 lines
14 KiB
C#

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Unit coverage for <see cref="MapPageController"/>'s pure math: the
/// calendar formatter (<c>"Date: %s\nTime: %s"</c>,
/// <c>gmMapUI::Update @0x004a1eb0</c>) and the 53-town static table
/// (verbatim port of <c>s_rgLocations</c>). Marker-placement math itself is
/// <see cref="RadarCoordinates"/>/<see cref="LandDefs.GidToLcoord"/> — both
/// already unit-tested elsewhere; this file only proves the wiring
/// reproduces their output through the real fixture (no re-derivation).
/// </summary>
public sealed class MapPageControllerTests
{
// ── Calendar formatter ───────────────────────────────────────────────
[Fact]
public void FormatDateTime_OrdinaryHour_NoAndHalfSuffix()
{
var calendar = new DerethDateTime.Calendar(
119, DerethDateTime.MonthName.Frostfell, 27, DerethDateTime.HourName.Dawnsong);
string text = MapPageController.FormatDateTime(calendar);
Assert.Equal("Date: Frostfell 27, 119 P.Y.\nTime: Dawnsong", text);
}
[Fact]
public void FormatDateTime_AndHalfHour_RewritesSuffixWithHyphens()
{
var calendar = new DerethDateTime.Calendar(
10, DerethDateTime.MonthName.Morningthaw, 1, DerethDateTime.HourName.MorntideAndHalf);
string text = MapPageController.FormatDateTime(calendar);
Assert.Equal("Date: Morningthaw 1, 10 P.Y.\nTime: Morntide-and-Half", text);
}
[Theory]
[InlineData(DerethDateTime.HourName.Darktide, "Darktide")]
[InlineData(DerethDateTime.HourName.DarktideAndHalf, "Darktide-and-Half")]
[InlineData(DerethDateTime.HourName.Gloaming, "Gloaming")]
[InlineData(DerethDateTime.HourName.GloamingAndHalf, "Gloaming-and-Half")]
[InlineData(DerethDateTime.HourName.WarmtideAndHalf, "Warmtide-and-Half")]
public void FormatDateTime_EveryHourName_MatchesExpectedDisplayText(
DerethDateTime.HourName hour, string expectedHourText)
{
var calendar = new DerethDateTime.Calendar(
10, DerethDateTime.MonthName.Morningthaw, 1, hour);
string text = MapPageController.FormatDateTime(calendar);
Assert.EndsWith($"Time: {expectedHourText}", text);
}
// ── Town table ────────────────────────────────────────────────────────
[Fact]
public void MapLocations_Has53Entries()
{
Assert.Equal(53, MapLocations.All.Length);
}
[Fact]
public void MapLocations_AllNamesAreUnique()
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (MapLocation loc in MapLocations.All)
Assert.True(names.Add(loc.Name), $"duplicate town name: {loc.Name}");
}
[Fact]
public void MapLocations_Holtburg_MatchesDecompiledByteValues()
{
// s_rgLocations[0x13] (pc:977379): X=0xa4 Y=0x4d W=9 H=8.
MapLocation holtburg = Assert.Single(MapLocations.All, l => l.Name == "Holtburg");
Assert.Equal(0xa4, holtburg.X);
Assert.Equal(0x4d, holtburg.Y);
Assert.Equal(9, holtburg.Width);
Assert.Equal(8, holtburg.Height);
}
[Fact]
public void MapLocations_EveryRectIsWithinTheMapWidgetsAuthoredExtent()
{
// m_pMap's own authored size (MapHousePanelSlotProbeTests: markerArea
// (6,8)-(247,258) — the widest observed extent). Town rects are
// independent of the marker-area rect but should still land inside
// a sane 0..300 canvas — a coarse sanity check that the verbatim
// port didn't transpose a digit.
foreach (MapLocation loc in MapLocations.All)
{
Assert.InRange(loc.X, 0, 260);
Assert.InRange(loc.Y, 0, 260);
Assert.InRange(loc.Width, 1, 20);
Assert.InRange(loc.Height, 1, 20);
}
}
// ── Marker placement math (GOLDEN PIXEL values, byte-decoded formula) ──
//
// gmMapUI::PlaceMarkerOnMap @0x004a18b0. Binary Ninja elides the entire
// FPU chain to bare, operand-less _ftol2() calls; the formula below was
// recovered by disassembling the raw bytes of the PDB-paired
// acclient.exe directly (capstone) — see MapPageController.ComputeMarkerPosition's
// doc comment and docs/research/2026-08-17-map-house-recon.md's
// corrected PlaceMarkerOnMap entry. Every expected value here is a
// LITERAL computed independently from the formula (by hand / an
// external script), never by calling the port itself — that is the
// whole point of a golden-value test.
//
// X = m_x0 - w/2 - (int)((m_x1-m_x0+1) * (x*10+1024) * (-1/2048))
// Y = m_y0 - h/2 - (int)((m_y1-m_y0+1) * (2047-(y*10+1024)) * (-1/2048))
//
// Marker area used throughout: (6,8)-(247,258) — the live-fixture value
// (MapHousePanelSlotProbeTests). Icon: 10x10 (matches the town hotspot
// template's "plain 10x10 hotspot dot" and this test file's own fixture
// resolver).
[Theory]
// Canonical case: dead center of Dereth (0.0N/0.0E) -> the marker
// area's own true center pixel.
[InlineData(0.0, 0.0, 122, 128)]
// Far west (x very negative): pixel X moves toward the marker area's
// left edge (m_x0=6), well below the center-case 122.
[InlineData(-100.0, 0.0, 3, 128)]
// Far north (y very positive): pixel Y moves toward the marker area's
// top edge (m_y0=8) — the FSUBR north-up flip means +Y in-game means
// SMALLER pixel Y, not larger.
[InlineData(0.0, 100.0, 122, 5)]
// A real town-table entry: Arwic's landblock cell 0x11CE0001 fed
// through the ALREADY-VERIFIED RadarCoordinates.TryFromCell (a
// different, independently-tested subsystem) to get x=-88.3/y=62.9,
// then through the formula above to get the expected pixel.
[InlineData(-88.30000000000001, 62.900000000000006, 17, 51)]
public void ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels(
double x, double y, int expectedLeft, int expectedTop)
{
(float left, float top) = MapPageController.ComputeMarkerPosition(
markerX0: 6, markerX1: 247, markerY0: 8, markerY1: 258,
iconWidth: 10, iconHeight: 10, x: x, y: y);
Assert.Equal(expectedLeft, left);
Assert.Equal(expectedTop, top);
}
[Fact]
public void ComputeMarkerPosition_ArwicCell_MatchesRadarCoordinates()
{
// Cross-check that the golden (x,y) literal used above for the
// "town-table entry" case really is what RadarCoordinates.TryFromCell
// produces for Arwic's landblock, so the golden test above can't
// silently drift from the coordinate subsystem it's chained to.
const uint cellId = 0x11CE0001u;
Assert.True(RadarCoordinates.TryFromCell(cellId, out RadarCoordinates coords));
Assert.Equal(-88.30000000000001, coords.X, precision: 12);
Assert.Equal(62.900000000000006, coords.Y, precision: 12);
}
// ── Marker placement wiring (real fixture, no re-derivation) ────────────
[Fact]
public void Bind_PlayerMarker_OutdoorCell_ReproducesRadarCoordinatesPlacement()
{
// Arwic's landblock cell id (0x11CE0001 — an arbitrary real outdoor
// cell, picked only because RadarCoordinates.TryFromCell already
// proves gid-to-lcoord conformance elsewhere; this test proves the
// WIRING, not the formula — the formula itself is golden-tested
// above).
const uint cellId = 0x11CE0001u;
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var callbacks = new MapHousePanelController.Callbacks(
Toggle: () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: () => cellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
Assert.NotNull(playerIcon);
Assert.True(playerIcon!.Visible);
// markerArea from the live fixture (MapHousePanelSlotProbeTests):
// (6,8)-(247,258). GOLDEN pixel value computed independently above
// (ComputeMarkerPosition_MatchesByteDecodedFormula_GoldenPixels'
// Arwic case) — (17,51) for this exact (x,y).
Assert.Equal(17f, playerIcon.Left);
Assert.Equal(51f, playerIcon.Top);
}
[Fact]
public void Bind_PlayerMarker_IndoorCell_HidesIconAndClearsCoordinateText()
{
// Envcell low word (>= 0x100) fails RadarCoordinates.TryFromCell —
// the indoor branch (gmMapUI::Update's else: SetVisible(0)).
const uint indoorCellId = 0x0012_0100u;
Assert.False(RadarCoordinates.TryFromCell(indoorCellId, out _));
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var callbacks = new MapHousePanelController.Callbacks(
Toggle: () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: () => indoorCellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
UiElement? playerIcon = UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId);
Assert.NotNull(playerIcon);
Assert.False(playerIcon!.Visible);
}
[Fact]
public void Refresh_PlayerIconTemplateResolutionFails_CoordinateTextStaysEmptyToo()
{
// F15 (night-round review): gmMapUI::Update @0x004a2078's gate is
// `if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` — BOTH
// widgets present, not "at least one". A player-icon template
// resolution failure (leaving _playerIcon null, e.g. a future DAT
// regression) must skip the coordinate-text write too, not just the
// marker placement — the OLD `_coordinateText is null &&
// _playerIcon is null` gate only skipped when BOTH were absent, so
// it would have written coordinate text here even with a missing
// player icon.
const uint cellId = 0x11CE0001u; // outdoor: TryFromCell succeeds.
Assert.True(RadarCoordinates.TryFromCell(cellId, out _));
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var callbacks = new MapHousePanelController.Callbacks(
Toggle: () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: () => cellId,
HousePosition: static () => (CreateObject.ServerPosition?)null,
// Simulates the player icon's own standalone template
// resolution failing (ResolveSwallowedIcon's own null path)
// while every other swallowed-icon/town-marker resolution
// still succeeds normally.
TemplateResolver: (_, e) => e == MapPageController.PlayerIconId
? null
: new UiText { Width = 10f, Height = 10f, DatElementId = e }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
Assert.Null(
UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId));
var coordinateText = Assert.IsType<UiText>(
UiElement.FindDescendant(controller.Root, MapPageController.CoordinateTextId));
Assert.Empty(coordinateText.LinesProvider());
}
[Fact]
public void Bind_HouseMarker_NullPosition_StaysHidden()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
var callbacks = new MapHousePanelController.Callbacks(
Toggle: () => { },
Map: new MapPageController.Bindings(
CurrentCalendar: static () => default,
PlayerCellId: static () => 0u,
HousePosition: static () => (CreateObject.ServerPosition?)null,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty<string>()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
UiElement? houseIcon = UiElement.FindDescendant(controller!.Root, MapPageController.HouseIconId);
Assert.NotNull(houseIcon);
Assert.False(houseIcon!.Visible);
}
}