acdream/tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Erik 466272ec55 fix(plugin-ui): Slice B review fixes — no magenta for bad DIDs, contract threshold, integral icon bindings, nearest did art, loud markup errors
Two Opus reviews of commit 8217a349e (Slice B: DAT icons in plugin
markup) found one BLOCKING defect and 14 SHOULD-FIX findings. All 15
fixed here in one commit per the review contract.

BLOCKING (finding 1): an unresolvable did painted a magenta square.
TextureCache.GetOrUploadRenderSurface's 1x1 magenta placeholder for a
missing RenderSurface is load-bearing for authored chrome, but
RetailMarkupIconResolver.ResolveDid only short-circuited did==0, so any
other unresolvable id fell through to that placeholder and got scaled
up by UiMarkupIcon/UiMarkupList/UiSimpleButton -- the classic
resolve(0)-style footgun (claude-memory/feedback_ui_resolve_zero_magenta.md),
just triggered by a missing id instead of a literal 0. Fixed by probing
Portal/HighRes existence via IDatReaderWriter.TryGet<RenderSurface>
BEFORE ever calling GetOrUploadRenderSurface -- that TryGet already
serializes concurrent DAT access internally (DatDatabaseWrapper's own
_databaseLock), the same synchronization IconComposer.TryDecode relies
on, so no additional lock was needed. RetailMarkupIconResolver now
takes IDatReaderWriter + TextureCache directly (RetailUiAssets gained a
TextureCache field, its one construction site in
InteractionRetainedUiComposition.cs updated) instead of the old
resolveSprite delegate, since it also needs the nearest-sampled upload
path for finding 6 below.

Finding 2 -- Smoke panel wiring bugs: its list fed iconkind="spell"
raw art DIDs (PluginSpellInfo.IconId) instead of spell ids, so
IMarkupIconResolver.ResolveSpell composited the wrong (or no) badge
every row. SmokeIconPanel.Binding.SpellIds now yields SpellId (the
printed text still shows IconId alongside). The bare-index demo and the
descriptor both moved from the unverified literal 7735 to 0x165 --
retail's real Melee Defense skill icon (SampleData.cs:64,
0x06000165) -- so the owner's visual gate proves real art, not a guess.
StartVisible flipped true, and a character with no self-buffs known
falls back to spell 1's real catalog entry (or an honest "no spells
known" row with icon 0 if even that fails) rather than fabricating art.

Finding 3 -- PluginIcons.Normalize's threshold was silently rewritten
from the contract's 0x01000000 to 0x06000000 during Slice B. Restored
to 0x01000000; the class/method XML docs now state the number directly
(no cref to the private const); the test table adds 0x02000000 (a value
that only distinguishes the two thresholds) and 0x01000000 itself
(passes through unchanged).

Finding 4 -- an unknown iconkind (e.g. "spel") only threw when a
resolver happened to be wired, because BuildIconSource/
BuildRowIconResolve validated inside their own null-icons early return.
A new ValidateIconKind helper runs UNCONDITIONALLY before that branch,
so a malformed iconkind is a Build-time author error on every host.

Finding 5 -- BindUintLiteralOrBinding required an exact uint property
type, rejecting the int-typed bindings Decal-facing code commonly uses
(MosswartMassacre's HudPictureBox.Image is int end to end). It now
matches BindUint's existing leniency: any property, converted via
Convert.ToUInt32 at read time. BindUintList likewise now accepts
IEnumerable<int> alongside IEnumerable<uint> (unchecked per-element
reinterpret -- icon ids never go negative in practice).

Finding 6 -- TextureCache._renderSurfaceGpuTextures was keyed by id
alone, so whichever caller asked for a given RenderSurface id FIRST won
the sampler for every later caller of the same id -- UiDatFont's glyph
atlases already request nearest:true while ResolveChrome's background
art requests nearest:false, so this was a real, reachable collision,
not hypothetical. Rekeyed to (id, nearest); RetailMarkupIconResolver.
ResolveDid now requests nearest:true (pixel-exact 32x32 icon art);
ResolveChrome is untouched (still nearest:false/linear). Audited every
other _renderSurfaceGpuTextures use site (TryGetValue/set/Dispose
iteration+Clear) plus the separate _nearestUiTextureSources/
_linearUiTwinHandles/_uploadMetadata dictionaries (all keyed by handle
or accounting name, unaffected) -- no other eviction/accounting path
assumed id-only keying.

Finding 7 -- column-reservation semantics, per the DECIDED shape:
MarkupDocument now sets button.IconSource / list.IconIdsSource +
IconResolve ONLY when a resolver (icons parameter) is actually wired --
previously button.IconSource was always assigned (even to an
always-empty func on an icons:null host); combined with this finding's
other half -- UiSimpleButton.OnDraw now reserves its icon column
whenever IconSource is non-null, regardless of a per-frame resolve miss,
so a bound id that goes briefly to 0 no longer slides the caption back
and forth -- would have permanently reserved a blank column on such a
host. UiMarkupList already reserved its column whenever IconIdsSource
was set; no draw-side change needed there.

Finding 8 -- added a with/without-icons comparison test for
UiMarkupList (mirroring the existing UiSimpleButton one): asserts the
row text quad's x is strictly greater with an icon column present, and
the icon quad itself has non-zero width.

Finding 9 -- <icon tooltip=""> (empty string) was still treated as
"has a tooltip" by a bare attribute-presence check, making the icon
swallow clicks with no visible tooltip ever appearing. Now uses
!string.IsNullOrWhiteSpace, matching ApplyCommon's own predicate for
every other element's tooltip.

Finding 10 -- PluginShelfButton.OnDraw drew nothing when a non-zero
descriptor icon id resolved to no texture (a bad Decal index, a DAT id
from a different install), rather than falling back to Initials the
way a zero id already did. Now decides once, on the first draw
(memoized, so Initials' string work never repeats every frame): a
failed resolve permanently switches Text to the initials fallback,
computed and assigned BEFORE base.OnDraw actually paints the caption.

Finding 11 -- MarkupDocument.AddElement's switch had no default arm, so
an unknown or miscased element name (<Icon>, <butotn>) silently
vanished from the built tree instead of failing loudly like every
other malformed-markup case. Added a default arm that throws
FormatException. Ran AcDream.Plugins.MossTank.Tests (337/337,
unchanged) and the full App markup suite to confirm no existing markup
relies on an unknown element.

Finding 12 -- PluginPanelDescriptor.IconSurfaceId's XML doc now states
that a bare Decal index is accepted and normalized, citing
PluginIcons.Normalize.

Finding 13 -- docs/plugin-ui-markup.md: replaced the blanket "wrong
type/missing property throws at Build" sentence with the per-attribute
truth table the review produced (which attributes are silent at
runtime vs. throw at Build, and each one's bound CLR/delegate type);
restated the icon-id boundary as 0x01000000; added the "do NOT add
0x06000000 to the four already-full IconId records" warning (citing
SkillBase._iconID / UIRegion::SetImageByDID @0x004f150e); documented
that 0x-prefixed hex is required (an unprefixed all-digit literal
parses as decimal); noted unknown element names now throw; called out
list colors (0xRRGGBB) vs. color=/background=/border= (#AARRGGBB) as
non-interchangeable grammars; documented the root <panel visible>
binding-only exception; corrected the shelf's collapse toggle glyphs
(</>, not the old doc's arrows) and the 28px collapsed-tab size; added
the IconId record-equality API-v1 note; and called out iconkind as
per-<list> (mixed id spaces need pre-normalized DIDs; the composited
spell badge has no did-space escape hatch) plus the existing
one-text-column LIMITATION being deferred to MossTank.

Coverage added for finding 14: a PluginSidePanelTests case proving the
shelf button normalizes a bare descriptor index before resolving, and a
reflection-based unit on AppAutomationSurface.ProjectWorldObject (its
public callers gate on IsAvailable, which needs a fully connected
session heavier than this mapping needs -- the plan's own documented
fallback) proving PluginWorldObject.IconId carries ClientObject.IconId
through unchanged; PluginInventoryItem.IconId uses the identical
one-line pattern inline in CaptureOwnedItems, reviewed by inspection.

Finding 15: recorded a "Review ledger" section in the plan doc with
both slices' commits, both review verdicts, and the two items
explicitly deferred to the MossTank plugin work (multi-column list,
root literal visible).

Verification: full solution builds green. Targeted filter
(Markup|PluginSidePanel|PluginIcons|AppAutomation|TextureCache|
UiDatFont) passes 131/131, including the two InstalledDat-lane tests
(RetailMarkupIconResolverInstalledDatTests,
AppAutomationSurfaceIconInstalledDatTests) actually resolving against
the real installed DAT, not skipping. AcDream.Plugins.MossTank.Tests
passes 337/337 unchanged. Full AcDream.App.Tests suite: 7351 passed /
97 skipped / 36 failed -- identical failure set/count to the
7334/97/36 baseline (the +17 passes are exactly the new/expanded
tests: 2 new PluginIconsTests.Normalize theory rows, 10 new
MarkupIconTests cases, 2 new PluginSidePanelTests cases, 1 new
AppAutomationSurfaceTests case, and the 2 new standalone test files).

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

675 lines
26 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 AcDream.Plugin.Abstractions;
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);
}
[Fact]
public void ButtonIcon_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
{
// Review fix round finding 4: a malformed iconkind is an author
// error regardless of whether the host happens to have icon support
// turned on.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" " +
"icon=\"1\" iconkind=\"spel\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new object(), Sprite));
}
[Fact]
public void ButtonIcon_NoResolverWired_IconSourceStaysNull()
{
// Review fix round finding 7: IconSource must be set ONLY when a
// resolver actually exists — otherwise UiSimpleButton (which now
// reserves its icon column whenever IconSource is non-null) would
// permanently reserve a column that never draws anything on a host
// with no icon support wired.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"1\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.Null(button.IconSource);
}
private sealed class IntIconBinding
{
public int IconIdInt { get; set; } = 42;
}
[Fact]
public void ButtonIcon_BindsToAnIntProperty_NotOnlyUint()
{
// Review fix round finding 5: Decal-facing bindings are commonly
// `int` end to end (MosswartMassacre's HudPictureBox.Image), so
// requiring an exact `uint` property rejected every one of them at
// Build.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"{IconIdInt}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IntIconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
button.IconSource!();
Assert.Equal(("did", PluginIcons.Normalize(42u)), resolver.Calls[^1]);
}
// ── <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);
}
[Fact]
public void ListIcons_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" " +
"icons=\"{IconIds}\" iconkind=\"spel\" selected=\"{Selected}\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new ListIconBinding(), Sprite));
}
[Fact]
public void ListIcons_NoResolverWired_IconIdsSourceStaysNull()
{
// Review fix round finding 7: same rule as the button — IconIdsSource
// (and IconResolve) must be set ONLY when a resolver actually
// exists, since UiMarkupList already reserves its icon column
// whenever IconIdsSource is non-null.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" " +
"icons=\"{IconIds}\" selected=\"{Selected}\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new ListIconBinding(), Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Null(list.IconIdsSource);
Assert.Null(list.IconResolve);
}
private sealed class IntListIconBinding
{
public IReadOnlyList<string> Items { get; } = new[] { "First", "Second" };
public IEnumerable<int> IconIds { get; } = new[] { 7735, 0 };
public int Selected { get; set; } = -1;
}
[Fact]
public void ListIcons_BindsToAnIEnumerableOfInt_NotOnlyIEnumerableOfUint()
{
// Review fix round finding 5: Decal is int end to end.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" " +
"icons=\"{IconIds}\" iconkind=\"did\" selected=\"{Selected}\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new IntListIconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.IconIdsSource);
Assert.Equal(new uint[] { 7735u, 0u }, list.IconIdsSource!());
}
// ── <icon tooltip>: empty tooltip must not swallow clicks ─────────────────
[Fact]
public void Icon_EmptyTooltip_StaysClickThrough()
{
// Review fix round finding 9: an empty tooltip="" must not make the
// icon a hit-test target — match ApplyCommon's own
// !IsNullOrWhiteSpace predicate rather than a bare attribute-
// presence check.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"1\" tooltip=\"\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
Assert.True(icon.ClickThrough);
}
[Fact]
public void Icon_NonEmptyTooltip_BecomesARealHitTestTarget()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"1\" tooltip=\"real tooltip\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new object(), Sprite);
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
Assert.False(icon.ClickThrough);
}
// ── Unknown element names throw at Build (finding 11) ─────────────────────
[Fact]
public void UnknownElementName_ThrowsFormatExceptionAtBuild()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<butotn x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new object(), Sprite));
}
// ── 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);
}
[Fact]
public void UiMarkupList_WithIconColumn_TextStartsRightOfWithoutColumn_AndIconHasNonZeroWidth()
{
// Review fix round finding 8: a "with vs. without icons" comparison
// pin — the button-icon test above already proves this shape for
// UiSimpleButton; the list's own icon column (a SEPARATE code path
// in UiMarkupList.OnDraw) had no equivalent pin until now, so a
// regression that stopped shifting the text column (or drew a
// zero-width icon) would have passed every existing list test.
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 withoutIcons = new UiMarkupList
{
Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font,
ItemsSource = () => new[] { "G" },
BackgroundColor = default, BorderColor = default,
};
var withIcons = new UiMarkupList
{
Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font,
ItemsSource = () => new[] { "G" },
IconIdsSource = () => new uint[] { 9u },
IconResolve = id => (id, 16, 16),
BackgroundColor = default, BorderColor = default,
};
var (rendererWithout, ctxWithout) = MakeContext(200f, 200f);
withoutIcons.DrawSelfAndChildren(ctxWithout);
var (rendererWith, ctxWith) = MakeContext(200f, 200f);
withIcons.DrawSelfAndChildren(ctxWith);
// The icon sprite itself drew, with a real (non-zero) width.
var iconQuad = Assert.Single(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 9u);
float iconWidth = iconQuad.Verts[8] - iconQuad.Verts[0];
Assert.True(iconWidth > 0f, $"expected a non-zero icon quad width, got {iconWidth}");
// Both lists draw exactly one glyph quad (font texture 1u) for row 0's
// text; the icon-bearing list's text starts strictly to the right of
// the icon-less list's, because the reserved icon column shifted it.
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 list's text ({textWith.Verts[0]}) to start right of the icon-less list's ({textWithout.Verts[0]})");
}
}