acdream/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
Erik 2153bee247 fix #390: retail display-change UI cascade — clamp + per-res reload
Decomp-first per the block's rule: the research doc
(docs/research/2026-08-13-retail-ui-display-change.md, committed here)
pulled retail's actual mechanism before any code. A display change runs
UIElementManager::RefreshEvent @0x0045C530 ->
UIElement::UpdateForParentSizeChange @0x00462640, which unconditionally
re-applies every floating window's own clamping MoveTo override
(x = max(0, min(x, parentW - selfW)) - top-left priority, oversized
windows pin to 0), then broadcasts global message 0xE whose sole
listener reloads the per-resolution auto layout. No proportional moves,
no resets; retail saves layouts only via @saveui.

Port: RetailWindowLayoutPersistence.ClampAllToScreen() is the cascade
clamp (no store I/O; _restoring suppresses the per-move save so a live
drag-resize cannot write settings.json per frame), and
RetailUiRuntime.Draw carries a two-step screen-size edge detector:
change frame -> clamp; first stable frame -> one
RestoreAll(saveBack:false) per-resolution reload (the 0xE analog; no
lazy save-back, matching retail's save-only-on-command). The login
restore path already used retail's exact clamp math (Apply) - the live
trigger was the missing half, which is precisely the stranding the user
reported.

Deliberate deviation, register AD-91: retail's gmFloatyChatUI windows
have NO clamp and can strand; the block's requirement ("UI windows must
stay reachable") clamps every registered window uniformly.

Tests: 5 new persistence facts (clamp/top-left-pin/no-move/no-save-on-
clamp/no-save-on-live-reload). App suite 4,967/3 skips. Gate script
section D3 filled in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:42:50 +02:00

395 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI;
public sealed class RetailWindowLayoutPersistenceTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), "acdream-window-layout-tests-" + Guid.NewGuid().ToString("N"));
private string PathName => Path.Combine(_directory, "settings.json");
[Fact]
public void CompletedInteractions_SaveCompleteCharacterResolutionLayout()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(root, "chat");
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
handle.MoveTo(44f, 55f);
handle.ResizeTo(320f, 180f);
handle.Hide();
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", "chat", default));
Assert.Equal((44f, 55f, 320f, 180f), (saved.X, saved.Y, saved.Width, saved.Height));
Assert.False(saved.Visible);
}
[Fact]
public void MainPanelMove_PersistsCanonicalPositionForEveryPrimaryChild()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory);
RetailWindowHandle character = Mount(root, WindowNames.Character);
inventory.Hide();
character.Hide();
using var panels = new RetailPanelUiController(
root.IsWindowVisible,
root.ShowWindow,
root.HideWindow);
panels.RegisterMainPanel(
RetailPanelCatalog.Inventory,
WindowNames.Inventory,
inventory);
panels.RegisterMainPanel(
RetailPanelCatalog.Character,
WindowNames.Character,
character);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager,
store,
() => "Alice",
() => (800, 600));
panels.SetPanelVisibility(RetailPanelCatalog.Inventory, visible: true);
inventory.MoveTo(244f, 133f);
UiWindowLayout savedInventory = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Inventory, default));
UiWindowLayout savedCharacter = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Character, default));
Assert.Equal((244f, 133f), (savedInventory.X, savedInventory.Y));
Assert.Equal((244f, 133f), (savedCharacter.X, savedCharacter.Y));
}
[Fact]
public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility()
{
var store = new SettingsStore(PathName);
store.SaveWindowLayout(
"Alice",
"640x480",
"chat",
new UiWindowLayout(900f, 700f, 500f, 300f, false, false, true));
var root = new UiRoot { Width = 640, Height = 480 };
var state = new FakeWindowState();
RetailWindowHandle handle = Mount(root, "chat", state);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (640, 480));
persistence.RestoreAll();
Assert.Equal((500f, 300f), (handle.Width, handle.Height));
Assert.Equal((140f, 180f), (handle.Left, handle.Top));
Assert.False(handle.IsVisible);
Assert.True(state.Restored.Maximized);
}
[Fact]
public void Restore_OlderAuthoredGeometryRevision_ResetsOnlySavedExtent()
{
var store = new SettingsStore(PathName);
store.SaveWindowLayout(
"Alice",
"800x600",
WindowNames.Examination,
new UiWindowLayout(
44f,
55f,
310f,
545f,
true,
false,
false,
AuthoredGeometryRevision: 0));
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(
root,
WindowNames.Examination,
authoredGeometryRevision: 1,
width: 310f,
height: 400f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager,
store,
() => "Alice",
() => (800, 600));
persistence.RestoreAll();
Assert.Equal((44f, 55f), (handle.Left, handle.Top));
Assert.Equal((310f, 400f), (handle.Width, handle.Height));
UiWindowLayout migrated = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout(
"Alice",
"800x600",
WindowNames.Examination,
default));
Assert.Equal(1, migrated.AuthoredGeometryRevision);
}
[Fact]
public void RestoreNamed_OlderRevisionUsesMountedAuthoredExtent_NotCurrentSize()
{
var store = new SettingsStore(PathName);
store.SaveNamedWindowLayout(
"legacy",
WindowNames.Examination,
new UiWindowLayout(
44f,
55f,
310f,
545f,
true,
false,
false,
AuthoredGeometryRevision: 0));
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(
root,
WindowNames.Examination,
authoredGeometryRevision: 1,
width: 310f,
height: 400f);
handle.ResizeTo(500f, 300f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager,
store,
() => "Alice",
() => (800, 600));
persistence.RestoreNamed("legacy");
Assert.Equal((44f, 55f), (handle.Left, handle.Top));
Assert.Equal((310f, 400f), (handle.Width, handle.Height));
}
[Fact]
public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(root, "radar");
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "default", () => (800, 600));
handle.MoveTo(123f, 234f);
Assert.Null(store.LoadWindowLayout("default", "800x600", "radar", default));
}
[Fact]
public void Restore_StateManagedWindow_PreservesAuthoritativeVisibility()
{
var store = new SettingsStore(PathName);
store.SaveWindowLayout(
"Alice",
"800x600",
"combat",
new UiWindowLayout(44f, 55f, 200f, 100f, true, false, false));
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(root, "combat");
handle.Hide();
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager,
store,
() => "Alice",
() => (800, 600),
stateManagedVisibilityWindows: ["combat"]);
persistence.RestoreAll();
Assert.Equal((44f, 55f), (handle.Left, handle.Top));
Assert.False(handle.IsVisible);
// Combat mode owns subsequent show/hide transitions as well; they must
// not turn into saved user visibility preferences.
handle.Show();
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", "combat", default));
Assert.False(saved.Visible);
}
[Fact]
public void NamedProfile_SaveAndRestore_AppliesEveryMountedWindow()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle chat = Mount(root, "chat");
RetailWindowHandle radar = Mount(root, "radar");
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
chat.MoveTo(44f, 55f);
radar.MoveTo(300f, 120f);
radar.Hide();
persistence.SaveNamed("hunting");
chat.MoveTo(1f, 2f);
radar.MoveTo(3f, 4f);
radar.Show();
persistence.RestoreNamed("hunting");
Assert.Equal((44f, 55f), (chat.Left, chat.Top));
Assert.Equal((300f, 120f), (radar.Left, radar.Top));
Assert.False(radar.IsVisible);
}
// ── #390: retail's display-change anti-stranding cascade ────────────
// The clamp rule is each floating window's own MoveTo override,
// x = max(0, min(x, parentW selfW)) (top-left priority), re-applied
// unconditionally on every display change — see
// docs/research/2026-08-13-retail-ui-display-change.md.
[Fact]
public void ClampAllToScreen_ReclampsStrandedWindows_TopLeftPriority()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(2200f, 1000f); // parked near the bottom-right corner
(int Width, int Height) screen = (2560, 1440);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (1280, 720); // the display shrank
persistence.ClampAllToScreen();
Assert.Equal(1280f - 310f, handle.Left);
Assert.Equal(720f - 400f, handle.Top);
}
[Fact]
public void ClampAllToScreen_OversizedWindow_PinsTopLeftToZero()
{
// max(0, min(...)) — the max applies LAST, so a window larger than
// the screen pins to 0 and its top-left chrome stays reachable.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(400f, 300f);
(int Width, int Height) screen = (800, 600);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (200, 200); // smaller than the window itself
persistence.ClampAllToScreen();
Assert.Equal(0f, handle.Left);
Assert.Equal(0f, handle.Top);
}
[Fact]
public void ClampAllToScreen_InBoundsWindow_DoesNotMove()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(100f, 120f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (1280, 720));
persistence.ClampAllToScreen();
Assert.Equal((100f, 120f), (handle.Left, handle.Top));
}
[Fact]
public void ClampAllToScreen_DoesNotSaveTheClampedPositions()
{
// The clamp runs per resize FRAME during a live window drag; its
// MoveTo must not trip the per-move save (settings.json writes per
// frame, keyed by whatever intermediate resolution the drag passes
// through). Only USER moves save.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(2200f, 1000f); // pre-attach: no save subscription yet
(int Width, int Height) screen = (2560, 1440);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (1280, 720);
persistence.ClampAllToScreen();
Assert.Equal(1280f - 310f, handle.Left); // it DID clamp…
Assert.Null(store.LoadWindowLayout( // …and did NOT write.
"Alice", "1280x720", WindowNames.Examination, default));
}
[Fact]
public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore()
{
// The live display-change reload (#390) must not persist — retail
// saves layouts only via @saveui/@saveautoui, and a mid-drag reload
// writing would litter settings.json with intermediate-resolution
// keys.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
_ = Mount(root, WindowNames.Examination, width: 310f, height: 400f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
persistence.RestoreAll(saveBack: false);
Assert.Null(store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Examination, default));
}
private static RetailWindowHandle Mount(
UiRoot root,
string name,
IRetainedWindowStateController? state = null,
int authoredGeometryRevision = 0,
float width = 200f,
float height = 100f)
{
var frame = new UiPanel
{
Left = 10f,
Top = 20f,
Width = width,
Height = height,
MinWidth = 100f,
MinHeight = 80f,
MaxWidth = 600f,
MaxHeight = 400f,
Draggable = true,
Resizable = true,
};
root.AddChild(frame);
return root.RegisterWindow(
name,
frame,
stateController: state,
authoredGeometryRevision: authoredGeometryRevision);
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private sealed class FakeWindowState : IRetainedWindowStateController
{
public RetainedWindowState Restored { get; private set; }
public RetainedWindowState CaptureWindowState() => Restored;
public void RestoreWindowState(RetainedWindowState state) => Restored = state;
}
}