The Config tab's footer sat mid-panel with further rows drawing below the window's bottom edge. Live-DAT measured: the mounted tab-host root is authored 300x362 (retail's real default window size), but the Config page slot underneath keeps its own larger design geometry (298x575 against a 300x600 canvas) until retail's real four-edge UiLayoutPolicy (UIElement::UpdateForParentSizeChange @0x00462640) shrinks it on the first ApplyAnchor pass -- verified stable, this part already worked. The actual bug: UiTemplateListBox.Viewport (the UiScrollablePanel that hosts + clips every row) is a programmatic C# element seeded at Bind time, BEFORE the tree's first draw frame -- before the ListBox has ever shrunk. Its legacy anchor baseline is captured lazily on its own first ApplyAnchor call, which lands AFTER the ListBox has already shrunk earlier in that same frame (parent-before-child draw order). That capture measures a negative bottom margin the stretch math preserves forever: the viewport stayed locked at its original 560px design height, clipping rows to a bound retail never actually gave the window on screen. Fix: force the viewport's anchor capture to happen immediately after seeding it, while its Width/Height still exactly equal a zero-margin baseline against the CURRENT (pre-shrink) parent, instead of lazily on the first draw frame against an already-shrunk parent. This is #372's sequel -- #372 fixed the 0x0 collapse case; this is the "ListBox itself later shrinks" case #372's own fixture never exercised. Three new tests (UiTemplateListBoxViewportTests using the live-DAT-measured 298x575/276x560 numbers, plus two ConfigOptionsPageControllerTests against the real production Bind path and the committed host fixture) all fail pre-fix, confirmed by temporarily reverting the change. Scoped to UiTemplateListBox's own viewport; UiScrollablePanel/ApplyAnchor/ ComputeAnchoredRect are untouched, so chat's transcript scrolling and every other UiScrollablePanel/UiItemList consumer are unaffected. fix #412 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1226 lines
56 KiB
C#
1226 lines
56 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);
|
|
}
|
|
|
|
/// <summary>User gate report 2026-08-13: every Config dropdown drew its
|
|
/// text gold + left-aligned and its popup a fixed 6 rows. The corrected
|
|
/// values are MEASURED authored facts (menuprobe3,
|
|
/// <see cref="OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle"/>):
|
|
/// label child 0x10000355 + row template 0x1000035A are white +
|
|
/// hJustify=Center, and popup ListBox 0x10000358 is edge-docked
|
|
/// (L=T=R=B=1), arming retail's RecalculatePopupSize size-to-content
|
|
/// path. Asserted on ALL 8 menus — one shared ApplyMenuChrome must not
|
|
/// quietly skip any.</summary>
|
|
[Fact]
|
|
public void MenuRows_All8_UseTheAuthoredTextStyleAndSizeToContent()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
new OptionsPanelController.Callbacks(
|
|
Toggle: () => { },
|
|
RequestExitToCharacterSelection: () => { },
|
|
ExitGame: () => { },
|
|
UseMouseTurningSettings: () => { },
|
|
DisplaySystemMessage: _ => { }))!;
|
|
var fakeBindings = new FakeBindings();
|
|
Assert.True(ConfigOptionsPageController.Bind(
|
|
layout,
|
|
controller.ConfigPage,
|
|
MakeTemplateResolver(),
|
|
(_, _) => null,
|
|
fakeBindings.ToBindings(),
|
|
resolveSprite: _ => (1u, 8, 8)));
|
|
|
|
var listBox = Assert.IsType<UiTemplateListBox>(
|
|
layout.FindElement(ConfigOptionsPageController.ListBoxElementId));
|
|
List<UiMenu> menus = CollectMenus(listBox);
|
|
Assert.Equal(8, menus.Count);
|
|
foreach (UiMenu menu in menus)
|
|
{
|
|
Assert.Equal(System.Numerics.Vector4.One, menu.TextColor);
|
|
Assert.True(menu.ButtonTextCentered);
|
|
Assert.True(menu.ItemTextCentered);
|
|
Assert.True(menu.PopupSizeToContent);
|
|
}
|
|
}
|
|
|
|
[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
|
|
|
|
"1280x720", // 12 Resolution (#391: fixture fallback default —
|
|
// DisplaySettings.Default.Resolution; production
|
|
// passes the desktop mode via DisplayModeCatalog)
|
|
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: (_, _) => { },
|
|
DefaultOpacityCaption: new ChatOptionsDatCaptions.Caption("Inactive Opacity", null),
|
|
ActiveOpacityCaption: new ChatOptionsDatCaptions.Caption("Active Opacity", null));
|
|
}
|
|
|
|
[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}.");
|
|
}
|
|
}
|
|
|
|
// ── #412-class regression: Config tab content escaping the window frame ──
|
|
//
|
|
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's
|
|
// screenshot showed the Config tab's Apply/Reset/Defaults footer sitting
|
|
// mid-panel with further rows (Full Screen, Sync With Refresh Rate,
|
|
// Screen Brightness, Adaptive Degrade, the quality dropdowns...) drawing
|
|
// BELOW the window's bottom edge, outside the panel frame. Live-DAT
|
|
// measured root cause: the tab host's authored page slot (0x10000213,
|
|
// 298x575) is taller than its actual mounted container (the merged
|
|
// 0x1000018D root, authored 300x362 — retail's own
|
|
// UIElement::UpdateForParentSizeChange @0x00462640 four-edge policy
|
|
// shrinks it correctly on the first ApplyAnchor pass). The Config ListBox
|
|
// (0x10000200, 276x560) shrinks right behind it via the SAME per-element
|
|
// UiLayoutPolicy. But UiTemplateListBox.Viewport (the UiScrollablePanel
|
|
// that actually hosts + clips every row) is a programmatic C# element
|
|
// seeded at Bind time — BEFORE any ApplyAnchor pass has ever run — with
|
|
// the ListBox's THEN-current (pre-shrink) 276x560 size. Its own legacy
|
|
// Left|Top|Right|Bottom anchor baseline is captured lazily, on its first
|
|
// ApplyAnchor call, which lands AFTER the ListBox has already shrunk in
|
|
// that same frame — producing a negative captured bottom margin that
|
|
// ComputeAnchoredRect's stretch math preserves forever: the viewport
|
|
// stayed locked at its original 560px height, well past the real ~297px
|
|
// available, so rows drew (and were culled) against a bound retail never
|
|
// actually gave the window on screen. Fixed in UiTemplateListBox.Viewport
|
|
// by forcing the capture immediately after seeding, while the viewport's
|
|
// own Width/Height still exactly equal a zero-margin baseline.
|
|
|
|
[Fact]
|
|
public void ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass()
|
|
{
|
|
// Pins the LIVE-DAT-measured authored facts this whole bug turns on:
|
|
// the tab host's merged root is the SLOT's own (cropped) 300x362
|
|
// extent, but the Config page slot underneath keeps ITS OWN larger
|
|
// authored design geometry (298x575, drawn against a 300x600 design
|
|
// canvas) until a layout pass actually reflows it.
|
|
(OptionsPanelController controller, _, bool bound) = BindReal();
|
|
Assert.True(bound);
|
|
|
|
Assert.Equal(300f, controller.TabPanel.Width);
|
|
Assert.Equal(362f, controller.TabPanel.Height);
|
|
|
|
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
|
|
Assert.Equal(575f, configSlot.Height);
|
|
Assert.NotNull(configSlot.LayoutPolicy);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass()
|
|
{
|
|
(OptionsPanelController controller, _, bool bound) = BindReal();
|
|
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);
|
|
|
|
// Drive the SAME top-down ApplyAnchor walk DrawSelfAndChildren runs
|
|
// every real frame — parent before children, all the way down —
|
|
// TWICE, to prove the result is a stable steady state and not an
|
|
// artifact of a single simulated pass.
|
|
for (int frame = 0; frame < 2; frame++)
|
|
ApplyAnchorRecursive(controller.TabPanel);
|
|
|
|
// The viewport must track the REAL (shrunk) ListBox extent, not stay
|
|
// locked at its original oversized 560px design height.
|
|
Assert.True(
|
|
viewport.Height <= listBox.Height + 0.5f,
|
|
$"viewport height {viewport.Height} exceeds its ListBox's actual "
|
|
+ $"height {listBox.Height} — rows will draw/cull past where "
|
|
+ "the window actually is (the #412-class bug).");
|
|
|
|
// Nothing in the Config page may extend past the slot's own bottom
|
|
// edge, and the slot itself may not extend past the mounted window's
|
|
// own bottom edge — the exact "content escapes the window frame"
|
|
// symptom the user's screenshot showed.
|
|
float slotBottom = configSlot.Top + configSlot.Height;
|
|
Assert.True(
|
|
slotBottom <= controller.TabPanel.Height + 0.5f,
|
|
$"Config slot bottom {slotBottom} exceeds the mounted window's "
|
|
+ $"own height {controller.TabPanel.Height}.");
|
|
|
|
foreach (uint footerId in new[]
|
|
{
|
|
ConfigOptionsPageController_ApplyButtonId,
|
|
ConfigOptionsPageController_ResetButtonId,
|
|
ConfigOptionsPageController_DefaultsButtonId,
|
|
})
|
|
{
|
|
UiElement? btn = UiElement.FindDescendant(configSlot, footerId);
|
|
Assert.NotNull(btn);
|
|
float bottom = btn!.Top + btn.Height;
|
|
Assert.True(
|
|
bottom <= slotBottom + 0.5f,
|
|
$"footer 0x{footerId:X8} bottom {bottom} exceeds the Config "
|
|
+ $"slot's own bottom {slotBottom}.");
|
|
}
|
|
}
|
|
|
|
// Apply/Reset/Defaults element ids — ConfigOptionsPageController's own
|
|
// constants of the same name are private; mirrored here rather than
|
|
// widening that class's surface just for this test.
|
|
private const uint ConfigOptionsPageController_ApplyButtonId = 0x100001FCu;
|
|
private const uint ConfigOptionsPageController_ResetButtonId = 0x100001FDu;
|
|
private const uint ConfigOptionsPageController_DefaultsButtonId = 0x100001FEu;
|
|
|
|
private static void ApplyAnchorRecursive(UiElement e)
|
|
{
|
|
foreach (UiElement child in e.Children)
|
|
{
|
|
child.ApplyAnchor(e.Width, e.Height);
|
|
ApplyAnchorRecursive(child);
|
|
}
|
|
}
|
|
}
|