Root cause: a live-DAT probe found retail authors NO backing element behind the Character/Chat/Config tabs' Apply/Reset/Defaults footer — each page root has exactly five children (the row ListBox, its scrollbar, and the three buttons) with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the three buttons; the bleed-through is a rendering gap in our own composition, not a missing import. Fix: new minimal widget UiSolidSpriteFill tiles RetailChromeSprites.CenterFill (the SAME panel-background sprite the Options window's own chrome already draws behind everything, not an invented color) across the footer strip's rect, derived from the three buttons' own resolved Top/Height and z-ordered strictly behind every other child so it can never intercept input or occlude the buttons. Register row AP-205 records the synthesis. Regressed by OptionsPanelControllerTests. Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults, which pins exactly one backing field per page, sized from the live button rects, z-ordered behind every sibling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
466 lines
18 KiB
C#
466 lines
18 KiB
C#
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.Core.Chat;
|
|
|
|
namespace AcDream.App.Tests.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Controller-level tests for <see cref="OptionsPanelController"/> against
|
|
/// the committed <c>options_panel_2100006E_1000018D.json</c> fixture
|
|
/// (Campaign OP slice OP3) — the SAME <c>LayoutImporter.ImportInfos(dats,
|
|
/// 0x2100006Eu, 0x1000018Du)</c> catalog import
|
|
/// <see cref="RetailUiRuntime.MountOptionsPanel"/> performs against real
|
|
/// DATs. No DAT access, no live runtime — dat-free per the established
|
|
/// <c>FixtureLoader</c> pattern.
|
|
/// </summary>
|
|
public sealed class OptionsPanelControllerTests
|
|
{
|
|
private static UiButton Click(ImportedLayout layout, uint elementId)
|
|
{
|
|
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
|
|
button.OnEvent(new UiEvent(0, button, UiEventType.Click));
|
|
return button;
|
|
}
|
|
|
|
private static OptionsPanelController.Callbacks MakeCallbacks(
|
|
List<string> calls,
|
|
Action<string>? displaySystemMessage = null)
|
|
=> new(
|
|
Toggle: () => calls.Add("toggle"),
|
|
RequestExitToCharacterSelection: () => calls.Add("exit-to-char-select"),
|
|
ExitGame: () => calls.Add("exit-game"),
|
|
UseMouseTurningSettings: () => calls.Add("mouse-turning"),
|
|
DisplaySystemMessage: displaySystemMessage ?? (text => calls.Add($"message:{text}")));
|
|
|
|
// ── Mount conformance ────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Bind_RootBuildsAsUiTabPanel()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
|
|
Assert.IsType<UiTabPanel>(layout.Root);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bind_Succeeds_AndExposesFourEmptyPages()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
|
|
OptionsPanelController? controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Assert.NotNull(controller);
|
|
Assert.Equal(4, controller!.Pages.Count);
|
|
Assert.Empty(controller.GameplayPage.Rows);
|
|
Assert.Empty(controller.CharacterPage.Rows);
|
|
Assert.Empty(controller.ChatPage.Rows);
|
|
Assert.Empty(controller.ConfigPage.Rows);
|
|
}
|
|
|
|
/// <summary>Every <see cref="UiSolidSpriteFill"/> under a subtree, in
|
|
/// build (document) order.</summary>
|
|
private static List<UiSolidSpriteFill> CollectFooterBackings(UiElement root)
|
|
{
|
|
var found = new List<UiSolidSpriteFill>();
|
|
Walk(root, found);
|
|
return found;
|
|
|
|
static void Walk(UiElement node, List<UiSolidSpriteFill> acc)
|
|
{
|
|
if (node is UiSolidSpriteFill fill) acc.Add(fill);
|
|
foreach (UiElement child in node.Children) Walk(child, acc);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #381 regression (2026-08-11, gate 4): before this fix, the
|
|
/// Character/Chat/Config pages drew their Apply/Reset/Defaults buttons
|
|
/// directly over the ListBox content with nothing behind them — a live-
|
|
/// DAT probe found retail authors no backing element either (page root
|
|
/// has exactly 5 children, zero direct-state media), so this is a
|
|
/// genuine acdream synthesis (register row AP-205), not a missing
|
|
/// import. Pins that exactly one opaque field is synthesized per page,
|
|
/// sized/positioned from the buttons' OWN resolved rects, and z-ordered
|
|
/// strictly behind everything else on that page (so it can never
|
|
/// occlude the buttons it backs).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
|
|
OptionsPanelController? controller = OptionsPanelController.Bind(
|
|
layout, MakeCallbacks(calls), resolveSprite: _ => (1u, 8, 8));
|
|
|
|
Assert.NotNull(controller);
|
|
// Page slot ids — the SAME literals ConfigOptionsPageController's
|
|
// own PageSlotElementId/ChatOptionsPageController's own
|
|
// PageSlotElementId cite, and this file's own TabSwitch tests
|
|
// already use for Character (0x10000211).
|
|
foreach (uint pageSlotId in new[] { 0x10000211u, 0x1000050Cu, 0x10000213u })
|
|
{
|
|
UiElement pageRoot = UiElement.FindDescendant(controller!.TabPanel, pageSlotId)!;
|
|
Assert.NotNull(pageRoot);
|
|
List<UiSolidSpriteFill> backings = CollectFooterBackings(pageRoot);
|
|
UiSolidSpriteFill backing = Assert.Single(backings);
|
|
|
|
Assert.Equal(RetailChromeSprites.CenterFill, backing.SpriteId);
|
|
Assert.NotNull(backing.SpriteResolve);
|
|
Assert.True(backing.ClickThrough);
|
|
Assert.Equal(pageRoot.Width, backing.Width);
|
|
|
|
// Drawn BEHIND every sibling already on the page (buttons,
|
|
// ListBox, scrollbar) — lowest ZOrder wins the painter's-algorithm
|
|
// back-to-front sort (UiElement.cs's own convention).
|
|
Assert.All(pageRoot.Children.Where(c => !ReferenceEquals(c, backing)),
|
|
sibling => Assert.True(backing.ZOrder < sibling.ZOrder));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivateTabs_SelectsGameplayAsDefault_ButNeverFlushesIt()
|
|
{
|
|
// Mechanism review S1 (2026-08-11 fix round): gmGameplayOptionsUI is
|
|
// NOT an OptionPage in retail (acclient.h:55857) — its own OptionPage
|
|
// instance is constructed with AfterApply deliberately left null
|
|
// (OptionsPanelController's constructor), regardless of what the
|
|
// controller's OWN AfterApply callback is, so the initial default-
|
|
// tab activation (OnShown -> Apply) never publishes a flush.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
int gameplayFlushCount = 0;
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout,
|
|
MakeCallbacks(calls) with { AfterApply = () => gameplayFlushCount++ })!;
|
|
|
|
controller.ActivateTabs();
|
|
|
|
Assert.True(controller.TabPanel.BehaviorActive);
|
|
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); // Gameplay slot
|
|
Assert.Equal(0, gameplayFlushCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TabSwitch_RevertsLeavingGameplayPage_AndAppliesEnteringCharacterPage()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
var flushes = new List<string>();
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout, MakeCallbacks(calls) with { AfterApply = () => flushes.Add("flush") })!;
|
|
controller.ActivateTabs();
|
|
flushes.Clear(); // drop the initial-activation flush (Gameplay never flushes anyway)
|
|
|
|
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
|
|
|
|
Assert.Equal(0x10000211u, controller.TabPanel.ActivePageElementId);
|
|
// OnHidden (Gameplay, Reset — no flush regardless) and OnShown
|
|
// (Character, a REAL AfterApply-wired page — Apply flushes).
|
|
Assert.Equal(["flush"], flushes);
|
|
}
|
|
|
|
[Fact]
|
|
public void WholeWindowHide_RevertsCurrentlyActivePage()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
controller.ActivateTabs();
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
controller.GameplayPage.Register(row);
|
|
row.SetCurrentValue(true);
|
|
Assert.True(controller.GameplayPage.Changed);
|
|
|
|
controller.OnHidden(); // IRetainedPanelController hook — whole window closing
|
|
|
|
Assert.False(controller.GameplayPage.Changed);
|
|
}
|
|
|
|
[Fact]
|
|
public void WholeWindowShow_AppliesCurrentlyActivePage()
|
|
{
|
|
// Switches off the Gameplay default onto Character FIRST — Gameplay
|
|
// never flushes (S1), so testing "OnShown applies the currently
|
|
// active page" needs a REAL AfterApply-wired page active, exactly
|
|
// like a returning user re-opening the window on a non-default tab.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
int flushCount = 0;
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
|
|
controller.ActivateTabs();
|
|
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
|
|
flushCount = 0;
|
|
|
|
controller.OnShown();
|
|
|
|
Assert.Equal(1, flushCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameplayPage_OnShownAndOnHidden_NeverFlush_EvenWhenControllerAfterApplyIsWired()
|
|
{
|
|
// Direct pin of S1's fix: cycling Gameplay's own OnShown/OnHidden
|
|
// (the page-model hooks OnActivePageChanged drives) never publishes
|
|
// a flush, independent of TabSwitch/ActivateTabs framing.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
int flushCount = 0;
|
|
OptionsPanelController controller = OptionsPanelController.Bind(
|
|
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
|
|
|
|
controller.GameplayPage.OnShown();
|
|
controller.GameplayPage.OnHidden();
|
|
|
|
Assert.Equal(0, flushCount);
|
|
}
|
|
|
|
// ── MUST-FIX 2 (OP4 review-fix round, 2026-08-11): Apply/Reset gating ──────
|
|
|
|
private static (UiButton Apply, UiButton Reset, UiButton Defaults) GetCharacterPageButtons(
|
|
OptionsPanelController controller)
|
|
{
|
|
UiElement pageRoot = UiElement.FindDescendant(controller.Root, 0x10000211u)!; // Character page slot
|
|
var apply = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FCu));
|
|
var reset = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FDu));
|
|
var defaults = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FEu));
|
|
return (apply, reset, defaults);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterPage_ApplyAndReset_StartDisabled_OnFreshBind()
|
|
{
|
|
// Retail's PostInit calls InitOptions() then OnOptionChanged(0), so
|
|
// the pair starts Ghosted before any row has ever been touched.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
|
|
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
|
|
|
|
Assert.False(apply.Enabled);
|
|
Assert.False(reset.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterPage_OneLedClick_EnablesApplyAndReset()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
controller.CharacterPage.Register(row);
|
|
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
|
|
|
|
row.SetCurrentValue(true);
|
|
|
|
Assert.True(apply.Enabled);
|
|
Assert.True(reset.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterPage_Apply_DisablesApplyAndResetAgain()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
controller.CharacterPage.Register(row);
|
|
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
|
|
row.SetCurrentValue(true);
|
|
|
|
controller.CharacterPage.Apply();
|
|
|
|
Assert.False(apply.Enabled);
|
|
Assert.False(reset.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterPage_Defaults_LeavesApplyAndResetEnabled_WhenSomethingActuallyChanged()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
var row = new BoolOptionRow(initial: false, defaultValue: true);
|
|
controller.CharacterPage.Register(row);
|
|
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
|
|
|
|
controller.CharacterPage.Defaults();
|
|
|
|
Assert.True(row.Current);
|
|
Assert.True(controller.CharacterPage.Changed);
|
|
Assert.True(apply.Enabled);
|
|
Assert.True(reset.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void CharacterPage_Defaults_IsNeverGated()
|
|
{
|
|
// Retail's Defaults override never fetches its own child id at all
|
|
// — it must never grey itself out, before OR after a real change.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController controller =
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
|
|
var row = new BoolOptionRow(initial: false, defaultValue: false);
|
|
controller.CharacterPage.Register(row);
|
|
(_, _, UiButton defaults) = GetCharacterPageButtons(controller);
|
|
|
|
Assert.True(defaults.Enabled);
|
|
|
|
row.SetCurrentValue(true);
|
|
Assert.True(defaults.Enabled);
|
|
|
|
controller.CharacterPage.Apply();
|
|
Assert.True(defaults.Enabled);
|
|
|
|
controller.CharacterPage.Defaults();
|
|
Assert.True(defaults.Enabled);
|
|
}
|
|
|
|
// ── Close button ─────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void CloseButton_FiresToggleCallback()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000210u);
|
|
|
|
Assert.Equal(["toggle"], calls);
|
|
}
|
|
|
|
// ── The seven Gameplay-tab buttons ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ExitToCharacterSelectionButton_FiresRequestCallback()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000203u);
|
|
|
|
Assert.Equal(["exit-to-char-select"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExitGameButton_FiresExitGameCallback()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000617u);
|
|
|
|
Assert.Equal(["exit-game"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void UseMouseTurningSettingsButton_FiresMacroCallback()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x100005CCu);
|
|
|
|
Assert.Equal(["mouse-turning"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void UrgentAssistanceButton_DisplaysItsOwnByteVerifiedFailureText()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000206u);
|
|
|
|
Assert.Equal([$"message:{OptionsPanelText.UrgentAssistanceUnavailable}"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReportAbuseButton_DisplaysItsOwnByteVerifiedFailureText()
|
|
{
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000207u);
|
|
|
|
Assert.Equal([$"message:{OptionsPanelText.ReportAbuseUnavailable}"], calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void UrgentAssistanceAndReportAbuse_UseDifferentText()
|
|
{
|
|
// The two buttons share retail's identical body template but differ
|
|
// in ONE clause ("urgent assistance request" vs "abuse report").
|
|
Assert.NotEqual(
|
|
OptionsPanelText.UrgentAssistanceUnavailable,
|
|
OptionsPanelText.ReportAbuseUnavailable);
|
|
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.UrgentAssistanceUnavailable);
|
|
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.ReportAbuseUnavailable);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConfigureKeyboardButton_IsInert_ClickDoesNothing()
|
|
{
|
|
// D4/OP8: authored, clickable, no handler this slice.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000204u);
|
|
|
|
Assert.Empty(calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void InGameHelpFilesButton_IsInert_ClickDoesNothing()
|
|
{
|
|
// D5: retail's own KeyStone::OpenHelp fails silently without the
|
|
// missing ACHelpPlugin.dll — mirrored as an inert button.
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
var calls = new List<string>();
|
|
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
|
|
|
|
Click(layout, 0x10000205u);
|
|
|
|
Assert.Empty(calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllSevenGameplayButtons_ResolveInTheBuiltLayout()
|
|
{
|
|
// Guards against a future fixture regeneration silently dropping an
|
|
// id (a missing button degrades to a logged no-op, not a test
|
|
// failure, unless asserted here).
|
|
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
|
|
|
|
uint[] buttonIds =
|
|
{
|
|
0x10000203u, // Exit to Character Selection
|
|
0x10000204u, // Configure Keyboard
|
|
0x10000205u, // In-Game Help Files
|
|
0x10000206u, // Urgent Assistance
|
|
0x10000207u, // Report Abuse
|
|
0x100005CCu, // Use Mouse Turning Settings
|
|
0x10000617u, // Exit Game
|
|
};
|
|
|
|
foreach (uint id in buttonIds)
|
|
Assert.IsType<UiButton>(layout.FindElement(id));
|
|
}
|
|
}
|