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 RestoreAfterDisplayChange_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.RestoreAfterDisplayChange(); Assert.Null(store.LoadWindowLayout( "Alice", "800x600", WindowNames.Examination, default)); } [Fact] public void RestoreAfterDisplayChange_RestoresGeometryButPreservesLiveVisibility() { // Retail's global-message-0xE auto-layout file contains only X/Y/W/H. // Our richer ambient settings schema also remembers visibility and // collapsed/maximized state, but replaying visibility during a // resolution change used to hide an open Options panel. Config's OnHidden reset // then restored the previous resolution, creating the user-observed // resize-out/resize-back blip. var store = new SettingsStore(PathName); store.SaveWindowLayout( "Alice", "1280x720", WindowNames.Options, new UiWindowLayout( X: 700f, Y: 200f, Width: 310f, Height: 400f, Visible: false, Collapsed: true, Maximized: true)); var root = new UiRoot { Width = 1280, Height = 720 }; var state = new FakeWindowState(); var lifecycle = new FakePanelController(); RetailWindowHandle handle = Mount( root, WindowNames.Options, state, controller: lifecycle, width: 300f, height: 300f); using var persistence = new RetailWindowLayoutPersistence( root.WindowManager, store, () => "Alice", () => (1280, 720)); persistence.RestoreAfterDisplayChange(); Assert.Equal((700f, 200f, 310f, 400f), (handle.Left, handle.Top, handle.Width, handle.Height)); Assert.True(handle.IsVisible); Assert.Equal(0, lifecycle.HiddenCount); Assert.Equal(1, state.RestoreCount); Assert.True(state.Restored.Collapsed); Assert.True(state.Restored.Maximized); } // ── Review fix round finding 4 (plugin-shelf campaign, 2026-09-06): // attach late registrations, not only windows present at construction ── [Fact] public void WindowRegisteredAfterConstruction_StillRoundTrips() { var store = new SettingsStore(PathName); var root = new UiRoot { Width = 800, Height = 600 }; using var persistence = new RetailWindowLayoutPersistence( root.WindowManager, store, () => "Alice", () => (800, 600)); // The window is registered AFTER persistence already exists — before // this fix only windows present in RetailWindowManager.Windows at // construction time ever got a save subscription. RetailWindowHandle handle = Mount(root, "late-window"); handle.MoveTo(77f, 88f); UiWindowLayout saved = Assert.IsType( store.LoadWindowLayout("Alice", "800x600", "late-window", default)); Assert.Equal((77f, 88f), (saved.X, saved.Y)); } // ── 2026-09-07: a resizable plugin markup panel's resized geometry // persists and restores exactly like any other registered window — no // persistence-layer change was needed for this, since RetailWindowLayoutPersistence // already clamps against the frame's own MinWidth/MinHeight/MaxWidth/ // MaxHeight (Apply's ClampDimension) for every registered handle. ──────── [Fact] public void ResizableMarkupPluginPanel_ResizedSize_RoundTripsThroughPersistence() { const string xml = ""; var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)); var store = new SettingsStore(PathName); var root = new UiRoot { Width = 800, Height = 600 }; root.AddChild(panel); RetailWindowHandle handle = root.RegisterWindow("plugin-resizable-panel", panel); using var persistence = new RetailWindowLayoutPersistence( root.WindowManager, store, () => "Alice", () => (800, 600)); handle.MoveTo(60f, 70f); handle.ResizeTo(420f, 260f); UiWindowLayout saved = Assert.IsType( store.LoadWindowLayout("Alice", "800x600", "plugin-resizable-panel", default)); Assert.Equal((60f, 70f, 420f, 260f), (saved.X, saved.Y, saved.Width, saved.Height)); // Simulate a fresh session: rebuild the same markup (a fresh, un-resized // panel) and restore — the saved 420x260 must come back, not the // authored 300x200. var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)); var freshRoot = new UiRoot { Width = 800, Height = 600 }; freshRoot.AddChild(freshPanel); RetailWindowHandle freshHandle = freshRoot.RegisterWindow( "plugin-resizable-panel", freshPanel); using var freshPersistence = new RetailWindowLayoutPersistence( freshRoot.WindowManager, store, () => "Alice", () => (800, 600)); freshPersistence.RestoreAll(); Assert.Equal((420f, 260f), (freshPanel.Width, freshPanel.Height)); Assert.Equal((60f, 70f), (freshPanel.Left, freshPanel.Top)); } [Fact] public void ResizableMarkupPluginPanel_RestoreClampsBelowFloorToAuthoredMin() { // A legacy save from before this feature's minw/minh existed (or one // from a plugin update that raised its floor) can carry a size below // the CURRENT authored minimum. Restore must clamp up to that floor — // this is what RetailWindowLayoutPersistence.Apply's ClampDimension // already does against frame.MinWidth/MinHeight for every registered // window; it only bites here because MarkupDocument now sets those // fields from minw/minh instead of leaving them at UiElement's // generic 40x40 default. const string xml = ""; var store = new SettingsStore(PathName); store.SaveWindowLayout( "Alice", "800x600", "plugin-resizable-floor", new UiWindowLayout(60f, 70f, 80f, 60f, true, false, false)); var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)); var root = new UiRoot { Width = 800, Height = 600 }; root.AddChild(panel); root.RegisterWindow("plugin-resizable-floor", panel); using var persistence = new RetailWindowLayoutPersistence( root.WindowManager, store, () => "Alice", () => (800, 600)); persistence.RestoreAll(); Assert.Equal((200f, 150f), (panel.Width, panel.Height)); } private static RetailWindowHandle Mount( UiRoot root, string name, IRetainedWindowStateController? state = null, int authoredGeometryRevision = 0, float width = 200f, float height = 100f, IRetainedPanelController? controller = null) { 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, controller: controller, 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 int RestoreCount { get; private set; } public RetainedWindowState CaptureWindowState() => Restored; public void RestoreWindowState(RetainedWindowState state) { Restored = state; RestoreCount++; } } private sealed class FakePanelController : IRetainedPanelController { public int HiddenCount { get; private set; } public void OnShown() { } public void OnHidden() => HiddenCount++; public void Dispose() { } } }