acdream/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLayoutConformanceTests.cs
Erik df9c7a35eb feat(ui): Campaign OP slice OP2 — tab control, template ListBox, UIOption widget mappings
Ships the two new widget primitives the retail Options panel needs plus the
four remaining UIOption_* factory mappings, so every tab page (OP3-OP6) has
somewhere to mount.

- ElementReader/ElementInfo gain three new dat-property readers, following
  the existing effective-state-resolution pattern (never a per-state
  first-wins scan, per the round-5 N1 lesson): the Type-8 tab table
  (property 0x2E -> TabTable), a ListBox's row-template list (property
  0x64 -> TemplateList), and scrollbar linkage (property 0x72 ->
  ScrollbarElementId). LayoutImporter gains one hook
  (IUiChildrenAttachedListener) so a widget can resolve cross-references
  its own dat properties name by id once its subtree actually exists.

- UiTabControl (Type 8): switches exactly one page-slot child visible,
  syncs each tab button's Open/Closed state via the existing
  RetailTabBinding helper, and honors the authored default tab on mount.

- UiTemplateListBox (Type 5 with an authored template list): wraps a
  UiScrollablePanel viewport (sealed, so composition not inheritance) and
  ports AddItemFromTemplateList(index) — the resolver seam a page
  controller wires with real DAT access via the SAME
  LayoutImporter.ImportInfos(dats, layoutId, elementId) overload
  RetailDialogFactory already uses for its catalog LayoutDesc.

- DatWidgetFactory maps the four remaining UIOption_* widgets, each
  verified against the regenerated options_2100002B.json fixture before
  writing any code: 0x10000037 (Slider) is structurally an ordinary
  horizontal UIElement_Scrollbar, so it reuses BuildScrollbar directly;
  0x10000038 (Menu) is structurally identical to the vendor category
  dropdown UiMenu already models, so it reuses `new UiMenu()` like the
  Type-6 case; 0x10000036 (CheckboxSlider) composes an existing
  UIOption_Checkbox child + UIOption_Slider child via the new
  UiOptionToggleSlider wrapper; 0x10000044 (CheckboxBitfield64) authors
  zero children in the dat (every row is added at runtime via retail's own
  AddChild(lowMask, highMask, label, tooltip) call shape), so it's a new
  UiCheckboxBitfield64 composing UiButton per row. No new drawing code
  anywhere in this set.

- Five new committed fixtures (options_2100002B/2100002A/21000028/
  2100005C/21000029) plus 25 new conformance tests pinning the tab table
  (4 entries, Gameplay default), all three template arrays, scrollbar
  linkage, every new widget-type mapping, and a UiTabControl behavioral
  test (switch -> exactly one page visible, click-through the tab
  button). The Character ListBox's authored 6-header/49-toggle shape
  (lane B section counts) is proven reachable end-to-end through
  AddItemFromTemplateList against the committed fixture.

- Regenerating fixtures also touched 27 PRE-EXISTING, unrelated fixtures
  (an Outline/OutlineColor field pair added by an earlier commit,
  bcc34ee3, that predates when those fixtures were last regenerated).
  Per the slice contract, that drift was NOT committed — reverted back to
  HEAD, only the five new Options-panel fixtures are new files here.

- Filed TS-72: UiCheckboxBitfield64's click-toggle bit math (AND/OR
  set/clear semantics) is a documented approximation — the decompiled
  excerpt this campaign pulled covers UIOption_CheckboxBitfield64::Apply's
  WRITE side, not its own click-handler's bit math. Flagged for OP5 (the
  Chat tab controller, the first consumer that reaches the wire) to
  verify against the real decomp before any live transaction depends on
  it; nothing user-reachable can observe this yet.

Full Release suite: 12,770 passed / 4 skipped / 0 failed (was 12,745/4/0
post-OP1 — 25 net new tests, zero regressions).

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

440 lines
19 KiB
C#

using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Dat-free conformance tests for the committed <c>options_2100002B.json</c> /
/// <c>options_gameplay_2100002A.json</c> / <c>options_character_21000028.json</c> /
/// <c>options_chat_2100005C.json</c> / <c>options_config_21000029.json</c> golden
/// fixtures (Campaign OP slice OP2). Pins the tab table (dat property <c>0x2E</c>),
/// the three ListBox row-template lists (dat property <c>0x64</c>), scrollbar
/// linkage (dat property <c>0x72</c>), the new Type-8/5/<c>0x10000036..0x44</c>
/// widget mappings, and <see cref="UiTabControl"/>'s switch behavior. Retail anchors:
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.3, §1.5, §10.1.
/// </summary>
public class OptionsPanelLayoutConformanceTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children)
{
var f = Find(c, id);
if (f is not null) return f;
}
return null;
}
// ── Tab table (property 0x2E) ───────────────────────────────────────────
[Fact]
public void OptionsPanelFixture_TabControl_HasFourEntriesInAuthoredOrder_GameplayDefault()
{
var root = FixtureLoader.LoadOptionsPanelInfos();
var tabControl = Find(root, 0x10000208u);
Assert.NotNull(tabControl);
Assert.Equal(8u, tabControl!.Type);
var tabs = tabControl.TabTable;
Assert.Equal(4, tabs.Count);
Assert.Equal(new UiTabTableEntry(0x1000020Du, 0x10000212u, true), tabs[0]); // Gameplay Options (default)
Assert.Equal(new UiTabTableEntry(0x1000020Eu, 0x10000211u, false), tabs[1]); // Character
Assert.Equal(new UiTabTableEntry(0x1000050Bu, 0x1000050Cu, false), tabs[2]); // Chat
Assert.Equal(new UiTabTableEntry(0x1000020Fu, 0x10000213u, false), tabs[3]); // Config
Assert.Single(tabs, t => t.IsDefault);
}
[Fact]
public void OptionsPanelFixture_TabControl_BuildsAsUiTabControl_WithSameTabTable()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
Assert.Equal(4, tabControl.Tabs.Count);
Assert.True(tabControl.Tabs[0].IsDefault);
Assert.Equal(0x10000212u, tabControl.Tabs[0].PageElementId);
}
// ── Row-template lists (property 0x64) ──────────────────────────────────
[Fact]
public void CharacterListBoxFixture_TemplateList_MatchesThreeRowTemplates()
{
var root = FixtureLoader.LoadOptionsCharacterInfos();
var listBox = Find(root, 0x100001FAu);
Assert.NotNull(listBox);
Assert.Equal(5u, listBox!.Type);
var templates = listBox.TemplateList;
Assert.Equal(3, templates.Count);
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000216u), templates[0]); // header
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000217u), templates[1]); // separator
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000218u), templates[2]); // toggle
}
[Fact]
public void ConfigListBoxFixture_TemplateList_MatchesEightRowTemplates()
{
var root = FixtureLoader.LoadOptionsConfigInfos();
var listBox = Find(root, 0x10000200u);
Assert.NotNull(listBox);
var templates = listBox!.TemplateList;
Assert.Equal(8, templates.Count);
uint[] expectedElementIds =
{
0x10000216u, 0x10000217u, 0x10000218u, 0x1000021Au,
0x10000222u, 0x10000220u, 0x1000021Du, 0x10000221u,
};
for (int i = 0; i < expectedElementIds.Length; i++)
{
Assert.Equal(0x2100002Bu, templates[i].TemplateLayoutId);
Assert.Equal(expectedElementIds[i], templates[i].TemplateElementId);
}
}
[Fact]
public void ChatListBoxFixture_TemplateList_MatchesNineRowTemplatesInclBitfield64()
{
var root = FixtureLoader.LoadOptionsChatInfos();
var listBox = Find(root, 0x1000050Du);
Assert.NotNull(listBox);
var templates = listBox!.TemplateList;
Assert.Equal(9, templates.Count);
uint[] expectedElementIds =
{
0x10000216u, 0x10000217u, 0x10000218u, 0x1000021Au, 0x10000222u,
0x10000220u, 0x1000021Du, 0x10000221u, 0x10000520u, // bitfield64
};
for (int i = 0; i < expectedElementIds.Length; i++)
{
Assert.Equal(0x2100002Bu, templates[i].TemplateLayoutId);
Assert.Equal(expectedElementIds[i], templates[i].TemplateElementId);
}
}
// ── Scrollbar linkage (property 0x72) ───────────────────────────────────
[Fact]
public void CharacterListBoxFixture_ScrollbarLinkage_Names0x100001FB()
{
var root = FixtureLoader.LoadOptionsCharacterInfos();
var listBox = Find(root, 0x100001FAu);
Assert.Equal(0x100001FBu, listBox!.ScrollbarElementId);
}
[Fact]
public void ConfigListBoxFixture_ScrollbarLinkage_Names0x10000201()
{
var root = FixtureLoader.LoadOptionsConfigInfos();
var listBox = Find(root, 0x10000200u);
Assert.Equal(0x10000201u, listBox!.ScrollbarElementId);
}
[Fact]
public void ChatListBoxFixture_ScrollbarLinkage_SharesElement0x10000201WithConfig()
{
// Research doc §10.1: Config's and Chat's ListBoxes both name the SAME
// scrollbar element id — the two tabs share one gutter widget authored twice.
var root = FixtureLoader.LoadOptionsChatInfos();
var listBox = Find(root, 0x1000050Du);
Assert.Equal(0x10000201u, listBox!.ScrollbarElementId);
}
// ── Built-widget mapping: ListBox → UiTemplateListBox ───────────────────
[Fact]
public void OptionsPanelFixture_CharacterListBox_BuildsAsUiTemplateListBoxWithSameData()
{
var layout = FixtureLoader.LoadOptionsPanel();
var listBox = Assert.IsType<UiTemplateListBox>(layout.FindElement(0x100001FAu));
Assert.Equal(3, listBox.Templates.Count);
Assert.Equal(0x100001FBu, listBox.ScrollbarElementId);
}
[Fact]
public void OptionsPanelFixture_CharacterScrollbar_StillBuildsAsUiScrollbar()
{
// Regression guard: a real scrollbar sibling (Type 11) must keep building
// through the EXISTING BuildScrollbar mapping — OP2 must not disturb it.
var layout = FixtureLoader.LoadOptionsPanel();
Assert.IsType<UiScrollbar>(layout.FindElement(0x100001FBu));
}
// ── The four remaining UIOption_* widget mappings ───────────────────────
[Fact]
public void OptionsPanelFixture_SliderControl_BuildsAsHorizontalUiScrollbar()
{
// UIOption_Slider (0x10000037), template element 0x1000021C: 120x12,
// W>H so BuildScrollbar's existing horizontal branch applies, with the
// child-id-1 thumb convention it already implements.
var layout = FixtureLoader.LoadOptionsPanel();
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(0x1000021Cu));
Assert.True(slider.Horizontal);
Assert.NotEqual(0u, slider.ThumbSprite);
}
[Fact]
public void OptionsPanelFixture_MenuControl_BuildsAsUiMenu()
{
// UIOption_Menu (0x10000038), template element 0x10000224.
var layout = FixtureLoader.LoadOptionsPanel();
Assert.IsType<UiMenu>(layout.FindElement(0x10000224u));
}
[Fact]
public void OptionsPanelFixture_ToggleSliderRow_ComposesCheckboxAndSlider()
{
// UIOption_CheckboxSlider (0x10000036), unlabelled row template 0x10000220:
// composes an existing UIOption_Checkbox (0x10000219 -> UiButton) and an
// existing UIOption_Slider (0x1000021C -> UiScrollbar) — no new drawing code.
// (0x10000219/0x1000021C are reused verbatim across every checkbox/slider
// template instance, so this asserts identity against row.Children directly
// rather than layout.FindElement — which, like CharacterStatController's own
// documented caveat, returns only the LAST of several same-id duplicates.)
var layout = FixtureLoader.LoadOptionsPanel();
var row = Assert.IsType<UiOptionToggleSlider>(layout.FindElement(0x10000220u));
Assert.NotNull(row.Toggle);
Assert.NotNull(row.Slider);
Assert.Contains(row.Toggle, row.Children);
Assert.Contains(row.Slider, row.Children);
Assert.Equal(0x10000219u, row.Toggle!.DatElementId);
Assert.Equal(0x1000021Cu, row.Slider!.DatElementId);
}
[Fact]
public void OptionsPanelFixture_LabelledToggleSliderRow_AlsoComposesCheckboxAndSlider()
{
// Labelled variant 0x10000221 (four children: checkbox + slider + two range
// labels) — the two extra Text children must not confuse the composition.
var layout = FixtureLoader.LoadOptionsPanel();
var row = Assert.IsType<UiOptionToggleSlider>(layout.FindElement(0x10000221u));
Assert.NotNull(row.Toggle);
Assert.NotNull(row.Slider);
}
[Fact]
public void OptionsPanelFixture_Bitfield64Template_BuildsAsEmptyUiCheckboxBitfield64()
{
// UIOption_CheckboxBitfield64 (0x10000044), template element 0x10000520:
// authors zero children/media in the dat — every row is added at runtime.
var layout = FixtureLoader.LoadOptionsPanel();
var bitfield = Assert.IsType<UiCheckboxBitfield64>(layout.FindElement(0x10000520u));
Assert.Empty(bitfield.Rows);
}
[Fact]
public void OptionsPanelFixture_LabelledSliderRow_StillPlainContainer()
{
// Template idx 6 (0x1000021D) is the LABELLED slider (label + slider + two
// range labels) but its OWN root Type is 3 (plain container), not
// UIOption_CheckboxSlider — distinguishing this from 0x10000221 is exactly
// what makes the mapping data-driven rather than guessed.
var layout = FixtureLoader.LoadOptionsPanel();
var row = layout.FindElement(0x1000021Du);
Assert.IsNotType<UiOptionToggleSlider>(row);
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(0x1000021Cu));
Assert.NotNull(slider);
}
// ── UiCheckboxBitfield64 behavior (structural, click semantics flagged) ──
[Fact]
public void UiCheckboxBitfield64_AddChild_TracksMaskAndTogglesOnClick()
{
var bitfield = new UiCheckboxBitfield64 { Width = 272f };
bitfield.SetDefaultValue(0x00000000_FBFFFFFFul, 0ul); // retail main-window default (research §5.2)
// "Error" (mask 0x04000000) is the ONE bit clear in the FBFFFFFF default —
// research §5.2's own cross-check ("Society bit opt-in") confirms every
// other row's bits are already set in that default, so Error is the only
// mask that starts observably OFF and round-trips cleanly through OR/AND-NOT.
var row = bitfield.AddChild(0x00000000_04000000ul, 0ul, "Error");
Assert.False(row.Selected);
ulong? changedLow = null;
bitfield.ValueChanged += (low, _) => changedLow = low;
row.OnClick!.Invoke();
Assert.True(row.Selected);
Assert.Equal(0x00000000_FFFFFFFFul, changedLow); // FBFFFFFF | 04000000 == FFFFFFFF
row.OnClick!.Invoke();
Assert.False(row.Selected);
Assert.Equal(0x00000000_FBFFFFFFul, changedLow); // back to the seeded default
}
// ── The template mechanism, exercised end-to-end ────────────────────────
/// <summary>
/// Proves the Character ListBox's authored structure (6 headers + 49 toggles,
/// research doc §7 / §10.1) is REACHABLE through
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> — each call resolves
/// its template through the SAME <c>LayoutImporter.Build</c> machinery a real
/// controller would use, sourced from the committed <c>options_2100002B.json</c>
/// fixture (the templates' one true home).
/// </summary>
[Fact]
public void CharacterListBox_TemplateMechanism_ReachesSixHeadersAndFortyNineToggles()
{
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
UiElement? Resolve(uint layoutId, uint elementId)
{
if (layoutId != 0x2100002Bu) return null;
ElementInfo? templateInfo = Find(panelRoot, elementId);
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
}
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
var listBox = new UiTemplateListBox(characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
{
TemplateResolver = Resolve,
};
// Lane B §7 authored section row counts (docs/research/2026-08-10-options-panel-structure.md):
// User Interface Behavior=3, User Interface Display=15, Grouping=6,
// Other Players=11, Character Behavior=7, Chat=7 -> 49 toggles under 6 headers.
int[] sectionToggleCounts = { 3, 15, 6, 11, 7, 7 };
int headerCalls = 0, toggleCalls = 0;
foreach (int toggleCount in sectionToggleCounts)
{
var header = listBox.AddItemFromTemplateList(0); // header template
Assert.IsType<UiText>(header);
headerCalls++;
for (int i = 0; i < toggleCount; i++)
{
var toggleRow = listBox.AddItemFromTemplateList(2); // toggle template
Assert.NotNull(toggleRow);
// Toggle template 0x10000218 is a plain Type-3 container wrapping ONE
// UIOption_Checkbox child — proves the SAME BuildCheckbox mapping
// fires through the template mechanism, not just for authored trees.
Assert.IsType<UiButton>(Assert.Single(toggleRow!.Children));
toggleCalls++;
}
}
Assert.Equal(6, headerCalls);
Assert.Equal(49, toggleCalls);
// 6 headers @22px + 49 toggles @20px (template heights 0x10000216/0x10000218).
Assert.Equal(6 * 22 + 49 * 20, listBox.ContentHeight);
}
[Fact]
public void TemplateListBox_AddItemFromTemplateList_WithoutResolver_ReturnsNull()
{
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
var listBox = new UiTemplateListBox(
characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId);
// No TemplateResolver wired — matches DatWidgetFactory's own construction
// (OP2 ships the mechanism; a page controller supplies the resolver).
Assert.Null(listBox.AddItemFromTemplateList(0));
}
[Fact]
public void TemplateListBox_AddItemFromTemplateList_OutOfRangeIndex_ReturnsNull()
{
var listBox = new UiTemplateListBox(new[] { new UiTemplateListEntry(1u, 2u) }, 0u)
{
TemplateResolver = (_, _) => new UiDatElement(new ElementInfo(), NoTex),
};
Assert.Null(listBox.AddItemFromTemplateList(-1));
Assert.Null(listBox.AddItemFromTemplateList(1));
Assert.NotNull(listBox.AddItemFromTemplateList(0));
}
// ── UiTabControl behavior ────────────────────────────────────────────────
[Fact]
public void UiTabControl_DefaultMount_ShowsOnlyTheGameplayPage()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
Assert.Equal(0x10000212u, tabControl.ActivePageElementId); // Gameplay page slot
UiElement gameplayPage = layout.FindElement(0x10000212u)!;
UiElement characterPage = layout.FindElement(0x10000211u)!;
UiElement chatPage = layout.FindElement(0x1000050Cu)!;
UiElement configPage = layout.FindElement(0x10000213u)!;
Assert.True(gameplayPage.Visible);
Assert.False(characterPage.Visible);
Assert.False(chatPage.Visible);
Assert.False(configPage.Visible);
}
[Fact]
public void UiTabControl_SwitchTo_ShowsExactlyOnePage()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
UiElement gameplayPage = layout.FindElement(0x10000212u)!;
UiElement characterPage = layout.FindElement(0x10000211u)!;
UiElement chatPage = layout.FindElement(0x1000050Cu)!;
UiElement configPage = layout.FindElement(0x10000213u)!;
var visiblePages = new[] { gameplayPage, characterPage, chatPage, configPage }
.Where(p => p.Visible)
.ToList();
Assert.Single(visiblePages);
Assert.Same(chatPage, visiblePages[0]);
Assert.Equal(0x1000050Cu, tabControl.ActivePageElementId);
}
[Fact]
public void UiTabControl_SwitchTo_UpdatesTabButtonOpenClosedState()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
var gameplayButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Du));
var characterButton = Assert.IsType<UiText>(layout.FindElement(0x1000020Eu));
Assert.Equal(RetailUiStateIds.Open, gameplayButton.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, characterButton.ActiveRetailStateId);
tabControl.SwitchTo(0x10000211u); // Character page slot
Assert.Equal(RetailUiStateIds.Closed, gameplayButton.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, characterButton.ActiveRetailStateId);
}
[Fact]
public void UiTabControl_ClickingTabButton_SwitchesActivePage()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
var characterButton = layout.FindElement(0x1000020Eu)!;
characterButton.OnEvent(new UiEvent(0, characterButton, UiEventType.Click));
Assert.Equal(0x10000211u, tabControl.ActivePageElementId);
Assert.True(layout.FindElement(0x10000211u)!.Visible);
Assert.False(layout.FindElement(0x10000212u)!.Visible);
}
[Fact]
public void UiTabControl_SwitchToSameActivePage_IsANoOp()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabControl>(layout.FindElement(0x10000208u));
uint before = tabControl.ActivePageElementId;
tabControl.SwitchTo(before);
Assert.Equal(before, tabControl.ActivePageElementId);
Assert.True(layout.FindElement(before)!.Visible);
}
}