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( 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( store.LoadWindowLayout( "Alice", "800x600", WindowNames.Inventory, default)); UiWindowLayout savedCharacter = Assert.IsType( 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( 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( 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; } }