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 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,
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);
}
[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);
}
}