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>
This commit is contained in:
parent
7e9f372ce8
commit
22b86b9ff4
25 changed files with 1674 additions and 41 deletions
|
|
@ -0,0 +1,62 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP4 (2026-08-11) — D7 Group-C re-point:
|
||||
/// <see cref="CharacterOptionCombatSettingsSource"/> reads
|
||||
/// AutoTarget/AutoRepeatAttack/ViewCombatTarget from the canonical
|
||||
/// <see cref="RuntimeCharacterOptionsState"/> instead of the client-local
|
||||
/// <c>GameplaySettings</c> record <see cref="GameplaySettingsState"/> used
|
||||
/// to be the only implementation of.
|
||||
/// </summary>
|
||||
public sealed class CharacterOptionCombatSettingsSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsLiveBits_NotAConstructionTimeSnapshot()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.SetOptionBit((uint)CharacterOptionId.AutoTarget, false);
|
||||
options.SetOptionBit((uint)CharacterOptionId.AutoRepeatAttack, false);
|
||||
options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, false);
|
||||
var source = new CharacterOptionCombatSettingsSource(options);
|
||||
|
||||
Assert.False(source.AutoTarget);
|
||||
Assert.False(source.AutoRepeatAttack);
|
||||
Assert.False(source.ViewCombatTarget);
|
||||
|
||||
// Live: a later write is observed with no re-construction, matching
|
||||
// ICombatGameplaySettingsSource's poll-per-call contract.
|
||||
options.SetOptionBit((uint)CharacterOptionId.AutoTarget, true);
|
||||
Assert.True(source.AutoTarget);
|
||||
Assert.False(source.AutoRepeatAttack);
|
||||
Assert.False(source.ViewCombatTarget);
|
||||
|
||||
options.SetOptionBit((uint)CharacterOptionId.AutoRepeatAttack, true);
|
||||
options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, true);
|
||||
Assert.True(source.AutoRepeatAttack);
|
||||
Assert.True(source.ViewCombatTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReflectsClientDefaults_ForAFreshCharacterOptionsState()
|
||||
{
|
||||
// CharacterOptionTable's byte-verified client-Defaults column:
|
||||
// AutoTarget/AutoRepeatAttack ON, ViewCombatTarget OFF.
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
var source = new CharacterOptionCombatSettingsSource(options);
|
||||
|
||||
Assert.True(source.AutoTarget);
|
||||
Assert.True(source.AutoRepeatAttack);
|
||||
Assert.False(source.ViewCombatTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_RejectsNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
static () => new CharacterOptionCombatSettingsSource(null!));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,46 @@ public sealed class WeatherSystemTests
|
|||
}
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): DisableDistanceFogSource —
|
||||
// PlayerOption DisableDistanceFog -> LScape::m_fFogEnabled = !value.
|
||||
|
||||
[Fact]
|
||||
public void DisableDistanceFogSource_Unbound_LeavesKeyframeFogModeUnchanged()
|
||||
{
|
||||
var sys = new WeatherSystem();
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
Assert.Equal(kf.FogMode, snap.FogMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisableDistanceFogSource_True_ForcesFogModeOff()
|
||||
{
|
||||
var sys = new WeatherSystem { DisableDistanceFogSource = () => true };
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
Assert.NotEqual(FogMode.Off, kf.FogMode); // sanity: the keyframe itself authors real fog
|
||||
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
Assert.Equal(FogMode.Off, snap.FogMode);
|
||||
// Distances are left alone (the shader never reads them once off).
|
||||
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
|
||||
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisableDistanceFogSource_False_LeavesFogModeAlone()
|
||||
{
|
||||
var sys = new WeatherSystem { DisableDistanceFogSource = () => false };
|
||||
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
||||
|
||||
var snap = sys.Snapshot(in kf);
|
||||
|
||||
Assert.Equal(kf.FogMode, snap.FogMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironOverride_ForcesTintedFog()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -286,6 +286,45 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.Equal(beforeRevision, options.Revision);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): GetOptionBit — the read
|
||||
// counterpart every Character-tab row seed and every re-pointed
|
||||
// Group-C consumer polls.
|
||||
|
||||
[Theory]
|
||||
[InlineData(CharacterOptionId.AutoTarget)]
|
||||
[InlineData(CharacterOptionId.ViewCombatTarget)]
|
||||
[InlineData(CharacterOptionId.ListenToGeneralChat)]
|
||||
[InlineData(CharacterOptionId.DisableDistanceFog)]
|
||||
public void GetOptionBit_RoundTripsWithSetOptionBit(CharacterOptionId id)
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
|
||||
options.SetOptionBit((uint)id, true);
|
||||
Assert.True(options.GetOptionBit(id));
|
||||
Assert.True(options.GetOptionBit((uint)id));
|
||||
|
||||
options.SetOptionBit((uint)id, false);
|
||||
Assert.False(options.GetOptionBit(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOptionBit_UnrecognizedId_ReturnsFalse()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
Assert.False(options.GetOptionBit(0xFFFFu));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOptionBit_ReflectsReplace_NotJustSetOptionBit()
|
||||
{
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
Assert.True(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); // default ON
|
||||
|
||||
options.Replace(options.Options1, 0u); // every Options2 bit off, incl. ListenToGeneralChat
|
||||
|
||||
Assert.False(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat));
|
||||
}
|
||||
|
||||
// ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared
|
||||
// local-write-then-send/dirty seam, + the dirty/flush state machine ────
|
||||
|
||||
|
|
|
|||
|
|
@ -241,6 +241,59 @@ public sealed class RuntimeCommunicationStateTests
|
|||
Assert.Equal("", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): DisplayTimestampsSource —
|
||||
// PlayerOption DisplayTimeStamps prefix.
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsUnbound_NoPrefix()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState();
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsFalse_NoPrefix()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => false };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_PrefixesTranscriptLine()
|
||||
{
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
|
||||
|
||||
string text = state.Chat.Snapshot()[0].Text;
|
||||
Assert.EndsWith("Your spell fizzled.", text);
|
||||
Assert.NotEqual("Your spell fizzled.", text);
|
||||
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
|
||||
// hour, zero-padded minute:second, trailing space before the
|
||||
// text) — .NET "H:mm:ss " is the exact equivalent.
|
||||
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox()
|
||||
{
|
||||
// Retail's own ClientLocal (0x1A) exemption already skips the
|
||||
// whole AddTextToScroll destination — timestamps are a transcript
|
||||
// concept, never applied to the transient SpewBox line.
|
||||
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
|
||||
|
||||
state.AddText("Out of Range!", RetailLogTextType.ClientLocal);
|
||||
|
||||
state.SpewBox.Tick(0d);
|
||||
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_ResetsSpewBox()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -574,4 +574,31 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
|||
controller.ApplyServerPhysicsState(initial));
|
||||
Assert.Equal(pushed, body.State);
|
||||
}
|
||||
|
||||
// ── OP4 (Campaign OP, 2026-08-11): RunAsDefaultMovementSource —
|
||||
// PlayerOption RunAsDefaultMovement, polled.
|
||||
|
||||
[Fact]
|
||||
public void RunAsDefaultMovement_Unbound_DefaultsToTrue()
|
||||
{
|
||||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
|
||||
Assert.Null(movement.RunAsDefaultMovementSource);
|
||||
Assert.True(movement.RunAsDefaultMovement);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void RunAsDefaultMovement_ReflectsBoundSourceLive(bool value)
|
||||
{
|
||||
var movement = new RuntimeLocalPlayerMovementState();
|
||||
bool current = value;
|
||||
movement.RunAsDefaultMovementSource = () => current;
|
||||
|
||||
Assert.Equal(value, movement.RunAsDefaultMovement);
|
||||
|
||||
current = !value;
|
||||
Assert.Equal(!value, movement.RunAsDefaultMovement); // polled, not cached
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue