acdream/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
Erik 05f22d46ff fix #490 (part 2): plugin panels adopt an authored size change instead of keeping a stale saved size forever
MountPlugins registered every plugin window with authoredGeometryRevision
hard-coded to 0 (RetailUiRuntime.cs), so RetailWindowLayoutPersistence's
MigrateAuthoredGeometry -- gated on "saved revision >= authored revision"
-- never migrated a plugin window's saved size: 0 >= 0 forever. MossTank's
panel went 856x236 -> 984x271 and every user with a stored layout stayed
stuck at 856x236 with no way to see the new default.

Two changes:

1. MountPlugins now passes RetailWindowManager.ComputeAuthoredGeometryRevision
   (added previous commit) as the plugin window's authoredGeometryRevision,
   derived from the panel's own authored width/height/minw/minh/resizable.

2. MigrateAuthoredGeometry now compares revisions for INEQUALITY
   (saved.Revision == authored.Revision) instead of ordering
   (saved.Revision >= authored.Revision). A hash is not an incrementing
   counter -- two different authored sizes can hash in either order -- so
   "the authored size changed" has to mean "the value differs", not "the
   value went up". Built-in windows' hand-picked incrementing literals
   (chat: authoredGeometryRevision = 1) are unaffected: no existing saved
   revision is ever equal to a later, different literal either way.

Mutation shown to fail first: the two new PluginMarkupPanel_AuthoredSizeChanged_*
tests in RetailWindowLayoutPersistenceTests.cs reproduce the exact bug with
concrete literals (856x236/400/150/true -> 984x271/... and
200x100/100/80/true -> 220x110/...) chosen so ComputeAuthoredGeometryRevision's
OLD hash is >= the NEW hash for each pair -- confirmed via a throwaway probe
before writing the assertions, so the pre-fix run fails deterministically
rather than by chance of hash ordering. Both failed before this commit
(size stayed at the old authored extent) and pass after
(PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize, unaffected
either way, is a regression-safety companion). Full RetailWindow/Markup/
PluginSidePanel filter: 249 passed (was 246), 0 failed, 0 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:51:46 +02:00

697 lines
28 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 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<UiWindowLayout>(
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 =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
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<UiWindowLayout>(
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 =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
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));
}
// ── #490 part 2: a plugin's authored panel size changes across a plugin
// update (MossTank went 856x236 -> 984x271) and every user's stored
// layout must adopt the new authored size rather than keep the old one
// forever. MountPlugins derives authoredGeometryRevision from the
// authored geometry tuple via RetailWindowManager.ComputeAuthoredGeometryRevision
// (#490 part 2) instead of a manual literal, so these tests register the
// way MountPlugins does: pass the SAME computed hash to RegisterWindow. ──
[Fact]
public void PluginMarkupPanel_AuthoredSizeChanged_ReplacesStoredSizeButKeepsPosition()
{
const string oldXml =
"<panel x=\"0\" y=\"0\" w=\"856\" h=\"236\" resizable=\"true\" minw=\"400\" minh=\"150\"></panel>";
const string newXml =
"<panel x=\"0\" y=\"0\" w=\"984\" h=\"271\" resizable=\"true\" minw=\"400\" minh=\"150\"></panel>";
var store = new SettingsStore(PathName);
// "Old session": the plugin's previous authored size registers and
// the user drags the window.
var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32));
var oldRoot = new UiRoot { Width = 1280, Height = 720 };
oldRoot.AddChild(oldPanel);
RetailWindowHandle oldHandle = oldRoot.RegisterWindow(
"moss-tank",
oldPanel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable));
using (var oldPersistence = new RetailWindowLayoutPersistence(
oldRoot.WindowManager, store, () => "Alice", () => (1280, 720)))
{
oldHandle.MoveTo(120f, 90f);
}
// "New session": the plugin ships its new authored 984x271 size.
var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32));
var newRoot = new UiRoot { Width = 1280, Height = 720 };
newRoot.AddChild(newPanel);
newRoot.RegisterWindow(
"moss-tank",
newPanel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable));
using var persistence = new RetailWindowLayoutPersistence(
newRoot.WindowManager, store, () => "Alice", () => (1280, 720));
persistence.RestoreAll();
Assert.Equal((984f, 271f), (newPanel.Width, newPanel.Height));
Assert.Equal((120f, 90f), (newPanel.Left, newPanel.Top));
}
[Fact]
public void PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"856\" h=\"236\" resizable=\"true\" minw=\"400\" minh=\"150\"></panel>";
var store = new SettingsStore(PathName);
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 1280, Height = 720 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow(
"moss-tank",
panel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
panel.Width, panel.Height, panel.MinWidth, panel.MinHeight, panel.Resizable));
using (var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (1280, 720)))
{
handle.MoveTo(50f, 50f);
handle.ResizeTo(900f, 300f);
}
// Fresh session: the SAME authored geometry (same markup) registers
// again — the derived revision is unchanged, so the user's own
// resize must survive.
var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var freshRoot = new UiRoot { Width = 1280, Height = 720 };
freshRoot.AddChild(freshPanel);
freshRoot.RegisterWindow(
"moss-tank",
freshPanel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
freshPanel.Width, freshPanel.Height, freshPanel.MinWidth, freshPanel.MinHeight, freshPanel.Resizable));
using var freshPersistence = new RetailWindowLayoutPersistence(
freshRoot.WindowManager, store, () => "Alice", () => (1280, 720));
freshPersistence.RestoreAll();
Assert.Equal((900f, 300f), (freshPanel.Width, freshPanel.Height));
}
[Fact]
public void PluginMarkupPanel_AuthoredSizeChanged_ClampsPositionToNewScreenBounds()
{
// The authored-size migration keeps the saved POSITION, but that
// position still runs through the ordinary restore clamp (Apply's
// MoveTo clamp) — a larger authored size can push a near-edge saved
// X/Y off the live screen.
const string oldXml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\" resizable=\"true\" minw=\"100\" minh=\"80\"></panel>";
const string newXml =
"<panel x=\"0\" y=\"0\" w=\"220\" h=\"110\" resizable=\"true\" minw=\"100\" minh=\"80\"></panel>";
var store = new SettingsStore(PathName);
var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32));
var oldRoot = new UiRoot { Width = 800, Height = 600 };
oldRoot.AddChild(oldPanel);
RetailWindowHandle oldHandle = oldRoot.RegisterWindow(
"moss-tank",
oldPanel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable));
using (var oldPersistence = new RetailWindowLayoutPersistence(
oldRoot.WindowManager, store, () => "Alice", () => (800, 600)))
{
oldHandle.MoveTo(590f, 490f); // fits the OLD 200x100 size exactly
}
var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32));
var newRoot = new UiRoot { Width = 800, Height = 600 };
newRoot.AddChild(newPanel);
newRoot.RegisterWindow(
"moss-tank",
newPanel,
authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable));
using var persistence = new RetailWindowLayoutPersistence(
newRoot.WindowManager, store, () => "Alice", () => (800, 600));
persistence.RestoreAll();
Assert.Equal((220f, 110f), (newPanel.Width, newPanel.Height));
// maxX = 800 - 220 = 580 (590 clamps down); maxY = 600 - 110 = 490 (already in bounds).
Assert.Equal((580f, 490f), (newPanel.Left, newPanel.Top));
}
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() { }
}
}