acdream/tests/AcDream.App.Tests/UI/RetailMarkupIconResolverInstalledDatTests.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

144 lines
6.1 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Review fix round finding 1 (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>
/// Slice B): <see cref="RetailMarkupIconResolver.ResolveDid"/> must return
/// <c>(0, 0, 0)</c> for an id that does not resolve to a real installed
/// RenderSurface — NEVER fall through to
/// <see cref="TextureCache.GetOrUploadRenderSurface"/>'s 1x1 magenta
/// placeholder, which is load-bearing for authored chrome and would
/// otherwise get scaled up to a full magenta square by
/// <see cref="UiMarkupIcon"/>/<see cref="UiMarkupList"/>/<see cref="UiSimpleButton"/>
/// (see <c>claude-memory/feedback_ui_resolve_zero_magenta.md</c>: guard on
/// the id, never on the resolved handle).
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class RetailMarkupIconResolverInstalledDatTests
{
/// <summary>
/// A Decal-habit "add the block prefix again" mistake applied to an
/// already-full DID: <c>0x06000165</c> (Melee Defense's real installed
/// icon, <c>SampleData.cs:69</c>) plus another <c>0x06000000</c> lands at
/// <c>0x0C000165</c> — a value almost certainly absent from both Portal
/// and HighRes.
/// </summary>
private const uint DecalHabitDoubleNormalizedId = 0x0C000165u;
/// <summary>Retail's Melee Defense skill icon — a known-real installed DID.</summary>
private const uint KnownRealDid = 0x06000165u;
[Fact]
public void ResolveDid_UnresolvableId_ReturnsNothing_AndKnownRealId_ReturnsATexture()
{
string? datDir = InstalledDatTestPath.Resolve();
if (datDir is null)
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, adapter);
var icons = new IconComposer(adapter, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects);
(uint missTex, int missW, int missH) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
Assert.Equal((0u, 0, 0), (missTex, missW, missH));
(uint realTex, int realW, int realH) = resolver.ResolveDid(KnownRealDid);
Assert.NotEqual(0u, realTex);
Assert.True(realW > 0 && realH > 0);
}
/// <summary>
/// Residual round finding N1: proves the production wiring fix end to
/// end against the REAL resolver — <see cref="RetailUiRuntime.MountPlugins"/>
/// now passes <c>iconResolver.ResolveDid</c> (this class) to
/// <see cref="PluginSidePanel"/>, not
/// <c>_bindings.Assets.ResolveSprite</c> (= ResolveChrome =
/// <see cref="TextureCache.GetOrUploadRenderSurface"/>, which returns a
/// non-zero 1x1 magenta placeholder for a missing id and would have kept
/// <see cref="PluginSidePanel.PluginShelfButton"/>'s initials fallback
/// from ever firing). A bogus descriptor icon id run through the real
/// resolver must resolve to nothing, and the shelf button must fall
/// back to its initials text — exactly the shape that would have caught
/// the pre-fix wiring bug (ResolveSprite would have returned a non-zero
/// magenta handle here instead of (0,0,0)).
/// </summary>
[Fact]
public void ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials()
{
string? datDir = InstalledDatTestPath.Resolve();
if (datDir is null)
{
Assert.Fail(
"Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, adapter);
var icons = new IconComposer(adapter, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(adapter, cache, icons, objects);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager,
resolver.ResolveDid,
font: null);
root.AddChild(shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle handle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin")
{
IconText = "TP",
// Already >= the Normalize boundary (0x01000000), so
// PluginShelfButton's ctor leaves it unchanged — a real DID
// shape that is nonetheless absent from both Portal and
// HighRes (see DecalHabitDoubleNormalizedId's own doc above).
IconSurfaceId = DecalHabitDoubleNormalizedId,
},
handle);
PluginSidePanel.PluginShelfButton button = Assert.Single(
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
Assert.Equal(string.Empty, button.Text);
var textRenderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
textRenderer.Begin(new Vector2(200f, 200f));
var ctx = new UiRenderContext(textRenderer, new Vector2(200f, 200f));
button.DrawSelfAndChildren(ctx);
Assert.Equal("TP", button.Text);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
}