acdream/tests/AcDream.App.Tests/UI/Layout/SocialPanelLiveMountProbeTests.cs
Erik ae77270939 fix(ui): fellowship checkbox count, row-text + ListBox Flush doc corrections, promote probe findings to assertions
Mechanism SHOULD-FIX 4: SocialFellowshipPageController's class doc said
0x1000026B holds "all three visible option checkboxes" — the fixture and
the decomp both authors FOUR (Ignore Fellowship Requests / Auto-Accept
Requests / Share XP / Share Loot, 0x10000270-0x10000273). Corrected.

Mechanism SHOULD-FIX 9: SocialPanelRowText.FindDeepest's doc promised
"the deepest UiText descendant" but the implementation returns the LAST
match in pre-order traversal order, which only equals the deepest when
the subtree is a single chain. Both real row templates ARE single
chains today, so behavior is unaffected — the doc now describes what the
code actually does instead of a stronger guarantee it doesn't implement.

Blast SHOULD-FIX 5: UiTemplateListBox.Flush()'s doc only mentioned the
ContentHeight reset; UiScrollablePanel.ClearContent() also resets scroll
position to 0, which the sibling UiItemList.Flush() (same method name,
different semantics) does NOT do. Documented explicitly, including the
UX cost this creates for a scrolled-in Friends/Squelch roster once its
scrollbar is wired (this fix round's blast MF-1) — flagged for whoever
revisits Friends/Squelch scrolling next rather than silently fixed as an
unasked behavior change.

Mechanism SHOULD-FIX 3: SocialPanelLiveMountProbeTests printed two
headline findings (the 0x10000492-authored-twice count, page exclusivity
after ActivateTabBehavior) without ever asserting them — a future
importer regression collapsing/dropping an instance, or breaking
exclusivity, could not fail this test. Both are now real assertions
(Assert.Equal(2, passupCount); exactly one page Visible and it is
Allegiance).

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

229 lines
10 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>
/// Campaign FA slice FA3, item 7: extends the ACDREAM_PROBE_LIVE_MOUNT pattern
/// (the #372/#375/#378 lesson — fixture-green alone is NOT acceptance for
/// anything mounted) to the four-tab social panel. Asserts the PRODUCTION
/// mount path resolves: the tab host as <see cref="UiTabPanel"/> with its
/// 4-entry table, all four page elements, the fellowship empty/full frame
/// pair, the allegiance signature elements, and non-empty captions on the
/// tab buttons (the #375 resolver class). Also dumps the raw tab table so
/// its button→page pairing + default entry can be recorded — coordinator
/// addendum, docs/research/2026-08-11-fa-panel-structure.md §10.
/// </summary>
public sealed class SocialPanelLiveMountProbeTests
{
[Fact]
public void ProbeLiveMountShapes()
{
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? root = LayoutImporter.ImportInfos(
dats, SocialPanelController.HostLayoutId, SocialPanelController.SlotElementId);
Assert.NotNull(root);
ImportedLayout layout = LayoutImporter.Build(
root!, _ => (1u, 8, 8), null, null, strings.Resolve);
Console.WriteLine(
$"[socialprobe] root id=0x{root!.Id:X8} type={root.Type} "
+ $"({root.X},{root.Y} {root.Width}x{root.Height}) children={root.Children.Count} "
+ $"restorePrevious={(root.TryGetEffectiveBool(RetailPanelUiController.RestorePreviousPropertyId, out bool rp) && rp)}");
foreach (ElementInfo c in root.Children)
{
string p12 = c.TryGetEffectiveProperty(0x12u, out var v12) ? $"0x{v12.UnsignedValue:X8}" : "ABSENT";
string p57 = c.TryGetEffectiveProperty(0x57u, out var v57) ? $"{v57.Kind}=0x{v57.UnsignedValue:X8}" : "ABSENT";
Console.WriteLine(
$"[socialprobe] root-child 0x{c.Id:X8} type=0x{c.Type:X8} ({c.X},{c.Y} {c.Width}x{c.Height}) "
+ $"kids={c.Children.Count} P0x12={p12} P0x57={p57}");
}
{
string rootP57 = root.TryGetEffectiveProperty(0x57u, out var rv57) ? $"{rv57.Kind}=0x{rv57.UnsignedValue:X8}" : "ABSENT";
Console.WriteLine($"[socialprobe] root P0x57={rootP57}");
}
UiTabPanel tabs = Assert.IsType<UiTabPanel>(layout.Root);
Console.WriteLine($"[socialprobe] tab table entries={tabs.Tabs.Count}");
foreach (UiTabTableEntry t in tabs.Tabs)
Console.WriteLine(
$"[socialprobe] button=0x{t.ButtonElementId:X8} page=0x{t.PageElementId:X8} default={t.IsDefault}");
Assert.True(tabs.Tabs.Count >= 4);
// Production mount: activate behavior exactly like SocialPanelController.ActivateTabs.
tabs.ActivateTabBehavior();
Assert.Empty(tabs.UnresolvedEntries);
// Fix-round mechanism SF-3: page exclusivity was activated but never
// asserted — the #372 class ("the panel mounts but the pages are
// wrong/blank") deserves a real assertion, not just a hope.
bool sawVisiblePage = false;
string? visiblePageName = null;
foreach ((uint pageId, string name) in new[]
{
(0x10000513u, "Friends"),
(0x10000291u, "Allegiance"),
(0x10000292u, "Fellowship"),
(0x1000054Au, "Squelch"),
})
{
UiElement? page = UiElement.FindDescendant(tabs, pageId);
Console.WriteLine(
$"[socialprobe] page {name} 0x{pageId:X8} -> {(page is null ? "MISSING" : page.GetType().Name)} "
+ $"Visible={page?.Visible}");
Assert.NotNull(page);
if (page!.Visible)
{
Assert.False(
sawVisiblePage,
$"page exclusivity violated: both '{visiblePageName}' and '{name}' report Visible=true after ActivateTabBehavior()");
sawVisiblePage = true;
visiblePageName = name;
}
}
Assert.True(sawVisiblePage, "no page reports Visible=true after ActivateTabBehavior()");
Assert.Equal("Allegiance", visiblePageName);
// Fellowship empty/full frame pair.
foreach ((uint id, string name) in new[]
{
(0x1000026Bu, "NotInAFellowshipFrame"),
(0x10000275u, "InAFellowshipFrame"),
})
{
UiElement? el = UiElement.FindDescendant(tabs, id);
Console.WriteLine($"[socialprobe] fellowship frame {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
Assert.NotNull(el);
}
// Allegiance signature elements.
foreach ((uint id, string name) in new[]
{
(0x10000255u, "MonarchField"),
(0x1000025Au, "PatronField"),
(0x10000260u, "VassalListBox"),
(0x10000263u, "SwearButton"),
(0x10000264u, "BreakButton"),
(0x10000265u, "KickButton"),
})
{
UiElement? el = UiElement.FindDescendant(tabs, id);
Console.WriteLine($"[socialprobe] allegiance element {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
Assert.NotNull(el);
}
// 0x10000492 is authored TWICE inside the allegiance page (monarch
// block child AND patron block child) — count occurrences under the
// allegiance page root specifically, not the whole tree.
UiElement? allegiancePage = UiElement.FindDescendant(tabs, 0x10000291u);
Assert.NotNull(allegiancePage);
int passupCount = CountDescendants(allegiancePage!, 0x10000492u);
Console.WriteLine($"[socialprobe] 0x10000492 occurrences under allegiance page = {passupCount}");
// Fix-round mechanism SF-3: this was printed but never asserted — a
// future importer change that collapses or drops one instance must
// fail this test, not just the log.
Assert.Equal(2, passupCount);
// Tab button captions — non-empty (the #375 resolver class: a missing
// string resolver renders blank captions even though the layout mounts).
foreach (UiTabTableEntry t in tabs.Tabs)
{
UiElement? button = UiElement.FindDescendant(tabs, t.ButtonElementId);
string? caption = button switch
{
UiText text => text.LinesProvider?.Invoke() is { Count: > 0 } lines ? lines[0].Text : null,
UiButton btn => btn.Label,
_ => null,
};
Console.WriteLine(
$"[socialprobe] tab button 0x{t.ButtonElementId:X8} ({button?.GetType().Name}) caption='{caption}'");
Assert.False(string.IsNullOrEmpty(caption));
}
// U6: containment of the fellowship empty/full frames.
ElementInfo? notInFellowshipFrame = FindInfo(root, 0x1000026Bu);
ElementInfo? inFellowshipFrame = FindInfo(root, 0x10000275u);
Console.WriteLine(
$"[socialprobe] 0x1000026B (NotInAFellowshipFrame) children: "
+ string.Join(",", (notInFellowshipFrame?.Children ?? new()).ConvertAll(c => $"0x{c.Id:X8}")));
Console.WriteLine(
$"[socialprobe] 0x10000275 (InAFellowshipFrame) children: "
+ string.Join(",", (inFellowshipFrame?.Children ?? new()).ConvertAll(c => $"0x{c.Id:X8}")));
// Dump two levels of children under each page slot for U3/U4/U6/U7 sweep.
foreach (uint pageId in new[] { 0x10000513u, 0x10000291u, 0x10000292u, 0x1000054Au })
{
ElementInfo? pageInfo = FindInfo(root, pageId);
if (pageInfo is null)
{
Console.WriteLine($"[socialprobe] page-info 0x{pageId:X8} MISSING from ElementInfo tree");
continue;
}
Console.WriteLine(
$"[socialprobe] page-info 0x{pageId:X8} P0x57={(pageInfo.TryGetEffectiveProperty(0x57u, out var p57) ? p57.UnsignedValue.ToString() : "ABSENT")} children={pageInfo.Children.Count}");
DumpInfoTree(pageInfo, 1, maxDepth: 3);
}
// Friends/Squelch row templates — find the name-text child.
foreach ((uint tLayout, uint tElement, string name) in new[]
{
(0x2100005Du, 0x10000519u, "FriendsRow"),
(0x21000060u, 0x10000541u, "SquelchRow"),
})
{
ElementInfo? row = LayoutImporter.ImportInfos(dats, tLayout, tElement);
if (row is null)
{
Console.WriteLine($"[socialprobe] {name} template 0x{tLayout:X8}/0x{tElement:X8} IMPORT NULL");
continue;
}
Console.WriteLine($"[socialprobe] {name} template:");
DumpInfoTree(row, 1, maxDepth: 3);
}
}
private static void DumpInfoTree(ElementInfo info, int depth, int maxDepth)
{
if (depth > maxDepth) return;
string templates = info.TemplateList.Count > 0
? $" templates={info.TemplateList.Count}[{string.Join(",", info.TemplateList.ConvertAll(t => $"0x{t.TemplateLayoutId:X8}/0x{t.TemplateElementId:X8}"))}] scrollbar=0x{info.ScrollbarElementId:X8}"
: "";
Console.WriteLine(
$"[socialprobe] {new string(' ', depth * 2)}0x{info.Id:X8} type=0x{info.Type:X8} "
+ $"({info.X},{info.Y} {info.Width}x{info.Height}) kids={info.Children.Count}{templates}");
foreach (ElementInfo c in info.Children)
DumpInfoTree(c, depth + 1, maxDepth);
}
private static int CountDescendants(UiElement root, uint id)
{
int count = root.DatElementId == id ? 1 : 0;
foreach (UiElement child in root.Children)
count += CountDescendants(child, id);
return count;
}
private static ElementInfo? FindInfo(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo c in root.Children)
{
ElementInfo? found = FindInfo(c, id);
if (found is not null) return found;
}
return null;
}
}