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>
501 lines
24 KiB
C#
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 < after I minimize
|
|
/// the window" — after collapsing the plugin shelf with the <c>></c>
|
|
/// toggle, the <c><</c> (expand) glyph was not visible.
|
|
///
|
|
/// <para>
|
|
/// Reproduction findings (a real-DAT draw probe against font 0x40000000, the
|
|
/// shelf's default font): '<' and '>' 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><</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);
|
|
}
|
|
}
|