acdream/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs
Erik b2e68c2343 feat(ui): add stable authored-geometry-revision hash for plugin windows
Part of #490 part 2: plugin panels register with authoredGeometryRevision
hard-coded to 0, so a plugin author who ships a new authored panel size
(MossTank: 856x236 -> 984x271) has no way to signal the change short of
adding a manual revision-bump call site, and every user's stored layout
keeps the old size forever. Built-in retail-imported windows solve this
with an explicit incrementing int literal at each Register call; a plugin
author does not maintain that call site by hand.

RetailWindowManager.ComputeAuthoredGeometryRevision derives the revision
from the authored geometry tuple itself (width, height, minw, minh,
resizable) via a fixed FNV-1a-style combine over the values' raw IEEE-754
bit patterns -- deliberately not System.HashCode, whose per-process reseed
would make the "same" authored geometry hash differently on every launch.
The sign bit is masked off so the result is never negative (Register's own
Math.Max(0, revision) would otherwise silently fold distinct negative
hashes onto the same "unversioned" 0 bucket used by legacy saves).

Mutation shown to fail first: without this method,
RetailWindowManagerTests.ComputeAuthoredGeometryRevision_* (7 new tests)
fails to compile (CS0117, method does not exist). No wiring yet -- this
commit only adds the pure, inert helper; MountPlugins still passes no
revision. That lands next along with the comparison-semantics fix it
depends on.

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

349 lines
14 KiB
C#

using System.Collections.Generic;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class RetailWindowManagerTests
{
[Fact]
public void Hide_ClearsRootOwnership_AndShowRestoresDefaultInput()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 300, Height = 150 };
var input = new UiField { Width = 200, Height = 20 };
frame.AddChild(input);
root.AddChild(frame);
root.DefaultTextInput = input;
root.Modal = frame;
root.SetKeyboardFocus(input);
root.SetCapture(input);
var controller = new RecordingController();
RetailWindowHandle handle = root.RegisterWindow("chat", frame, frame, controller);
var events = new List<string>();
handle.Hidden += _ => events.Add("hidden");
handle.Shown += _ => events.Add("shown");
frame.Visible = false;
Assert.False(frame.Visible);
Assert.Null(root.KeyboardFocus);
Assert.Null(root.Captured);
Assert.Null(root.Modal);
Assert.Null(root.DefaultTextInput);
Assert.Equal(1, controller.ShownCount);
Assert.Equal(1, controller.HiddenCount);
Assert.Equal(new[] { "hidden" }, events);
Assert.True(handle.Show());
Assert.True(frame.Visible);
Assert.Same(input, root.DefaultTextInput);
Assert.Equal(2, controller.ShownCount);
Assert.Equal(new[] { "hidden", "shown" }, events);
}
[Fact]
public void Handle_ReportsGeometryCloseAndLockLifecycle()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel
{
Left = 10,
Top = 20,
Width = 100,
Height = 80,
MinWidth = 50,
MinHeight = 40,
};
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("inventory", frame);
int moved = 0, resized = 0, hidden = 0, shown = 0, closed = 0;
var locks = new List<bool>();
handle.Moved += _ => moved++;
handle.Resized += _ => resized++;
handle.Hidden += _ => hidden++;
handle.Shown += _ => shown++;
handle.Closed += _ => closed++;
handle.LockChanged += (_, locked) => locks.Add(locked);
Assert.True(handle.MoveTo(30, 45));
Assert.Equal((30f, 45f), (frame.Left, frame.Top));
Assert.Equal(1, moved);
Assert.True(handle.ResizeTo(160, 120));
Assert.Equal((160f, 120f), (frame.Width, frame.Height));
Assert.Equal(1, resized);
root.WindowManager.SetLocked(true);
root.WindowManager.SetLocked(true);
root.WindowManager.SetLocked(false);
Assert.Equal(new[] { true, false }, locks);
Assert.True(handle.Close());
Assert.True(handle.Close());
Assert.False(handle.IsVisible);
Assert.Equal(1, hidden);
Assert.Equal(1, closed);
Assert.True(handle.Show());
Assert.True(handle.Close());
Assert.Equal(1, shown);
Assert.Equal(2, hidden);
Assert.Equal(2, closed);
}
[Fact]
public void PointerResize_ReportsCompletionThroughTypedHandle()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel
{
Left = 10,
Top = 20,
Width = 100,
Height = 80,
Resizable = true,
};
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("chat", frame);
int resized = 0;
handle.Resized += _ => resized++;
root.OnMouseDown(UiMouseButton.Left, 109, 60);
root.OnMouseMove(149, 60);
root.OnMouseUp(UiMouseButton.Left, 149, 60);
Assert.Equal(140f, frame.Width);
Assert.Equal(1, resized);
}
[Fact]
public void ProgrammaticResize_ConstrainedToParent_UsesCurrentWindowPosition()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel
{
Left = 40,
Top = 125,
Width = 300,
Height = 200,
ResizeX = false,
ResizeY = true,
ConstrainResizeToParent = true,
MinHeight = 100,
};
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("main-panel", frame);
Assert.True(handle.ResizeTo(frame.Width, 2_000f));
Assert.Equal(475f, frame.Height);
Assert.Equal(root.Height, frame.Top + frame.Height);
}
[Fact]
public void FocusAndCaptureTransitions_AreScopedToOwningHandle()
{
var root = new UiRoot { Width = 800, Height = 600 };
var first = new UiPanel { Width = 200, Height = 100 };
var second = new UiPanel { Left = 250, Width = 200, Height = 100 };
var firstField = new UiField { Width = 100, Height = 20 };
var secondField = new UiField { Width = 100, Height = 20 };
first.AddChild(firstField);
second.AddChild(secondField);
root.AddChild(first);
root.AddChild(second);
var firstController = new RecordingController();
var secondController = new RecordingController();
RetailWindowHandle firstHandle = root.RegisterWindow("first", first, first, firstController);
RetailWindowHandle secondHandle = root.RegisterWindow("second", second, second, secondController);
var firstCaptures = new List<UiElement?>();
var secondCaptures = new List<UiElement?>();
firstHandle.DescendantCaptureChanged += (_, element) => firstCaptures.Add(element);
secondHandle.DescendantCaptureChanged += (_, element) => secondCaptures.Add(element);
root.SetKeyboardFocus(firstField);
root.SetKeyboardFocus(secondField);
root.SetCapture(firstField);
root.SetCapture(secondField);
root.ReleaseCapture();
Assert.Equal(new UiElement?[] { firstField, null }, firstController.FocusChanges);
Assert.Equal(new UiElement?[] { secondField }, secondController.FocusChanges);
Assert.Equal(new UiElement?[] { firstField, null }, firstCaptures);
Assert.Equal(new UiElement?[] { secondField, null }, secondCaptures);
}
[Fact]
public void UnregisterAndTreeRemoval_DisposeControllersExactlyOnce()
{
var root = new UiRoot { Width = 800, Height = 600 };
var first = new UiPanel { Width = 100, Height = 100 };
var second = new UiPanel { Width = 100, Height = 100 };
root.AddChild(first);
root.AddChild(second);
var firstController = new RecordingController();
var secondController = new RecordingController();
RetailWindowHandle firstHandle = root.RegisterWindow("first", first, first, firstController);
RetailWindowHandle secondHandle = root.RegisterWindow("second", second, second, secondController);
Assert.True(root.UnregisterWindow("first"));
Assert.False(firstHandle.IsRegistered);
Assert.Equal(1, firstController.DisposeCount);
Assert.False(root.ShowWindow("first"));
Assert.True(root.RemoveChild(second));
Assert.False(secondHandle.IsRegistered);
Assert.Equal(1, secondController.DisposeCount);
Assert.False(root.ShowWindow("second"));
root.WindowManager.Dispose();
Assert.Equal(1, firstController.DisposeCount);
Assert.Equal(1, secondController.DisposeCount);
}
[Fact]
public void AttachController_AfterRegistration_DeliversVisibilityAndOwnsDisposal()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 100, Height = 100 };
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("inventory", frame);
var controller = new RecordingController();
root.WindowManager.AttachController("inventory", controller);
Assert.Same(controller, handle.Controller);
Assert.Equal(1, controller.ShownCount);
Assert.Throws<InvalidOperationException>(() =>
root.WindowManager.AttachController("inventory", new RecordingController()));
root.WindowManager.Dispose();
root.WindowManager.Dispose();
Assert.Equal(1, controller.HiddenCount);
Assert.Equal(1, controller.DisposeCount);
}
[Fact]
public void VisibilityEvent_tracksEveryRegisteredWindowTransition()
{
var root = new UiRoot { Width = 800, Height = 600 };
var inventory = new UiPanel { Width = 100, Height = 100 };
root.AddChild(inventory);
root.RegisterWindow("inventory", inventory);
var transitions = new List<(string Name, bool Visible)>();
root.WindowManager.WindowVisibilityChanged +=
(name, visible) => transitions.Add((name, visible));
Assert.True(root.HideWindow("inventory"));
Assert.True(root.ShowWindow("inventory"));
Assert.False(root.ToggleWindow("inventory"));
Assert.Equal(
new[]
{
("inventory", false),
("inventory", true),
("inventory", false),
},
transitions);
}
// ── 2026-09-07: plugin markup's resizable="true" panel through the real
// window-manager ResizeTo path (no host wiring beyond what MarkupDocument
// already sets on the panel — Resizable/ResizeX/ResizeY/MinWidth/MinHeight
// are ordinary UiElement properties RetailWindowManager.ResizeTo already
// respects for any registered window). ───────────────────────────────────
[Fact]
public void ResizableMarkupPluginWindow_AcceptsResizeWithinMinAndParentConstraints()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"250\" minh=\"150\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow("plugin-resizable", panel);
Assert.True(handle.ResizeTo(500f, 400f));
Assert.Equal((500f, 400f), (panel.Width, panel.Height));
// Below the authored floor clamps to minw/minh rather than shrinking further.
Assert.True(handle.ResizeTo(50f, 50f));
Assert.Equal((250f, 150f), (panel.Width, panel.Height));
}
[Fact]
public void NonResizableMarkupPluginWindow_RefusesResize()
{
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow("plugin-fixed", panel);
int resized = 0;
handle.Resized += _ => resized++;
handle.ResizeTo(500f, 400f);
Assert.Equal((300f, 200f), (panel.Width, panel.Height));
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; }
public int HiddenCount { get; private set; }
public int DisposeCount { get; private set; }
public List<UiElement?> FocusChanges { get; } = new();
public void OnShown() => ShownCount++;
public void OnHidden() => HiddenCount++;
public void OnDescendantFocusChanged(UiElement? focusedDescendant)
=> FocusChanges.Add(focusedDescendant);
public void Dispose() => DisposeCount++;
}
}