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; // #380: realistic test defaults matching the live-DAT-verified // strings (docs/research live-mount probe, 2026-08-11) so existing // tests exercise the same shape production sees. public ChatOptionsDatCaptions.Caption DefaultOpacityCaption = new("Inactive Opacity", "Adjusts the opacity of the chat window when it is inactive"); public ChatOptionsDatCaptions.Caption ActiveOpacityCaption = new("Active Opacity", "Adjusts the opacity of the chat window when it is active"); public List DefaultOpacitySets { get; } = new(); public List ActiveOpacitySets { get; } = new(); public int OpacityFlushes { get; private set; } 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); }, FlushOpacity: () => OpacityFlushes++, DefaultOpacityDatDefault: DefaultOpacityDatDefault, ActiveOpacityDatDefault: ActiveOpacityDatDefault, DefaultOpacityCaption: DefaultOpacityCaption, ActiveOpacityCaption: ActiveOpacityCaption, 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); } } /// The two opacity-slider widgets, in build order (slider1 = /// Default, slider2 = Active) — both are s built /// with Horizontal = true (import-time from the authored template's /// own wide-vs-tall extent, DatWidgetFactory.cs:214) driven through /// rather than Model. Scoped /// to the ListBox's own subtree, so the page-level vertical list scrollbar /// (a DIFFERENT, non-horizontal, sibling-not-descendant element) is never /// collected. private static List CollectScalarSliders(UiElement listBoxRoot) { var found = new List(); Walk(listBoxRoot, found); return found; static void Walk(UiElement node, List acc) { if (node is UiScrollbar { Horizontal: true } bar) acc.Add(bar); 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); } /// /// #380 regression (2026-08-11, gate 4): before this fix, NOTHING wired /// element 0x1000021B on either slider row, so both rows rendered /// with no caption at all — the user's literal complaint ("I also miss /// the text next to the bars for Inactive and Active Opacity"). Drives /// the row-caption text down from / /// ActiveOpacityCaption the same way production resolves it /// ( against the live DAT) and reads /// back the ACTUAL built next to each slider — not /// just that the field was set somewhere, but that IT rendered on the /// correct row (Default → row 1's own caption, Active → row 2's own, /// never swapped or bled onto the wrong slider). /// [Fact] public void Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog() { var fakeBindings = new FakeBindings { DefaultOpacityCaption = new ChatOptionsDatCaptions.Caption("Inactive Opacity", "inactive tip"), ActiveOpacityCaption = new ChatOptionsDatCaptions.Caption("Active Opacity", "active tip"), }; 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 sliders = CollectScalarSliders(listBox); Assert.Equal(2, sliders.Count); // build order: [0]=Default, [1]=Active UiElement defaultRowRoot = FindRowRoot(sliders[0]); UiElement activeRowRoot = FindRowRoot(sliders[1]); var defaultCaption = Assert.IsType( UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu)); var activeCaption = Assert.IsType( UiElement.FindDescendant(activeRowRoot, 0x1000021Bu)); Assert.Equal("Inactive Opacity", Assert.Single(defaultCaption.LinesProvider()).Text); Assert.Equal("Active Opacity", Assert.Single(activeCaption.LinesProvider()).Text); // The tooltip attaches to the interactive slider itself, per-row. Assert.Equal("inactive tip", sliders[0].TooltipText); Assert.Equal("active tip", sliders[1].TooltipText); } /// A DAT read failure (or a missing catalog entry) must never /// invent English — the row renders with NO caption, exactly like every /// other resolve-miss path already established in this controller /// (filter labels, section headers, range captions). [Fact] public void Bind_MissingCaption_RendersNoText_NeverInventsEnglish() { var fakeBindings = new FakeBindings { DefaultOpacityCaption = default, // both Name/Tooltip null ActiveOpacityCaption = default, }; 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 sliders = CollectScalarSliders(listBox); UiElement defaultRowRoot = FindRowRoot(sliders[0]); var defaultCaption = Assert.IsType( UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu)); Assert.Empty(defaultCaption.LinesProvider()); Assert.Null(sliders[0].TooltipText); } /// Walks up from a built slider leaf to the row root /// stacked directly under /// the viewport — the same subtree /// itself scopes its FindDescendant caption lookup to. private static UiElement FindRowRoot(UiElement leaf) { UiElement node = leaf; while (node.Parent is { } parent && parent is not UiScrollablePanel) node = parent; return node; } [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); } // ── OP5 review fix M1: single-row Reset must revert that row's OWN thumb ─ [Fact] public void Reset_AfterOnlyDefaultSliderChanged_RevertsTheThumbToo() { // MUST-FIX M1 (OP5 review, 2026-08-11): defaultRow's own apply closure // must push slider1's OWN thumb (mirroring the OP4 binding pattern), // not just the LINKED slider2 via RefreshFromLink. Drag Default DOWN // only, well below Active, so the link never raises Active and // activeRow.Changed stays false — the exact single-row-changed // scenario the review names as reachable and previously uncovered. var fakeBindings = new FakeBindings { DefaultOpacity = 0.3f, ActiveOpacity = 0.9f }; 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 sliders = CollectScalarSliders(listBox); Assert.Equal(2, sliders.Count); UiScrollbar slider1 = sliders[0]; var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); defaultRow.SetCurrentValue(0.1f); Assert.Equal(0.1f, slider1.ScalarPosition, 3); Assert.True(defaultRow.Changed); Assert.False(activeRow.Changed); // link never touched Active controller.ChatPage.Reset(); Assert.Equal(0.3f, defaultRow.Current); Assert.Equal(0.3f, fakeBindings.DefaultOpacity); Assert.Equal(0.3f, slider1.ScalarPosition, 3); // the thumb reverted too, not just the value } [Fact] public void Reset_AfterOnlyActiveSliderChanged_RevertsTheThumbToo() { // Symmetric case the review also names: drag Active UP only, Reset. var fakeBindings = new FakeBindings { DefaultOpacity = 0.3f, ActiveOpacity = 0.6f }; 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 sliders = CollectScalarSliders(listBox); Assert.Equal(2, sliders.Count); UiScrollbar slider2 = sliders[1]; var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); var activeRow = Assert.IsType(controller.ChatPage.Rows[1]); activeRow.SetCurrentValue(0.95f); Assert.Equal(0.95f, slider2.ScalarPosition, 3); Assert.True(activeRow.Changed); Assert.False(defaultRow.Changed); // link never touched Default (0.6 < 0.95) controller.ChatPage.Reset(); Assert.Equal(0.6f, activeRow.Current); Assert.Equal(0.6f, fakeBindings.ActiveOpacity); Assert.Equal(0.6f, slider2.ScalarPosition, 3); // the thumb reverted too, not just the value } // ── OP5 review fix S1: settings write batches to drag-end, not per tick ─ [Fact] public void DraggingDefaultSlider_DefersTheSettingsWriteUntilDragEnd() { // SHOULD-FIX S1 (OP5 review, 2026-08-11): N MouseMove ticks inside one // thumb-drag gesture must flush the settings write ZERO times (only // the LIVE opacity applies per tick); the matching MouseUp flushes // exactly once. Drives the REAL UiScrollbar event pipeline (not // FloatOptionRow.SetCurrentValue directly) so UiScrollbar.IsDragging // is genuinely true for the duration, exactly like a real mouse drag. var fakeBindings = new FakeBindings { DefaultOpacity = 0.2f, ActiveOpacity = 1.0f }; 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 sliders = CollectScalarSliders(listBox); UiScrollbar slider1 = sliders[0]; Assert.True(slider1.Width > 16f, $"fixture slider1.Width={slider1.Width} too narrow for this test's thumb math"); // Click INSIDE the current thumb (no "jump to click position" branch) // so this is a clean drag start with no incidental extra flush. float thumbWidth = MathF.Min(16f, slider1.Width); float travel = MathF.Max(1f, slider1.Width - thumbWidth); float thumbX = travel * slider1.ScalarPosition; int clickX = (int)(thumbX + thumbWidth * 0.5f); Assert.True(slider1.OnEvent(new UiEvent(0u, slider1, UiEventType.MouseDown, Data1: clickX))); Assert.True(slider1.IsDragging); Assert.Equal(0, fakeBindings.OpacityFlushes); for (int i = 1; i <= 10; i++) { Assert.True(slider1.OnEvent(new UiEvent(0u, slider1, UiEventType.MouseMove, Data1: clickX + i))); Assert.Equal(0, fakeBindings.OpacityFlushes); // N drag ticks = 0 saves } // Live opacity DID apply on every tick even though nothing flushed. Assert.NotEqual(0.2f, fakeBindings.DefaultOpacity); Assert.True(slider1.OnEvent(new UiEvent(0u, slider1, UiEventType.MouseUp, Data1: clickX + 10))); Assert.False(slider1.IsDragging); Assert.Equal(1, fakeBindings.OpacityFlushes); // drag end = 1 save } [Fact] public void ResetClick_FlushesImmediately_NotMidDrag() { // A discrete Reset click (never mid-drag) keeps writing immediately — // the SAME single-write-per-discrete-edit shape the pre-fix code had // for every call, matching the review's "the filter checkboxes are // discrete clicks and are fine as written" characterization applied // to Reset/Defaults on the opacity rows too. var fakeBindings = new FakeBindings { DefaultOpacity = 0.3f, ActiveOpacity = 0.9f }; (OptionsPanelController controller, FakeBindings bindings, bool bound) = BindRealWith(fakeBindings); Assert.True(bound); var defaultRow = Assert.IsType(controller.ChatPage.Rows[0]); defaultRow.SetCurrentValue(0.1f); // not mid-drag either — flushes immediately Assert.Equal(1, bindings.OpacityFlushes); controller.ChatPage.Reset(); Assert.Equal(2, bindings.OpacityFlushes); } [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); } // The tab host's own private per-page SLOT id (OptionsPanelController's // ChatPageId) — the id that actually survives base-merge in the // host-mounted tree (see ChatOptionsPageController.PageSlotElementId's // own doc for why the standalone layout's root id does not survive). // OptionsPanelController keeps its own copy private; this test-local // literal mirrors it for a scoped lookup exactly the way the controller // itself scopes its scrollbar linkage (ConfigOptionsPageControllerTests // uses the identical pattern for its own scrollbar-linkage tests). private const uint ChatPageSlotId = 0x1000050Cu; [Fact] public void ScrollbarLinkage_ModelPointsAtTheChatListBoxScroll() { // OP5 review fix N11 (2026-08-11): 0x10000201 is authored in BOTH the // Chat and Config page slots (research doc §10.1); ImportedLayout's // flat FindElement is last-write-wins across the whole tree, so a // flat lookup here would pass even if the controller's OWN scoped // lookup (ChatOptionsPageController.Bind) regressed to a flat one — // it happens to resolve the same instance today only because Chat is // built last. Assert through the SAME scoped path the controller // itself uses (UiElement.FindDescendant from the page's own slot), // matching OP6's ScrollbarLinkage_ModelPointsAtTheConfigListBoxScroll. 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 chatSlot = UiElement.FindDescendant(controller.TabPanel, ChatPageSlotId)!; var listBox = Assert.IsType( UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ListBoxElementId)); var scrollbar = Assert.IsType( UiElement.FindDescendant(chatSlot, 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); } }