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;
///
/// Unit coverage for 's pure math: the
/// calendar formatter ("Date: %s\nTime: %s",
/// gmMapUI::Update @0x004a1eb0) and the 53-town static table
/// (verbatim port of s_rgLocations). Marker-placement math itself is
/// / — both
/// already unit-tested elsewhere; this file only proves the wiring
/// reproduces their output through the real fixture (no re-derivation).
///
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(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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty()));
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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty()));
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_PlayerIconResolutionFails_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 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,
TemplateResolver: (_, e) => new UiText { Width = 10f, Height = 10f, DatElementId = e },
// Simulates the player icon's own build failing
// (ResolveSwallowedIcon's null-build path, e.g. a future DAT
// regression) while the house icon and every town-marker
// resolution still succeed normally.
IconBuilder: info => info.Id == MapPageController.PlayerIconId
? null
: new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty()));
MapHousePanelController? controller = MapHousePanelController.Bind(rootInfo, layout, callbacks);
Assert.NotNull(controller);
Assert.Null(
UiElement.FindDescendant(controller!.Root, MapPageController.PlayerIconId));
var coordinateText = Assert.IsType(
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 },
IconBuilder: info => new UiText { Width = 10f, Height = 10f, DatElementId = info.Id }),
House: new HousePageController.Bindings(Lines: static () => Array.Empty()));
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);
}
}