SocialPanelLiveMountProbeTests wrote the four checkbox labels and the two
Open/Close captions to the console with no assertion, yet the FA4 ledger's
live-DAT paragraph cited them as verified -- the same finding FA3's own
mechanism SF-3 raised for a different table ("printed but never asserted
-- deserves a real assertion, not just a hope"). Now asserts each label is
non-null/non-empty and the two captions equal the exact retail strings
"Open"/"Close". Env-gated (ACDREAM_PROBE_LIVE_MOUNT=1, real installed
DATs) -- inert in this session's build/test run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
368 lines
18 KiB
C#
368 lines
18 KiB
C#
using System.IO;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Runtime;
|
|
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);
|
|
}
|
|
|
|
// Campaign FA slice FA4, item 8: the fellowship page's own control
|
|
// inventory against the PRODUCTION mount (not a fixture) — the
|
|
// name-entry field must build as UiField (proving the DAT authors
|
|
// Editable=1, SocialFellowshipPageController's load-bearing
|
|
// assumption), and every button/checkbox must resolve as UiButton.
|
|
UiElement? nameField = UiElement.FindDescendant(tabs, 0x1000026Fu);
|
|
Console.WriteLine(
|
|
$"[socialprobe] fellowship name-entry field 0x1000026F -> {(nameField is null ? "MISSING" : nameField.GetType().Name)}");
|
|
Assert.IsType<UiField>(nameField);
|
|
|
|
foreach ((uint id, string name) in new[]
|
|
{
|
|
(0x10000274u, "CreateFellowshipButton"),
|
|
(0x1000027Bu, "FellowLeaderButton"),
|
|
(0x1000027Cu, "FellowQuitButton"),
|
|
(0x1000027Du, "FellowOpenButton"),
|
|
(0x1000027Eu, "FellowRecruitButton"),
|
|
(0x1000027Fu, "FellowDismissButton"),
|
|
(0x10000280u, "FellowDisbandButton"),
|
|
(0x10000270u, "IgnoreFellowshipRequestsCheckbox"),
|
|
(0x10000271u, "FellowshipAutoAcceptRequestsCheckbox"),
|
|
(0x10000272u, "FellowshipShareXPCheckbox"),
|
|
(0x10000273u, "FellowshipShareLootCheckbox"),
|
|
})
|
|
{
|
|
UiElement? el = UiElement.FindDescendant(tabs, id);
|
|
Console.WriteLine($"[socialprobe] fellowship control {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
|
Assert.IsType<UiButton>(el);
|
|
}
|
|
|
|
// The fellowship ListBox's own authored template pair (lane A U10 —
|
|
// read P0x64 on 0x10000279) and its scrollbar (P0x72).
|
|
UiElement? fellowshipListBoxEl = UiElement.FindDescendant(tabs, 0x10000279u);
|
|
UiTemplateListBox fellowshipListBox = Assert.IsType<UiTemplateListBox>(fellowshipListBoxEl);
|
|
Console.WriteLine(
|
|
$"[socialprobe] fellowship ListBox 0x10000279 templates={fellowshipListBox.Templates.Count} "
|
|
+ $"scrollbar=0x{fellowshipListBox.ScrollbarElementId:X8}");
|
|
Assert.NotEmpty(fellowshipListBox.Templates);
|
|
UiTemplateListEntry fellowRowTemplate = fellowshipListBox.Templates[0];
|
|
|
|
// Production-resolver build of the row template (FA4 carry-forward
|
|
// 2): the SAME RowTemplateResolver shape MountSocialPanel uses in
|
|
// production — Import then Build against the LIVE dats, not a
|
|
// fixture — proving the 8-part row (lane A §3.1) actually resolves
|
|
// end-to-end, not merely that the id pair is authored.
|
|
var rowTemplates = new RowTemplateResolver(
|
|
(layoutId, elementId) => LayoutImporter.ImportInfos(dats, layoutId, elementId),
|
|
info => LayoutImporter.Build(info, _ => (1u, 8, 8), null, null, strings.Resolve).Root);
|
|
UiElement? fellowRow = rowTemplates.Resolve(
|
|
fellowRowTemplate.TemplateLayoutId, fellowRowTemplate.TemplateElementId);
|
|
Console.WriteLine(
|
|
$"[socialprobe] fellowship row template 0x{fellowRowTemplate.TemplateLayoutId:X8}/"
|
|
+ $"0x{fellowRowTemplate.TemplateElementId:X8} -> {(fellowRow is null ? "IMPORT NULL" : fellowRow.GetType().Name)}");
|
|
Assert.NotNull(fellowRow);
|
|
|
|
foreach ((uint id, string name, Type expectedType) in new (uint, string, Type)[]
|
|
{
|
|
(0x10000283u, "FellowName", typeof(UiText)),
|
|
(0x10000284u, "FellowStats", typeof(UiText)),
|
|
(0x10000285u, "HealthMeter", typeof(UiMeter)),
|
|
(0x10000287u, "StaminaMeter", typeof(UiMeter)),
|
|
(0x10000289u, "ManaMeter", typeof(UiMeter)),
|
|
})
|
|
{
|
|
UiElement? el = UiElement.FindDescendant(fellowRow!, id);
|
|
Console.WriteLine($"[socialprobe] fellowship row field {name} 0x{id:X8} -> {(el is null ? "MISSING" : el.GetType().Name)}");
|
|
Assert.IsType(expectedType, el);
|
|
}
|
|
|
|
// Checkbox labels/tooltips (0x23000003) and the Open/Close caption
|
|
// pair (0x23000001) — the two string tables SocialFellowshipPageController
|
|
// resolves at Bind time (lane A §4.3). Fix-round SF-6: these were
|
|
// previously PRINTED but never asserted even though the ledger's
|
|
// live-DAT paragraph cited them as verified — the same finding
|
|
// FA3's own mechanism SF-3 raised for a different table.
|
|
foreach (string retailName in new[]
|
|
{
|
|
"IgnoreFellowshipRequests", "FellowshipAutoAcceptRequests",
|
|
"FellowshipShareXP", "FellowshipShareLoot",
|
|
})
|
|
{
|
|
string? label = strings.Resolve(0x23000003u, DatStringResolver.ComputeHash($"ID_PlayerOption_{retailName}"));
|
|
Console.WriteLine($"[socialprobe] checkbox label ID_PlayerOption_{retailName} -> '{label}'");
|
|
Assert.False(
|
|
string.IsNullOrEmpty(label),
|
|
$"checkbox label ID_PlayerOption_{retailName} (table 0x23000003) did not resolve.");
|
|
}
|
|
|
|
string? openLabel = strings.Resolve(
|
|
0x23000001u, DatStringResolver.ComputeHash("ID_Fellowship_OpenFellowshipButtonText"));
|
|
string? closeLabel = strings.Resolve(
|
|
0x23000001u, DatStringResolver.ComputeHash("ID_Fellowship_CloseFellowshipButtonText"));
|
|
Console.WriteLine($"[socialprobe] fellowship caption ID_Fellowship_OpenFellowshipButtonText -> '{openLabel}'");
|
|
Console.WriteLine($"[socialprobe] fellowship caption ID_Fellowship_CloseFellowshipButtonText -> '{closeLabel}'");
|
|
Assert.Equal("Open", openLabel);
|
|
Assert.Equal("Close", closeLabel);
|
|
|
|
// Full end-to-end Bind against the PRODUCTION mount (the same
|
|
// resolver/scrollbar/frame wiring MountSocialPanel does) — proves
|
|
// Bind() finds every element it needs with zero "not found"
|
|
// console warnings, not just that the individual ids resolve in
|
|
// isolation above.
|
|
UiElement? fellowshipPageForBind = UiElement.FindDescendant(tabs, 0x10000292u);
|
|
Assert.NotNull(fellowshipPageForBind);
|
|
var originalOut = Console.Out;
|
|
var capture = new StringWriter();
|
|
Console.SetOut(capture);
|
|
SocialFellowshipPageController? fellowshipController;
|
|
try
|
|
{
|
|
fellowshipController = SocialFellowshipPageController.Bind(
|
|
fellowshipPageForBind!,
|
|
new SocialFellowshipPageController.Bindings(
|
|
Snapshot: () => new RuntimeFellowshipSnapshot(),
|
|
Members: () => [],
|
|
TemplateResolver: rowTemplates.Resolve,
|
|
Create: (_, _) => default,
|
|
Recruit: _ => default,
|
|
Dismiss: _ => default,
|
|
Quit: _ => default,
|
|
AssignLeader: _ => default,
|
|
SetOpen: _ => default,
|
|
SetPanelOpen: _ => default,
|
|
Selection: new AcDream.Core.Selection.SelectionState(),
|
|
LocalPlayerGuid: () => 0u,
|
|
CurrentCharacterOption: _ => false,
|
|
SetCharacterOption: (_, _) => { },
|
|
ResolveString: (tableId, stringId) => strings.Resolve(tableId, stringId)));
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
}
|
|
string bindLog = capture.ToString();
|
|
Console.WriteLine($"[socialprobe] fellowship Bind() console output:\n{bindLog}");
|
|
Assert.NotNull(fellowshipController);
|
|
Assert.DoesNotContain("not found", bindLog);
|
|
|
|
// 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;
|
|
}
|
|
}
|