acdream/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
Erik 47eb2d575b fix(plugin-ui): shelf children must not anchor — the per-draw anchor pass was undoing collapse/reflow geometry
EVIDENCE (live UI probe dump at 1280x720, build 2ebcc0164): after collapsing
the plugin shelf, PluginSidePanel rect=(1240,233,24,28) was correct, but
ShelfGripPanel rect=(1240,233,20,18) and the toggle rect=(1260,233,16,18)
stayed UNCHANGED from the expanded geometry. The toggle then sat outside the
24px shelf and the ancestor clip removed it, so the owner saw the tab with
no "<".

ROOT CAUSE: UiElement.ApplyAnchor (src/AcDream.App/UI/UiElement.cs ~829-856)
runs for every child on every draw (called at :699). For any child whose
Anchors != AnchorEdges.None, it captures the Left/Top/Width/Height margins
ONCE (_anchorCaptured) on the first draw and re-applies that snapshot every
subsequent draw, overwriting whatever PluginSidePanel.LayoutChrome/Reflow had
just set. The grip, the toggle, and each PluginShelfButton entry were
constructed with the default Anchors (Left|Top), so their first-draw
geometry froze. The shelf itself already used AnchorEdges.None for exactly
this reason. Unit tests never caught it because UiRoot.Tick does not draw —
the anchor snapshot only exists after a real Draw pass, and the prior
draw-level toggle tests only ever drew once, before any collapse.

FIX: set Anchors = AnchorEdges.None on _grip, _toggle (PluginSidePanel
constructor) and each PluginShelfButton entry (PluginSidePanel.Add) — the
shelf is the sole layout owner of these children and anchoring is the wrong
mechanism for them, not a per-reflow patch via ResetAnchorCapture().
PluginMinimizeButton is untouched (it is a child of the plugin window and
deliberately anchors Top|Right).

TESTS (tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs):
- Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry:
  draws the shelf, collapses via a real UiRoot press/release, draws again,
  and asserts the grip/toggle geometry actually reflects the collapsed
  Width/Height rather than the frozen expanded snapshot. Failed-first
  (pre-fix) at line 364 with "Expected: 8, Actual: 20" (grip.Width frozen at
  the pre-collapse value instead of the new collapsed Width - ToggleWidth).
- MultiColumnReflow_AfterADraw_EveryRemainingButtonMatchesAFreshSinglePassLayout:
  12 entries, draw, unregister one window (a real removal), draw again, and
  compares every surviving button's geometry against an independent
  reference shelf built directly with the same final 11-entry set. Failed-
  first with "Expected: 16, Actual: 48" (a surviving button's Left frozen at
  its stale 12-entry column/row instead of the fresh 11-entry reflow).

Both tests use font: null (bitmap fallback) so they run in every CI lane
without an installed retail DAT, unlike the Lane=InstalledDat tests above.

VERIFY: dotnet build (Release) green for src/AcDream.App and the test
project. Targeted filter (PluginSidePanel|Markup|UiRootInput): 130/130
passed. Full tests/AcDream.App.Tests suite: 7353 passed / 97 skipped / 36
failed - matching the stated baseline (7351/97/36) plus the two new tests;
the 36 failures are the pre-existing environment-gated set (installed-DAT
version mismatch, Linux-only waiter, Lane=Manual live-mount probes) and are
unrelated to this change.

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

501 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Owner-reported defect (2026-09-06): "I dont see the &lt; after I minimize
/// the window" — after collapsing the plugin shelf with the <c>&gt;</c>
/// toggle, the <c>&lt;</c> (expand) glyph was not visible.
///
/// <para>
/// Reproduction findings (a real-DAT draw probe against font 0x40000000, the
/// shelf's default font): '&lt;' and '&gt;' share IDENTICAL glyph metrics
/// (OffsetY=4, Width=5, Height=7, VerticalOffsetBefore=5 — symmetric
/// characters, as expected), so there is no per-glyph asymmetry to explain
/// "I see one but not the other." Against the PRE-FIX 12px grip band
/// (<c>GripHeight</c>), the FILL glyph plane measured fully INSIDE the band
/// (local y=[3,10] of [0,12]) in both collapsed and expanded states — the
/// text's "16px line box overhangs a 12px band" theory does not, on its own,
/// erase the glyph. What IS measurably true: the border-inflated OUTLINE
/// (background/shadow) plane, which retail draws first
/// (<c>UIElement_Text::DrawSelf</c>), spans y=[-1,14] before clipping and was
/// cropped by the 12px band's self-clip (<c>UiElement.ClipsChildren</c>) to
/// exactly [0,12] — a real, if minor (1px top / 2px bottom), defect. The
/// PRIMARY explanation for the report is discoverability, not erasure: the
/// collapsed shelf shrank to a bare 24x12 near-black sliver at the screen
/// edge, several times smaller than any other clickable affordance in the
/// UI, easy to overlook even though its pixels were, in fact, being drawn.
/// </para>
///
/// <para>
/// Fix: <see cref="PluginSidePanel.ExpandedGripBandHeight"/> derives the
/// EXPANDED band from the real font metrics
/// (<c>max(12, font.LineHeight + 2)</c>) so neither the fill nor the
/// border-inflated outline plane can ever clip, for any font. The COLLAPSED
/// tab is now <c>ButtonExtent</c> (28px) tall — the same size as an ordinary
/// entry button — instead of the 12px grip band, making it findable. This
/// file pins both: full containment of the measured ink (fill AND outline)
/// inside the toggle's own clip rect in BOTH states against the REAL
/// installed DAT font, and the collapsed tab's button-sized geometry.
/// </para>
/// </summary>
public sealed class PluginSidePanelToggleGlyphClipTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static string? ResolveDatDir()
{
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string defaultDir = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(defaultDir) ? defaultDir : null;
}
/// <summary>Decodes (x0,y0)/(x1,y1) of every quad (6 verts / 8 floats each)
/// in a recorded sprite segment — same 8-floats-per-vertex layout every
/// other draw-level test in this tree decodes
/// (<c>UiRenderContextDrawStringDatOutlineTests.DecodeQuads</c>).</summary>
private static IEnumerable<(float y0, float y1)> DecodeVerticalSpans(IReadOnlyList<float> verts)
{
const int floatsPerVertex = 8;
const int floatsPerQuad = floatsPerVertex * 6;
for (int i = 0; i + floatsPerQuad <= verts.Count; i += floatsPerQuad)
{
float y0 = verts[i + 1];
float y1 = verts[i + 8 + 1];
yield return (y0, y1);
}
}
/// <summary>
/// Independently computes the FULL, unclipped vertical ink span (fill
/// plane union the border-inflated outline plane) that
/// <c>UiRenderContext.DrawStringDatPass</c>/<c>DrawOutlineGlyph</c> would
/// produce for a single centered glyph drawn at
/// <c>(Height - font.LineHeight) * 0.5f</c> (exactly
/// <see cref="UiSimpleButton.OnDraw"/>'s formula) in a box of the given
/// <paramref name="bandHeight"/> — mirroring the production math
/// (<c>UiRenderContext.cs</c>'s <c>baseY</c>/<c>gy</c>/outline-inflation
/// lines) independently, so this test can tell a genuinely unclipped draw
/// apart from one whose recorded (already-clipped) geometry merely
/// happens to fill the band.
/// </summary>
private static (float top, float bottom) ComputeUnclippedInkSpan(
UiDatFont font, char glyph, float bandHeight)
{
Assert.True(font.TryGetGlyph(glyph, out FontCharDesc g), $"font is missing glyph '{glyph}'");
float y = (bandHeight - font.LineHeight) * 0.5f;
float baseY = MathF.Floor(y + 0.5f);
float gy = baseY + g.VerticalOffsetBefore;
float gh = g.Height;
float top = gy;
float bottom = gy + gh;
if (font.HasBackground)
{
float iy = gy - font.BorderY;
float ih = gh + 2f * font.BorderY;
top = MathF.Min(top, iy);
bottom = MathF.Max(bottom, iy + ih);
}
return (top, bottom);
}
[Fact]
[Trait("Lane", "InstalledDat")]
public void ToggleGlyph_InkFullyFitsInsideTheGripBand_ExpandedAndCollapsed()
{
string? datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var device = new RecordingGpuDevice();
var cache = new TextureCache(device, adapter);
UiDatFont? font = UiDatFont.Load(adapter, cache);
Assert.NotNull(font);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font);
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);
UiSimpleButton toggle = Assert.Single(
shelf.Children,
c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton
?? throw new InvalidOperationException("toggle button not found");
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
// ── Expanded state ('>') ────────────────────────────────────────
Assert.False(shelf.CaptureWindowState().Collapsed);
AssertGlyphInkFullyContained(font!, '>', toggle, renderer, ctx);
// ── Collapse via the REAL toggle click (production input path,
// not the state controller directly) ───────────────────────────
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.True(shelf.CaptureWindowState().Collapsed);
root.Tick(0.016d, 16L);
AssertGlyphInkFullyContained(font!, '<', toggle, renderer, ctx);
}
private static void AssertGlyphInkFullyContained(
UiDatFont font, char glyph, UiSimpleButton toggle, TextRenderer renderer, UiRenderContext ctx)
{
(float expectedTop, float expectedBottom) = ComputeUnclippedInkSpan(font, glyph, toggle.Height);
Assert.True(
expectedTop >= -0.01f && expectedBottom <= toggle.Height + 0.01f,
$"'{glyph}': computed unclipped ink span [{expectedTop},{expectedBottom}] " +
$"does not fit inside the {toggle.Height}px band — the band is too short for this font's metrics " +
$"(LineHeight={font.LineHeight}, BorderY={font.BorderY}).");
// Draw the toggle IN ISOLATION (its own DrawSelfAndChildren call, no
// ancestor transform) so the recorded quads are in the toggle's own
// local [0,Width]x[0,Height] space and reflect only its own self-clip
// (UiElement.ClipsChildren's [0,Width]x[0,Height] push).
renderer.Begin(new Vector2(800f, 600f));
toggle.DrawSelfAndChildren(ctx);
float observedMinY = float.MaxValue, observedMaxY = float.MinValue;
int quadCount = 0;
foreach (var seg in renderer.DebugSpriteSegmentVerts)
{
foreach ((float y0, float y1) in DecodeVerticalSpans(seg.Verts))
{
observedMinY = MathF.Min(observedMinY, MathF.Min(y0, y1));
observedMaxY = MathF.Max(observedMaxY, MathF.Max(y0, y1));
quadCount++;
}
}
Assert.True(quadCount > 0, $"toggle drew no glyph quads at all for '{glyph}'");
// The recorded (already self-clipped) geometry must match the
// INDEPENDENTLY computed unclipped span within a pixel — if the band
// were too short, the recorded span would be narrower than the
// computed one (clipped away), not equal to it.
Assert.Equal(expectedTop, observedMinY, 1);
Assert.Equal(expectedBottom, observedMaxY, 1);
// And, restated directly in terms of the toggle's own clip rect (the
// form the investigation asked for): every recorded quad lies fully
// inside [0, toggle.Height].
Assert.True(observedMinY >= -0.01f, $"'{glyph}': ink top ({observedMinY}) is above the band (0)");
Assert.True(
observedMaxY <= toggle.Height + 0.01f,
$"'{glyph}': ink bottom ({observedMaxY}) overflows the {toggle.Height}px band");
}
[Fact]
[Trait("Lane", "InstalledDat")]
public void CollapsedTab_IsButtonSized_ForFindability()
{
string? datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var device = new RecordingGpuDevice();
var cache = new TextureCache(device, adapter);
UiDatFont? font = UiDatFont.Load(adapter, cache);
Assert.NotNull(font);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font);
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 expandedHeight = shelf.Height;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
// The collapsed tab is button-sized, not the thin 12px grip band —
// this is the actual owner-reported fix (the tab is findable), not
// merely "the glyph is unclipped".
Assert.True(shelf.Height > 20f, $"collapsed shelf height ({shelf.Height}) is not button-sized");
Assert.NotEqual(expandedHeight, shelf.Height);
}
/// <summary>
/// Owner-reported defect (2026-09-06 live probe): after collapsing the
/// shelf, the grip and toggle stayed pinned at the EXPANDED geometry —
/// the toggle ended up outside the 24px collapsed shelf and the ancestor
/// clip removed it, so the owner saw the tab with no <c>&lt;</c>.
///
/// <para>
/// Root cause: <see cref="UiElement.ApplyAnchor"/> is called for every
/// child on every draw. For any child whose <see cref="UiElement.Anchors"/>
/// is not <see cref="AnchorEdges.None"/>, it captures the Left/Top/Width/
/// Height margins ONCE on the FIRST draw and re-applies that snapshot on
/// every subsequent draw — overwriting whatever
/// <see cref="PluginSidePanel.LayoutChrome"/>/<c>Reflow</c> set in between.
/// <see cref="UiRoot.Tick"/> never draws, so a test that only ticks (as
/// every prior collapse test in this tree does) never exercises this path
/// at all — this test drives two REAL <see cref="UiRoot.Draw"/> passes,
/// with a real toggle click in between, to reproduce it.
/// </para>
///
/// <para>
/// Uses a bitmap-fallback font (<c>font: null</c>) deliberately, per the
/// investigation's direction, so this reproduction runs in every CI lane
/// without an installed retail DAT — unlike the <c>Lane=InstalledDat</c>
/// tests above, which require one.
/// </para>
/// </summary>
[Fact]
public void Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry()
{
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
for (int i = 0; i < 2; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
$"plugin:acdream.test:{i}", frame);
shelf.Add(
new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"),
new PluginPanelDescriptor("main", $"Test Plugin {i}"),
handle);
}
root.Tick(0.016d, 16L);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
// ── First draw: this is what CAPTURES the anchor snapshot (at the
// expanded geometry) — nothing is wrong yet, since the snapshot
// matches the current (correct) layout. ──────────────────────────
renderer.Begin(new Vector2(800f, 600f));
root.Draw(ctx);
UiSimpleButton toggle = Assert.Single(
shelf.Children,
c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton
?? throw new InvalidOperationException("toggle button not found");
UiElement grip = Assert.Single(
shelf.Children,
c => c is UiPanel && c is not UiSimpleButton);
float expandedShelfHeight = shelf.Height;
float expandedBandHeight = shelf.ExpandedGripBandHeight;
float toggleWidth = toggle.Width;
Assert.Equal(0f, grip.Left);
Assert.Equal(0f, grip.Top);
Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
Assert.Equal(expandedBandHeight, grip.Height, 3);
Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
Assert.Equal(0f, toggle.Top);
Assert.Equal(expandedBandHeight, toggle.Height, 3);
// ── Collapse via a REAL press/release through UiRoot (the production
// input path), not the state controller directly. ────────────────
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.True(shelf.CaptureWindowState().Collapsed);
// ── Second draw: must reflect the COLLAPSED geometry. Pre-fix, the
// anchor snapshot captured above wins instead: grip/toggle stay at
// their EXPANDED Left/Width/Height regardless of what LayoutChrome
// just wrote via Reflow. ───────────────────────────────────────────
renderer.Begin(new Vector2(800f, 600f));
root.Draw(ctx);
Assert.Equal(0f, grip.Left);
Assert.Equal(0f, grip.Top);
Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
Assert.Equal(shelf.Height, grip.Height, 3);
Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
Assert.Equal(0f, toggle.Top);
Assert.Equal(shelf.Height, toggle.Height, 3);
Assert.True(
toggle.Left + toggle.Width <= shelf.Width + 0.01f,
$"toggle (Left={toggle.Left}, Width={toggle.Width}) lies outside " +
$"the collapsed shelf (Width={shelf.Width}) — the ancestor clip " +
"would remove it, matching the owner's \"no <\" report.");
// ── Expand again: the geometry must be restored too, via another
// real click. ──────────────────────────────────────────────────────
int toggleX2 = (int)shelf.Left + (int)shelf.Width - 8;
int toggleY2 = (int)shelf.Top + 4;
root.OnMouseDown(UiMouseButton.Left, toggleX2, toggleY2);
root.OnMouseUp(UiMouseButton.Left, toggleX2, toggleY2);
Assert.False(shelf.CaptureWindowState().Collapsed);
renderer.Begin(new Vector2(800f, 600f));
root.Draw(ctx);
Assert.Equal(expandedShelfHeight, shelf.Height, 3);
Assert.Equal(0f, grip.Left);
Assert.Equal(0f, grip.Top);
Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
Assert.Equal(expandedBandHeight, grip.Height, 3);
Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
Assert.Equal(0f, toggle.Top);
Assert.Equal(expandedBandHeight, toggle.Height, 3);
}
/// <summary>
/// Same double-draw pin as
/// <see cref="Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry"/>,
/// but for the <see cref="PluginSidePanel.PluginShelfButton"/> ENTRIES
/// rather than the grip/toggle: a multi-column row-wrapped layout, draw,
/// remove one entry (a real window unregister — the same path a plugin
/// unload takes), draw again. Every remaining button's Left/Top must
/// match what a FRESH single-pass layout of the same final entry set
/// would produce — not a stale position captured from the 12-entry
/// layout on the first draw.
///
/// <para>
/// Compares against an independent "reference" shelf built directly with
/// the final (post-removal) entry set and drawn exactly once, so the
/// expected geometry is derived from the real layout math rather than
/// re-hard-coded private constants (<c>OuterPadding</c>/<c>ButtonExtent</c>/
/// <c>ButtonGap</c> are private to <see cref="PluginSidePanel"/>).
/// </para>
/// </summary>
[Fact]
public void MultiColumnReflow_AfterADraw_EveryRemainingButtonMatchesAFreshSinglePassLayout()
{
const int entryCount = 12;
var root = new UiRoot { Width = 800f, Height = 260f };
using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
var handles = new RetailWindowHandle[entryCount];
for (int i = 0; i < entryCount; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
handles[i] = root.WindowManager.Register($"plugin:acdream.test:{i}", frame);
shelf.Add(
new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"),
new PluginPanelDescriptor("main", $"Test Plugin {i}"),
handles[i]);
}
root.Tick(0.016d, 16L);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
// ── First draw: captures anchors at the full 12-entry layout. ─────
renderer.Begin(new Vector2(800f, 600f));
root.Draw(ctx);
// ── Remove the FIRST entry — every remaining button shifts down one
// slot, so this exercises the widest possible reflow. ─────────────
root.WindowManager.Unregister(handles[0].Name);
Assert.Equal(entryCount - 1, shelf.EntryCount);
// ── Second draw: must reflect the fresh 11-entry reflow. Pre-fix,
// each surviving button stays pinned at its ORIGINAL 12-entry
// position/size instead. ───────────────────────────────────────────
renderer.Begin(new Vector2(800f, 600f));
root.Draw(ctx);
List<PluginSidePanel.PluginShelfButton> afterRemoval =
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>().ToList();
Assert.Equal(entryCount - 1, afterRemoval.Count);
// ── Reference: an independent shelf built directly with the SAME
// final 11-entry set (never having been through a 12-entry layout),
// ticked and drawn exactly once. ───────────────────────────────────
var referenceRoot = new UiRoot { Width = 800f, Height = 260f };
using var referenceShelf = new PluginSidePanel(
referenceRoot.WindowManager, _ => (0u, 0, 0), font: null);
referenceRoot.AddChild(referenceShelf);
for (int i = 1; i < entryCount; i++)
{
var frame = new UiPanel { Width = 200f, Height = 100f };
referenceRoot.AddChild(frame);
RetailWindowHandle handle = referenceRoot.WindowManager.Register(
$"plugin:acdream.reference:{i}", frame);
referenceShelf.Add(
new PluginUiOwner($"acdream.reference.{i}", $"Test Plugin {i}"),
new PluginPanelDescriptor("main", $"Test Plugin {i}"),
handle);
}
referenceRoot.Tick(0.016d, 16L);
var referenceDevice = new RecordingGpuDevice();
var referenceRenderer = new TextRenderer(referenceDevice, new NullGpuFrameSource(), "unused");
var referenceCtx = new UiRenderContext(referenceRenderer, new Vector2(800f, 600f));
referenceRenderer.Begin(new Vector2(800f, 600f));
referenceRoot.Draw(referenceCtx);
List<PluginSidePanel.PluginShelfButton> reference =
referenceShelf.Children.OfType<PluginSidePanel.PluginShelfButton>().ToList();
Assert.Equal(reference.Count, afterRemoval.Count);
for (int i = 0; i < afterRemoval.Count; i++)
{
Assert.Equal(reference[i].Left, afterRemoval[i].Left, 3);
Assert.Equal(reference[i].Top, afterRemoval[i].Top, 3);
Assert.Equal(reference[i].Width, afterRemoval[i].Width, 3);
Assert.Equal(reference[i].Height, afterRemoval[i].Height, 3);
}
Assert.Equal(shelf.Width, referenceShelf.Width, 3);
Assert.Equal(shelf.Height, referenceShelf.Height, 3);
}
}