using System.Collections.Generic; using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.UI.Layout; /// /// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for /// — the CH4 registry- /// conformance pattern: every one of the 50 authored rows pinned against /// in BOTH directions (an invented row /// or a dropped row fails the build), the authored group/order pinned /// against the committed fixture, and the row-building/Apply-Reset- /// Defaults/wire-publish behavior exercised end-to-end against the /// committed options_panel_2100006E_1000018D.json fixture — no /// live DAT access, following the same hermetic pattern /// OptionsPanelControllerTests/OptionsPanelLayoutConformanceTests /// already established. /// public sealed class CharacterOptionsPageControllerTests { // The three PlayerOption ids that exist but carry NO Character-tab row // (research doc §2.7): AppearOffline, UseMouseTurning, LockUI. private static readonly CharacterOptionId[] NotOnCharacterTab = [ CharacterOptionId.AppearOffline, CharacterOptionId.UseMouseTurning, CharacterOptionId.LockUI, ]; private static IEnumerable AllRows() => CharacterOptionsPageController.Groups.SelectMany(static g => g.Rows); // ── Pure data conformance (no fixtures, no widgets) ───────────────────── [Fact] public void Groups_HasSixGroups_InAuthoredHeaderOrder() { string[] expectedHeaders = [ "ID_CharacterOption_UIBehavior_Section", "ID_CharacterOption_UIDisplay_Section", "ID_CharacterOption_Grouping_Section", "ID_CharacterOption_OtherPlayers_Section", "ID_CharacterOption_CharacterBehavior_Section", "ID_CharacterOption_Chat_Section", ]; Assert.Equal(6, CharacterOptionsPageController.Groups.Length); Assert.Equal( expectedHeaders, CharacterOptionsPageController.Groups.Select(static g => g.HeaderKey)); } [Fact] public void Groups_RowCountsPerGroup_Match3_15_6_11_7_8() { // research doc §2 / §7: 3/15/6/11/7/7, +1 for D3's HearPKDeaths // appended to the Chat group -> 8. int[] expected = { 3, 15, 6, 11, 7, 8 }; Assert.Equal( expected, CharacterOptionsPageController.Groups.Select(static g => g.Rows.Length)); } [Fact] public void TotalRowCount_Is50() { Assert.Equal(50, CharacterOptionsPageController.TotalRowCount); Assert.Equal(50, AllRows().Count()); } [Fact] public void EveryRow_ResolvesInCharacterOptionTable() { // "An invented row fails the build" — direction 1. foreach (CharacterOptionsPageController.RowSpec row in AllRows()) { Assert.True( CharacterOptionTable.TryGet(row.Id, out _), $"{row.RetailName} (0x{(uint)row.Id:X2}) is authored on the Character tab " + "but missing from CharacterOptionTable."); } } [Fact] public void EveryRow_IsPairwiseDistinct() { var ids = AllRows().Select(static r => r.Id).ToList(); Assert.Equal(ids.Count, ids.Distinct().Count()); } [Fact] public void EveryCharacterOptionTableId_ExceptTheThreeExcluded_HasExactlyOneRow() { // "A dropped row fails the build" — direction 2. CharacterOptionTable // has 53 ids; the Character tab authors exactly 53 - 3 = 50 of them // (research doc §2.7's three exclusions). var rowIds = AllRows().Select(static r => r.Id).ToHashSet(); foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All) { bool expectedOnTab = !NotOnCharacterTab.Contains(entry.Id); Assert.True( rowIds.Contains(entry.Id) == expectedOnTab, $"{entry.Id} (0x{(uint)entry.Id:X2}): expected authored-on-tab=" + $"{expectedOnTab} but rowIds.Contains={rowIds.Contains(entry.Id)}."); } } [Theory] [InlineData(CharacterOptionId.AppearOffline)] [InlineData(CharacterOptionId.UseMouseTurning)] [InlineData(CharacterOptionId.LockUI)] public void ExcludedIds_HaveNoRow(CharacterOptionId excludedId) { Assert.DoesNotContain(AllRows(), r => r.Id == excludedId); } [Fact] public void HearPkDeathMessages_D3Row_IsLastInTheChatGroup() { CharacterOptionsPageController.RowSpec[] chatRows = CharacterOptionsPageController.Groups[5].Rows; Assert.Equal( CharacterOptionId.HearPkDeathMessages, chatRows[^1].Id); Assert.Equal("HearPKDeaths", chatRows[^1].RetailName); } [Fact] public void HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringId() { // structure doc §7 build-version divergence note: // compute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3 // exactly, matching the DAT string "Listen to PK death messages." Assert.Equal( 0x0D16E9A3u, DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths")); } [Theory] [InlineData("ID_CharacterOption_UIBehavior_Section", 0x06489B6Eu)] [InlineData("ID_CharacterOption_UIDisplay_Section", 0x0A9BC99Eu)] [InlineData("ID_CharacterOption_Grouping_Section", 0x0CBAAFAEu)] [InlineData("ID_CharacterOption_OtherPlayers_Section", 0x0872DFFEu)] [InlineData("ID_CharacterOption_CharacterBehavior_Section", 0x08674D5Eu)] [InlineData("ID_CharacterOption_Chat_Section", 0x0987FE8Eu)] public void HeaderKey_RetailNameHash_MatchesByteVerifiedStringId( string headerKey, uint expectedHash) { // SF-2 (OP4 review-fix round, 2026-08-11): structure doc §7's six // byte-verified header string ids — previously verified only by // hand in the mechanism review, not pinned by a test. A typo in a // HeaderKey literal would otherwise produce a silently blank // header that only the user's eye catches. Assert.Equal(expectedHash, DatStringResolver.ComputeHash(headerKey)); Assert.Contains(headerKey, CharacterOptionsPageController.Groups.Select(g => g.HeaderKey)); } [Theory] [MemberData(nameof(RetailEnumNameCases))] public void RetailName_MatchesVerbatimAcclientEnumSpelling( CharacterOptionId id, string expectedRetailName) { // acclient.h:4162-4218's OWN PlayerOption enumerator names — the six // Hear*Chat ids (+ D3's HearPKDeaths) differ from acdream's own // ListenTo*Chat CharacterOptionId spelling; every other id matches // 1:1. Only a representative spot-check here — the FULL authored // list is pinned row-by-row in AuthoredOrder_MatchesResearchDocRowByRow. CharacterOptionsPageController.RowSpec row = AllRows().Single(r => r.Id == id); Assert.Equal(expectedRetailName, row.RetailName); } public static IEnumerable RetailEnumNameCases() { yield return [CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"]; yield return [CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"]; yield return [CharacterOptionId.ListenToTradeChat, "HearTradeChat"]; yield return [CharacterOptionId.ListenToLFGChat, "HearLFGChat"]; yield return [CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"]; yield return [CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"]; yield return [CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"]; // Spot-check a few that DON'T differ from CharacterOptionId's own name. yield return [CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"]; yield return [CharacterOptionId.MainPackPreferred, "MainPackPreferred"]; yield return [CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"]; } [Fact] public void AuthoredOrder_MatchesResearchDocRowByRow() { // docs/research/2026-08-10-options-panel-structure.md §7's exact // authored PlayerOption enumerator sequence, transcribed // independently of CharacterOptionsPageController.cs. CharacterOptionId[][] expectedGroups = [ [ CharacterOptionId.ViewCombatTarget, CharacterOptionId.SalvageMultiple, CharacterOptionId.MainPackPreferred, ], [ CharacterOptionId.VividTargetingIndicator, CharacterOptionId.ShowTooltips, CharacterOptionId.CoordinatesOnRadar, CharacterOptionId.SideBySideVitals, CharacterOptionId.SpellDuration, CharacterOptionId.DisableMostWeatherEffects, CharacterOptionId.DisableDistanceFog, CharacterOptionId.PersistentAtDay, CharacterOptionId.DisableHouseRestrictionEffects, CharacterOptionId.UseCraftSuccessDialog, CharacterOptionId.ConfirmVolatileRareUse, CharacterOptionId.DisplayTimeStamps, CharacterOptionId.FilterLanguage, CharacterOptionId.ShowHelm, CharacterOptionId.ShowCloak, ], [ CharacterOptionId.IgnoreAllegianceRequests, CharacterOptionId.IgnoreFellowshipRequests, CharacterOptionId.DisplayAllegianceLogonNotifications, CharacterOptionId.FellowshipShareXP, CharacterOptionId.FellowshipShareLoot, CharacterOptionId.FellowshipAutoAcceptRequests, ], [ CharacterOptionId.AcceptLootPermits, CharacterOptionId.UseDeception, CharacterOptionId.AllowGive, CharacterOptionId.IgnoreTradeRequests, CharacterOptionId.DragItemOnPlayerOpensSecureTrade, CharacterOptionId.DisplayDateOfBirth, CharacterOptionId.DisplayAge, CharacterOptionId.DisplayChessRank, CharacterOptionId.DisplayFishingSkill, CharacterOptionId.DisplayNumberDeaths, CharacterOptionId.DisplayNumberCharacterTitles, ], [ CharacterOptionId.ToggleRun, CharacterOptionId.AdvancedCombatUI, CharacterOptionId.AutoTarget, CharacterOptionId.AutoRepeatAttack, CharacterOptionId.UseChargeAttack, CharacterOptionId.LeadMissileTargets, CharacterOptionId.UseFastMissiles, ], [ CharacterOptionId.StayInChatMode, CharacterOptionId.ListenToAllegianceChat, CharacterOptionId.ListenToGeneralChat, CharacterOptionId.ListenToTradeChat, CharacterOptionId.ListenToLFGChat, CharacterOptionId.ListenToRoleplayChat, CharacterOptionId.ListenToSocietyChat, CharacterOptionId.HearPkDeathMessages, ], ]; for (int g = 0; g < expectedGroups.Length; g++) { CharacterOptionId[] actual = CharacterOptionsPageController.Groups[g].Rows .Select(static r => r.Id).ToArray(); Assert.Equal(expectedGroups[g], actual); } } // ── Behavioral: built against the committed fixture ───────────────────── 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 OptionsPanelLayoutConformanceTests /// uses for its own end-to-end template-mechanism test. 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 Dictionary Values { get; } = new(); public List<(CharacterOptionId Id, bool Value)> Sets { get; } = new(); public CharacterOptionsPageController.Bindings ToBindings() => new( CurrentValue: id => Values.TryGetValue(id, out bool v) && v, SetOption: (id, value) => { Values[id] = value; Sets.Add((id, value)); }); } private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal( Func? resolveString = null) { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); var calls = new List(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => calls.Add("toggle"), RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); bool bound = CharacterOptionsPageController.Bind( layout, controller.CharacterPage, MakeTemplateResolver(), resolveString ?? ((_, _) => null), fakeBindings.ToBindings()); return (controller, fakeBindings, bound); } [Fact] public void Bind_Succeeds_AndRegistersExactly50Rows() { (OptionsPanelController controller, _, bool bound) = BindReal(); Assert.True(bound); Assert.Equal(50, controller.CharacterPage.Rows.Count); } [Fact] public void Bind_SeedsEveryRowFromCurrentValue() { var fakeBindings = new FakeBindings(); // Seed a handful of ids ON; everything else defaults to off in the // fake's dictionary lookup. fakeBindings.Values[CharacterOptionId.ViewCombatTarget] = true; fakeBindings.Values[CharacterOptionId.IgnoreAllegianceRequests] = true; ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; bool bound = CharacterOptionsPageController.Bind( layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); // No SetOption calls yet — seeding reads CurrentValue, it never // writes back through the bindings. Assert.Empty(fakeBindings.Sets); } [Fact] public void ClickingARow_PublishesSetOption_WithTheAuthoredId() { (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); // AutoRepeatAttack (0x00) is the FIRST row's checkbox // (0x10000219) instantiated for the Character page — resolve it by // walking the built tree rather than layout.FindElement (which // collides across the Chat/Config pages' own same-id copies, per // OptionsPanelController's Apply/Reset/Defaults comment). IOptionRow row = Assert.Single( controller.CharacterPage.Rows.Skip(0).Take(1)); var boolRow = Assert.IsType(row); boolRow.SetCurrentValue(true); Assert.Single(bindings.Sets); } [Fact] public void Apply_CommitsBaseline_AndReset_NoLongerReverts() { (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); var row = Assert.IsType(controller.CharacterPage.Rows[0]); bool initial = row.Current; row.SetCurrentValue(!initial); Assert.True(controller.CharacterPage.Changed); controller.CharacterPage.Apply(); Assert.False(controller.CharacterPage.Changed); Assert.Equal(!initial, row.Saved); controller.CharacterPage.Reset(); // nothing changed since Apply -> no-op Assert.Equal(!initial, row.Current); } [Fact] public void Reset_RevertsToSavedBaseline_AndRePublishesTheRevertedValue() { (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); var row = Assert.IsType(controller.CharacterPage.Rows[0]); bool initial = row.Current; row.SetCurrentValue(!initial); bindings.Sets.Clear(); controller.CharacterPage.Reset(); Assert.Equal(initial, row.Current); Assert.Contains(bindings.Sets, s => s.Value == initial); } [Fact] public void Defaults_AppliesClientDefault_ForEveryRow_WithoutCommitting() { (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); // Drive every row to the OPPOSITE of its own default first. foreach (IOptionRow r in controller.CharacterPage.Rows) { var b = (BoolOptionRow)r; b.SetCurrentValue(!b.DefaultValue); } bindings.Sets.Clear(); controller.CharacterPage.Defaults(); foreach (IOptionRow r in controller.CharacterPage.Rows) { var b = (BoolOptionRow)r; Assert.Equal(b.DefaultValue, b.Current); } // Defaults applies live but does NOT commit — Changed stays true // for every row that isn't already equal to its own saved value. Assert.True(controller.CharacterPage.Changed); } [Fact] public void EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault() { // U1: the Defaults button restores PlayerModule::GetDefaultOptionValue // (CharacterOptionTable.ClientDefault), NOT a DAT DBPropertyCollection // read — see CharacterOptionsPageController's own type doc for the // full trace. This is the direct pin. (OptionsPanelController controller, _, _) = BindReal(); var rowsById = new Dictionary(); int i = 0; foreach (CharacterOptionsPageController.RowSpec spec in AllRows()) rowsById[spec.Id] = (BoolOptionRow)controller.CharacterPage.Rows[i++]; foreach ((CharacterOptionId id, BoolOptionRow row) in rowsById) { Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry)); Assert.Equal(entry.ClientDefault, row.DefaultValue); } } [Fact] public void TabHide_RevertsUncommittedCharacterEdits_ViaOnVisibilityChanged() { // Structure doc §3.6: switching tabs away reverts uncommitted edits // on the page you left — exercised through the SAME // OnActivePageChanged wiring OptionsPanelControllerTests already // covers for the (then-empty) Character page; this proves it still // holds once the page has REAL rows. (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); controller.ActivateTabs(); controller.TabPanel.SwitchTo(0x10000211u); // Character page slot var row = Assert.IsType(controller.CharacterPage.Rows[0]); bool initial = row.Current; row.SetCurrentValue(!initial); Assert.True(controller.CharacterPage.Changed); controller.TabPanel.SwitchTo(0x10000212u); // Gameplay page slot Assert.Equal(initial, row.Current); Assert.False(controller.CharacterPage.Changed); } // ── MUST-FIX 1 (OP4 review-fix round, 2026-08-11): the panel re-seeds // from the live binding on OnShown instead of the pre-login // constructor-default word it was constructed with. ───────────────── [Fact] public void OnShown_ReSeedsRow_FromLiveBindingValue_ChangedBehindItsBack() { // Simulates a fresh PlayerDescription landing (or simply "the // character's real server value differs from the word this row // was constructed with") without ever calling SetCurrentValue. (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); CharacterOptionId id = AllRows().First().Id; var row = Assert.IsType(controller.CharacterPage.Rows[0]); bool initial = row.Current; bindings.Values[id] = !initial; bindings.Sets.Clear(); controller.CharacterPage.OnShown(); Assert.Equal(!initial, row.Current); Assert.Equal(!initial, row.Saved); Assert.False(controller.CharacterPage.Changed); // The re-read must never round-trip through SetOption — that would // send the re-seeded value back out over the wire (retail's own // GetValue()-into-SaveCurrentValue never calls SetPlayerOption). Assert.Empty(bindings.Sets); } [Fact] public void OnShown_AllFiftyRows_ConvergeToTheLiveBindingSnapshot() { // The pre-login case: bind against a constructor-default word (the // fake's dictionary starts empty -> every row seeds false), THEN // the "server" state is populated (a PlayerDescription landing), // THEN the page is shown — every one of the 50 rows must converge, // matching retail's own InitOptions()+PostInit() / first tab- // activation schedule (SaveCurrentValue re-reads GetValue() live). var fakeBindings = new FakeBindings(); var random = new Random(20260811); foreach (CharacterOptionsPageController.RowSpec spec in AllRows()) fakeBindings.Values[spec.Id] = random.Next(2) == 0; ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; bool bound = CharacterOptionsPageController.Bind( layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); controller.CharacterPage.OnShown(); int i = 0; foreach (CharacterOptionsPageController.RowSpec spec in AllRows()) { var row = (BoolOptionRow)controller.CharacterPage.Rows[i++]; Assert.Equal(fakeBindings.Values[spec.Id], row.Current); Assert.Equal(fakeBindings.Values[spec.Id], row.Saved); } Assert.False(controller.CharacterPage.Changed); } [Fact] public void Reset_AfterReseed_RestoresTheLiveValue_NotTheStaleConstructionDefault() { // The historical bug MF-1 closes: before the fix, Reset/tab-switch // could only ever restore whatever the row was seeded with AT BIND // TIME (the pre-login constructor word) — visually-idempotent // "toggle then cancel" could silently mutate the server bit in the // wrong direction. After the fix, OnShown re-seeds _saved from the // live bit first, so Reset can only revert to what was ACTUALLY // live at the last show. (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); CharacterOptionId id = AllRows().First().Id; var row = Assert.IsType(controller.CharacterPage.Rows[0]); bindings.Values[id] = true; controller.CharacterPage.OnShown(); // re-seed: current == saved == true bindings.Sets.Clear(); row.SetCurrentValue(false); // user toggles it off, never clicks Apply controller.CharacterPage.Reset(); Assert.True(row.Current); Assert.Contains(bindings.Sets, s => s.Id == id && s.Value); } [Fact] public void ClickingTheRealCheckboxWidget_PublishesSetOption_ViaMouseDownUpClick() { // SF-2/S6 (OP4 review-fix round, 2026-08-11): every other test in // this suite drives BoolOptionRow.SetCurrentValue directly, which // would stay green even if the toggle template's checkbox // (0x10000219) ever lost its authored DAT property 0x0B // (UiButton.ToggleBehavior) — the mechanism the whole LED click // interaction rests on (mechanism review §1.5). This drives the // REAL MouseDown/MouseUp/Click sequence: MouseUp flips // UiButton.Selected FIRST (ToggleBehavior), then Click invokes // checkbox.OnClick, which reads the NEW Selected value. ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); bool bound = CharacterOptionsPageController.Bind( layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); Assert.True(bound); var listBox = Assert.IsType( layout.FindElement(CharacterOptionsPageController.ListBoxElementId)); var checkbox = Assert.IsType( UiElement.FindDescendant(listBox, 0x10000219u)); CharacterOptionId id = AllRows().First().Id; Assert.False(checkbox.Selected); checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseDown, Data1: 0, Data2: 0)); checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseUp, Data1: 0, Data2: 0)); checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.Click)); Assert.True(checkbox.Selected); var set = Assert.Single(fakeBindings.Sets); Assert.Equal(id, set.Id); Assert.True(set.Value); } [Fact] public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll() { ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); OptionsPanelController controller = OptionsPanelController.Bind( layout, new OptionsPanelController.Callbacks( Toggle: () => { }, RequestExitToCharacterSelection: () => { }, ExitGame: () => { }, UseMouseTurningSettings: () => { }, DisplaySystemMessage: _ => { }))!; var fakeBindings = new FakeBindings(); CharacterOptionsPageController.Bind( layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null, fakeBindings.ToBindings()); var listBox = Assert.IsType( layout.FindElement(CharacterOptionsPageController.ListBoxElementId)); var scrollbar = Assert.IsType( layout.FindElement(CharacterOptionsPageController.ScrollbarElementId)); Assert.Same(listBox.Scroll, scrollbar.Model); } [Fact] public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow() { // A layout whose root did not build the Character ListBox at all — // exercises the degrade-gracefully path. var emptyRoot = new ElementInfo { Id = 0, Type = 3 }; ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null); var page = new OptionPage(); bool bound = CharacterOptionsPageController.Bind( emptyLayout, page, MakeTemplateResolver(), (_, _) => null, new CharacterOptionsPageController.Bindings( CurrentValue: _ => false, SetOption: (_, _) => { })); Assert.False(bound); Assert.Empty(page.Rows); } [Fact] public void LabelResolutionFailure_LeavesCheckboxLabelNull_NeverInventsEnglish() { (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null); Assert.True(bound); // Every row still registers (structural build succeeds) even // though every string lookup returns null — "no invented text" // degrades to "no text", never a fabricated label. Assert.Equal(50, controller.CharacterPage.Rows.Count); } }