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>
1089 lines
46 KiB
C#
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;
|
|
}
|
|
}
|