Two Opus reviews of commit 8217a349e (Slice B: DAT icons in plugin
markup) found one BLOCKING defect and 14 SHOULD-FIX findings. All 15
fixed here in one commit per the review contract.
BLOCKING (finding 1): an unresolvable did painted a magenta square.
TextureCache.GetOrUploadRenderSurface's 1x1 magenta placeholder for a
missing RenderSurface is load-bearing for authored chrome, but
RetailMarkupIconResolver.ResolveDid only short-circuited did==0, so any
other unresolvable id fell through to that placeholder and got scaled
up by UiMarkupIcon/UiMarkupList/UiSimpleButton -- the classic
resolve(0)-style footgun (claude-memory/feedback_ui_resolve_zero_magenta.md),
just triggered by a missing id instead of a literal 0. Fixed by probing
Portal/HighRes existence via IDatReaderWriter.TryGet<RenderSurface>
BEFORE ever calling GetOrUploadRenderSurface -- that TryGet already
serializes concurrent DAT access internally (DatDatabaseWrapper's own
_databaseLock), the same synchronization IconComposer.TryDecode relies
on, so no additional lock was needed. RetailMarkupIconResolver now
takes IDatReaderWriter + TextureCache directly (RetailUiAssets gained a
TextureCache field, its one construction site in
InteractionRetainedUiComposition.cs updated) instead of the old
resolveSprite delegate, since it also needs the nearest-sampled upload
path for finding 6 below.
Finding 2 -- Smoke panel wiring bugs: its list fed iconkind="spell"
raw art DIDs (PluginSpellInfo.IconId) instead of spell ids, so
IMarkupIconResolver.ResolveSpell composited the wrong (or no) badge
every row. SmokeIconPanel.Binding.SpellIds now yields SpellId (the
printed text still shows IconId alongside). The bare-index demo and the
descriptor both moved from the unverified literal 7735 to 0x165 --
retail's real Melee Defense skill icon (SampleData.cs:64,
0x06000165) -- so the owner's visual gate proves real art, not a guess.
StartVisible flipped true, and a character with no self-buffs known
falls back to spell 1's real catalog entry (or an honest "no spells
known" row with icon 0 if even that fails) rather than fabricating art.
Finding 3 -- PluginIcons.Normalize's threshold was silently rewritten
from the contract's 0x01000000 to 0x06000000 during Slice B. Restored
to 0x01000000; the class/method XML docs now state the number directly
(no cref to the private const); the test table adds 0x02000000 (a value
that only distinguishes the two thresholds) and 0x01000000 itself
(passes through unchanged).
Finding 4 -- an unknown iconkind (e.g. "spel") only threw when a
resolver happened to be wired, because BuildIconSource/
BuildRowIconResolve validated inside their own null-icons early return.
A new ValidateIconKind helper runs UNCONDITIONALLY before that branch,
so a malformed iconkind is a Build-time author error on every host.
Finding 5 -- BindUintLiteralOrBinding required an exact uint property
type, rejecting the int-typed bindings Decal-facing code commonly uses
(MosswartMassacre's HudPictureBox.Image is int end to end). It now
matches BindUint's existing leniency: any property, converted via
Convert.ToUInt32 at read time. BindUintList likewise now accepts
IEnumerable<int> alongside IEnumerable<uint> (unchecked per-element
reinterpret -- icon ids never go negative in practice).
Finding 6 -- TextureCache._renderSurfaceGpuTextures was keyed by id
alone, so whichever caller asked for a given RenderSurface id FIRST won
the sampler for every later caller of the same id -- UiDatFont's glyph
atlases already request nearest:true while ResolveChrome's background
art requests nearest:false, so this was a real, reachable collision,
not hypothetical. Rekeyed to (id, nearest); RetailMarkupIconResolver.
ResolveDid now requests nearest:true (pixel-exact 32x32 icon art);
ResolveChrome is untouched (still nearest:false/linear). Audited every
other _renderSurfaceGpuTextures use site (TryGetValue/set/Dispose
iteration+Clear) plus the separate _nearestUiTextureSources/
_linearUiTwinHandles/_uploadMetadata dictionaries (all keyed by handle
or accounting name, unaffected) -- no other eviction/accounting path
assumed id-only keying.
Finding 7 -- column-reservation semantics, per the DECIDED shape:
MarkupDocument now sets button.IconSource / list.IconIdsSource +
IconResolve ONLY when a resolver (icons parameter) is actually wired --
previously button.IconSource was always assigned (even to an
always-empty func on an icons:null host); combined with this finding's
other half -- UiSimpleButton.OnDraw now reserves its icon column
whenever IconSource is non-null, regardless of a per-frame resolve miss,
so a bound id that goes briefly to 0 no longer slides the caption back
and forth -- would have permanently reserved a blank column on such a
host. UiMarkupList already reserved its column whenever IconIdsSource
was set; no draw-side change needed there.
Finding 8 -- added a with/without-icons comparison test for
UiMarkupList (mirroring the existing UiSimpleButton one): asserts the
row text quad's x is strictly greater with an icon column present, and
the icon quad itself has non-zero width.
Finding 9 -- <icon tooltip=""> (empty string) was still treated as
"has a tooltip" by a bare attribute-presence check, making the icon
swallow clicks with no visible tooltip ever appearing. Now uses
!string.IsNullOrWhiteSpace, matching ApplyCommon's own predicate for
every other element's tooltip.
Finding 10 -- PluginShelfButton.OnDraw drew nothing when a non-zero
descriptor icon id resolved to no texture (a bad Decal index, a DAT id
from a different install), rather than falling back to Initials the
way a zero id already did. Now decides once, on the first draw
(memoized, so Initials' string work never repeats every frame): a
failed resolve permanently switches Text to the initials fallback,
computed and assigned BEFORE base.OnDraw actually paints the caption.
Finding 11 -- MarkupDocument.AddElement's switch had no default arm, so
an unknown or miscased element name (<Icon>, <butotn>) silently
vanished from the built tree instead of failing loudly like every
other malformed-markup case. Added a default arm that throws
FormatException. Ran AcDream.Plugins.MossTank.Tests (337/337,
unchanged) and the full App markup suite to confirm no existing markup
relies on an unknown element.
Finding 12 -- PluginPanelDescriptor.IconSurfaceId's XML doc now states
that a bare Decal index is accepted and normalized, citing
PluginIcons.Normalize.
Finding 13 -- docs/plugin-ui-markup.md: replaced the blanket "wrong
type/missing property throws at Build" sentence with the per-attribute
truth table the review produced (which attributes are silent at
runtime vs. throw at Build, and each one's bound CLR/delegate type);
restated the icon-id boundary as 0x01000000; added the "do NOT add
0x06000000 to the four already-full IconId records" warning (citing
SkillBase._iconID / UIRegion::SetImageByDID @0x004f150e); documented
that 0x-prefixed hex is required (an unprefixed all-digit literal
parses as decimal); noted unknown element names now throw; called out
list colors (0xRRGGBB) vs. color=/background=/border= (#AARRGGBB) as
non-interchangeable grammars; documented the root <panel visible>
binding-only exception; corrected the shelf's collapse toggle glyphs
(</>, not the old doc's arrows) and the 28px collapsed-tab size; added
the IconId record-equality API-v1 note; and called out iconkind as
per-<list> (mixed id spaces need pre-normalized DIDs; the composited
spell badge has no did-space escape hatch) plus the existing
one-text-column LIMITATION being deferred to MossTank.
Coverage added for finding 14: a PluginSidePanelTests case proving the
shelf button normalizes a bare descriptor index before resolving, and a
reflection-based unit on AppAutomationSurface.ProjectWorldObject (its
public callers gate on IsAvailable, which needs a fully connected
session heavier than this mapping needs -- the plan's own documented
fallback) proving PluginWorldObject.IconId carries ClientObject.IconId
through unchanged; PluginInventoryItem.IconId uses the identical
one-line pattern inline in CaptureOwnedItems, reviewed by inspection.
Finding 15: recorded a "Review ledger" section in the plan doc with
both slices' commits, both review verdicts, and the two items
explicitly deferred to the MossTank plugin work (multi-column list,
root literal visible).
Verification: full solution builds green. Targeted filter
(Markup|PluginSidePanel|PluginIcons|AppAutomation|TextureCache|
UiDatFont) passes 131/131, including the two InstalledDat-lane tests
(RetailMarkupIconResolverInstalledDatTests,
AppAutomationSurfaceIconInstalledDatTests) actually resolving against
the real installed DAT, not skipping. AcDream.Plugins.MossTank.Tests
passes 337/337 unchanged. Full AcDream.App.Tests suite: 7351 passed /
97 skipped / 36 failed -- identical failure set/count to the
7334/97/36 baseline (the +17 passes are exactly the new/expanded
tests: 2 new PluginIconsTests.Normalize theory rows, 10 new
MarkupIconTests cases, 2 new PluginSidePanelTests cases, 1 new
AppAutomationSurfaceTests case, and the 2 new standalone test files).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1064 lines
45 KiB
C#
1064 lines
45 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:64) 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);
|
|
}
|
|
|
|
[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 };
|
|
using var shelf = new PluginSidePanel(
|
|
root.WindowManager,
|
|
_ => (0u, 0, 0),
|
|
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;
|
|
}
|
|
}
|