using System; using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Chat; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign OP slice OP5 (2026-08-11) conformance + behavior tests for /// — the same hermetic pattern /// CharacterOptionsPageControllerTests established: pure-data conformance /// against the byte-verified research doc/decomp tables, then behavioral tests /// against the committed options_panel_2100006E_1000018D.json + /// options_2100002B.json fixtures — no live DAT access. /// public sealed class ChatOptionsPageControllerTests { // ── Pure data conformance ──────────────────────────────────────────────── [Fact] public void FilterRows_Has13EntriesInByteVerifiedAuthoredOrder() { // docs/research/2026-08-10-options-panel-structure.md §5.2, cross-checked // against the raw gmChatOptionsUI::InitOptions / AddCheckboxBitfield64Option // pseudo-C AddChild call sequence (this class's own doc trace). (ulong Mask, string Key)[] expected = { (0x0000000083912021ul, "ID_ChatOption_TextFilter_Gameplay"), (0x0000000000600040ul, "ID_ChatOption_TextFilter_Combat"), (0x0000000000020080ul, "ID_ChatOption_TextFilter_Magic"), (0x0000000000001004ul, "ID_ChatOption_TextFilter_AreaSpeech"), (0x0000000000000018ul, "ID_ChatOption_TextFilter_Tells"), (0x0000000000040C00ul, "ID_ChatOption_TextFilter_Allegience"), (0x0000000000080000ul, "ID_ChatOption_TextFilter_Fellowship"), (0x0000000008000000ul, "ID_ChatOption_TextFilter_General"), (0x0000000010000000ul, "ID_ChatOption_TextFilter_Trade"), (0x0000000020000000ul, "ID_ChatOption_TextFilter_LFG"), (0x0000000040000000ul, "ID_ChatOption_TextFilter_Roleplay"), (0x0000000100000000ul, "ID_ChatOption_TextFilter_Society"), (0x0000000004000000ul, "ID_ChatOption_TextFilter_Error"), }; Assert.Equal(13, ChatOptionsPageController.FilterRows.Length); for (int i = 0; i < expected.Length; i++) { Assert.Equal(expected[i].Mask, ChatOptionsPageController.FilterRows[i].Mask); Assert.Equal(expected[i].Key, ChatOptionsPageController.FilterRows[i].RetailLabelKey); } } [Fact] public void FilterRows_EveryMaskIsPairwiseDistinct_AndNonZero() { var masks = ChatOptionsPageController.FilterRows.Select(static r => r.Mask).ToList(); Assert.Equal(masks.Count, masks.Distinct().Count()); Assert.All(masks, m => Assert.NotEqual(0ul, m)); } [Fact] public void FilterBlocks_Has5EntriesInAuthoredOrder_WithRetailWindowIds() { // Research doc §5: main(8), floaty1(2), floaty2(3), floaty3(4), floaty4(5) — // authored top-to-bottom order in gmChatOptionsUI::InitOptions. (int RetailId, int CompactId, string HeaderKey, ulong Default, bool IncludesGameplay)[] expected = { (8, ChatWindowState.MainWindowId, "ID_ChatOption_MainChatWindow_Section", 0xFBFFFFFFul, false), (2, 1, "ID_ChatOption_FloatyChatWindow1_Section", 0x0000101Cul, true), (3, 2, "ID_ChatOption_FloatyChatWindow2_Section", 0x00040C00ul, true), (4, 3, "ID_ChatOption_FloatyChatWindow3_Section", 0x00080000ul, true), (5, 4, "ID_ChatOption_FloatyChatWindow4_Section", 0x78000000ul, true), }; Assert.Equal(5, ChatOptionsPageController.FilterBlocks.Length); for (int i = 0; i < expected.Length; i++) { ChatOptionsPageController.FilterBlockSpec spec = ChatOptionsPageController.FilterBlocks[i]; Assert.Equal(expected[i].RetailId, spec.RetailWindowId); Assert.Equal(expected[i].CompactId, spec.CompactWindowId); Assert.Equal(expected[i].HeaderKey, spec.HeaderKey); Assert.Equal(expected[i].Default, spec.DefaultFilter); Assert.Equal(expected[i].IncludesGameplay, spec.IncludesGameplayRow); } } [Fact] public void FilterBlocks_DefaultsMatchChatWindowStateNamedConstants() { // These MUST be the exact same source (ChatWindowState's own constants), // not two independently-typed literal tables that could drift apart. Assert.Equal(ChatWindowState.MainWindowDefaultFilter, ChatOptionsPageController.FilterBlocks[0].DefaultFilter); Assert.Equal(ChatWindowState.Floaty1DefaultFilter, ChatOptionsPageController.FilterBlocks[1].DefaultFilter); Assert.Equal(ChatWindowState.Floaty2DefaultFilter, ChatOptionsPageController.FilterBlocks[2].DefaultFilter); Assert.Equal(ChatWindowState.Floaty3DefaultFilter, ChatOptionsPageController.FilterBlocks[3].DefaultFilter); Assert.Equal(ChatWindowState.Floaty4DefaultFilter, ChatOptionsPageController.FilterBlocks[4].DefaultFilter); } // ── Row/header string-hash conformance ─────────────────────────────────── [Theory] [InlineData("ID_ChatOption_TextFilter_Gameplay")] [InlineData("ID_ChatOption_TextFilter_Combat")] [InlineData("ID_ChatOption_TextFilter_Magic")] [InlineData("ID_ChatOption_TextFilter_AreaSpeech")] [InlineData("ID_ChatOption_TextFilter_Tells")] [InlineData("ID_ChatOption_TextFilter_Allegience")] [InlineData("ID_ChatOption_TextFilter_Fellowship")] [InlineData("ID_ChatOption_TextFilter_General")] [InlineData("ID_ChatOption_TextFilter_Trade")] [InlineData("ID_ChatOption_TextFilter_LFG")] [InlineData("ID_ChatOption_TextFilter_Roleplay")] [InlineData("ID_ChatOption_TextFilter_Society")] [InlineData("ID_ChatOption_TextFilter_Error")] public void EveryFilterRowKey_IsAuthoredOnTheFilterRowsTable(string key) { Assert.Contains(ChatOptionsPageController.FilterRows, r => r.RetailLabelKey == key); } // ── Behavioral: built against the committed fixtures ───────────────────── 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 (ElementInfo c in n.Children) { ElementInfo? f = Find(c, id); if (f is not null) return f; } return null; } /// The same "resolve a row template from the standalone 0x2100002B /// fixture" resolver CharacterOptionsPageControllerTests uses. private static Func MakeTemplateResolver() { ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos(); return (layoutId, elementId) => { if (layoutId != 0x2100002Bu) return null; ElementInfo? templateInfo = Find(panelRoot, elementId); return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root; }; } private sealed class FakeBindings { public float DefaultOpacity = 0.5f; public float ActiveOpacity = 1.0f; public float DefaultOpacityDatDefault = 0.5f; public float ActiveOpacityDatDefault = 1.0f; public List DefaultOpacitySets { get; } = new(); public List ActiveOpacitySets { get; } = new(); public Dictionary Filters { get; } = new() { [ChatWindowState.MainWindowId] = ChatWindowState.MainWindowDefaultFilter, [1] = ChatWindowState.Floaty1DefaultFilter, [2] = ChatWindowState.Floaty2DefaultFilter, [3] = ChatWindowState.Floaty3DefaultFilter, [4] = ChatWindowState.Floaty4DefaultFilter, }; public List<(int WindowId, ulong Value)> FilterSets { get; } = new(); public ChatOptionsPageController.Bindings ToBindings() => new( CurrentDefaultOpacity: () => DefaultOpacity, CurrentActiveOpacity: () => ActiveOpacity, SetDefaultOpacity: value => { (DefaultOpacity, ActiveOpacity) = ChatOpacityLinkFor(ActiveOpacity, value, isDefault: true); DefaultOpacitySets.Add(value); }, SetActiveOpacity: value => { (DefaultOpacity, ActiveOpacity) = ChatOpacityLinkFor(DefaultOpacity, value, isDefault: false); ActiveOpacitySets.Add(value); }, DefaultOpacityDatDefault: DefaultOpacityDatDefault, ActiveOpacityDatDefault: ActiveOpacityDatDefault, CurrentFilter: windowId => Filters[windowId], SetFilter: (windowId, value) => { Filters[windowId] = value; FilterSets.Add((windowId, value)); }); // Mirrors AcDream.UI.Abstractions.Panels.Settings.ChatOpacityLink's own // math exactly (the fake stands in for RetailWindowOpacityController, // which itself just calls that link) — kept local so this test file has // no cross-project dependency for a two-line clamp/drag rule. private static (float Default, float Active) ChatOpacityLinkFor( float other, float value, bool isDefault) { value = Math.Clamp(value, 0f, 1f); if (isDefault) return (value, other < value ? value : other); return (other > value ? value : other, value); } } private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal( Func? resolveString = null) { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); bool bound = ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), resolveString ?? ((_, _) => null), fakeBindings.ToBindings()); return (controller, fakeBindings, bound); } [Fact] public void Bind_Succeeds_AndRegistersExactlySevenRows() { // 2 opacity sliders + 5 filter blocks = 7 IOptionRow registrations // (each block is ONE composite row, not one row per checkbox). (OptionsPanelController controller, _, bool bound) = BindReal(); Assert.True(bound); Assert.Equal(7, controller.ChatPage.Rows.Count); } [Fact] public void Bind_MainWindowBlock_Has12Rows_FloatyBlocks_Have13Rows() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); bool bound = ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); List blocks = CollectBlocks(listBox); Assert.Equal(5, blocks.Count); Assert.Equal(12, blocks[0].Rows.Count); // main window — no Gameplay row for (int i = 1; i < 5; i++) Assert.Equal(13, blocks[i].Rows.Count); // the four floaties } [Fact] public void Bind_FilterBlocks_ResolveTheAP195LedSprites() { // AP-195 end-to-end: the regenerated fixture now carries dat properties // 0x10000082/0x10000083 on template consumer 0x10000520 // (0x06004D17/0x06004D19), and DatWidgetFactory threads them into the // constructed UiCheckboxBitfield64 — every one of the five blocks shares // the SAME authored template, so every block resolves the same sprites. ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); List blocks = CollectBlocks(listBox); Assert.Equal(5, blocks.Count); Assert.All(blocks, b => { Assert.Equal(0x06004D17u, b.CheckedLedSprite); Assert.Equal(0x06004D19u, b.UncheckedLedSprite); }); } [Fact] public void Bind_FilterBlocks_SelfSizeToFitTheirRows() { // AP-195's self-sizing tail — the block grows past its authored 272x100 // extent to fit N rows (13 floaty rows * 20px template height > 100). ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); List blocks = CollectBlocks(listBox); Assert.All(blocks, b => Assert.True(b.Height > 100f, $"block Height={b.Height}")); Assert.Equal(272f, blocks[0].Width); // width (GetWidth()) untouched } private static List CollectBlocks(UiElement root) { var found = new List(); Walk(root, found); return found; static void Walk(UiElement node, List acc) { if (node is UiCheckboxBitfield64 block) acc.Add(block); foreach (UiElement child in node.Children) Walk(child, acc); } } [Fact] public void Bind_SeedsSlidersFromCurrentOpacity_NotTheDatDefault() { var fakeBindings = new FakeBindings { DefaultOpacity = 0.25f, ActiveOpacity = 0.75f }; ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; bool bound = ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); Assert.Equal(0.25f, defaultRow.Current); Assert.Equal(0.75f, activeRow.Current); // Never wrote back just from seeding. Assert.Empty(fakeBindings.DefaultOpacitySets); Assert.Empty(fakeBindings.ActiveOpacitySets); } [Fact] public void DraggingDefaultSlider_AppliesLive_AndDragsActiveUp_NeverClamping() { var fakeBindings = new FakeBindings { DefaultOpacity = 0.3f, ActiveOpacity = 0.3f }; (OptionsPanelController controller, FakeBindings bindings, bool bound) = BindRealWith(fakeBindings); Assert.True(bound); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); // Raise Default ABOVE the current Active value — retail's link drags // Active UP to match, never clamps Default down. defaultRow.SetCurrentValue(0.9f); Assert.Equal(0.9f, bindings.DefaultOpacity); Assert.Equal(0.9f, bindings.ActiveOpacity); // dragged up Assert.Equal(0.9f, defaultRow.Current); Assert.Equal(0.9f, activeRow.Current); // the OTHER row's own value moved too Assert.Single(bindings.DefaultOpacitySets); // Live-apply: Changed reflects immediately (retail's Apply(1) shape). Assert.True(controller.ChatPage.Changed); } [Fact] public void DraggingActiveSliderBelowDefault_DragsDefaultDown() { var fakeBindings = new FakeBindings { DefaultOpacity = 0.6f, ActiveOpacity = 0.6f }; (OptionsPanelController controller, FakeBindings bindings, _) = BindRealWith(fakeBindings); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); activeRow.SetCurrentValue(0.1f); Assert.Equal(0.1f, bindings.ActiveOpacity); Assert.Equal(0.1f, bindings.DefaultOpacity); // dragged down Assert.Equal(0.1f, defaultRow.Current); Assert.Equal(0.1f, activeRow.Current); } [Fact] public void OnShown_ReSeedsBothSliders_FromLiveOpacity() { var fakeBindings = new FakeBindings { DefaultOpacity = 0.5f, ActiveOpacity = 1.0f }; (OptionsPanelController controller, FakeBindings bindings, _) = BindRealWith(fakeBindings); bindings.DefaultOpacity = 0.2f; bindings.ActiveOpacity = 0.9f; controller.ChatPage.OnShown(); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); Assert.Equal(0.2f, defaultRow.Current); Assert.Equal(0.2f, defaultRow.Saved); Assert.Equal(0.9f, activeRow.Current); Assert.Equal(0.9f, activeRow.Saved); Assert.False(controller.ChatPage.Changed); } [Fact] public void Defaults_RestoresDatExtractedSliderDefaults() { var fakeBindings = new FakeBindings { DefaultOpacity = 0.1f, ActiveOpacity = 0.1f, DefaultOpacityDatDefault = 0.5f, ActiveOpacityDatDefault = 1.0f, }; (OptionsPanelController controller, FakeBindings bindings, _) = BindRealWith(fakeBindings); controller.ChatPage.Defaults(); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); Assert.Equal(0.5f, defaultRow.Current); Assert.Equal(1.0f, activeRow.Current); Assert.Equal(0.5f, bindings.DefaultOpacity); Assert.Equal(1.0f, bindings.ActiveOpacity); } [Fact] public void CheckingAFilterRow_PublishesSetFilter_ForItsOwnCompactWindowId() { var fakeBindings = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; bool bound = ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); List blocks = CollectBlocks(listBox); UiCheckboxBitfield64 floaty1Block = blocks[1]; // compact window id 1 UiCheckboxBitfield64.Row row = floaty1Block.Rows[0]; // Gameplay (floaty has it) row.Toggle.OnClick!.Invoke(); var (windowId, value) = Assert.Single(fakeBindings.FilterSets); Assert.Equal(1, windowId); Assert.Equal(fakeBindings.Filters[1], value); } [Fact] public void MainWindowBlock_HasNoGameplayRow() { var fakeBindings = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); UiCheckboxBitfield64 mainBlock = CollectBlocks(listBox)[0]; Assert.All(mainBlock.Rows, r => Assert.NotEqual("Gameplay", r.Label)); Assert.DoesNotContain(mainBlock.Rows, r => r.LowMask == 0x83912021ul); } [Fact] public void OnShown_ReSeedsFilterBlock_FromLiveChatWindowState() { var fakeBindings = new FakeBindings(); (OptionsPanelController controller, FakeBindings bindings, _) = BindRealWith(fakeBindings); // Simulate the filter having changed behind the panel's back (e.g. a // future second write path) between mount and the next tab-show. bindings.Filters[ChatWindowState.MainWindowId] = 0x1ul; controller.ChatPage.OnShown(); var mainRow = Assert.IsType(controller.ChatPage.Rows[2]); // 2 sliders + main block Assert.Equal(0x1ul, mainRow.Current); Assert.Equal(0x1ul, mainRow.Saved); Assert.False(controller.ChatPage.Changed); } [Fact] public void Reset_RevertsAFilterBlock_ToItsSavedBaseline() { var fakeBindings = new FakeBindings(); (OptionsPanelController controller, FakeBindings bindings, _) = BindRealWith(fakeBindings); var mainRow = Assert.IsType(controller.ChatPage.Rows[2]); ulong initial = mainRow.Current; bindings.FilterSets.Clear(); mainRow.SetCurrentValue(0x0ul); Assert.True(controller.ChatPage.Changed); controller.ChatPage.Reset(); Assert.Equal(initial, mainRow.Current); Assert.Contains(bindings.FilterSets, s => s.WindowId == ChatWindowState.MainWindowId && s.Value == initial); } [Fact] public void LabelResolutionFailure_LeavesRowsBuilt_NeverInventsEnglish() { (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null); Assert.True(bound); Assert.Equal(7, controller.ChatPage.Rows.Count); } [Fact] public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow() { var emptyRoot = new ElementInfo { Id = 0, Type = 3 }; ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null); var page = new OptionPage(); bool bound = ChatOptionsPageController.Bind( emptyLayout, page, MakeTemplateResolver(), (_, _) => null, new FakeBindings().ToBindings()); Assert.False(bound); Assert.Empty(page.Rows); } [Fact] public void ScrollbarLinkage_ModelPointsAtTheChatListBoxScroll() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); var listBox = Assert.IsType( layout.FindElement(ChatOptionsPageController.ListBoxElementId)); var scrollbar = Assert.IsType( layout.FindElement(ChatOptionsPageController.ScrollbarElementId)); Assert.Same(listBox.Scroll, scrollbar.Model); } private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindRealWith( FakeBindings fakeBindings) { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; bool bound = ChatOptionsPageController.Bind( layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); return (controller, fakeBindings, bound); } }