acdream/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
Erik 22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00

536 lines
21 KiB
C#

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;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for
/// <see cref="CharacterOptionsPageController"/> — the CH4 registry-
/// conformance pattern: every one of the 50 authored rows pinned against
/// <see cref="CharacterOptionTable"/> 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 <c>options_panel_2100006E_1000018D.json</c> fixture — no
/// live DAT access, following the same hermetic pattern
/// <c>OptionsPanelControllerTests</c>/<c>OptionsPanelLayoutConformanceTests</c>
/// already established.
/// </summary>
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<CharacterOptionsPageController.RowSpec> 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]
[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<object[]> 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;
}
/// <summary>The SAME "resolve a row template from the standalone
/// 0x2100002B fixture" resolver <c>OptionsPanelLayoutConformanceTests</c>
/// uses for its own end-to-end template-mechanism test.</summary>
private static Func<uint, uint, UiElement?> 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<CharacterOptionId, bool> 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<uint, uint, string?>? resolveString = null)
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
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<BoolOptionRow>(row);
boolRow.SetCurrentValue(true);
Assert.Single(bindings.Sets);
}
[Fact]
public void Apply_CommitsBaseline_AndReset_NoLongerReverts()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = Assert.IsType<BoolOptionRow>(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<BoolOptionRow>(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<CharacterOptionId, BoolOptionRow>();
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<BoolOptionRow>(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);
}
[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<UiTemplateListBox>(
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
var scrollbar = Assert.IsType<UiScrollbar>(
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);
}
}