acdream/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Erik 2ebcc01640 fix(plugin-ui): size the shelf grip from the DAT font and make the collapsed tab findable
Owner report: "I dont see the < after I minimize the window" — after
collapsing the plugin shelf with the > toggle, the < (expand) glyph was not
visible.

Root cause, established with a real-DAT draw probe (font 0x40000000) before
changing anything: '<' and '>' share IDENTICAL glyph metrics (OffsetY=4,
Width=5, Height=7, VerticalOffsetBefore=5), so there is no per-glyph
asymmetry to explain "I see one but not the other." Against the pre-fix 12px
grip band, the toggle's FILL glyph plane measured fully INSIDE the band in
both states (local y=[3,10] of [0,12]) — the "16px line box overhangs a 12px
band" theory alone does not erase the glyph, so a bare clip fix would not
have addressed the report. What IS true: the border-inflated OUTLINE
(background/shadow) plane, drawn first per retail's UIElement_Text::DrawSelf,
spans y=[-1,14] before clipping and was cropped by the band's self-clip
(UiElement.ClipsChildren) to exactly [0,12] — a real but minor defect. The
actual explanation for the report is discoverability: the collapsed shelf
shrank to a bare 24x12 near-black sliver at the screen edge, several times
smaller than any other clickable affordance in the UI — easy to overlook even
though its pixels were, in fact, being drawn.

Fix (src/AcDream.App/UI/PluginSidePanel.cs):
- ExpandedGripBandHeight (new internal property) derives the EXPANDED
  grip/toggle band from the real font metrics — max(12, font.LineHeight + 2)
  — so neither the fill nor the border-inflated outline plane can clip for
  any font; the 12px constant remains only as the bitmap-font fallback.
  Threaded through the ctor's initial Height, OnTick's row-wrap height calc,
  LayoutChrome, and Reflow's entry-Top/expanded-Height math.
- The COLLAPSED tab is now ButtonExtent (28px) tall instead of the 12px grip
  band — the same size as an ordinary entry button — with the toggle glyph
  filling and centering in the taller band. This is the actual fix for the
  report: the collapsed affordance is now button-sized and findable, not a
  bug-for-bug-identical-but-larger clip fix.

Tests:
- tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs (new,
  Lane=InstalledDat): loads the real DAT font, builds a live shelf, and
  proves via TextRenderer.DebugSpriteSegmentVerts that the toggle's ink (fill
  + outline) is fully contained in its own clip band in BOTH the expanded
  ('>') and real-click-collapsed ('<') states, plus that the collapsed tab is
  button-sized. Verified failing against the pre-fix code (git stash of just
  this file) with concrete numbers: computed unclipped span [-1,14] does not
  fit inside the 12px band; collapsed height measured 12 (not button-sized).
  Passes after the fix.
- tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs: added
  CollapseThenExpand_WhileStillDocked_ReturnsToTheIdenticalLeftAndTop (the
  dock/anchor invariant survives the collapsed-height change), and derived
  Drag_StartingOnShelfPadding_DoesNotMoveTheShelf's press-below-the-grip-band
  Y coordinate from the new ExpandedGripBandHeight accessor instead of a
  re-hard-coded literal.

Verification: dotnet build (App + tests) green. Filtered run
(PluginSidePanel|UiDatFont|Markup|UiRootInput) 131/131 passed, 0 skipped —
the InstalledDat lane tests actually ran (DAT dir resolved). Full
AcDream.App.Tests suite: 7334 passed / 97 skipped / 36 failed (baseline was
7331/97/36) — the 3 new tests are the only delta; the 36 failed test names
are byte-identical to the pre-existing set (cathedral collision installed-dat
gates, alpha-flush conformance, layout live-mount probes, Linux frame-pacing,
credential resolver — all unrelated to this change).

No retail-divergence register change needed: IA-27 already covers the
plugin shelf's non-retail collapse-toggle glyphs/behavior in general; this
is a bug fix within that already-declared deviation, not a new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 15:16:03 +02:00

974 lines
42 KiB
C#

using System.Numerics;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI;
public sealed class PluginSidePanelTests
{
[Fact]
public void ManyPluginsWrapIntoReachableColumnsWithinTheLiveScreenHeight()
{
var root = new UiRoot { Width = 800f, Height = 260f };
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
for (int i = 0; i < 12; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
$"plugin:test:{i}",
frame);
shelf.Add(
new PluginUiOwner($"test.{i}", $"Plugin {i}"),
new PluginPanelDescriptor("main", $"Plugin {i}"),
handle);
}
root.Tick(0.016d, 16L);
Assert.Equal(12, shelf.EntryCount);
Assert.True(shelf.Width > 36f);
Assert.True(shelf.Top + shelf.Height <= root.Height);
Assert.All(
shelf.Children,
child => Assert.True(child.Top + child.Height <= shelf.Height));
Assert.Equal(root.Width - shelf.Width - 4f, shelf.Left);
}
[Fact]
public void ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
var frame = new UiPanel
{
Width = 320f,
Height = 180f,
Visible = true,
};
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.test:main",
frame);
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin")
{
IconText = "TP",
},
handle);
Assert.True(shelf.Visible);
Assert.Equal(1, shelf.EntryCount);
// Review fix round finding 1: the grip and collapse toggle are now
// real sibling children too, so shelf.Children is no longer just the
// one entry button — filter by the entry-button TYPE instead of
// asserting a raw child count.
PluginSidePanel.PluginShelfButton shelfButton = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.False(handle.IsVisible);
Assert.True(handle.IsRegistered);
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.True(handle.IsVisible);
UiSimpleButton minimize = Assert.IsAssignableFrom<UiSimpleButton>(
Assert.Single(frame.Children));
minimize.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.False(handle.IsVisible);
Assert.True(handle.IsRegistered);
root.WindowManager.Unregister(handle.Name);
Assert.Equal(0, shelf.EntryCount);
Assert.False(shelf.Visible);
}
[Fact]
public void FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var frame = new UiPanel
{
Left = 28f,
Top = 42f,
Width = 800f,
Height = 244f,
Visible = true,
};
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.mosstank:main",
frame);
using var shelf = new PluginSidePanel(
root.WindowManager,
_ => (0u, 0, 0),
font: null);
root.AddChild(shelf);
shelf.Add(
new PluginUiOwner("acdream.mosstank", "MossTank"),
new PluginPanelDescriptor("main", "MossTank"),
handle);
Assert.Equal(0f, handle.Left);
Assert.Equal(42f, handle.Top);
Assert.True(frame.ConstrainDragToParent);
Assert.True(frame.ConstrainResizeToParent);
frame.Left = 700f;
frame.Top = 590f;
root.Tick(0.016d, 16L);
Assert.Equal(0f, handle.Left);
Assert.Equal(356f, handle.Top);
}
// ── Slice A: movable, hideable plugin shelf ─────────────────────────────
// docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
[Fact]
public void DefaultDock_NoSavedLayout_SitsAtRightEdgeTopBar()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
Assert.Equal(800f - shelf.Width - 4f, shelf.Left);
Assert.Equal(116f, shelf.Top);
}
[Fact]
public void TopRightCorner_StaysFixed_WhenReflowChangesWidthWhileStillDocked()
{
// Forces a genuine multi-column re-wrap (not just an entry-count
// change, which Reflow's own infinite-height default keeps single-
// column — see the PluginSidePanel class doc) by shrinking the
// available height between two ticks, then confirms the shelf's
// RIGHT edge (not its Left) stayed put while docked.
var root = new UiRoot { Width = 800f, Height = 260f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
for (int i = 0; i < 6; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
$"plugin:test:{i}", frame);
shelf.Add(
new PluginUiOwner($"test.{i}", $"Plugin {i}"),
new PluginPanelDescriptor("main", $"Plugin {i}"),
handle);
}
root.Tick(0.016d, 16L);
float rightEdge = shelf.Left + shelf.Width;
float widthBefore = shelf.Width;
root.Height = 150f;
root.Tick(0.016d, 16L);
Assert.NotEqual(widthBefore, shelf.Width);
Assert.Equal(rightEdge, shelf.Left + shelf.Width, precision: 3);
}
[Fact]
public void Drag_ViaGripBand_MovesShelf_AndTopLeftSurvivesTheNextReflow()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
// Press inside the grip band (y < 12) but left of the collapse
// toggle's rect (which starts at Width-16) so this is a plain
// drag, not a toggle click.
int pressX = (int)shelf.Left + 10;
int pressY = (int)shelf.Top + 5;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 100, pressY + 100);
root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100);
Assert.Equal(764f, shelf.Left); // clamped to parent (800 - 36)
Assert.Equal(216f, shelf.Top);
float leftAfterDrag = shelf.Left;
float widthBeforeCollapse = shelf.Width;
// Force a width change the same way the collapse toggle does, and
// confirm the LEFT edge (not the right edge) is what survives now
// that the shelf has been moved once.
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.NotEqual(widthBeforeCollapse, shelf.Width);
Assert.Equal(leftAfterDrag, shelf.Left);
}
[Fact]
public void Drag_StartingOnShelfPadding_DoesNotMoveTheShelf()
{
// NEW-1 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): pins the actual Draggable:true -> false change 4fada238e made.
// A press on the shelf's own PADDING — left of the first entry button's
// Left=4 (OuterPadding), below the grip band — must never promote
// to a whole-window drag now that Draggable is false; only the grip's
// WindowMoveHandle subtree can start a move (see the class doc). Proven
// to fail against the PRE-4fada238e shelf: temporarily flipping the
// ctor's `Draggable = false;` to `Draggable = true;` and re-running this
// test moved the shelf by the full drag delta (100,100) instead of
// leaving it in place, confirming this test actually exercises the
// fixed behavior — see the commit message for the exact numbers observed.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
int pressX = (int)shelf.Left + 2; // left of button.Left (4)
// Below the grip band: derived from the shelf's own EXPANDED band
// height (owner-reported-defect fix, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md)
// rather than a re-hard-coded literal — with font: null this is
// still the 12px bitmap-fallback band, so the numeric press point is
// unchanged, but the intent now survives a font change.
int pressY = (int)shelf.Top + (int)shelf.ExpandedGripBandHeight + 8;
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 100, pressY + 100);
root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100);
Assert.Equal(leftBefore, shelf.Left);
Assert.Equal(topBefore, shelf.Top);
}
[Fact]
public void Drag_Refused_WhileUiLocked()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
root.UiLocked = true;
int pressX = (int)shelf.Left + 10;
int pressY = (int)shelf.Top + 5;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 100, pressY + 100);
root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100);
Assert.Equal(leftBefore, shelf.Left);
Assert.Equal(topBefore, shelf.Top);
}
[Fact]
public void Collapse_HidesButtonsAndShrinksWidth_RoundTripsThroughWindowState()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
float expandedWidth = shelf.Width;
float leftBeforeCollapse = shelf.Left;
PluginSidePanel.PluginShelfButton button = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
Assert.True(button.Visible);
// Click the collapse toggle at the grip's right end through the real
// mouse pipeline (not the state controller directly), proving the
// drawn toggle rect is genuinely clickable and does not promote to a
// window drag.
int toggleX = (int)shelf.Left + (int)shelf.Width - 8;
int toggleY = (int)shelf.Top + 4;
root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
Assert.False(button.Visible);
Assert.True(shelf.Width < expandedWidth);
RetainedWindowState captured = shelf.CaptureWindowState();
Assert.True(captured.Collapsed);
// Right edge preserved (still docked — this test never dragged it).
Assert.Equal(
leftBeforeCollapse + expandedWidth,
shelf.Left + shelf.Width,
precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
Assert.True(button.Visible);
Assert.Equal(expandedWidth, shelf.Width);
Assert.False(shelf.CaptureWindowState().Collapsed);
}
[Fact]
public void CollapseThenExpand_WhileStillDocked_ReturnsToTheIdenticalLeftAndTop()
{
// Owner-reported defect fix (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md):
// the collapsed tab's HEIGHT changed (button-sized, not the thin grip
// band) — confirms that change alone does not disturb the docked
// anchor math: collapsing shifts Left to preserve the right edge (a
// width change), and expanding back must return to the EXACT same
// Left/Top, not merely an equal Width.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
// Width shrank while still docked, so Left shifted right to keep the
// right edge fixed; Top never moves on a collapse/expand.
Assert.NotEqual(leftBefore, shelf.Left);
Assert.Equal(topBefore, shelf.Top);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
Assert.Equal(leftBefore, shelf.Left, precision: 3);
Assert.Equal(topBefore, shelf.Top, precision: 3);
}
[Fact]
public void ToggleVisibility_ShowHideShow_AndStaysHiddenWhenANewWindowRegisters()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame1 = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame1);
RetailWindowHandle handle1 = root.WindowManager.Register(
"plugin:test:1", frame1);
shelf.Add(
new PluginUiOwner("test.1", "Plugin 1"),
new PluginPanelDescriptor("main", "Plugin 1"),
handle1);
Assert.True(shelf.Visible);
shelf.Hide();
Assert.False(shelf.Visible);
shelf.Show();
Assert.True(shelf.Visible);
shelf.Hide();
Assert.False(shelf.Visible);
var frame2 = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame2);
RetailWindowHandle handle2 = root.WindowManager.Register(
"plugin:test:2", frame2);
shelf.Add(
new PluginUiOwner("test.2", "Plugin 2"),
new PluginPanelDescriptor("main", "Plugin 2"),
handle2);
Assert.Equal(2, shelf.EntryCount);
Assert.False(shelf.Visible); // a new window does not un-hide it
}
[Fact]
public void Persistence_RoundTripsPositionVisibilityAndCollapsed()
{
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
root.WindowManager.MoveTo(WindowNames.PluginShelf, 120f, 88f);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
shelf.Hide();
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
Assert.Equal((120f, 88f), (saved.X, saved.Y));
Assert.False(saved.Visible);
Assert.True(saved.Collapsed);
// Restore onto a fresh shelf instance, mirroring a relog.
var root2 = new UiRoot { Width = 800f, Height = 600f };
using var shelf2 = new PluginSidePanel(
root2.WindowManager, _ => (0u, 0, 0), font: null);
root2.AddChild(shelf2);
root2.WindowManager.Register(WindowNames.PluginShelf, shelf2, shelf2, controller: shelf2);
var frame2 = new UiPanel { Width = 200f, Height = 100f };
root2.AddChild(frame2);
RetailWindowHandle pluginHandle2 = root2.WindowManager.Register(
"plugin:acdream.test:main", frame2);
shelf2.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle2);
using var persistence2 = new RetailWindowLayoutPersistence(
root2.WindowManager, store, () => "Alice", () => (800, 600));
persistence2.RestoreAll();
Assert.Equal((120f, 88f), (shelf2.Left, shelf2.Top));
Assert.False(shelf2.Visible);
Assert.True(shelf2.CaptureWindowState().Collapsed);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
// ── Review fix round (2026-09-06): grip/toggle real children, dialog
// z-order, persisted collapse/hide intent ──────────────────────────────
[Fact]
public void Drag_StartingOnAnEntryButton_DoesNotMoveTheShelf()
{
// Finding 1 / finding 7a: a press that lands on a real entry button
// (UiSimpleButton.HandlesClick => true) must be handled by the button,
// never promoted to a whole-shelf drag.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
PluginSidePanel.PluginShelfButton button = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
Vector2 buttonScreen = button.ScreenPosition;
int pressX = (int)buttonScreen.X + 5;
int pressY = (int)buttonScreen.Y + 5;
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 100, pressY + 100);
root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100);
Assert.Equal(leftBefore, shelf.Left);
Assert.Equal(topBefore, shelf.Top);
}
[Fact]
public void PerTickZOrderRaise_Removed_ADialogAddedAfterKeepsItsHigherZOrder()
{
// Finding 2: the OnTick block that used to bump the shelf's ZOrder
// above every sibling, every frame, is gone. Registration's own
// press-to-raise remains (a grip drag calls UiRoot.BringToFront before
// the drag starts) but ticking alone must never re-assert dominance —
// RetailDialogFactory.Tick re-raises open dialogs every frame the same
// way, and the two must not fight.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
int shelfZBefore = shelf.ZOrder;
var dialog = new UiPanel { Width = 100f, Height = 60f, ZOrder = shelfZBefore + 50 };
root.AddChild(dialog);
root.Tick(0.016d, 16L);
root.Tick(0.016d, 16L);
Assert.Equal(shelfZBefore, shelf.ZOrder);
Assert.True(dialog.ZOrder > shelf.ZOrder);
}
[Fact]
public void ToggleClick_SavesCollapsedOnly_WithNoSubsequentHideOrMove()
{
// Finding 3a.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600),
stateManagedVisibilityWindows: [WindowNames.PluginShelf]);
float topBefore = shelf.Top;
float rightEdgeBefore = shelf.Left + shelf.Width;
int toggleX = (int)shelf.Left + (int)shelf.Width - 8;
int toggleY = (int)shelf.Top + 4;
root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
Assert.True(saved.Collapsed);
Assert.True(saved.Visible); // no Hide happened
Assert.Equal(topBefore, saved.Y); // no vertical Move happened
// Collapsing shrinks Width; the shelf is still DOCKED, so Reflow's
// own anchor math shifts Left to keep the right edge fixed — that
// is not a "Move" (it never goes through handle.MoveTo/NotifyMoved),
// just the collapse's own geometry, captured in the same save.
Assert.Equal(shelf.Left, saved.X);
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void UnregisteringTheLastPlugin_DoesNotPersistTheAvailabilityHideAsAUserHide()
{
// Finding 3b.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600),
stateManagedVisibilityWindows: [WindowNames.PluginShelf]);
// Establish a genuine "the user wants this visible" save.
shelf.Show();
Assert.True(shelf.Visible);
root.WindowManager.Unregister(pluginHandle.Name);
Assert.Equal(0, shelf.EntryCount);
Assert.False(shelf.Visible); // derived Visible DOES flip (no entries)...
// ...but the persisted INTENT must still read visible=true: the
// availability-hide is not subscribed for a state-managed window.
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
Assert.True(saved.Visible);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void RestoreWindowState_HiddenAndCollapsedIntent_AppliesDirectly_ThenShowExpandsAndReveals()
{
// Finding 3c: restoring Collapsed=true/RequestedVisible=false onto a
// fresh shelf that already has entries must hide AND collapse it —
// without going through Show/Hide (RestoreWindowState sets the intent
// and re-derives Visible through the same synchronous ApplyVisibility
// path every other state change uses). Show() must then reveal AND
// expand it.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L);
Assert.True(shelf.Visible);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true, RequestedVisible: false));
Assert.False(shelf.Visible);
Assert.True(shelf.CaptureWindowState().Collapsed);
shelf.Show();
Assert.True(shelf.Visible);
Assert.False(shelf.CaptureWindowState().Collapsed);
}
[Fact]
public void CollapseExpandViaTheToggle_DoesNotCollapseTheColumnWrapToOneColumn()
{
// Finding 5: every Reflow() call site OTHER than OnTick's row-wrap
// used to default to an unbounded height, forcing a single-column
// layout for one frame after Add/remove/collapse/expand/restore —
// reusing _lastLayoutHeight keeps the column count OnTick already
// established. 12 entries at 800x260 forces a real multi-column wrap.
var root = new UiRoot { Width = 800f, Height = 260f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
for (int i = 0; i < 12; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register($"plugin:test:{i}", frame);
shelf.Add(
new PluginUiOwner($"test.{i}", $"Plugin {i}"),
new PluginPanelDescriptor("main", $"Plugin {i}"),
handle);
}
root.Tick(0.016d, 16L);
// Collapse via the real toggle click.
int toggleX = (int)shelf.Left + (int)shelf.Width - 8;
int toggleY = (int)shelf.Top + 4;
root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
// Expand via the real toggle click (coordinates recomputed: the
// shelf's collapsed Width moved the toggle).
toggleX = (int)shelf.Left + (int)shelf.Width - 8;
toggleY = (int)shelf.Top + 4;
root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
root.Tick(0.016d, 16L);
Assert.True(shelf.Top + shelf.Height <= root.Height);
Assert.All(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>(),
button => Assert.True(button.Top + button.Height <= shelf.Height));
}
[Fact]
public void FreshShelf_OneTick_SavesTheDockedPositionImmediately()
{
// NEW-7 (residual round): the one-time dock used to write Left
// directly, so the FIRST-run docked position went unsaved until some
// later, unrelated event happened to fire a save. Routing the dock
// through the retained-window handle's MoveTo (when registered)
// means RetailWindowLayoutPersistence — subscribed to Moved — picks
// it up on the very first tick.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
root.Tick(0.016d, 16L); // the one-time dock happens inside this tick
float expectedLeft = 800f - shelf.Width - 4f;
Assert.Equal(expectedLeft, shelf.Left);
Assert.Equal(116f, shelf.Top);
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
Assert.Equal((expectedLeft, 116f), (saved.X, saved.Y));
// NEW-7 also asks: does routing the dock through MoveTo (which
// synchronously re-enters OnHandleMoved, NEW-2) itself flip
// _userPositioned? If it had, a later width change (collapse)
// would preserve the LEFT edge instead of the RIGHT edge.
float rightEdgeBefore = shelf.Left + shelf.Width;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ClampAllToScreen_AfterShrinkingTheRoot_DoesNotFlipAnchoring_ButARealDragStillDoes()
{
// NEW-2 (residual round): a screen shrink that forces
// RetailWindowLayoutPersistence.ClampAllToScreen to re-clamp the
// still-docked shelf must not be mistaken for a user drag, even
// though ClampAllToScreen's generic clamp formula
// (screen.Width - handle.Width) lands 4px (OuterPadding) off the
// dock formula's own result (parent.Width - Width - OuterPadding)
// for the SAME new size.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // dock at Left=760 (800-36-4) under an 800-wide parent
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (760, 600));
// Shrink the parent so the shelf's docked position no longer
// fits, then run the SAME reachability sweep
// RetailUiRuntime.Draw's #390 edge detector runs on a live resize.
root.Width = 760f;
persistence.ClampAllToScreen();
// Clamped hard to the new edge (760-36=724) — 4px right of what
// the dock formula itself would produce (760-36-4=720).
Assert.Equal(724f, shelf.Left);
float rightEdgeBefore = shelf.Left + shelf.Width;
root.Tick(0.016d, 16L); // a later reflow — still docked, right edge holds
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
// A REAL drag away from the edge afterward must still flip anchoring.
int pressX = (int)shelf.Left + 10;
int pressY = (int)shelf.Top + 5;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX - 100, pressY);
root.OnMouseUp(UiMouseButton.Left, pressX - 100, pressY);
float leftAfterDrag = shelf.Left;
Assert.NotEqual(724f, leftAfterDrag);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(leftAfterDrag, shelf.Left);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ZeroMovementGripReleaseDoesNotFlipAnchoring_ButARealDragDoes()
{
// Finding 6: RetailWindowHandle.Moved fires unconditionally on every
// completed window-drag gesture — including a press+release on the
// grip with NO intervening mouse move. (RetailWindowManager.MoveTo
// itself short-circuits an already-equal position before ever firing
// Moved, so the meaningful equivalent of "a MoveTo to the identical
// dock position" is this zero-pixel drag-release, not a same-value
// MoveTo call.) That must not be mistaken for a user drag; a REAL
// drag still must.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
int pressX = (int)shelf.Left + 10;
int pressY = (int)shelf.Top + 5;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseUp(UiMouseButton.Left, pressX, pressY); // zero movement
float rightEdgeBeforeCollapse = shelf.Left + shelf.Width;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
// Still docked: the RIGHT edge survived the width change.
Assert.Equal(rightEdgeBeforeCollapse, shelf.Left + shelf.Width, precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
// Now a REAL drag via the grip.
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 40, pressY + 40);
root.OnMouseUp(UiMouseButton.Left, pressX + 40, pressY + 40);
float leftAfterDrag = shelf.Left;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
// User-positioned now: the LEFT edge survives instead.
Assert.Equal(leftAfterDrag, shelf.Left);
}
}