Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and OP3's per-page OptionPage model: the General Options header + two DualHash-linked opacity sliders (Option_DefaultOpacity_Property 0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on drag through RetailWindowOpacityController, defaults read from the installed DAT's DBProperties collection at DID 0x78000001 via ChatOptionsDatDefaults), and the five per-window text-filter blocks (main window 12 rows minus Gameplay, four floaties 13 rows each — the byte-verified authored order cross-checked against the raw gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not just the research doc's own table) writing AcDream.Core.Chat. ChatWindowState directly, the same state CH6's chat windows already read. AP-195 retired: ported both halves left open at the OP2 re-review — the ALL-set LED media swap (new UiButton.FaceFileOverride, driven by the block-level P0x10000082/P0x10000083 sprites now threaded through ElementInfo/DatWidgetFactory) and the CreateChildren self-sizing tail (UiCheckboxBitfield64.Height grows with its stacked row content; the enclosing ListBox reflows around the block's FINAL height via the new UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking rather than a third stacking path). AP-187 broadened to cover the main window's own filter (previously only the four floaties) and the new live-editing write path. The main chat window's filter (retail window id 8, ChatWindowState id 0) gains its own settings.json persistence (ChatSettings. ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields; opacity persistence is now wired on every live slider change, not only through the old dev-scaffold Settings panel. Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all 19 committed layout fixtures — purely additive, confirmed against the live installed DAT (0x10000520's own 0x82/0x83 properties resolve to 0x06004D17/0x06004D19 exactly as AP-195 documented). Conformance: FilterRows/FilterBlocks pinned against the byte-verified authored order and ChatWindowState's own default constants; the AP-195 LED swap and self-sizing behavior; the DAT opacity-default extraction against the live installed DAT; live filter/opacity writes reaching ChatWindowState/RetailWindowOpacityController; OnShown re-seed and Reset/Defaults ghosting per the OP4 binding-pattern discipline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
808 lines
35 KiB
C#
808 lines
35 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, reworked 2026-08-11). 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="UiTabPanel"/>'s switch
|
|
/// behavior. Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
|
/// §1.3, §1.5, §10.1. See also <c>OP2ReworkBlastRadiusConformanceTests.cs</c> (built-
|
|
/// widget pins for the pre-existing shipped panels the rework's dormancy model
|
|
/// protects) and the reader-level tests appended to <c>ElementReaderTests.cs</c>.
|
|
/// </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_BuildsAsUiTabPanel_WithSameTabTable()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
Assert.Equal(4, tabControl.Tabs.Count);
|
|
Assert.True(tabControl.Tabs[0].IsDefault);
|
|
Assert.Equal(0x10000212u, tabControl.Tabs[0].PageElementId);
|
|
|
|
// OP2 rework: dormant until a controller opts in — importing alone must not
|
|
// have switched anything (docs/research/2026-08-11-op2-review-blast.md).
|
|
Assert.False(tabControl.BehaviorActive);
|
|
Assert.Equal(0u, tabControl.ActivePageElementId);
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
// OP2 rework: dormant until a controller adds its first row — importing alone
|
|
// must not have injected a viewport child (docs/research/2026-08-11-op2-review-
|
|
// blast.md SHOULD-FIX 1).
|
|
Assert.Empty(listBox.Children);
|
|
Assert.Equal(0, listBox.ContentHeight);
|
|
}
|
|
|
|
[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 of its OWN, but DOES author its own row-template
|
|
// list (dat property 0x64 -> {0x2100002B, 0x10000521}) — every row is built
|
|
// from THAT template at runtime (OP2 rework,
|
|
// docs/research/2026-08-11-op2-review-mechanism.md MUST-FIX 4).
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var bitfield = Assert.IsType<UiCheckboxBitfield64>(layout.FindElement(0x10000520u));
|
|
Assert.Empty(bitfield.Rows);
|
|
UiTemplateListEntry template = Assert.Single(bitfield.Templates);
|
|
Assert.Equal(new UiTemplateListEntry(0x2100002Bu, 0x10000521u), template);
|
|
}
|
|
|
|
[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 (rows built from the authored template) ──
|
|
|
|
/// <summary>Builds a hermetic <see cref="UiCheckboxBitfield64"/> whose
|
|
/// <see cref="UiCheckboxBitfield64.TemplateResolver"/> constructs a minimal subtree
|
|
/// containing ONLY the checkbox retail's own CreateChildren looks for
|
|
/// (<see cref="UiCheckboxBitfield64.TemplateCheckboxElementId"/>) — proves the
|
|
/// widget resolves and stamps that exact id rather than assuming row shape.</summary>
|
|
private static UiCheckboxBitfield64 NewBitfieldWithStubTemplate(
|
|
uint checkedLedSprite = 0u, uint uncheckedLedSprite = 0u)
|
|
{
|
|
var templates = new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000521u) };
|
|
return new UiCheckboxBitfield64(templates, checkedLedSprite, uncheckedLedSprite)
|
|
{
|
|
Width = 272f,
|
|
TemplateResolver = (layoutId, elementId) =>
|
|
{
|
|
Assert.Equal(0x2100002Bu, layoutId);
|
|
Assert.Equal(0x10000521u, elementId);
|
|
var checkboxInfo = new ElementInfo
|
|
{
|
|
Id = UiCheckboxBitfield64.TemplateCheckboxElementId,
|
|
Type = 1u,
|
|
Width = 260f,
|
|
Height = 14f,
|
|
};
|
|
return LayoutImporter.Build(checkboxInfo, NoTex, null).Root;
|
|
},
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public void UiCheckboxBitfield64_AddChild_ResolvesRowFromAuthoredTemplate()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate();
|
|
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.
|
|
UiButton? row = bitfield.AddChild(0x00000000_04000000ul, 0ul, "Error");
|
|
Assert.NotNull(row);
|
|
Assert.Equal(UiCheckboxBitfield64.TemplateCheckboxElementId, row!.DatElementId);
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// MUST-FIX 3a (`docs/research/2026-08-11-op2-review-mechanism.md`): retail
|
|
/// <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c> checks a row when ANY
|
|
/// mask bit is set, not when EVERY mask bit is set. A single-bit mask cannot
|
|
/// distinguish the two predicates — this seeds a value with only ONE of a
|
|
/// TWO-bit mask's bits set (Combat's real mask <c>0x00600040</c>, research §5.2)
|
|
/// and asserts the row still reads checked.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UiCheckboxBitfield64_MultiBitMask_IsSet_UsesAnyBitNotAllBits()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate();
|
|
const ulong combatMask = 0x00000000_00600040ul; // two bits
|
|
const ulong onlyOneBitOfMask = 0x00000000_00000040ul; // partially overlapping
|
|
bitfield.SetDefaultValue(onlyOneBitOfMask, 0ul);
|
|
|
|
UiButton? row = bitfield.AddChild(combatMask, 0ul, "Combat");
|
|
Assert.NotNull(row);
|
|
Assert.True(row!.Selected); // any-bit predicate: 0x40 & 0x600040 != 0
|
|
|
|
// Clicking now must turn the row OFF (it currently reads checked) and clear
|
|
// BOTH mask bits, matching retail's confirmed SetBitsOnOrOff AND-NOT.
|
|
ulong? changedLow = null;
|
|
bitfield.ValueChanged += (low, _) => changedLow = low;
|
|
row.OnClick!.Invoke();
|
|
Assert.False(row.Selected);
|
|
Assert.Equal(0ul, changedLow);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiCheckboxBitfield64_AddChild_NoTemplateResolverWired_ReturnsNullAndLeavesNoRow()
|
|
{
|
|
var templates = new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000521u) };
|
|
var bitfield = new UiCheckboxBitfield64(templates) { Width = 272f };
|
|
// No TemplateResolver wired — matches DatWidgetFactory's own construction
|
|
// (OP2 ships the mechanism; a page controller supplies the resolver).
|
|
Assert.Null(bitfield.AddChild(0x1ul, 0ul, "Unwired"));
|
|
Assert.Empty(bitfield.Rows);
|
|
}
|
|
|
|
// ── AP-195: LED media swap + self-sizing (Campaign OP slice OP5) ────────
|
|
|
|
private const uint CheckedLedDid = 0x06004D17u;
|
|
private const uint UncheckedLedDid = 0x06004D19u;
|
|
|
|
[Fact]
|
|
public void AddChild_AllMaskBitsSet_AppliesCheckedLedSprite()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate(CheckedLedDid, UncheckedLedDid);
|
|
const ulong combatMask = 0x00000000_00600040ul; // two bits
|
|
bitfield.SetDefaultValue(combatMask, 0ul); // BOTH bits already set
|
|
|
|
UiButton? row = bitfield.AddChild(combatMask, 0ul, "Combat");
|
|
|
|
Assert.NotNull(row);
|
|
Assert.True(row!.Selected);
|
|
Assert.Equal(CheckedLedDid, row.FaceFileOverride);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_PartialMaskBitsSet_AppliesUncheckedLedSprite()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate(CheckedLedDid, UncheckedLedDid);
|
|
const ulong combatMask = 0x00000000_00600040ul; // two bits
|
|
const ulong onlyOneBitOfMask = 0x00000000_00000040ul;
|
|
bitfield.SetDefaultValue(onlyOneBitOfMask, 0ul); // checked (any-set) but not all-set
|
|
|
|
UiButton? row = bitfield.AddChild(combatMask, 0ul, "Combat");
|
|
|
|
Assert.NotNull(row);
|
|
Assert.True(row!.Selected); // any-set half unaffected by AP-195
|
|
Assert.Equal(UncheckedLedDid, row.FaceFileOverride);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_NoMaskBitsSet_LeavesLedOverrideUnset()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate(CheckedLedDid, UncheckedLedDid);
|
|
bitfield.SetDefaultValue(0ul, 0ul);
|
|
|
|
UiButton? row = bitfield.AddChild(0x00000000_00600040ul, 0ul, "Combat");
|
|
|
|
Assert.NotNull(row);
|
|
Assert.False(row!.Selected);
|
|
Assert.Null(row.FaceFileOverride); // retail's own gate: swap only applies while checked
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleRow_TogglingOff_ClearsLedOverride()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate(CheckedLedDid, UncheckedLedDid);
|
|
const ulong singleBitMask = 0x00000000_00000040ul;
|
|
bitfield.SetDefaultValue(singleBitMask, 0ul); // fully set -> checked LED
|
|
|
|
UiButton row = bitfield.AddChild(singleBitMask, 0ul, "Combat")!;
|
|
Assert.Equal(CheckedLedDid, row.FaceFileOverride);
|
|
|
|
row.OnClick!.Invoke(); // turns OFF (was fully set)
|
|
|
|
Assert.False(row.Selected);
|
|
Assert.Null(row.FaceFileOverride);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_MissingLedSprites_LeavesOverrideNull_EvenWhileChecked()
|
|
{
|
|
// Constructed WITHOUT checkedLedSprite/uncheckedLedSprite (0/0 default) —
|
|
// e.g. an authoring gap or a fixture that predates AP-195's properties.
|
|
var bitfield = NewBitfieldWithStubTemplate();
|
|
const ulong mask = 0x1ul;
|
|
bitfield.SetDefaultValue(mask, 0ul);
|
|
|
|
UiButton? row = bitfield.AddChild(mask, 0ul, "Row");
|
|
|
|
Assert.NotNull(row);
|
|
Assert.True(row!.Selected);
|
|
Assert.Null(row.FaceFileOverride);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_MultipleRows_SelfSizesHeightToStackedContent()
|
|
{
|
|
// AP-195's self-sizing tail: retail's ResizeTo(GetWidth(), CalculatePaperSize)
|
|
// — the block grows to fit N rows instead of keeping its authored 272x100
|
|
// extent. Each stub row template is 14px tall (NewBitfieldWithStubTemplate).
|
|
var bitfield = NewBitfieldWithStubTemplate();
|
|
bitfield.SetDefaultValue(0ul, 0ul);
|
|
Assert.Equal(0f, bitfield.Height);
|
|
|
|
bitfield.AddChild(0x1ul, 0ul, "Row1");
|
|
Assert.Equal(14f, bitfield.Height);
|
|
|
|
bitfield.AddChild(0x2ul, 0ul, "Row2");
|
|
bitfield.AddChild(0x4ul, 0ul, "Row3");
|
|
|
|
Assert.Equal(42f, bitfield.Height); // 3 * 14
|
|
Assert.Equal(272f, bitfield.Width); // Width (GetWidth()) is untouched
|
|
}
|
|
|
|
[Fact]
|
|
public void SetCurrentValue_PushesValueAndVisuals_WithoutFiringValueChanged()
|
|
{
|
|
var bitfield = NewBitfieldWithStubTemplate(CheckedLedDid, UncheckedLedDid);
|
|
const ulong mask = 0x1ul;
|
|
bitfield.SetDefaultValue(0ul, 0ul); // starts unset
|
|
UiButton row = bitfield.AddChild(mask, 0ul, "Row")!;
|
|
Assert.False(row.Selected);
|
|
|
|
bool valueChangedFired = false;
|
|
bitfield.ValueChanged += (_, _) => valueChangedFired = true;
|
|
|
|
bitfield.SetCurrentValue(mask, 0ul); // the OnShown/Reset/Defaults re-seed path
|
|
|
|
Assert.Equal(mask, bitfield.CurrentLow);
|
|
Assert.True(row.Selected);
|
|
Assert.Equal(CheckedLedDid, row.FaceFileOverride);
|
|
Assert.False(valueChangedFired); // a re-seed must never loop back as "user edited this"
|
|
}
|
|
|
|
// ── UiTemplateListBox.AddPrebuiltRow (AP-195's "reuse the ListBox's own
|
|
// stacking" disposition) ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void AddPrebuiltRow_StacksBelowPreviousContent_UsingTheRowsFinalHeight()
|
|
{
|
|
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
|
|
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
|
var listBox = new UiTemplateListBox(
|
|
characterListBoxInfo, NoTex,
|
|
characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
|
|
{
|
|
TemplateResolver = (layoutId, elementId) =>
|
|
{
|
|
if (layoutId != 0x2100002Bu) return null;
|
|
ElementInfo? templateInfo = Find(panelRoot, elementId);
|
|
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
|
|
},
|
|
};
|
|
|
|
// A first ordinary row via the normal template path (header, 22px).
|
|
UiElement? header = listBox.AddItemFromTemplateList(0);
|
|
Assert.NotNull(header);
|
|
Assert.Equal(22, listBox.ContentHeight);
|
|
|
|
// A pre-sized row built OUTSIDE the ListBox's own resolve step (the shape a
|
|
// self-sizing UiCheckboxBitfield64 block uses) — its height is whatever the
|
|
// caller already grew it to BEFORE calling AddPrebuiltRow.
|
|
var prebuilt = new UiPanel { Width = 272f, Height = 260f };
|
|
|
|
UiElement returned = listBox.AddPrebuiltRow(prebuilt);
|
|
|
|
Assert.Same(prebuilt, returned);
|
|
Assert.Equal(22f, prebuilt.Top); // stacked directly below the header row
|
|
Assert.Equal(22 + 260, listBox.ContentHeight); // the FINAL 260px height, not a stale one
|
|
Assert.Contains(prebuilt, listBox.Children.OfType<UiScrollablePanel>().Single().Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddPrebuiltRow_SubsequentTemplateRow_StacksAfterThePrebuiltRowsFinalHeight()
|
|
{
|
|
ElementInfo characterListBoxInfo = Find(FixtureLoader.LoadOptionsCharacterInfos(), 0x100001FAu)!;
|
|
var listBox = new UiTemplateListBox(
|
|
characterListBoxInfo, NoTex,
|
|
characterListBoxInfo.TemplateList, characterListBoxInfo.ScrollbarElementId)
|
|
{
|
|
TemplateResolver = (layoutId, elementId) =>
|
|
{
|
|
if (layoutId != 0x2100002Bu) return null;
|
|
ElementInfo? templateInfo = Find(FixtureLoader.LoadOptionsPanelInfos(), elementId);
|
|
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
|
|
},
|
|
};
|
|
|
|
var prebuilt = new UiPanel { Width = 272f, Height = 260f };
|
|
listBox.AddPrebuiltRow(prebuilt);
|
|
Assert.Equal(260, listBox.ContentHeight);
|
|
|
|
// A separator row added AFTER the prebuilt block must stack below its FINAL
|
|
// 260px height, not the block's pre-build authored 100px extent — this is
|
|
// exactly the reflow problem AddPrebuiltRow exists to avoid.
|
|
UiElement? separator = listBox.AddItemFromTemplateList(1); // separator template
|
|
Assert.NotNull(separator);
|
|
Assert.Equal(260f, separator!.Top);
|
|
}
|
|
|
|
// ── 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, NoTex,
|
|
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, NoTex,
|
|
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));
|
|
// OP2 rework: a failed/never-attempted add must not have allocated a viewport.
|
|
Assert.Empty(listBox.Children);
|
|
}
|
|
|
|
[Fact]
|
|
public void TemplateListBox_AddItemFromTemplateList_OutOfRangeIndex_ReturnsNull()
|
|
{
|
|
var listBox = new UiTemplateListBox(
|
|
new ElementInfo(), NoTex, 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));
|
|
// The one successful add DID lazily create the viewport.
|
|
Assert.Single(listBox.Children);
|
|
}
|
|
|
|
// ── UiTabPanel behavior ──────────────────────────────────────────────────
|
|
// OP2 rework: these all explicitly call ActivateTabBehavior() first — a Type-8
|
|
// element is dormant on import (docs/research/2026-08-11-op2-review-blast.md /
|
|
// -mechanism.md), so a controller (here, the test itself) must opt in before any
|
|
// switching happens. This mirrors how OP3+'s OptionsPanelController will drive it.
|
|
|
|
[Fact]
|
|
public void UiTabPanel_ActivateTabBehavior_ShowsOnlyTheGameplayDefaultPage()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
|
|
tabControl.ActivateTabBehavior();
|
|
|
|
Assert.True(tabControl.BehaviorActive);
|
|
Assert.Empty(tabControl.UnresolvedEntries);
|
|
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 UiTabPanel_ActivateTabBehavior_IsIdempotent()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
|
|
tabControl.ActivateTabBehavior();
|
|
tabControl.SwitchTo(0x10000211u); // Character page slot — deliberately not the default
|
|
tabControl.ActivateTabBehavior(); // second call must be a no-op, not re-switch to default
|
|
|
|
Assert.Equal(0x10000211u, tabControl.ActivePageElementId);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiTabPanel_SwitchTo_ShowsExactlyOnePage()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior();
|
|
|
|
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 UiTabPanel_SwitchTo_UpdatesTabButtonOpenClosedState()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior();
|
|
|
|
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 UiTabPanel_ClickingTabButton_SwitchesActivePage()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior(); // clicks only bind once behavior is activated
|
|
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 UiTabPanel_ClickingTabButton_BeforeActivation_DoesNothing()
|
|
{
|
|
// OP2 rework's central regression guard: a dormant instance's tab buttons are
|
|
// NOT click-bound yet, so a click before ActivateTabBehavior() must be inert —
|
|
// this is exactly the property that keeps the four pre-existing shipped Type-8
|
|
// hosts (which never call ActivateTabBehavior) safe.
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
var characterButton = layout.FindElement(0x1000020Eu)!;
|
|
|
|
characterButton.OnEvent(new UiEvent(0, characterButton, UiEventType.Click));
|
|
|
|
Assert.Equal(0u, tabControl.ActivePageElementId);
|
|
Assert.False(tabControl.BehaviorActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiTabPanel_SwitchToSameActivePage_IsANoOp()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior();
|
|
uint before = tabControl.ActivePageElementId;
|
|
|
|
tabControl.SwitchTo(before);
|
|
|
|
Assert.Equal(before, tabControl.ActivePageElementId);
|
|
Assert.True(layout.FindElement(before)!.Visible);
|
|
}
|
|
|
|
// ── ActivePageChanged (Campaign OP slice OP3 — the page-model hook) ──────
|
|
|
|
[Fact]
|
|
public void ActivePageChanged_FiresOnInitialActivation_WithOldEqualsZero()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
var transitions = new List<(uint Old, uint New)>();
|
|
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
|
|
|
|
tabControl.ActivateTabBehavior();
|
|
|
|
Assert.Equal([(0u, 0x10000212u)], transitions); // Gameplay default, no prior page.
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePageChanged_FiresOnEverySwitch_WithBothOldAndNew()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior();
|
|
var transitions = new List<(uint Old, uint New)>();
|
|
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
|
|
|
|
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
|
|
|
|
Assert.Equal([(0x10000212u, 0x1000050Cu)], transitions);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePageChanged_DoesNotFireForANoOpSwitch()
|
|
{
|
|
var layout = FixtureLoader.LoadOptionsPanel();
|
|
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
|
|
tabControl.ActivateTabBehavior();
|
|
uint before = tabControl.ActivePageElementId;
|
|
var transitions = new List<(uint Old, uint New)>();
|
|
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
|
|
|
|
tabControl.SwitchTo(before);
|
|
|
|
Assert.Empty(transitions);
|
|
}
|
|
}
|