feat(ui): Campaign OP slice OP6 — the Config tab
Binds the retail Options panel's Config tab (LayoutDesc 0x21000029, 27 authored rows across 6 sections) through OP2's template mechanism and OP3's per-page OptionPage model, matching the Character/Chat tab controllers' established pattern. The row table is transcribed directly from two decompiled sources — gmConfigUI::InitOptions @0x0049E400 (row order, widget shape, defaults) and gmClient::InitUIPreferences @0x004035b0 (the complete UIPreferences::AttachPreference registration: every label/tooltip key, every slider's real-unit range, every menu's enum choices) — which resolves the research docs' own "U4" unverified slider-caption pairing: retail ships ZERO range captions on this tab (every SetSliderLabel call passes literal string id 0). Consumer disposition: LIVE — Sound/Ambient volume-trio sliders and their toggle halves (AudioSettings.SfxDisabled/AmbientDisabled now gate the already-live engine write; RuntimeSettingsController.SaveAudio newly pushes into OpenAlAudioEngine on every change, not just at startup), Resolution/Full Screen (immediate window resize on save). NEXT-LAUNCH (pre-existing precedent): Sync To Refresh, Field of View. STORE-ONLY (register rows AP-198/199/200, TS-74 extended): Sound Features/Interface trio/Play-Only-When-Active, the nine Graphics/Rendering-Quality rows (Vulkan has no per-feature render knobs), Camera/Input's six rows and Use Mouse Turning (no persistent mouse-turning camera mode), Chat Font Face/Size (distinct new fields from the existing live ChatSettings.FontSize). AudioSettings/DisplaySettings/CameraTurningSettings/ChatSettings each gain new fields for their slice of the 27 rows, backed by SettingsStore round-trips. A real bug caught by testing: the scrollbar scope lookup used the standalone-layout root id (0x100001FF), which does not survive base-merge into the host-mounted tree — fixed to scope from the tab host's own page-slot id (0x10000213), matching Chat's established pattern for the same shared-scrollbar-id hazard (0x10000201, authored by both the Chat and Config ListBoxes). 30 new tests (27 authored rows register as 30 IOptionRow instances — the three toggle+slider trios each register two). Full Release suite: 13,107 passed / 4 skipped / 0 failed (was 13,083/4/0 — net +24, the one existing RuntimeSettingsControllerTests case updated for SaveAudio's new live-apply call, not a regression). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
527a3a4056
commit
f5ac1742ba
16 changed files with 2188 additions and 30 deletions
|
|
@ -300,6 +300,10 @@ public sealed class RuntimeSettingsControllerTests
|
|||
"target-display",
|
||||
"target-quality",
|
||||
"save-audio",
|
||||
// Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the
|
||||
// saved snapshot into the live engine, same shape as
|
||||
// save-display's own target-display push a few lines up.
|
||||
"target-audio",
|
||||
"save-gameplay",
|
||||
"target-ui-lock:True",
|
||||
"save-chat",
|
||||
|
|
@ -1153,6 +1157,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
throw new InvalidOperationException("quality target failed");
|
||||
}
|
||||
|
||||
public List<AudioSettings> AudioCalls { get; } = [];
|
||||
|
||||
public void ApplyAudio(AudioSettings audio)
|
||||
{
|
||||
AudioCalls.Add(audio);
|
||||
events.Add("target-audio");
|
||||
}
|
||||
|
||||
public void ApplyUiLock(bool locked)
|
||||
{
|
||||
UiLockCalls++;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,596 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP6 (2026-08-11) conformance + behavior tests for
|
||||
/// <see cref="ConfigOptionsPageController"/> — the same hermetic pattern
|
||||
/// <c>CharacterOptionsPageControllerTests</c>/<c>ChatOptionsPageControllerTests</c>
|
||||
/// established: pure-data conformance against the byte-verified decomp
|
||||
/// tables (<c>gmConfigUI::InitOptions</c> + <c>gmClient::InitUIPreferences</c>),
|
||||
/// then behavioral tests against the committed
|
||||
/// <c>options_panel_2100006E_1000018D.json</c> + <c>options_2100002B.json</c>
|
||||
/// fixtures — no live DAT access. Also covers the shared scrollbar-id hazard
|
||||
/// (Config and Chat both author element <c>0x10000201</c>) and a settings-
|
||||
/// store round-trip per record OP6 touched.
|
||||
/// </summary>
|
||||
public sealed class ConfigOptionsPageControllerTests
|
||||
{
|
||||
// ── Pure data conformance ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SixSectionHeaders_AreDistinct()
|
||||
{
|
||||
string[] headers =
|
||||
[
|
||||
"ID_Sound_SoundSection",
|
||||
"ID_Camera_CameraSection",
|
||||
"ID_Graphics_GraphicsSection",
|
||||
"ID_Graphics_TextureSection",
|
||||
"ID_Input_InputSection",
|
||||
"ID_UI_UISection",
|
||||
];
|
||||
Assert.Equal(6, headers.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AudioSettings_Default_MatchesRetailByteVerifiedTrioDefaults()
|
||||
{
|
||||
// gmClient::InitUIPreferences: every Sound trio's checkbox defaults
|
||||
// to CHECKED ("Disabled"=true) — SetDefaultValue(1, 0x3f800000) in
|
||||
// gmConfigUI::InitOptions, ported faithfully (see
|
||||
// AudioSettings.Default's own doc for why this looks backwards but
|
||||
// isn't a "fix").
|
||||
AudioSettings d = AudioSettings.Default;
|
||||
Assert.Equal(0, d.SoundFeatures);
|
||||
Assert.True(d.SfxDisabled);
|
||||
Assert.True(d.AmbientDisabled);
|
||||
Assert.True(d.InterfaceDisabled);
|
||||
Assert.Equal(1.0f, d.InterfaceVolume);
|
||||
Assert.True(d.PlaySoundOnlyWhenActive);
|
||||
Assert.Equal(1.0f, d.Sfx);
|
||||
Assert.Equal(1.0f, d.Ambient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraTurningSettings_Default_MatchesRetailByteVerifiedConfigDefaults()
|
||||
{
|
||||
CameraTurningSettings d = CameraTurningSettings.Default;
|
||||
Assert.Equal(0.45f, d.Stiffness);
|
||||
Assert.Equal(40.0f, d.AdjustmentSpeed);
|
||||
Assert.Equal(0.55f, d.MouseLookSensitivity);
|
||||
Assert.True(d.AlignToSlope);
|
||||
Assert.False(d.InvertMouseLookYAxis);
|
||||
Assert.False(d.UseMouseTurning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplaySettings_Default_MatchesRetailByteVerifiedConfigDefaults()
|
||||
{
|
||||
DisplaySettings d = DisplaySettings.Default;
|
||||
Assert.False(d.AutomaticDegrades);
|
||||
Assert.Equal(0f, d.GraphicsPerformance);
|
||||
Assert.Equal(50f, d.DegradeDistance);
|
||||
Assert.Equal(2, d.LandscapeTextureDetail);
|
||||
Assert.Equal(1, d.EnvironmentTextureDetail);
|
||||
Assert.Equal(1, d.TextureFiltering);
|
||||
Assert.Equal(8, d.LandscapeDrawDistance);
|
||||
Assert.True(d.BuildingDetailTextures);
|
||||
Assert.False(d.MultiPassAlpha);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatSettings_Default_MatchesRetailByteVerifiedConfigDefaults()
|
||||
{
|
||||
ChatSettings d = ChatSettings.Default;
|
||||
Assert.Equal(2, d.ChatFontFace);
|
||||
Assert.Equal(1, d.ChatFontSizeIndex);
|
||||
}
|
||||
|
||||
// ── Settings-store round trips (SettingsStore, no live DAT) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void SettingsStore_AudioRoundTrip_PreservesTheSixOP6Fields()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"acdream-op6-audio-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var store = new SettingsStore(path);
|
||||
var written = AudioSettings.Default with
|
||||
{
|
||||
SoundFeatures = 1,
|
||||
SfxDisabled = false,
|
||||
AmbientDisabled = false,
|
||||
InterfaceDisabled = false,
|
||||
InterfaceVolume = 0.4f,
|
||||
PlaySoundOnlyWhenActive = false,
|
||||
};
|
||||
store.SaveAudio(written);
|
||||
AudioSettings read = store.LoadAudio();
|
||||
Assert.Equal(written, read);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsStore_DisplayRoundTrip_PreservesTheNineOP6Fields()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"acdream-op6-display-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var store = new SettingsStore(path);
|
||||
var written = DisplaySettings.Default with
|
||||
{
|
||||
AutomaticDegrades = true,
|
||||
GraphicsPerformance = 0.5f,
|
||||
DegradeDistance = 75f,
|
||||
LandscapeTextureDetail = 4,
|
||||
EnvironmentTextureDetail = 3,
|
||||
TextureFiltering = 2,
|
||||
LandscapeDrawDistance = 3,
|
||||
BuildingDetailTextures = false,
|
||||
MultiPassAlpha = true,
|
||||
};
|
||||
store.SaveDisplay(written);
|
||||
DisplaySettings read = store.LoadDisplay();
|
||||
Assert.Equal(written, read);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsStore_CameraTurningRoundTrip_PreservesUseMouseTurning()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"acdream-op6-camera-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var store = new SettingsStore(path);
|
||||
var written = CameraTurningSettings.Default with { UseMouseTurning = true };
|
||||
store.SaveCameraTurning(written);
|
||||
CameraTurningSettings read = store.LoadCameraTurning();
|
||||
Assert.Equal(written, read);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsStore_ChatRoundTrip_PreservesFontFaceAndSize()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"acdream-op6-chat-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
var store = new SettingsStore(path);
|
||||
var written = ChatSettings.Default with { ChatFontFace = 0, ChatFontSizeIndex = 3 };
|
||||
store.SaveChat(written);
|
||||
ChatSettings read = store.LoadChat();
|
||||
Assert.Equal(written.ChatFontFace, read.ChatFontFace);
|
||||
Assert.Equal(written.ChatFontSizeIndex, read.ChatFontSizeIndex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/// <summary>The SAME "resolve a row template from the standalone
|
||||
/// 0x2100002B fixture" resolver every other Options-panel page-controller
|
||||
/// test file uses — Config's own template array points at 0x2100002B too
|
||||
/// (the tab-host layout), not the standalone 0x21000029 fixture.</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 DisplaySettings Display = DisplaySettings.Default;
|
||||
public AudioSettings Audio = AudioSettings.Default;
|
||||
public CameraTurningSettings CameraTurning = CameraTurningSettings.Default;
|
||||
public ChatSettings Chat = ChatSettings.Default;
|
||||
|
||||
public List<DisplaySettings> DisplaySaves { get; } = new();
|
||||
public List<AudioSettings> AudioSaves { get; } = new();
|
||||
public List<CameraTurningSettings> CameraTurningSaves { get; } = new();
|
||||
public List<ChatSettings> ChatSaves { get; } = new();
|
||||
|
||||
public ConfigOptionsPageController.Bindings ToBindings() => new(
|
||||
LoadDisplay: () => Display,
|
||||
SaveDisplay: value => { Display = value; DisplaySaves.Add(value); },
|
||||
LoadAudio: () => Audio,
|
||||
SaveAudio: value => { Audio = value; AudioSaves.Add(value); },
|
||||
LoadCameraTurning: () => CameraTurning,
|
||||
SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); },
|
||||
LoadChat: () => Chat,
|
||||
SaveChat: value => { Chat = value; ChatSaves.Add(value); });
|
||||
}
|
||||
|
||||
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
|
||||
Func<uint, uint, string?>? 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 = ConfigOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ConfigPage,
|
||||
MakeTemplateResolver(),
|
||||
resolveString ?? ((_, _) => null),
|
||||
fakeBindings.ToBindings());
|
||||
|
||||
return (controller, fakeBindings, bound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Succeeds_AndRegistersExactly30Rows()
|
||||
{
|
||||
// 27 visible rows; the 3 toggle+slider trios each register TWO
|
||||
// IOptionRow instances (toggle half + slider half) — 27 + 3 = 30,
|
||||
// matching the doc's own "27 Add* calls... three toggle+slider rows
|
||||
// are one widget each" accounting.
|
||||
(OptionsPanelController controller, _, bool bound) = BindReal();
|
||||
|
||||
Assert.True(bound);
|
||||
Assert.Equal(30, controller.ConfigPage.Rows.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RowTypeSequence_MatchesAuthoredSectionOrder()
|
||||
{
|
||||
(OptionsPanelController controller, _, bool bound) = BindReal();
|
||||
Assert.True(bound);
|
||||
|
||||
Type[] expected =
|
||||
[
|
||||
typeof(IntOptionRow), // Sound Features menu
|
||||
typeof(BoolOptionRow), typeof(FloatOptionRow), // Sound trio
|
||||
typeof(BoolOptionRow), typeof(FloatOptionRow), // Ambient trio
|
||||
typeof(BoolOptionRow), typeof(FloatOptionRow), // Interface trio
|
||||
typeof(BoolOptionRow), // Play sound only when active
|
||||
|
||||
typeof(FloatOptionRow), // Camera Stiffness
|
||||
typeof(FloatOptionRow), // Camera Adjustment Speed
|
||||
typeof(FloatOptionRow), // Field of View
|
||||
typeof(BoolOptionRow), // Align To Slope
|
||||
|
||||
typeof(StringOptionRow), // Resolution
|
||||
typeof(BoolOptionRow), // Full Screen
|
||||
typeof(BoolOptionRow), // Sync To Refresh
|
||||
typeof(FloatOptionRow), // Screen Brightness
|
||||
typeof(BoolOptionRow), // Automatic Degrades
|
||||
typeof(FloatOptionRow), // Graphics Performance
|
||||
typeof(FloatOptionRow), // Degrade Distance
|
||||
|
||||
typeof(IntOptionRow), // Landscape Texture Detail
|
||||
typeof(IntOptionRow), // Environment Texture Detail
|
||||
typeof(IntOptionRow), // Texture Filtering
|
||||
typeof(IntOptionRow), // Landscape Draw Distance
|
||||
typeof(BoolOptionRow), // Building Detail Textures
|
||||
typeof(BoolOptionRow), // Multi-Pass Alpha
|
||||
|
||||
typeof(FloatOptionRow), // Mouse Look Sensitivity
|
||||
typeof(BoolOptionRow), // Invert Mouselook Y Axis
|
||||
typeof(BoolOptionRow), // Use Mouse Turning
|
||||
|
||||
typeof(IntOptionRow), // Chat Font Face
|
||||
typeof(IntOptionRow), // Chat Font Size
|
||||
];
|
||||
|
||||
Type[] actual = controller.ConfigPage.Rows.Select(r => r.GetType()).ToArray();
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleRow_SfxDisabled_WritesThroughAudioBindings()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
|
||||
|
||||
row.SetCurrentValue(!AudioSettings.Default.SfxDisabled);
|
||||
|
||||
Assert.Single(bindings.AudioSaves);
|
||||
Assert.Equal(!AudioSettings.Default.SfxDisabled, bindings.AudioSaves[0].SfxDisabled);
|
||||
// The slider half's own field must be untouched by the toggle write.
|
||||
Assert.Equal(AudioSettings.Default.Sfx, bindings.AudioSaves[0].Sfx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SliderRow_SoundVolume_ConvertsScalarToRealUnitRange()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (FloatOptionRow)controller.ConfigPage.Rows[2]; // Sound trio slider, [0,1]
|
||||
|
||||
row.SetCurrentValue(0.25f);
|
||||
|
||||
Assert.Equal(0.25f, bindings.AudioSaves[^1].Sfx);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SliderRow_CameraAdjustmentSpeed_ConvertsRealUnitOutOf0To1Range()
|
||||
{
|
||||
// Camera_AdjustmentSpeed's retail range is [5,80] — the widget only
|
||||
// ever sees a normalized [0,1] scalar; SetCurrentValue below drives
|
||||
// the ROW directly in real units (what CameraTurningSettings stores),
|
||||
// so this pins the row-level write, not the widget conversion (a
|
||||
// separate concern already covered by ToNormalized/FromNormalized's
|
||||
// own math, exercised implicitly by every slider apply/refresh path).
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (FloatOptionRow)controller.ConfigPage.Rows[9]; // AdjustmentSpeed
|
||||
|
||||
row.SetCurrentValue(62.5f);
|
||||
|
||||
Assert.Equal(62.5f, bindings.CameraTurningSaves[^1].AdjustmentSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuRow_SoundFeatures_WritesThroughAudioBindings()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (IntOptionRow)controller.ConfigPage.Rows[0]; // Sound Features
|
||||
|
||||
row.SetCurrentValue(1);
|
||||
|
||||
Assert.Equal(1, bindings.AudioSaves[^1].SoundFeatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (StringOptionRow)controller.ConfigPage.Rows[12]; // Resolution
|
||||
|
||||
row.SetCurrentValue("1920x1080");
|
||||
|
||||
Assert.Equal("1920x1080", bindings.DisplaySaves[^1].Resolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleRow_UseMouseTurning_WritesTheConfigTabOwnPreference_NotTheWireBit()
|
||||
{
|
||||
// CameraTurningSettings.UseMouseTurning is the Config tab's OWN
|
||||
// client-local checkbox — distinct from the Gameplay-tab macro's
|
||||
// server-synced PlayerOption.UseMouseTurning bit. This pins that
|
||||
// this row writes ONLY the store, never a wire command (there is no
|
||||
// wire seam wired into ConfigOptionsPageController.Bindings at all).
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (BoolOptionRow)controller.ConfigPage.Rows[27]; // Use Mouse Turning
|
||||
|
||||
row.SetCurrentValue(true);
|
||||
|
||||
Assert.True(bindings.CameraTurningSaves[^1].UseMouseTurning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuRow_ChatFontSize_WritesThroughChatBindings_WithoutTouchingHearFlags()
|
||||
{
|
||||
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
||||
var row = (IntOptionRow)controller.ConfigPage.Rows[29]; // Chat Font Size
|
||||
|
||||
row.SetCurrentValue(3);
|
||||
|
||||
Assert.Equal(3, bindings.ChatSaves[^1].ChatFontSizeIndex);
|
||||
Assert.Equal(ChatSettings.Default.HearGeneralChat, bindings.ChatSaves[^1].HearGeneralChat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_CommitsBaseline_ForAMixOfRowTypes()
|
||||
{
|
||||
(OptionsPanelController controller, _, _) = BindReal();
|
||||
var toggle = (BoolOptionRow)controller.ConfigPage.Rows[1];
|
||||
var slider = (FloatOptionRow)controller.ConfigPage.Rows[2];
|
||||
var menu = (IntOptionRow)controller.ConfigPage.Rows[0];
|
||||
|
||||
toggle.SetCurrentValue(!toggle.Current);
|
||||
slider.SetCurrentValue(0.1f);
|
||||
menu.SetCurrentValue(1);
|
||||
Assert.True(controller.ConfigPage.Changed);
|
||||
|
||||
controller.ConfigPage.Apply();
|
||||
|
||||
Assert.False(controller.ConfigPage.Changed);
|
||||
Assert.Equal(toggle.Current, toggle.Saved);
|
||||
Assert.Equal(slider.Current, slider.Saved);
|
||||
Assert.Equal(menu.Current, menu.Saved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_RestoresRetailDefaultValue_ForEveryRow_WithoutCommitting()
|
||||
{
|
||||
(OptionsPanelController controller, _, _) = BindReal();
|
||||
foreach (IOptionRow r in controller.ConfigPage.Rows)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case BoolOptionRow b: b.SetCurrentValue(!b.DefaultValue); break;
|
||||
case FloatOptionRow f: f.SetCurrentValue(f.DefaultValue + 1000f); break;
|
||||
case IntOptionRow i: i.SetCurrentValue(i.DefaultValue + 1); break;
|
||||
case StringOptionRow s: s.SetCurrentValue(s.DefaultValue + "-x"); break;
|
||||
}
|
||||
}
|
||||
|
||||
controller.ConfigPage.Defaults();
|
||||
|
||||
foreach (IOptionRow r in controller.ConfigPage.Rows)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case BoolOptionRow b: Assert.Equal(b.DefaultValue, b.Current); break;
|
||||
case FloatOptionRow f: Assert.Equal(f.DefaultValue, f.Current); break;
|
||||
case IntOptionRow i: Assert.Equal(i.DefaultValue, i.Current); break;
|
||||
case StringOptionRow s: Assert.Equal(s.DefaultValue, s.Current); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tab host's own private per-page SLOT ids (OptionsPanelController's
|
||||
// ConfigPageId/ChatPageId) — the ids that actually survive base-merge in
|
||||
// the host-mounted tree (each page's own standalone-layout root id does
|
||||
// NOT survive; see ConfigOptionsPageController.PageSlotElementId's own
|
||||
// doc). Both controllers keep their own copy private; these test-local
|
||||
// literals mirror them for scoped lookups exactly the way the
|
||||
// controllers themselves scope their scrollbar linkage.
|
||||
private const uint ConfigPageSlotId = 0x10000213u;
|
||||
private const uint ChatPageSlotId = 0x1000050Cu;
|
||||
|
||||
[Fact]
|
||||
public void ScrollbarLinkage_ModelPointsAtTheConfigListBoxScroll()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
var fakeBindings = new FakeBindings();
|
||||
bool bound = ConfigOptionsPageController.Bind(
|
||||
layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null,
|
||||
fakeBindings.ToBindings());
|
||||
Assert.True(bound);
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var listBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
var scrollbar = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId));
|
||||
|
||||
Assert.Same(listBox.Scroll, scrollbar.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedScrollbarId_ChatAndConfigBoundTogether_EachOwnsItsOwnScrollbar()
|
||||
{
|
||||
// The shared-id hazard this campaign's binding pattern exists for:
|
||||
// Chat's ListBox (0x1000050D) and Config's ListBox (0x10000200) both
|
||||
// author scrollbar element id 0x10000201. Binding BOTH pages against
|
||||
// the SAME host layout must not let the second Bind() call clobber
|
||||
// the first page's scrollbar linkage (or vice versa).
|
||||
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
||||
OptionsPanelController controller = OptionsPanelController.Bind(
|
||||
layout,
|
||||
new OptionsPanelController.Callbacks(
|
||||
Toggle: () => { },
|
||||
RequestExitToCharacterSelection: () => { },
|
||||
ExitGame: () => { },
|
||||
UseMouseTurningSettings: () => { },
|
||||
DisplaySystemMessage: _ => { }))!;
|
||||
|
||||
var chatBindings = new ChatOptionsPageControllerFakeBindings();
|
||||
bool chatBound = ChatOptionsPageController.Bind(
|
||||
layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null,
|
||||
chatBindings.ToBindings());
|
||||
var configBindings = new FakeBindings();
|
||||
bool configBound = ConfigOptionsPageController.Bind(
|
||||
layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null,
|
||||
configBindings.ToBindings());
|
||||
|
||||
Assert.True(chatBound);
|
||||
Assert.True(configBound);
|
||||
|
||||
var chatSlot = UiElement.FindDescendant(controller.TabPanel, ChatPageSlotId)!;
|
||||
var chatListBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ListBoxElementId));
|
||||
var chatScrollbar = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ScrollbarElementId));
|
||||
|
||||
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
||||
var configListBox = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
||||
var configScrollbar = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId));
|
||||
|
||||
Assert.Same(chatListBox.Scroll, chatScrollbar.Model);
|
||||
Assert.Same(configListBox.Scroll, configScrollbar.Model);
|
||||
Assert.NotSame(chatScrollbar, configScrollbar);
|
||||
Assert.NotSame(chatListBox.Scroll, configListBox.Scroll);
|
||||
}
|
||||
|
||||
private sealed class ChatOptionsPageControllerFakeBindings
|
||||
{
|
||||
public float DefaultOpacity = 0.5f;
|
||||
public float ActiveOpacity = 1.0f;
|
||||
|
||||
public ChatOptionsPageController.Bindings ToBindings() => new(
|
||||
CurrentDefaultOpacity: () => DefaultOpacity,
|
||||
CurrentActiveOpacity: () => ActiveOpacity,
|
||||
SetDefaultOpacity: value => DefaultOpacity = value,
|
||||
SetActiveOpacity: value => ActiveOpacity = value,
|
||||
DefaultOpacityDatDefault: 0.5f,
|
||||
ActiveOpacityDatDefault: 1.0f,
|
||||
CurrentFilter: _ => 0xFBFFFFFFul,
|
||||
SetFilter: (_, _) => { });
|
||||
}
|
||||
|
||||
[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();
|
||||
var fakeBindings = new FakeBindings();
|
||||
|
||||
bool bound = ConfigOptionsPageController.Bind(
|
||||
emptyLayout, page, MakeTemplateResolver(), (_, _) => null,
|
||||
fakeBindings.ToBindings());
|
||||
|
||||
Assert.False(bound);
|
||||
Assert.Empty(page.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LabelResolutionFailure_LeavesLabelsNull_NeverInventsEnglish_AndStillRegistersAllRows()
|
||||
{
|
||||
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null);
|
||||
|
||||
Assert.True(bound);
|
||||
Assert.Equal(30, controller.ConfigPage.Rows.Count);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue