Root cause: DatWidgetFactory builds Config-tab Type-0x10000038 menu leaves as bare UiMenu instances (matching the vendor/chat channel menu pattern), but unlike those two controllers, ConfigOptionsPageController never wired the menu's sprite/font/geometry properties after Bind — so every dropdown rendered as plain text with no button well, no arrow cap, and opened no popup on click (#374's fix only corrected click ROUTING, not the missing chrome). Fix: ConfigOptionsPageController.ApplyMenuChrome wires every Config-tab menu row with the SAME retail sprite ids VendorUiController/ ChatWindowController's channel menu already use for this shared popup catalog (LayoutDesc 0x21000043), verified against the live DAT via OptionsPanelLiveMountProbeTests' ProbeConfigMenuChrome/ ProbeConfigMenuPopupChrome probes. Regressed by ConfigOptionsPageControllerTests.MenuRow_SoundFeatures_OpensAndSelects ThroughRealHitPath_UsingAuthoredPopupGeometry, which drives the real click-to-open + item-pick path through the authored popup geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1064 lines
48 KiB
C#
1064 lines
48 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
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()
|
|
{
|
|
// OP6 rework (2026-08-11, review M2): every Sound trio's checkbox
|
|
// defaults to CHECKED — SetDefaultValue(1, 0x3f800000) in
|
|
// gmConfigUI::InitOptions — AND checked means ENABLED, not
|
|
// "Disabled" (SoundManager::effect_sounds_enabled/
|
|
// ambient_sounds_enabled/interface_sounds_enabled are all compiled
|
|
// = 1 in .data, and UserPreferences::RegisterPreference binds the
|
|
// checkbox's boolean value directly onto those statics — see
|
|
// AudioSettings' own class doc for the full byte evidence). A
|
|
// fresh character therefore hears sound by default; this is NOT
|
|
// the "looks backwards but isn't a fix" case the rejected slice
|
|
// claimed — the rejected slice's inversion was the actual bug.
|
|
AudioSettings d = AudioSettings.Default;
|
|
Assert.Equal(0, d.SoundFeatures);
|
|
Assert.True(d.SfxEnabled);
|
|
Assert.True(d.AmbientEnabled);
|
|
Assert.True(d.InterfaceEnabled);
|
|
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;
|
|
// OP6 rework (review S2): ScreenBrightness is its own field now.
|
|
Assert.Equal(0f, d.ScreenBrightness);
|
|
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);
|
|
// OP6 rework (review M2): renamed Enabled-sense fields, fresh
|
|
// JSON keys ("sfxEnabled" etc.) — see AudioSettings' own doc.
|
|
var written = AudioSettings.Default with
|
|
{
|
|
SoundFeatures = 1,
|
|
SfxEnabled = false,
|
|
AmbientEnabled = false,
|
|
InterfaceEnabled = 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_ExistingProfileMissingTheEnabledKeys_LoadsWithSoundOn()
|
|
{
|
|
// OP6 rework (review M2 blast radius): an existing settings.json
|
|
// written by a build that never had the sfxEnabled/ambientEnabled/
|
|
// interfaceEnabled keys (e.g. only the "display" section present)
|
|
// must fall back to AudioSettings.Default — enabled=true — NOT to
|
|
// an inverted/missing-means-muted reading. This is the exact
|
|
// scenario the review's blast-radius finding named: "every existing
|
|
// settings.json... loads as disabled" under the rejected slice.
|
|
string path = System.IO.Path.Combine(
|
|
System.IO.Path.GetTempPath(), $"acdream-op6-audio-legacy-{Guid.NewGuid():N}.json");
|
|
try
|
|
{
|
|
System.IO.File.WriteAllText(path, """
|
|
{
|
|
"version": 2,
|
|
"display": { "resolution": "1920x1080" }
|
|
}
|
|
""");
|
|
var store = new SettingsStore(path);
|
|
AudioSettings read = store.LoadAudio();
|
|
|
|
Assert.True(read.SfxEnabled);
|
|
Assert.True(read.AmbientEnabled);
|
|
Assert.True(read.InterfaceEnabled);
|
|
Assert.Equal(1.0f, read.Sfx);
|
|
Assert.Equal(1.0f, read.Ambient);
|
|
|
|
(float sfx, float ambient) =
|
|
AcDream.App.Settings.RuntimeSettingsStartupTargets.ComputeEffectiveCategoryVolumes(read);
|
|
Assert.Equal(1.0f, sfx);
|
|
Assert.Equal(1.0f, ambient);
|
|
}
|
|
finally
|
|
{
|
|
System.IO.File.Delete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SettingsStore_DisplayRoundTrip_PreservesTheTenOP6Fields()
|
|
{
|
|
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
|
|
{
|
|
// OP6 rework (review S2): ScreenBrightness's own field.
|
|
ScreenBrightness = 0.25f,
|
|
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);
|
|
}
|
|
|
|
/// <summary>Every built <see cref="UiMenu"/> under a subtree, in
|
|
/// document (build) order — the same recursive-walk shape
|
|
/// <c>ChatOptionsPageControllerTests.CollectScalarSliders</c> uses for
|
|
/// its own widget-type collection.</summary>
|
|
private static List<UiMenu> CollectMenus(UiElement root)
|
|
{
|
|
var found = new List<UiMenu>();
|
|
Walk(root, found);
|
|
return found;
|
|
|
|
static void Walk(UiElement node, List<UiMenu> acc)
|
|
{
|
|
if (node is UiMenu menu) acc.Add(menu);
|
|
foreach (UiElement child in node.Children) Walk(child, acc);
|
|
}
|
|
}
|
|
|
|
[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 Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows()
|
|
{
|
|
// OP6 rework (review S1): retail's own InitOptions ends with a
|
|
// SIXTH AddSeperator tailcall (0x0049e80d @Bind) — a trailing
|
|
// separator after the LAST section, not just the five interior
|
|
// ones. 6 headers + 6 separators + 27 option-widget-rows (the 3
|
|
// trios are ONE widget row each despite registering TWO
|
|
// IOptionRow instances — see Bind_Succeeds_AndRegistersExactly30Rows)
|
|
// = 39 stacked ListBox items total, matching retail exactly. The
|
|
// rejected slice built 38 (five interior separators only).
|
|
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));
|
|
|
|
UiElement viewport = Assert.Single(listBox.Children);
|
|
Assert.Equal(39, viewport.Children.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleRow_SfxEnabled_WritesThroughAudioBindings()
|
|
{
|
|
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
|
|
var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle
|
|
|
|
row.SetCurrentValue(!AudioSettings.Default.SfxEnabled);
|
|
|
|
Assert.Single(bindings.AudioSaves);
|
|
Assert.Equal(!AudioSettings.Default.SfxEnabled, bindings.AudioSaves[0].SfxEnabled);
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #378 regression (2026-08-11, gate 4): before this fix, NOTHING wired
|
|
/// any Config-tab dropdown's chrome — every sprite id was 0, no arrow
|
|
/// cap, no popup. Drives the REAL <see cref="UiMenu"/> hit-test/pick
|
|
/// path <see cref="VendorUiControllerTests.CategoryMenu_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry"/>
|
|
/// already established for vendor's own dropdown — the SAME
|
|
/// authored popup catalog (LayoutDesc <c>0x21000043</c>) both dropdowns
|
|
/// derive from, byte-verified identical (see
|
|
/// <see cref="ConfigOptionsPageController.MenuChromeSprites"/>'s own
|
|
/// doc), so the SAME click-geometry formula applies.
|
|
/// </summary>
|
|
[Fact]
|
|
public void MenuRow_SoundFeatures_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry()
|
|
{
|
|
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(),
|
|
resolveSprite: _ => (1u, 8, 8));
|
|
Assert.True(bound);
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
layout.FindElement(ConfigOptionsPageController.ListBoxElementId));
|
|
List<UiMenu> menus = CollectMenus(listBox);
|
|
Assert.Equal(8, menus.Count); // the 8 Config-tab dropdown rows
|
|
UiMenu soundFeatures = menus[0]; // build order matches Rows order — Sound Features first
|
|
|
|
// Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing
|
|
// renders) and the button face never draws either — the bare-text
|
|
// symptom #378 reported.
|
|
Assert.NotNull(soundFeatures.SpriteResolve);
|
|
Assert.NotEqual(0u, soundFeatures.NormalSprite);
|
|
Assert.NotEqual(0u, soundFeatures.PressedSprite);
|
|
Assert.NotEqual(0u, soundFeatures.ItemNormalSprite);
|
|
Assert.NotEqual(0u, soundFeatures.ItemHighlightSprite);
|
|
Assert.NotEqual(0u, soundFeatures.ArrowCapClosedSprite);
|
|
Assert.NotEqual(0u, soundFeatures.ArrowCapOpenSprite);
|
|
|
|
// Geometry from the live-DAT-verified popup catalog (0x21000043) —
|
|
// see MenuChromeSprites' own doc.
|
|
Assert.True(soundFeatures.Scrollable);
|
|
Assert.Equal(6, soundFeatures.RowsPerColumn);
|
|
Assert.Equal(18f, soundFeatures.RowHeight);
|
|
Assert.Equal(100f, soundFeatures.ColumnWidth);
|
|
Assert.False(soundFeatures.OpenUpward); // no authored attribute 5 — absent defaults false
|
|
|
|
Assert.False(soundFeatures.IsOpen);
|
|
|
|
// Open via the real widget event path (button click).
|
|
Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, 5)));
|
|
Assert.True(soundFeatures.IsOpen);
|
|
|
|
// "Mono" is the SECOND choice (Stereo, then Mono) -> row index 1.
|
|
// Popup opens DOWNWARD (OpenUpward=false), so its top sits at the
|
|
// button's own bottom edge (ly = Height) — same formula
|
|
// VendorUiControllerTests already established for the identical
|
|
// popup catalog.
|
|
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
|
|
const int targetRow = 1;
|
|
float iy = targetRow * soundFeatures.RowHeight + soundFeatures.RowHeight / 2f;
|
|
float ly = soundFeatures.Height + iy + border;
|
|
|
|
Assert.True(soundFeatures.OnEvent(new UiEvent(0, soundFeatures, UiEventType.MouseDown, 0, 10, (int)ly)));
|
|
|
|
Assert.False(soundFeatures.IsOpen); // picking a row closes the popup
|
|
Assert.Equal(1, fakeBindings.Audio.SoundFeatures);
|
|
Assert.Equal(1, fakeBindings.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EveryRow_DefaultValue_MatchesRetailLiteral_NotJustSelfConsistency()
|
|
{
|
|
// OP6 rework (review S5): Defaults_RestoresRetailDefaultValue...
|
|
// above only proves each row's Current becomes its OWN
|
|
// DefaultValue — it says nothing about whether that DefaultValue
|
|
// IS retail's byte-verified literal. This pins every row's
|
|
// DefaultValue directly against the decompiled
|
|
// gmConfigUI::InitOptions/gmClient::InitUIPreferences constant,
|
|
// independent of the underlying settings-record defaults (already
|
|
// pinned separately above) — Build*Row's own inline
|
|
// `defaultValue:` parameters could drift from those record
|
|
// defaults without either test alone catching it.
|
|
(OptionsPanelController controller, _, _) = BindReal();
|
|
IReadOnlyList<IOptionRow> rows = controller.ConfigPage.Rows;
|
|
Assert.Equal(30, rows.Count);
|
|
|
|
object[] expected =
|
|
[
|
|
0, // 0 Sound Features menu (Stereo=0)
|
|
true, 1.0f, // 1-2 Sound trio (Enabled, Sfx)
|
|
true, 1.0f, // 3-4 Ambient trio
|
|
true, 1.0f, // 5-6 Interface trio
|
|
true, // 7 Play Sound Only When Active
|
|
|
|
0.45f, // 8 Camera Stiffness
|
|
40.0f, // 9 Camera Adjustment Speed
|
|
90.0f, // 10 Field Of View
|
|
true, // 11 Align To Slope
|
|
|
|
"800x600", // 12 Resolution
|
|
true, // 13 Full Screen
|
|
false, // 14 Sync To Refresh
|
|
0f, // 15 Screen Brightness
|
|
false, // 16 Automatic Degrades
|
|
0f, // 17 Graphics Performance
|
|
50.0f, // 18 Degrade Distance
|
|
|
|
2, // 19 Landscape Texture Detail
|
|
1, // 20 Environment Texture Detail
|
|
1, // 21 Texture Filtering
|
|
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
|
|
true, // 23 Building Detail Textures
|
|
false, // 24 Multi-Pass Alpha
|
|
|
|
0.55f, // 25 Mouse Look Sensitivity
|
|
false, // 26 Invert Mouselook Y Axis
|
|
false, // 27 Use Mouse Turning
|
|
|
|
2, // 28 Chat Font Face (PalatinoLinotype)
|
|
1, // 29 Chat Font Size
|
|
];
|
|
|
|
for (int i = 0; i < rows.Count; i++)
|
|
{
|
|
object actual = rows[i] switch
|
|
{
|
|
BoolOptionRow b => b.DefaultValue,
|
|
FloatOptionRow f => f.DefaultValue,
|
|
IntOptionRow n => n.DefaultValue,
|
|
StringOptionRow s => s.DefaultValue,
|
|
_ => throw new InvalidOperationException(
|
|
$"row {i} has unexpected type {rows[i].GetType()}"),
|
|
};
|
|
Assert.True(
|
|
Equals(expected[i], actual),
|
|
$"row {i}: expected retail default {expected[i]} but row.DefaultValue was {actual}.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey()
|
|
{
|
|
// OP6 rework (review S5): the Character tab's registry-conformance
|
|
// pattern ("an invented row or a dropped row fails the build")
|
|
// applied to Config's label/tooltip/choice STRING KEYS — the exact
|
|
// dimension M3's 1-vs-5-entry ChatFontFaceChoices bug lived in and
|
|
// that LabelResolutionFailure... (null resolver) cannot see, since
|
|
// a null resolver makes every key invisible. Every string this
|
|
// page queries is transcribed here directly from
|
|
// gmClient::InitUIPreferences/gmConfigUI::InitOptions (bare label
|
|
// keys, their "<key>_Help" tooltip siblings, and every menu choice
|
|
// key) — an extra query (a typo'd/invented key) fails immediately;
|
|
// a query this test expected but never saw (a dropped key) fails
|
|
// at the end.
|
|
string[] expectedKeys =
|
|
[
|
|
// Headers
|
|
"ID_Sound_SoundSection", "ID_Camera_CameraSection",
|
|
"ID_Graphics_GraphicsSection", "ID_Graphics_TextureSection",
|
|
"ID_Input_InputSection", "ID_UI_UISection",
|
|
|
|
// Sound section
|
|
"ID_Sound_SoundFeatures", "ID_Sound_SoundFeatures_Help",
|
|
"ID_Sound_Stereo", "ID_Sound_Mono",
|
|
"ID_Sound_DisableSound", "ID_Sound_DisableSound_Help",
|
|
"ID_Sound_EffectVolume_Help",
|
|
"ID_Sound_DisableAmbientSound", "ID_Sound_DisableAmbientSound_Help",
|
|
"ID_Sound_AmbientVolume_Help",
|
|
"ID_Sound_DisableInterfaceSound", "ID_Sound_DisableInterfaceSound_Help",
|
|
"ID_Sound_InterfaceVolume_Help",
|
|
"ID_Sound_NoFocusNoSound", "ID_Sound_NoFocusNoSound_Help",
|
|
|
|
// Camera section
|
|
"ID_Camera_Stiffness", "ID_Camera_Stiffness_Help",
|
|
"ID_Graphics_Value_Soft", "ID_Graphics_Value_Hard",
|
|
"ID_Camera_AdjustmentSpeed", "ID_Camera_AdjustmentSpeed_Help",
|
|
"ID_Graphics_Value_Slow", "ID_Graphics_Value_Fast",
|
|
"ID_Graphics_FieldOfView", "ID_Graphics_FieldOfView_Help",
|
|
"ID_Graphics_Value_Narrow", "ID_Graphics_Value_Wide",
|
|
"ID_Camera_AlignToSlope", "ID_Camera_AlignToSlope_Help",
|
|
|
|
// Graphics section
|
|
"ID_Rendering_DisplayResolution", "ID_Rendering_DisplayResolution_Help",
|
|
"ID_Rendering_FullScreen", "ID_Rendering_FullScreen_Help",
|
|
"ID_Rendering_SyncToDisplayRefresh", "ID_Rendering_SyncToDisplayRefresh_Help",
|
|
"ID_Graphics_ScreenBrightness", "ID_Graphics_ScreenBrightness_Help",
|
|
"ID_Graphics_Value_Dark", "ID_Graphics_Value_Bright",
|
|
"ID_Graphics_AdaptiveDegrade", "ID_Graphics_AdaptiveDegrade_Help",
|
|
"ID_Graphics_AdaptiveDegradeBias", "ID_Graphics_AdaptiveDegradeBias_Help",
|
|
"ID_Graphics_Value_Speed", "ID_Graphics_Value_Detail",
|
|
"ID_Graphics_DegradeDistance", "ID_Graphics_DegradeDistance_Help",
|
|
"ID_Graphics_Value_Close", "ID_Graphics_Value_Far",
|
|
|
|
// Rendering Quality section
|
|
"ID_Graphics_LandscapeTextureDetail", "ID_Graphics_LandscapeTextureDetail_Help",
|
|
"ID_Graphics_EnvironmentTextureDetail", "ID_Graphics_EnvironmentTextureDetail_Help",
|
|
"ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium",
|
|
"ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh",
|
|
"ID_Graphics_TextureFiltering", "ID_Graphics_TextureFiltering_Help",
|
|
"ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear",
|
|
"ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic",
|
|
"ID_Graphics_LandscapeDrawDistance", "ID_Graphics_LandscapeDrawDistance_Help",
|
|
"ID_Graphics_Value_Extreme",
|
|
"ID_Graphics_BuildingDetailTextures", "ID_Graphics_BuildingDetailTextures_Help",
|
|
"ID_Graphics_MultiPassAlpha", "ID_Graphics_MultiPassAlpha_Help",
|
|
|
|
// Input section
|
|
"ID_Input_MouseLookSensitivity", "ID_Input_MouseLookSensitivity_Help",
|
|
"ID_Input_InvertMouseLookYAxis", "ID_Input_InvertMouseLookYAxis_Help",
|
|
"ID_Input_UseMouseTurning", "ID_Input_UseMouseTurning_Help",
|
|
|
|
// UI section
|
|
"ID_UI_ChatFontFace", "ID_UI_ChatFontFace_Help",
|
|
"ID_UI_Value_Arial", "ID_UI_Value_CourierNew", "ID_UI_Value_PalatinoLinotype",
|
|
"ID_UI_Value_Tahoma", "ID_UI_Value_TimesNewRoman",
|
|
"ID_UI_ChatFontSize", "ID_UI_ChatFontSize_Help",
|
|
"ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium",
|
|
"ID_UI_Value_Large", "ID_UI_Value_XLarge",
|
|
];
|
|
|
|
var expectedByHash = new Dictionary<uint, string>();
|
|
foreach (string key in expectedKeys)
|
|
expectedByHash[DatStringResolver.ComputeHash(key)] = key;
|
|
|
|
var seen = new HashSet<string>();
|
|
Func<uint, uint, string?> recordingResolver = (table, hash) =>
|
|
{
|
|
Assert.Equal(0x23000003u, table);
|
|
Assert.True(
|
|
expectedByHash.TryGetValue(hash, out string? key),
|
|
$"resolveString queried hash 0x{hash:X8} — no key in the expected table hashes "
|
|
+ "to this value. Either an invented/typo'd key was added, or this test's "
|
|
+ "expected-key table is stale.");
|
|
seen.Add(key!);
|
|
return "x"; // any non-null value keeps the row-build path fully populated.
|
|
};
|
|
|
|
(_, _, bool bound) = BindReal(recordingResolver);
|
|
Assert.True(bound);
|
|
|
|
IEnumerable<string> missing = expectedByHash.Values.Except(seen);
|
|
Assert.True(
|
|
!missing.Any(),
|
|
"Bind never queried these expected keys (a dropped row/key): "
|
|
+ string.Join(", ", missing));
|
|
}
|
|
|
|
// 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,
|
|
FlushOpacity: () => { },
|
|
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);
|
|
}
|
|
|
|
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
|
|
|
|
private enum RowKind { Toggle, TrioToggle, Slider, Menu }
|
|
|
|
/// <summary>
|
|
/// One literal expectation per option-row-bearing viewport item, in the
|
|
/// SAME order <see cref="ConfigOptionsPageController.Bind"/> builds them
|
|
/// (39 stacked items: 6 headers + 6 separators + 27 option rows — see
|
|
/// <see cref="Bind_ListBoxStacks39Items_SixHeaders_SixSeparators_27OptionRows"/>
|
|
/// for the index arithmetic this table shares). <b>Wiring a future
|
|
/// consumer for any row here means flipping BOTH this literal's
|
|
/// <c>StoreOnly</c> AND the matching <c>ConfigOptionsPageController</c>
|
|
/// <c>BindXxxSection</c> call site's own <c>storeOnly:</c> argument
|
|
/// consciously — leaving either one stale fails this test.</b>
|
|
/// </summary>
|
|
private static readonly (int ViewportIndex, RowKind Kind, bool StoreOnly, string Label)[]
|
|
DimmingExpectations =
|
|
{
|
|
(1, RowKind.Menu, true, "Sound Features"), // AP-199
|
|
(2, RowKind.TrioToggle, false, "Disable Sound Effects"), // LIVE
|
|
(3, RowKind.TrioToggle, false, "Disable Ambient Sound"), // LIVE
|
|
(4, RowKind.TrioToggle, true, "Disable Interface Sound"), // AP-174/AP-199
|
|
(5, RowKind.Toggle, true, "Play Sound Only When Active"), // AP-199
|
|
(8, RowKind.Slider, true, "Camera Stiffness"), // TS-74
|
|
(9, RowKind.Slider, true, "Camera Adjustment Speed"), // TS-74
|
|
(10, RowKind.Slider, false, "Field Of View"), // NEXT-LAUNCH
|
|
(11, RowKind.Toggle, true, "Align To Slope"), // TS-74
|
|
(14, RowKind.Menu, false, "Resolution"), // LIVE
|
|
(15, RowKind.Toggle, false, "Full Screen"), // LIVE
|
|
(16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH
|
|
(17, RowKind.Slider, true, "Screen Brightness"), // review S2
|
|
(18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198
|
|
(19, RowKind.Slider, true, "Graphics Performance"), // AP-198
|
|
(20, RowKind.Slider, true, "Degrade Distance"), // AP-198
|
|
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
|
|
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
|
|
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
|
|
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
|
|
(27, RowKind.Toggle, true, "Building Detail Textures"), // AP-198
|
|
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
|
|
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
|
|
(32, RowKind.Toggle, true, "Invert Mouselook Y Axis"), // TS-74
|
|
(33, RowKind.Toggle, true, "Use Mouse Turning"), // TS-74
|
|
(36, RowKind.Menu, true, "Chat Font Face"), // AP-200
|
|
(37, RowKind.Menu, true, "Chat Font Size"), // AP-200
|
|
};
|
|
|
|
private static Vector4? FindTextLineColor(UiElement root, uint elementId)
|
|
{
|
|
if (UiElement.FindDescendant(root, elementId) is not UiText text)
|
|
return null;
|
|
IReadOnlyList<UiText.Line> lines = text.LinesProvider();
|
|
return lines.Count == 0 ? null : lines[0].Color;
|
|
}
|
|
|
|
[Fact]
|
|
public void CaptionDimming_MatchesTheStoreOnlySetExactly()
|
|
{
|
|
// A non-null constant resolver is required here (unlike most of this
|
|
// file's BindReal() default): SetLabelText's dim color is baked into
|
|
// the UiText.Line closure only when a label actually resolves, so a
|
|
// null resolver would leave every slider/menu label's LinesProvider
|
|
// at its empty default and hide the very thing this test checks.
|
|
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x");
|
|
Assert.True(bound);
|
|
|
|
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId));
|
|
UiElement viewport = Assert.Single(listBox.Children);
|
|
IReadOnlyList<UiElement> items = viewport.Children.ToList();
|
|
Assert.Equal(39, items.Count);
|
|
|
|
const uint ToggleCheckboxElementId = 0x10000219u;
|
|
const uint SliderLabelElementId = 0x1000021Bu;
|
|
const uint MenuLabelElementId = 0x10000223u;
|
|
|
|
foreach ((int index, RowKind kind, bool storeOnly, string label) in DimmingExpectations)
|
|
{
|
|
Vector4 expected = storeOnly
|
|
? UiRenderContext.StoreOnlyCaptionColor
|
|
: Vector4.One;
|
|
Vector4? actual = kind switch
|
|
{
|
|
RowKind.Toggle =>
|
|
(UiElement.FindDescendant(items[index], ToggleCheckboxElementId) as UiButton)?.LabelColor,
|
|
RowKind.TrioToggle =>
|
|
Assert.IsType<UiOptionToggleSlider>(items[index]).Toggle?.LabelColor,
|
|
RowKind.Slider => FindTextLineColor(items[index], SliderLabelElementId),
|
|
RowKind.Menu => FindTextLineColor(items[index], MenuLabelElementId),
|
|
_ => throw new InvalidOperationException($"unhandled row kind {kind}"),
|
|
};
|
|
Assert.True(
|
|
actual.HasValue,
|
|
$"{label} (viewport index {index}, kind {kind}): could not locate the "
|
|
+ "caption widget/line to check its color.");
|
|
Assert.True(
|
|
expected == actual.Value,
|
|
$"{label} (viewport index {index}): expected "
|
|
+ $"{(storeOnly ? "DIMMED" : "LIVE")} caption color {expected} but the "
|
|
+ $"built widget rendered {actual.Value}.");
|
|
}
|
|
}
|
|
}
|