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); } }