acdream/tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Erik 8217a349e0 feat(plugin-ui): Slice B — DAT icons in plugin markup (icon element, button/list icons, plugin icon ids)
Owner request: plugin panels (Decal/VirindiViewService-class, per the
MosswartMassacre reference usage) need to embed real DAT icons the way
FlagTrackerView.SafeSetListImage does — spell/skill art, raw portal
indices, and a window icon. This is Slice B of
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md (Slice A, the
movable/collapsible shelf, landed in 01b98ca30/4fada238e/718005b21).

What shipped:

- AcDream.Plugin.Abstractions.PluginIcons.Normalize: the one Decal-style
  bare-index -> 0x06xxxxxx RenderSurface DID grammar, applied at every
  icon SINK (descriptor IconSurfaceId in PluginShelfButton, and markup
  <icon did>/<button icon>/<list icons> did-kind ids) rather than on the
  plugin-facing records, which already carry real DIDs read straight
  from the client's tables.
- PluginSpellInfo.IconId / PluginSkillInfo.IconId /
  PluginInventoryItem.IconId / PluginWorldObject.IconId: additive init
  properties (default 0), filled in AppAutomationSurface from
  SpellMetadata.IconId (already projected from SpellBase.Icon by
  RetailSpellMetadataProjector — no gap there), a new BindSkillIcons
  parallel to BindSkillNames (GameWindow reads
  DatReaderWriter.Types.SkillBase.IconId — confirmed via reflection over
  the installed Chorizite.DatReaderWriter package, since its XML docs
  don't cover Pack/Unpack-generated public fields: Description, Name,
  IconId (uint), TrainedCost, SpecializedCost, Category, ChargenUse,
  MinLevel, Formula, UpperBound, LowerBound, LearnMod), and
  ClientObject.IconId in CaptureOwnedItems/ProjectWorldObject.
- IMarkupIconResolver (AcDream.App.UI): ResolveDid/ResolveSpell/
  ResolveItem. MarkupDocument.Build gains an optional parameter (null by
  default -> every icon sink resolves to nothing rather than throwing,
  so pre-Slice-B callers/tests are unaffected). RetailUiRuntime.
  MountPlugins builds ONE RetailMarkupIconResolver per pass from
  RetailUiAssets.ResolveSprite + RetailUiAssets.Icons (the shared
  IconComposer) + Toolbar.Objects (the SAME ClientObjectTable
  Magic/Toolbar bindings already borrow for their own icon resolution —
  no second object lookup introduced).
- New UiMarkupIcon widget (<icon x y w h did|spell|item tooltip>):
  exactly one source required (FormatException at Build otherwise,
  matching every other malformed-attribute rule), aspect-preserved,
  centered, click-through unless a tooltip makes it a real hit-test
  target.
- UiSimpleButton.IconSource and UiMarkupList.IconIdsSource/IconResolve:
  additive, default null/no-op, so every existing button/list caller
  (including the plugin shelf's own toggle/minimize buttons) is
  unaffected. Button icon draws flush left and shifts the caption's
  centering region right; list icons reserve a leading RowHeight-2
  column (Decal's IconColumn) and skip rows whose id is 0 or
  unresolvable.
- MarkupDocument centralizes the did/spell/item dispatch (including
  PluginIcons.Normalize for did) in two small helpers (BuildIconSource
  for <icon>/<button>, BuildRowIconResolve for <list>) so all three
  markup surfaces share one resolver call path.
- AcDream.Plugins.Smoke ships a RegisterPanelContent (in-memory KSML,
  no plugin-side .xml file) proof panel exercising every new surface:
  a bare-index <icon>, a literal-hex <icon>, a composited <icon
  spell=...>, a <button icon=...>, and a <list icons=... iconkind=
  spell> of the first five known self-buffs with their IconId printed
  alongside. Descriptor IconSurfaceId reuses the same bare index to
  prove the shelf button and the panel's own icon normalize identically.
- docs/plugin-ui-markup.md is the new SSOT for the full markup
  vocabulary + icon grammar + the Slice A shelf; linked from
  docs/README.md and docs/plans/2026-04-24-ui-framework.md.

Design decisions where the plan left room:
- Normalize runs inside the resolver dispatch (BuildIconSource/
  BuildRowIconResolve), not scattered at each markup call site, so
  every did-kind sink shares one choke point.
- did/spell/item all accept either a literal (decimal or 0x-hex) or a
  {Binding}, via one BindUintLiteralOrBinding helper, for symmetry —
  the plan only showed spell/item as bindings but didn't forbid a
  literal.
- <icon> requires exactly one source INCLUDING zero (not just two);
  an icon with no source is not a coherent element.
- The button/list icon draw math (icon column extent, padding) lives
  in the widgets themselves (UiSimpleButton/UiMarkupList), not in
  MarkupDocument, keeping the parser only responsible for wiring
  Func<(tex,w,h)> sources.

Tests: PluginIconsTests (Normalize table), MarkupIconTests (icon/button/
list resolver dispatch via a fake IMarkupIconResolver, plus draw-level
pins via the RecordingGpuDevice/TextRenderer apparatus already used by
UiAncestorClipTests/UiRenderContextDrawStringDatOutlineTests — "draws
nothing when unresolvable" and "button/list icon shifts the text"),
and AppAutomationSurfaceIconInstalledDatTests (Lane=InstalledDat: a
known spell's IconId matches the real installed SpellTable's own Icon
field exactly). Verified every new test fails to COMPILE without this
change (git-stashed the src/ changes, rebuilt the test project: CS0246
on IMarkupIconResolver) before restoring. Full App suite: 7331 passed /
97 skipped / 36 failed (identical pre-existing failure set/count to the
7306/97/36 baseline; the +25 passes are exactly the new tests).
AcDream.Plugins.MossTank.Tests (the main consumer of the touched
Plugin.Abstractions records) passes 337/337 unchanged, confirming
API-v1 binary/source compatibility. Full solution builds green.

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

452 lines
16 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// <c>&lt;icon&gt;</c>, <c>&lt;button icon&gt;</c>, and <c>&lt;list icons&gt;</c>
/// markup, backed by a fake <see cref="IMarkupIconResolver"/> that records
/// every call so the resolver-dispatch (did/spell/item, normalized vs raw)
/// is directly assertable, plus draw-level pins (via the same
/// <see cref="RecordingGpuDevice"/>/<see cref="TextRenderer"/> apparatus
/// <c>UiAncestorClipTests</c>/<c>UiRenderContextDrawStringDatOutlineTests</c>
/// use) for "draws nothing when unresolvable" and "button/list icon shifts
/// the caption/text column".
/// </summary>
public sealed class MarkupIconTests
{
private sealed class FakeIconResolver : IMarkupIconResolver
{
public readonly List<(string Method, uint Id)> Calls = new();
public uint DidTexture = 5u;
public uint SpellTexture = 6u;
public uint ItemTexture = 7u;
public (uint tex, int w, int h) ResolveDid(uint did)
{
Calls.Add(("did", did));
return did == 0u ? (0u, 0, 0) : (DidTexture, 32, 32);
}
public (uint tex, int w, int h) ResolveSpell(uint spellId)
{
Calls.Add(("spell", spellId));
return spellId == 0u ? (0u, 0, 0) : (SpellTexture, 32, 32);
}
public (uint tex, int w, int h) ResolveItem(uint objectId)
{
Calls.Add(("item", objectId));
return objectId == 0u ? (0u, 0, 0) : (ItemTexture, 32, 32);
}
}
private sealed class IconBinding
{
public uint IconDid { get; set; } = 0x06001234u;
public uint SpellId { get; set; } = 42u;
public uint ObjectId { get; set; } = 0x50000001u;
public Action Go => () => { };
}
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
// ── <icon>: source dispatch + the Build-time contract ────────────────────
[Fact]
public void Icon_LiteralBareIndex_NormalizesBeforeResolving()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(32, w);
Assert.Equal(32, h);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]); // 0x06000000 + 7735
}
[Fact]
public void Icon_LiteralHexDid_AlreadyInRangeIsUnchangedByNormalize()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"0x06002D14\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
icon.IconSource();
Assert.Equal(("did", 0x06002D14u), resolver.Calls[^1]);
}
[Fact]
public void Icon_BoundDid_ReReadsTheBindingEveryFrame()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"{IconDid}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
icon.IconSource();
Assert.Equal(("did", binding.IconDid), resolver.Calls[^1]);
binding.IconDid = 0x06005678u;
icon.IconSource();
Assert.Equal(("did", 0x06005678u), resolver.Calls[^1]);
}
[Fact]
public void Icon_Spell_RoutesToResolveSpellWithTheBoundIdUnnormalized()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" spell=\"{SpellId}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, _, _) = icon.IconSource();
Assert.Equal(resolver.SpellTexture, tex);
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
}
[Fact]
public void Icon_Item_RoutesToResolveItemWithTheBoundId()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" item=\"{ObjectId}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, _, _) = icon.IconSource();
Assert.Equal(resolver.ItemTexture, tex);
Assert.Equal(("item", binding.ObjectId), resolver.Calls[^1]);
}
[Fact]
public void Icon_ZeroId_ResolvesToNothing()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"0\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(0u, tex);
}
[Fact]
public void Icon_NoResolverWired_ResolvesToNothingRatherThanThrowing()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
(uint tex, int w, int h) = icon.IconSource();
Assert.Equal(0u, tex);
}
[Fact]
public void Icon_TwoSources_ThrowsFormatExceptionAtBuild()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"7735\" spell=\"{SpellId}\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: new FakeIconResolver()));
}
[Fact]
public void Icon_NoSources_ThrowsFormatExceptionAtBuild()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new object(), Sprite));
}
[Fact]
public void Icon_WidthHeightDefaultTo32WhenOmitted()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"1\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
Assert.Equal(32f, icon.Width);
Assert.Equal(32f, icon.Height);
}
// ── <button icon>: resolver dispatch + draw-level "text shifts right" ────
[Fact]
public void ButtonIcon_DefaultsToDidKind_AndDrawsThroughTheResolver()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.NotNull(button.IconSource);
(uint tex, _, _) = button.IconSource!();
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
}
[Fact]
public void ButtonIcon_KindSpell_RoutesToResolveSpell()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Buff\" " +
"icon=\"{SpellId}\" iconkind=\"spell\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
button.IconSource!();
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
}
[Fact]
public void ButtonWithoutIconAttribute_HasNoIconSource()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.Null(button.IconSource);
}
// ── <list icons>: resolver dispatch + column reservation ──────────────────
private sealed class ListIconBinding
{
public IReadOnlyList<string> Items { get; } = new[] { "First", "Second" };
// Row 0 resolves; row 1's id is 0 (unresolvable) — "missing entries
// draw no icon" per the plan.
public IReadOnlyList<uint> IconIds { get; } = new[] { 7735u, 0u };
public int Selected { get; set; } = -1;
}
[Fact]
public void ListIcons_ReservesTheColumn_AndResolvesEachRowThroughTheSharedResolver()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
"items=\"{Items}\" icons=\"{IconIds}\" iconkind=\"did\" selected=\"{Selected}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new ListIconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.IconIdsSource);
Assert.Equal(binding.IconIds, list.IconIdsSource!());
Assert.NotNull(list.IconResolve);
(uint tex, _, _) = list.IconResolve!(7735u);
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
(uint missTex, _, _) = list.IconResolve!(0u);
Assert.Equal(0u, missTex);
}
[Fact]
public void ListWithoutIconsAttribute_HasNoIconColumn()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" selected=\"{Selected}\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new ListIconBinding(), Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Null(list.IconIdsSource);
}
// ── Draw-level: "draws nothing" / "shifts text" pinned against real quads ─
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 UiMarkupIcon_ResolvedTexture_DrawsASpriteAspectPreservedAndCentered()
{
// 32x16 source in a 32x32 box: scale = min(32/32, 32/16) = 1, so the
// drawn quad stays 32x16 (never stretched to fill the box) and is
// vertically centered: y = (32-16)/2 = 8.
var icon = new UiMarkupIcon
{
Width = 32f,
Height = 32f,
IconSource = () => (9u, 32, 16),
};
var (renderer, ctx) = MakeContext(200f, 200f);
icon.DrawSelfAndChildren(ctx);
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// First vertex = top-left (x,y); AppendQuad's V0..V5 layout, 8 floats/vertex.
Assert.Equal(0f, seg.Verts[0], 3);
Assert.Equal(8f, seg.Verts[1], 3);
}
[Fact]
public void UiMarkupIcon_UnresolvedTexture_DrawsNothing()
{
var icon = new UiMarkupIcon
{
Width = 32f,
Height = 32f,
IconSource = () => (0u, 0, 0),
};
var (renderer, ctx) = MakeContext(200f, 200f);
icon.DrawSelfAndChildren(ctx);
Assert.Empty(renderer.DebugSpriteSegmentVerts);
}
[Fact]
public void UiSimpleButton_WithIcon_DrawsTheIconSprite_AndShiftsTheCaptionRight()
{
var glyphs = new Dictionary<char, FontCharDesc>
{
['G'] = new FontCharDesc { Unicode = 'G', Width = 8, Height = 8 },
};
var font = new UiDatFont(
fgTex: 1u, fgW: 32, fgH: 32,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs);
var withoutIcon = new UiSimpleButton
{
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
BackgroundColor = default, BorderColor = default,
};
var withIcon = new UiSimpleButton
{
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
IconSource = () => (9u, 32, 32),
BackgroundColor = default, BorderColor = default,
};
var (rendererWithout, ctxWithout) = MakeContext(200f, 200f);
withoutIcon.DrawSelfAndChildren(ctxWithout);
var (rendererWith, ctxWith) = MakeContext(200f, 200f);
withIcon.DrawSelfAndChildren(ctxWith);
// The icon sprite itself drew.
Assert.Contains(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 9u);
Assert.DoesNotContain(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// Both buttons draw exactly one glyph quad (font texture 1u); the
// icon-bearing button's caption starts strictly to the right of the
// icon-less button's, because its centering region was shifted by
// the reserved icon column.
var textWithout = Assert.Single(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 1u);
var textWith = Assert.Single(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 1u);
Assert.True(
textWith.Verts[0] > textWithout.Verts[0],
$"expected the icon-bearing button's caption ({textWith.Verts[0]}) to start right of the icon-less caption ({textWithout.Verts[0]})");
}
[Fact]
public void UiMarkupList_IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
{
var list = new UiMarkupList
{
Width = 180f, Height = 60f, RowHeight = 18f,
ItemsSource = () => new[] { "First", "Second" },
IconIdsSource = () => new uint[] { 9u, 0u },
IconResolve = id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
BackgroundColor = default, BorderColor = default,
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 0's icon (id 9u) drew; row 1 has no icon id (0u -> unresolvable)
// and therefore emits no sprite for that row's column.
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// The icon column is RowHeight - 2 wide; the drawn quad's right edge
// (the second AppendQuad vertex's x, at float offset 8 — see
// UiRenderContextDrawStringDatOutlineTests.DecodeQuads for the same
// 8-floats/vertex, 6-vertices/quad layout) must sit entirely inside
// it, never overlapping where row text starts.
Assert.True(iconQuad.Verts[8] <= list.RowHeight - 2f + 0.01f);
}
}