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;
///
/// Owner-reported defect (2026-09-06): "I dont see the < after I minimize
/// the window" — after collapsing the plugin shelf with the >
/// toggle, the < (expand) glyph was not visible.
///
///
/// 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
/// (GripHeight), 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
/// (UIElement_Text::DrawSelf), spans y=[-1,14] before clipping and was
/// cropped by the 12px band's self-clip (UiElement.ClipsChildren) 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.
///
///
///
/// Fix: derives the
/// EXPANDED band from the real font metrics
/// (max(12, font.LineHeight + 2)) so neither the fill nor the
/// border-inflated outline plane can ever clip, for any font. The COLLAPSED
/// tab is now ButtonExtent (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.
///
///
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;
}
/// 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
/// (UiRenderContextDrawStringDatOutlineTests.DecodeQuads).
private static IEnumerable<(float y0, float y1)> DecodeVerticalSpans(IReadOnlyList 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);
}
}
///
/// Independently computes the FULL, unclipped vertical ink span (fill
/// plane union the border-inflated outline plane) that
/// UiRenderContext.DrawStringDatPass/DrawOutlineGlyph would
/// produce for a single centered glyph drawn at
/// (Height - font.LineHeight) * 0.5f (exactly
/// 's formula) in a box of the given
/// — mirroring the production math
/// (UiRenderContext.cs's baseY/gy/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.
///
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);
}
///
/// 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 <.
///
///
/// Root cause: is called for every
/// child on every draw. For any child whose
/// is not , it captures the Left/Top/Width/
/// Height margins ONCE on the FIRST draw and re-applies that snapshot on
/// every subsequent draw — overwriting whatever
/// /Reflow set in between.
/// 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 passes,
/// with a real toggle click in between, to reproduce it.
///
///
///
/// Uses a bitmap-fallback font (font: null) deliberately, per the
/// investigation's direction, so this reproduction runs in every CI lane
/// without an installed retail DAT — unlike the Lane=InstalledDat
/// tests above, which require one.
///
///
[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);
}
///
/// Same double-draw pin as
/// ,
/// but for the 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.
///
///
/// 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 (OuterPadding/ButtonExtent/
/// ButtonGap are private to ).
///
///
[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 afterRemoval =
shelf.Children.OfType().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 reference =
referenceShelf.Children.OfType().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);
}
}