acdream/tests/AcDream.App.Tests/UI/UiRectOutlinePainterOrderTests.cs
Erik f2d7562c86 chore(plugin-ui): review cleanup — hermetic memo tests, shelf button anchors, outline pin, bounded miss cache; file #486/#487; correct #461
- Split the two hermetic RetailMarkupIconResolver memoization tests (and
  their counting fakes) out of the Lane=InstalledDat class into a new
  untagged RetailMarkupIconResolverMemoizationTests.cs so CI's portable
  filter (Lane!=InstalledDat) actually runs them.
- PluginSidePanel: move the entry button's Anchors = AnchorEdges.None from
  the Add() call site into PluginShelfButton's own constructor (same
  comment carried over) so a second construction path cannot miss it.
- UiRectOutlinePainterOrderTests: assert the back panel's border segment
  carries exactly 4 quads (24 vertices, FloatsPerVertex each) so a partial
  outline cannot pass the painter-order check.
- RetailMarkupIconResolver: document the type as UI-thread-only (every
  caller is a draw-time icon source) and bound the MISS cache to 256
  entries with FIFO eviction — HIT entries stay unbounded (bounded by the
  DAT's own surface count already). New test proves the 257th distinct
  miss evicts the first (re-probe count rises); verified failing first
  against the un-bounded code (Expected 258, Actual 257) before restoring
  the fix.
- docs/plugin-ui-markup.md: split the icon-binding row's failure mode into
  Build-time (missing property only — the binder never checks CLR type)
  vs. draw-time (a resolved value that cannot convert to a number throws
  from the draw, not from Build).
- docs/ISSUES.md: filed #486 (credits picture scroll frozen by the
  per-draw anchor pass) and #487 (radar compass tokens candidate, same
  mechanism, unconfirmed); corrected #461's causality — the graceful
  logout/reveal-cancel log lines are printed by LiveSessionController.Tick's
  catch -> StopAfterFailure -> StopCore AFTER the motion-update exception,
  then it rethrows, so the logout is a consequence of the crash, not its
  cause; real chain is the #462 stalled login-reveal materialization
  leaving PlayerMovementController in RuntimeOwnedDormant outside its
  SetPosition ground phase when an inbound 0xF74C arrives.
- Plan doc: recorded the three fix-round commits' verdicts (all PASS) and
  the Smoke-plugin cleanup commit SHA in the Review ledger, plus a pointer
  to the two newly filed issues.

Verified: dotnet build AcDream.slnx -c Release (0/0), targeted filter
85/0/0, full App suite 7364 passed / 97 skipped / 36 failed (36 pre-existing
InstalledDat/Manual/Linux-only failures, unchanged by name from baseline;
net +1 passed test from the new eviction test).

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

125 lines
5.4 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Owner-reported symptom: with a plugin window BEHIND the inventory window,
/// the plugin button's BORDER outline drew on top of the inventory's paperdoll
/// even though the inventory window was added (and so composited) LATER. Root
/// cause — <see cref="TextRenderer"/> used to composite in three buckets per
/// layer: submission-ordered sprite segments, THEN every untextured
/// <see cref="TextRenderer.DrawRect"/> quad (regardless of when it was
/// submitted), THEN debug text. <see cref="UiRenderContext.DrawRect"/> (and so
/// <see cref="UiRenderContext.DrawRectOutline"/>, which every
/// <c>BorderColor</c> outline in the retained UI — <see cref="UiPanel"/>,
/// <see cref="UiMarkupList"/> — goes through) forwarded into that rect
/// bucket, so every outline composited above every window's sprite content
/// drawn after it, no matter the actual paint order.
///
/// <para>
/// This pins the mechanism directly against the real <see cref="UiPanel"/>
/// draw path: a back panel with a visible border, then a front sibling
/// (added AFTER — later paint order) that draws an opaque sprite over the
/// same screen rect. The back panel's border must composite UNDER the front
/// sprite, exactly like <see cref="UiRenderContext.DrawFill"/> already does
/// for panel backgrounds.
/// </para>
/// </summary>
public sealed class UiRectOutlinePainterOrderTests
{
private sealed class TestElement : UiElement { }
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
return (renderer, ctx);
}
[Fact]
public void BackPanelBorder_ComposesUnderAFrontSpriteAddedLater()
{
var root = new TestElement { Width = 200f, Height = 200f };
// Back window: added FIRST, drawn first (lower paint order). Its
// border is the ONLY thing it draws — background left transparent so
// any leaked geometry in the assertions below can only be the border.
var backPanel = new UiPanel
{
Left = 0f, Top = 0f, Width = 100f, Height = 60f,
BackgroundColor = default,
BorderColor = new Vector4(1f, 1f, 1f, 1f),
BorderThickness = 2f,
};
// Front window: added AFTER (drawn later / higher paint order) and
// covers the SAME screen rect with an opaque sprite — the inventory
// window's paperdoll, standing in for MossTank's button border.
const uint frontTexture = 55u;
var frontSprite = new UiSolidSpriteFill
{
Left = 0f, Top = 0f, Width = 100f, Height = 60f,
SpriteId = frontTexture,
SpriteResolve = id => (id, 8, 8),
};
root.AddChild(backPanel);
root.AddChild(frontSprite);
var (renderer, ctx) = MakeContext(200f, 200f);
root.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
int frontIndex = -1;
for (int i = 0; i < segs.Count; i++)
{
if (segs[i].Texture == frontTexture) { frontIndex = i; break; }
}
Assert.True(frontIndex >= 0, "the front sprite must be recorded in the sprite bucket");
int outlineIndex = -1;
for (int i = 0; i < segs.Count; i++)
{
if (segs[i].Texture == UiTextureTableHandle.None) { outlineIndex = i; break; }
}
Assert.True(
outlineIndex >= 0,
"the back panel's border must route through the painter-order sprite bucket " +
"(an untextured segment, UiTextureTableHandle.None), not the separate rect bucket");
Assert.True(
outlineIndex < frontIndex,
"the back panel's border segment must be submitted BEFORE the front sprite's " +
"segment so it composites underneath it, matching the actual paint order");
// UiRenderContext.DrawRectOutline draws exactly 4 sides (top, bottom,
// left, right), each one quad (2 triangles = 6 vertices) via
// TextRenderer.AppendQuad; consecutive same-texture (None) DrawSprite
// calls batch into the ONE segment found above. A partial outline —
// e.g. a side silently dropped or clipped away — must not pass by
// merely having SOME untextured geometry in the right bucket at the
// right order; it must have all four sides' worth of vertices.
const int expectedQuads = 4;
const int expectedVertices = expectedQuads * 6;
Assert.Equal(
expectedVertices * TextRenderer.FloatsPerVertex,
segs[outlineIndex].Verts.Count);
// No outline geometry may land in TextRenderer's separate untextured
// rect bucket at all: that bucket always flushes AFTER every sprite
// segment regardless of submission order, which is exactly the bug —
// an outline drawn there would win against every window painted after it.
Assert.Equal(0, renderer.DebugRectVertexCount);
}
}