diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dd732a10..bdf94ad0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,32 +74,32 @@ after each deliberate `Top` write for the imported-layout element. Precedent: `MapPageController.cs:235-249` (the same fix already landed for other runtime-repositioned imported/programmatic elements). -## #491 — MossTank extra/blacklisted buff lists are shown but not consumed by BuffPlan.Build - -**Status:** OPEN — found 2026-09-07 at the slice 7 architecture review. -**Severity:** LOW (honest UI after fix round B; behavior owed) -**Component:** `src/AcDream.Plugins.MossTank/BuffPlan.cs` (`Build`), `BuffSettings.ExtraBuffSpellNames`/`BlacklistedBuffFamilyNames` - -**Description.** Slice 7 added VTank's "Extra Buff Spells" and "Blacklisted Buff Families" lists to the Buffs tab. They are stored (persisted after fix round B) but `BuffPlan.Build` never reads them: extra exemplars are not cast, blacklisted families are not skipped. Belongs to VT2 slice 4 (buffs); wire `Build` to add the "best similar" spell per extra exemplar and to skip blacklisted families, with plan tests. - -## #490 — Plugin panel host: `StartVisible=false` + `ShowInSidePanel=false` is permanently unshowable; layout persistence has no revision bump - -**Status:** OPEN — found live 2026-09-07 at slice 7 fix round A (`78b42a519`), workaround in the plugin. -**Severity:** LOW/MEDIUM (host defect; silent) -**Component:** `src/AcDream.App/UI/RetailUiRuntime.cs` (~5735-5757, `PluginWindowVisibilityController`), `RetailWindowLayoutPersistence` - -**Description.** (1) `_requestedVisible = startVisible` and only `OnShown()` ever sets it true; a window registered with `ShowInSidePanel=false` has no shelf entry to raise `OnShown`, so `StartVisible=false` can never become visible, with no error. MossTank works around it by registering its popups `StartVisible=true` and gating on its own visibility bindings. Fix shape: validate the combination at `AddPanel` (throw, or coerce). (2) Plugin windows restore a stale persisted position over a changed authored default with no revision bump — the slice 7 screenshots needed an isolated `ACDREAM_CONFIG_DIR` to open at the authored 28,42. Fix shape: key the persisted layout by the panel's authored geometry hash (or a plugin-declared layout revision) so a redesign resets the stored position once. - -## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish - -**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). -**Severity:** LOW/MEDIUM (leak in long-lived bots) -**Component:** `src/AcDream.Runtime/.../SpewBoxState.cs` (`Enqueue` ~:110, `_pending`), `src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs` - -**Description.** `RuntimeCommunicationState.AddText` routes every `ClientLocal` (0x1A) line into `SpewBoxState.Enqueue`; the only `Tick` caller in the headless host is the console pump, so with the console disabled (every scripted/CI bot) `_pending` grows for the life of the session. Pre-existing before the console; the console merely made it visible. Fix shape: tick the SpewBox from the session tick regardless of the console (or drop `ClientLocal` text when nothing observes it), with a pin that a 10,000-line burst without a console does not grow the queue. - -**Polish carried from the same re-check:** in `--console` mode the JSON diagnostics/resources stream still interleaves with the chat lines on stdout — quiet it or send it to stderr when the console is on; `--console` missing from `--help`; `HeadlessConsoleOptions.cs:51` re-types the env-var literal (the LaunchOptions regex needs it — a const rename would split the two reads); the `/quit`/`/status`/"not handled" writes and `Pump()` sit outside the S4 try/catch (a broken stdout pipe would fault the session); the SpewBox's 4-entry visible cap can drop interface-text lines produced between two pumps. - +## #491 — MossTank extra/blacklisted buff lists are shown but not consumed by BuffPlan.Build + +**Status:** OPEN — found 2026-09-07 at the slice 7 architecture review. +**Severity:** LOW (honest UI after fix round B; behavior owed) +**Component:** `src/AcDream.Plugins.MossTank/BuffPlan.cs` (`Build`), `BuffSettings.ExtraBuffSpellNames`/`BlacklistedBuffFamilyNames` + +**Description.** Slice 7 added VTank's "Extra Buff Spells" and "Blacklisted Buff Families" lists to the Buffs tab. They are stored (persisted after fix round B) but `BuffPlan.Build` never reads them: extra exemplars are not cast, blacklisted families are not skipped. Belongs to VT2 slice 4 (buffs); wire `Build` to add the "best similar" spell per extra exemplar and to skip blacklisted families, with plan tests. + +## #490 — Plugin panel host: `StartVisible=false` + `ShowInSidePanel=false` is permanently unshowable; layout persistence has no revision bump + +**Status:** OPEN — found live 2026-09-07 at slice 7 fix round A (`78b42a519`), workaround in the plugin. +**Severity:** LOW/MEDIUM (host defect; silent) +**Component:** `src/AcDream.App/UI/RetailUiRuntime.cs` (~5735-5757, `PluginWindowVisibilityController`), `RetailWindowLayoutPersistence` + +**Description.** (1) `_requestedVisible = startVisible` and only `OnShown()` ever sets it true; a window registered with `ShowInSidePanel=false` has no shelf entry to raise `OnShown`, so `StartVisible=false` can never become visible, with no error. MossTank works around it by registering its popups `StartVisible=true` and gating on its own visibility bindings. Fix shape: validate the combination at `AddPanel` (throw, or coerce). Still OPEN. (2) **FIXED at `05f22d46f`.** Plugin windows restored a stale persisted SIZE over a changed authored default with no revision bump (MossTank's panel went 856x236 -> 984x271 and every stored layout stayed at 856x236 forever — not a position bug, a size bug; the earlier "28,42" framing above was about part (1)'s workaround, not this). `RetailWindowManager.ComputeAuthoredGeometryRevision` now derives the revision from the panel's own authored `(w, h, minw, minh, resizable)` tuple via a process-stable hash (deliberately not `System.HashCode`, which reseeds per process), and `MountPlugins` passes it instead of the hard-coded `0`. `RetailWindowLayoutPersistence.MigrateAuthoredGeometry` now compares revisions for inequality rather than "newer only", since a hash is not an ordered counter. Position is kept (not reset) and still clamps to the live screen; a genuinely unchanged authored size keeps the user's own resize. See `RetailWindowLayoutPersistence`'s class doc for the full contract. + +## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish + +**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). +**Severity:** LOW/MEDIUM (leak in long-lived bots) +**Component:** `src/AcDream.Runtime/.../SpewBoxState.cs` (`Enqueue` ~:110, `_pending`), `src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs` + +**Description.** `RuntimeCommunicationState.AddText` routes every `ClientLocal` (0x1A) line into `SpewBoxState.Enqueue`; the only `Tick` caller in the headless host is the console pump, so with the console disabled (every scripted/CI bot) `_pending` grows for the life of the session. Pre-existing before the console; the console merely made it visible. Fix shape: tick the SpewBox from the session tick regardless of the console (or drop `ClientLocal` text when nothing observes it), with a pin that a 10,000-line burst without a console does not grow the queue. + +**Polish carried from the same re-check:** in `--console` mode the JSON diagnostics/resources stream still interleaves with the chat lines on stdout — quiet it or send it to stderr when the console is on; `--console` missing from `--help`; `HeadlessConsoleOptions.cs:51` re-types the env-var literal (the LaunchOptions regex needs it — a const rename would split the two reads); the `/quit`/`/status`/"not handled" writes and `Pump()` sit outside the S4 try/catch (a broken stdout pipe would fault the session); the SpewBox's 4-entry visible cap can drop interface-text lines produced between two pumps. + ## #488 — MossTank `.utl` expression block: length prefix measured before newline normalization **Status:** OPEN — found 2026-09-07 by the final Opus re-check of Campaign VT diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index a51879c6..430216d5 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -210,7 +210,12 @@ No other markup or host wiring is needed to make a panel resizable: once `resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/ `MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other -retained window already has just applies. +retained window already has just applies. Changing a panel's authored `w`/ +`h`/`minw`/`minh`/`resizable` in a later plugin update resets every user's +stored SIZE to the new authored default exactly once (their saved position +is kept and re-clamped to the screen) — the host derives a stable revision +from that tuple automatically, so a plugin author never needs to bump one +by hand (#490 part 2). ```xml diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 02600df3..061a839d 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4767,11 +4767,20 @@ public sealed class RetailUiRuntime : IDisposable // later registration/sidepanel failure then rolls the mounted // subtree back through FailMount instead of leaking it. _bindings.Plugins.CompleteMount(panel, Host.Root, element); + // #490 part 2: derive the authored-geometry revision from + // the panel's own authored extent instead of a hard-coded 0 + // — see RetailWindowLayoutPersistence's class doc and + // RetailWindowManager.ComputeAuthoredGeometryRevision's own + // doc for why a plugin window can't use the built-in + // windows' manual-literal scheme. + int authoredGeometryRevision = RetailWindowManager.ComputeAuthoredGeometryRevision( + element.Width, element.Height, element.MinWidth, element.MinHeight, element.Resizable); RetailWindowHandle handle = Host.WindowManager.Register( panel.WindowName, element, element, - visibility); + visibility, + authoredGeometryRevision: authoredGeometryRevision); _bindings.Plugins.CompleteWindowMount( panel, () => Host.WindowManager.Unregister(panel.WindowName)); diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index ca1bf655..ee2baf95 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -9,6 +9,31 @@ namespace AcDream.App.UI; /// per-resolution settings. It deliberately ignores the temporary pre-login /// default character key so startup layout cannot overwrite a real /// character's state. +/// +/// +/// Authored-geometry revision (#490 part 2). Every registered window +/// carries an authoredGeometryRevision (see +/// ); a restore +/// whose saved revision differs from the handle's current one replaces only +/// the saved WIDTH/HEIGHT with the current authored size +/// () — position, visibility, and +/// collapsed/maximized state are untouched, and the clamp in +/// still re-fits the kept position to the live screen. +/// Built-in retail-imported windows hand-pick that revision as a small +/// incrementing literal at their Register call site (chat windows: +/// authoredGeometryRevision = 1) — a deliberate author decision each +/// time their authored size changes. Plugin windows have no such call site +/// an author remembers to touch, so MountPlugins instead derives the +/// revision automatically from the authored geometry tuple itself via +/// +/// (width, height, min width, min height, resizable): unchanged authored +/// geometry hashes to the same revision (a user's own resize survives +/// restore), and ANY authored geometry change hashes to a different one +/// (the stored size resets to the new default exactly once). Because a hash +/// is not an ordered counter, the comparison is for INEQUALITY — see +/// 's own doc for why the original +/// "newer revision only" read was wrong for this case. +/// /// public sealed class RetailWindowLayoutPersistence : IDisposable { @@ -295,11 +320,26 @@ public sealed class RetailWindowLayoutPersistence : IDisposable handle.AuthoredGeometryRevision); } + /// + /// #490 part 2: compares revisions for INEQUALITY, not ordering. Built-in + /// retail-imported windows hand-pick a small incrementing literal + /// (0, 1, 2…) that only ever grows, so the original "migrate only if + /// saved < authored" read fine for them. Plugin windows instead derive + /// their revision from a hash of the authored geometry itself + /// () so + /// their author never has to remember to bump a literal — but a hash is + /// not a counter, and two different authored sizes can hash in either + /// order. "The authored size changed" therefore means "the value + /// differs", not "the value went up"; treating it as ordered silently + /// dropped every size-decreasing (by hash value, not by pixels) plugin + /// update, which is exactly how MossTank's 856x236 -> 984x271 bump got + /// stuck at the old size for every user with a stored layout. + /// private static UiWindowLayout MigrateAuthoredGeometry( UiWindowLayout saved, UiWindowLayout authored) { - if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision) + if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision) return saved; return saved with diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 3a364047..418e7f9d 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -111,6 +111,45 @@ public sealed class RetailWindowManager : IDisposable return handle; } + /// + /// Derives a stable authored-geometry revision from a window's own + /// authored extent (width, height, min width, min height, resizable), so + /// a plugin window's call can invalidate an + /// obsolete saved size across an authored-size change WITHOUT the plugin + /// author remembering to bump an explicit revision literal the way + /// built-in retail-imported windows do (#490 part 2 — MossTank shipped + /// 856x236 -> 984x271 and every stored layout stayed at 856x236 forever). + /// Deliberately NOT : that type reseeds its + /// internal state once per process specifically to defeat hash-flooding + /// attacks, so the SAME geometry would hash to a DIFFERENT value on + /// every relaunch — every login would look like a fresh authored-geometry + /// revision and reset every plugin window's saved size, every time. This + /// instead combines the exact IEEE-754 bit patterns with a fixed FNV-1a- + /// style multiplier, which is stable across processes, machines, and + /// .NET versions. + /// compares revisions for INEQUALITY, not ordering — a hash is not a + /// counter, so "authored size changed" means "the value differs", + /// whichever direction it moved. The sign bit is masked off the result: + /// clamps a negative authoredGeometryRevision + /// up to 0 (its "no explicit revision" sentinel), and a hash landing + /// there would be indistinguishable from an old, pre-hash saved layout + /// that never had a revision at all. + /// + public static int ComputeAuthoredGeometryRevision( + float width, float height, float minWidth, float minHeight, bool resizable) + { + unchecked + { + int hash = 17; + hash = (hash * 31) + BitConverter.SingleToInt32Bits(width); + hash = (hash * 31) + BitConverter.SingleToInt32Bits(height); + hash = (hash * 31) + BitConverter.SingleToInt32Bits(minWidth); + hash = (hash * 31) + BitConverter.SingleToInt32Bits(minHeight); + hash = (hash * 31) + (resizable ? 1 : 0); + return hash & 0x7FFFFFFF; + } + } + public bool TryGet(string name, out RetailWindowHandle handle) => _byName.TryGetValue(name, out handle!); diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 67c0abe1..d82cf0ab 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -499,6 +499,143 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable 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 = + ""; + const string newXml = + ""; + 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 = + ""; + 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 = + ""; + const string newXml = + ""; + 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, diff --git a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs index 9a245023..8172897d 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs @@ -291,6 +291,48 @@ public sealed class RetailWindowManagerTests Assert.Equal(0, resized); } + // ── #490 part 2: plugin windows derive their authored-geometry revision + // from the authored geometry itself, so a plugin author who ships a new + // panel size does not also have to remember to bump an explicit revision + // literal the way built-in retail-imported windows do. ──────────────── + + [Fact] + public void ComputeAuthoredGeometryRevision_SameGeometry_IsStable() + { + int a = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true); + int b = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true); + + Assert.Equal(a, b); + } + + [Theory] + [InlineData(984f, 236f, 400f, 150f, true)] // width changed (the #490 MossTank case) + [InlineData(856f, 271f, 400f, 150f, true)] // height changed + [InlineData(856f, 236f, 420f, 150f, true)] // minw changed + [InlineData(856f, 236f, 400f, 160f, true)] // minh changed + [InlineData(856f, 236f, 400f, 150f, false)] // resizable changed + public void ComputeAuthoredGeometryRevision_AnyFieldDiffers_ChangesTheValue( + float width, float height, float minWidth, float minHeight, bool resizable) + { + int baseline = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true); + int changed = RetailWindowManager.ComputeAuthoredGeometryRevision( + width, height, minWidth, minHeight, resizable); + + Assert.NotEqual(baseline, changed); + } + + [Fact] + public void ComputeAuthoredGeometryRevision_IsNeverNegative() + { + // Register clamps a negative authoredGeometryRevision up to 0 (its + // "no explicit revision" sentinel) — a hash landing there would be + // indistinguishable from an old, pre-hash saved layout that never + // had a revision at all, so the function must never produce one. + Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true) >= 0); + Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(0f, 0f, 0f, 0f, false) >= 0); + Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(-1f, -1f, -1f, -1f, true) >= 0); + } + private sealed class RecordingController : IRetainedPanelController { public int ShownCount { get; private set; }