acdream/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs
Erik 8bd7e3b88d fix #375: Configure Keyboard live mount — string resolver + parked template prototypes
Campaign OP gate 2: the screen opened as a visual mess (textless
buttons/tabs, buttons above the window, overlapping text) while the
fixture conformance suite stayed green — the #372 class again. Two root
causes, both proven by the new env-gated live-DAT probe before fixing:

1. MountKeyboardConfig's main LayoutImporter.Build was the ONE mount in
   RetailUiRuntime not passing strings.Resolve — every AUTHORED caption
   (OK/Cancel/Defaults/Revert/Load/Save, the six ActionClass tab labels,
   the Command/Mapping column headers) built empty, while the
   controller's own resolveString row captions worked, which is why the
   screen was recognizable but textless. Fixed by passing the resolver
   like every sibling mount.

2. gmKeyboardUI authors its ListBox row templates (header 0x1000002E,
   action row 0x1000002F with the three 100x32 key buttons) as TOP-LEVEL
   siblings referenced by dat property 0x64. Retail never instantiates
   template-list elements as live widgets (AddItemFromTemplateList
   clones from the desc — the same re-import UiTemplateListBox's
   TemplateResolver performs), but ImportInfos built them parked at the
   screen's (0,0): three key buttons at y=0..32 ABOVE the framed panel
   (top y=62) — the 'outside the window' buttons — under a 570x40
   header text overlapping them and the top chrome. ImportInfos now
   skips top-level elements referenced by a SAME-LAYOUT template list
   (the same skip class as the existing BaseElement-prototype filter;
   same-layout only because element ids collide across layouts —
   0x10000211 is a page in BOTH the options and keyboard layouts).

The probe (ACDREAM_PROBE_LIVE_MOUNT=1) pins both against the real DATs:
prototypes absent from the built tree, and Defaults/Revert/OK/Cancel
resolving on the resolver-passing build. Post-fix the import collapses
to the framed 600x476 panel with every screen button inside its bounds.

Full Release suite: 13,082 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:27:30 +02:00

195 lines
9 KiB
C#

using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// TEMPORARY #375 gate-failure probe (Campaign OP connected gate 2,
/// 2026-08-11): the user found the Configure Keyboard screen a visual mess —
/// missing button/tab captions, buttons outside the window, overlapping text —
/// while KeyboardConfigControllerTests stays green against the committed
/// fixture (the #372 structural-false-negative class again). This probe runs
/// the PRODUCTION mount path against the live DATs and dumps per-element
/// rect + caption evidence for each observation. Env-gated like the other
/// live probes so CI/dat-less runs skip it.
/// </summary>
public sealed class KeyboardConfigLiveMountProbeTests
{
[Fact]
public void ProbeKeyboardLiveMount()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
return;
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var strings = new DatStringResolver(dats);
ElementInfo? info = LayoutImporter.ImportInfos(
dats, KeyboardConfigController.LayoutId);
Assert.NotNull(info);
// Production shape A — what MountKeyboardConfig actually built at the
// gate (NO string resolver on the main Build; the template build DOES
// pass one — the asymmetry under investigation).
ImportedLayout withoutStrings = LayoutImporter.Build(
info!, _ => (0u, 0, 0), null);
// Shape B — the same build WITH the resolver every sibling mount passes.
ImportedLayout withStrings = LayoutImporter.Build(
info!, _ => (0u, 0, 0), null, null, strings.Resolve);
Console.WriteLine(
$"[kbprobe] layout root id=0x{info!.Id:X8} rect=({withoutStrings.Root.Left},{withoutStrings.Root.Top} "
+ $"{withoutStrings.Root.Width}x{withoutStrings.Root.Height}) children={withoutStrings.Root.Children.Count}");
// The window-vs-content geometry question (observation 2): the layout
// is an 800x600 retail full-SCREEN — which child is the visible framed
// panel, and what escapes ITS rect?
DumpTreeRects(withoutStrings.Root, 0, maxDepth: 3);
UiElement? windowRoot = withoutStrings.FindElement(
KeyboardConfigController.WindowRootElementId);
Console.WriteLine(windowRoot is null
? "[kbprobe] window root 0x1000001F: MISSING from flat index"
: $"[kbprobe] window root 0x1000001F rect=({windowRoot.Left},{windowRoot.Top} "
+ $"{windowRoot.Width}x{windowRoot.Height}) children={windowRoot.Children.Count} "
+ $"type={windowRoot.GetType().Name}");
// Observation 1+3 — "buttons lacked descriptive text", "no text in
// tabs": dump every UiButton/UiText caption in BOTH shapes.
DumpCaptions("without-strings", withoutStrings.Root);
DumpCaptions("with-strings", withStrings.Root);
// Observation 2 — "some buttons were outside of the window": every
// element whose screen rect escapes the root's own extent.
DumpOutOfBounds(withoutStrings.Root);
// #375 regression pins (live-DAT, env-gated — the fix evidence):
// (a) the same-layout row-template prototypes (0x1000002E header,
// 0x1000002F action row) must NOT build as live elements —
// the parked copies were the "buttons outside the window" /
// "overlapping text" halves of the gate report.
Assert.Null(withStrings.FindElement(0x1000002Eu));
Assert.Null(withStrings.FindElement(0x1000002Fu));
// (b) the resolver-passing build resolves the screen's authored
// captions — the "buttons/tabs lacked text" halves. (Production
// now passes the resolver; the without-strings shape above is
// kept only as the delta record.)
foreach ((uint id, string expected) in new[]
{
(0x1000002Au, "Defaults"),
(0x1000002Bu, "Revert"),
(0x1000002Cu, "OK"),
(0x1000002Du, "Cancel"),
})
{
UiElement? el = withStrings.FindElement(id);
UiButton button = Assert.IsType<UiButton>(el);
Assert.Equal(expected, button.Label);
}
// Observation 4 — "text next to the buttons was overlapping": the row
// template's authored geometry vs the synthesized 260px caption column.
foreach ((string name, uint pageId) in new[]
{
("Movement", 0x1000049Du),
("Camera", 0x1000049Fu),
("Combat", 0x100004A1u),
("UI", 0x100004A3u),
("CharacterSettings", 0x10000211u),
("Emote", 0x100004A5u),
})
{
UiElement? page = UiElement.FindDescendant(withStrings.Root, pageId);
UiElement? lb = page is null ? null : UiElement.FindDescendant(page, 0x10000025u);
Console.WriteLine(
$"[kbprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : $"({page.Left},{page.Top} {page.Width}x{page.Height})")} "
+ $"listbox -> {(lb is null ? "MISSING" : $"{lb.GetType().Name} ({lb.Left},{lb.Top} {lb.Width}x{lb.Height})")}");
if (lb is UiTemplateListBox tlb)
{
for (int i = 0; i < tlb.Templates.Count; i++)
{
(uint layoutId, uint elementId) =
(tlb.Templates[i].TemplateLayoutId, tlb.Templates[i].TemplateElementId);
ElementInfo? tInfo = LayoutImporter.ImportInfos(dats, layoutId, elementId);
if (tInfo is null)
{
Console.WriteLine($"[kbprobe] template[{i}] 0x{layoutId:X8}/0x{elementId:X8} -> IMPORT MISSING");
continue;
}
UiElement built = LayoutImporter.Build(
tInfo, _ => (0u, 0, 0), null, null, strings.Resolve).Root;
Console.WriteLine(
$"[kbprobe] template[{i}] 0x{layoutId:X8}/0x{elementId:X8} -> {built.GetType().Name} "
+ $"({built.Left},{built.Top} {built.Width}x{built.Height}) children={built.Children.Count}");
foreach (uint keyBtn in new[] { 0x10000030u, 0x10000031u, 0x10000032u })
{
UiElement? b = UiElement.FindDescendant(built, keyBtn);
if (b is not null)
Console.WriteLine(
$"[kbprobe] key-button 0x{keyBtn:X8} ({b.Left},{b.Top} {b.Width}x{b.Height})");
}
}
}
// Only the first page's templates matter for geometry (all six share
// the same authored list) — stop after one full dump.
if (lb is not null) break;
}
}
private static void DumpCaptions(string tag, UiElement root)
{
Walk(root, el =>
{
string? caption = el switch
{
UiButton b => b.Label,
UiText t => string.Join(
" / ", t.LinesProvider().Select(static l => l.Text)),
_ => null,
};
if (el is UiButton or UiText)
Console.WriteLine(
$"[kbprobe] {tag} 0x{el.EventId:X8} {el.GetType().Name} "
+ $"({el.Left},{el.Top} {el.Width}x{el.Height}) caption='{caption ?? "<null>"}'");
});
}
private static void DumpOutOfBounds(UiElement root)
{
Walk(root, el =>
{
var p = el.ScreenPosition;
bool outside = p.X < root.Left - 0.5f || p.Y < root.Top - 0.5f
|| p.X + el.Width > root.Left + root.Width + 0.5f
|| p.Y + el.Height > root.Top + root.Height + 0.5f;
if (outside)
Console.WriteLine(
$"[kbprobe] OUT-OF-BOUNDS 0x{el.EventId:X8} {el.GetType().Name} "
+ $"screen=({p.X},{p.Y} {el.Width}x{el.Height}) rootExtent={root.Width}x{root.Height}");
});
}
private static void Walk(UiElement el, Action<UiElement> visit)
{
visit(el);
foreach (UiElement c in el.Children)
Walk(c, visit);
}
private static void DumpTreeRects(UiElement el, int depth, int maxDepth)
{
Console.WriteLine(
$"[kbprobe] tree {new string(' ', depth * 2)}{el.GetType().Name} "
+ $"({el.Left},{el.Top} {el.Width}x{el.Height}) children={el.Children.Count}");
if (depth >= maxDepth) return;
foreach (UiElement c in el.Children)
DumpTreeRects(c, depth + 1, maxDepth);
}
}