acdream/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
Erik ce05c4fb03 fix(plugin-ui): Slice B residuals - shelf icon sink without magenta, validated icon bindings, negative ids, memoized DID resolves
Bounded residual round on the Slice B review fix commit (466272ec5),
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md.

N1 (BLOCKING): PluginSidePanel's shelf entries resolved icons through
_bindings.Assets.ResolveSprite (= ResolveChrome =
TextureCache.GetOrUploadRenderSurface), which returns a non-zero 1x1
magenta placeholder for a missing id - so PluginShelfButton's initials
fallback could never fire in production. RetailUiRuntime.MountPlugins
now passes the same iconResolver.ResolveDid every markup icon sink
already uses, which returns (0,0,0) for an unresolvable id. Re-pointed
the existing initials-fallback unit test at a resolver matching
ResolveDid's real contract, and added an InstalledDat-lane test
(ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials)
proving a bogus descriptor id on the REAL resolver yields initials.

N2: MarkupDocument's <button icon>/<list icons> handling only built the
uint reader (BindUintLiteralOrBinding/BindUintList) when an
IMarkupIconResolver was wired, so a malformed icon="{Typo}" or
icons="notabinding" silently loaded instead of throwing at Build on a
resolver-less host. Both readers now build unconditionally (same rule
ValidateIconKind already followed); only the IconSource/IconIdsSource
assignment stays gated on icons is not null.

N3: a negative bound icon id threw OverflowException out of
Convert.ToUInt32 every frame from inside UiSimpleButton.OnDraw (scalar
path), while the list's IEnumerable<int> path silently wrapped -1 to
0xFFFFFFFF (unchecked reinterpret). Both paths now map any
out-of-range value (negative, or above uint.MaxValue) to 0u instead -
Decal's own "no icon" convention - via a shared ToUintOrZero helper
that catches exactly the OverflowException Convert.ToUInt32 already
throws for both cases.

N4 (perf): RetailMarkupIconResolver.ResolveDid probed Portal/HighRes
(two cache misses + two B-tree lookups under the database lock) on
EVERY call for an unresolvable id, and re-entered the DAT lock on
every resolve of a hit too. Memoizes the resolved (tex,w,h) tuple per
DID, including the (0,0,0) miss, in a plain Dictionary.

N5 (nit): documented in TextureCache.GetOrUploadRenderSurface that the
(id, nearest) cache key uploads the same RenderSurface twice when both
samplers are wanted (chrome via ResolveChrome, plugin icons via
ResolveDid's nearest:true) - GetOrCreateLinearUiTwin exists but only
shares in the nearest-registered-first direction, so wiring it through
here is left as a documented nit rather than a behavior change.

N6 (nit): corrected stale SampleData.cs:64 citations to :69 (Melee
Defense's real line after the file grew) across SmokeIconPanel.cs,
PluginSidePanelTests.cs, RetailMarkupIconResolverInstalledDatTests.cs,
and docs/plugin-ui-markup.md (including the 64-82 range, now 69-83).

N7 (nit): <icon iconkind="..."> was silently ignored (icon derives its
kind from which of did/spell/item is set, unlike button/list). Now
throws FormatException at Build with a message naming the correct
surfaces; documented in plugin-ui-markup.md.

N8 (nit): the truth-table's `list colors` row now says IEnumerable<uint>
or IEnumerable<int> (shared BindUintList), matching `list icons`.

N9 (ship check): added a "Before shipment" line to the plan's Review
ledger. Confirmed the Smoke plugin (including its auto-open Icon Smoke
panel) IS included in the launcher's client-<rid>.zip release payload:
tools/publish-bin.ps1's New-PayloadZip zips App's entire publish
directory unfiltered, and AcDream.App.csproj's
CopySmokePluginToPublishOutput target runs unconditionally
AfterTargets="Publish". No behavior changed per instruction - flagged
for follow-up after the owner's connected gate.

Verification: dotnet build AcDream.slnx -c Release green; filtered
test command 108/108 passed (0 skipped) including the InstalledDat
lane; full AcDream.App.Tests suite 7362 passed / 97 skipped / 36
failed (all 36 pre-existing, same names, none touching
Markup/PluginSidePanel/RetailMarkupIconResolver/TextureCache/PluginIcons);
AcDream.Plugins.MossTank.Tests 337/337 passed.

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

1089 lines
46 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
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);
}
[Fact]
public void ShelfButton_NormalizesADecalBareIndexDescriptorId_BeforeResolving()
{
// Review fix round finding 14 (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice B): the descriptor's bare Decal-style index must resolve
// through PluginIcons.Normalize at the shelf button, same as every
// markup did sink — 0x165 (Melee Defense's real installed-DAT index,
// SampleData.cs:69) becomes 0x06000165.
var resolvedIds = new List<uint>();
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager,
id => { resolvedIds.Add(id); return (9u, 32, 32); },
font: null);
root.AddChild(shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin")
{
IconSurfaceId = 0x165u,
},
handle);
PluginSidePanel.PluginShelfButton button = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
button.DrawSelfAndChildren(TestUiRenderContext());
Assert.Contains(0x06000165u, resolvedIds);
}
/// <summary>
/// Residual round finding N1: this fake's shape matters, not just its
/// return value. Production wires <see cref="PluginSidePanel"/> to
/// <c>IMarkupIconResolver.ResolveDid</c> (the same seam every other
/// markup icon sink shares) — NEVER to
/// <c>RetailUiAssets.ResolveSprite</c> (= ResolveChrome =
/// <c>TextureCache.GetOrUploadRenderSurface</c>), which returns a
/// non-zero 1x1 magenta placeholder handle for a missing id rather than
/// <c>(0, 0, 0)</c>. A bare <c>_ => (0u, 0, 0)</c> lambda proves the
/// button's OWN fallback logic works, but says nothing about whether the
/// resolver actually WIRED IN would ever produce that input — a
/// resolver that instead mirrored ResolveChrome's magenta-on-miss
/// contract would have passed a test built that way while still
/// leaving the fallback dead in production. This type exists so the
/// test reads as "given a resolver that behaves like
/// RetailMarkupIconResolver.ResolveDid", matching
/// <c>RetailMarkupIconResolverInstalledDatTests.ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials</c>,
/// which runs the same shape against the REAL resolver and a real DAT.
/// </summary>
private sealed class FakeResolveDidResolver
{
public (uint tex, int w, int h) ResolveDid(uint did) => (0u, 0, 0);
}
[Fact]
public void ShelfButton_FallsBackToInitials_WhenTheResolveNeverYieldsATexture()
{
// Review fix round finding 10: a non-zero descriptor icon id whose
// resolve never succeeds (a bad Decal index, a DAT id from a
// different installation) must fall back to the initials text
// rather than rendering a blank button forever.
var root = new UiRoot { Width = 800f, Height = 600f };
var resolver = new FakeResolveDidResolver();
using var shelf = new PluginSidePanel(
root.WindowManager,
resolver.ResolveDid,
font: null);
root.AddChild(shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin")
{
IconText = "TP",
IconSurfaceId = 0x165u,
},
handle);
PluginSidePanel.PluginShelfButton button = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
// Before any draw, the button still shows empty text (a non-zero
// icon id was supplied and has not been attempted yet).
Assert.Equal(string.Empty, button.Text);
button.DrawSelfAndChildren(TestUiRenderContext());
Assert.Equal("TP", button.Text);
}
private static UiRenderContext TestUiRenderContext()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(
device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(200f, 200f));
return new UiRenderContext(renderer, new Vector2(200f, 200f));
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
}