Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host 0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import pattern CharacterController already validates, registered through RetailPanelUiController so it shares retail's "one active gmPanelUI child" mutual exclusion with every other sibling panel for free. F11 and the toolbar's options button (0x1000019B, already authoring panel id 10) both now open it; the close button fires the same ToggleOptionsPanel action. OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact Apply/Reset/Defaults/visibility semantics from UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately, Apply commits every row unconditionally + flushes the batched blob, Reset reverts only Changed rows, Defaults restores without committing, and tab-switch/window-hide revert uncommitted edits. Wired for all four tabs; this slice registers real rows on none of them (Gameplay authentically has none — a pure button list). UiTabPanel gains an ActivePageChanged event so the page model can hook every tab transition, including the initial default-tab activation. The seven Gameplay-tab buttons: Exit Game reuses the existing graceful window-close path; Exit to Character Selection gets retail's confirmation dialog and byte-verified mid-air refusal but still behaves as Exit Game (AD-74 — no pre-world character-select flow exists); Configure Keyboard and In-Game Help Files are inert this slice (AD-76 for Help — the plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse short-circuit to their own byte-verified failure text through the interface-text seam instead of ShellExecute against a dead URL (AD-75); Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port, persisting five new CameraTurningSettings preferences and sending PlayerOption.UseMouseTurning — TS-74 records that acdream has no persistent mouse-turning camera mode for the bit to drive yet. Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline 12,871/4/0 — only new tests added). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
622 lines
27 KiB
C#
622 lines
27 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()
|
|
{
|
|
var templates = new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000521u) };
|
|
return new UiCheckboxBitfield64(templates)
|
|
{
|
|
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);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|