acdream/tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Erik ce05c4fb03 fix(plugin-ui): Slice B residuals - shelf icon sink without magenta, validated icon bindings, negative ids, memoized DID resolves
Bounded residual round on the Slice B review fix commit (466272ec5),
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md.

N1 (BLOCKING): PluginSidePanel's shelf entries resolved icons through
_bindings.Assets.ResolveSprite (= ResolveChrome =
TextureCache.GetOrUploadRenderSurface), which returns a non-zero 1x1
magenta placeholder for a missing id - so PluginShelfButton's initials
fallback could never fire in production. RetailUiRuntime.MountPlugins
now passes the same iconResolver.ResolveDid every markup icon sink
already uses, which returns (0,0,0) for an unresolvable id. Re-pointed
the existing initials-fallback unit test at a resolver matching
ResolveDid's real contract, and added an InstalledDat-lane test
(ShelfButton_BogusDescriptorId_OnTheRealResolver_FallsBackToInitials)
proving a bogus descriptor id on the REAL resolver yields initials.

N2: MarkupDocument's <button icon>/<list icons> handling only built the
uint reader (BindUintLiteralOrBinding/BindUintList) when an
IMarkupIconResolver was wired, so a malformed icon="{Typo}" or
icons="notabinding" silently loaded instead of throwing at Build on a
resolver-less host. Both readers now build unconditionally (same rule
ValidateIconKind already followed); only the IconSource/IconIdsSource
assignment stays gated on icons is not null.

N3: a negative bound icon id threw OverflowException out of
Convert.ToUInt32 every frame from inside UiSimpleButton.OnDraw (scalar
path), while the list's IEnumerable<int> path silently wrapped -1 to
0xFFFFFFFF (unchecked reinterpret). Both paths now map any
out-of-range value (negative, or above uint.MaxValue) to 0u instead -
Decal's own "no icon" convention - via a shared ToUintOrZero helper
that catches exactly the OverflowException Convert.ToUInt32 already
throws for both cases.

N4 (perf): RetailMarkupIconResolver.ResolveDid probed Portal/HighRes
(two cache misses + two B-tree lookups under the database lock) on
EVERY call for an unresolvable id, and re-entered the DAT lock on
every resolve of a hit too. Memoizes the resolved (tex,w,h) tuple per
DID, including the (0,0,0) miss, in a plain Dictionary.

N5 (nit): documented in TextureCache.GetOrUploadRenderSurface that the
(id, nearest) cache key uploads the same RenderSurface twice when both
samplers are wanted (chrome via ResolveChrome, plugin icons via
ResolveDid's nearest:true) - GetOrCreateLinearUiTwin exists but only
shares in the nearest-registered-first direction, so wiring it through
here is left as a documented nit rather than a behavior change.

N6 (nit): corrected stale SampleData.cs:64 citations to :69 (Melee
Defense's real line after the file grew) across SmokeIconPanel.cs,
PluginSidePanelTests.cs, RetailMarkupIconResolverInstalledDatTests.cs,
and docs/plugin-ui-markup.md (including the 64-82 range, now 69-83).

N7 (nit): <icon iconkind="..."> was silently ignored (icon derives its
kind from which of did/spell/item is set, unlike button/list). Now
throws FormatException at Build with a message naming the correct
surfaces; documented in plugin-ui-markup.md.

N8 (nit): the truth-table's `list colors` row now says IEnumerable<uint>
or IEnumerable<int> (shared BindUintList), matching `list icons`.

N9 (ship check): added a "Before shipment" line to the plan's Review
ledger. Confirmed the Smoke plugin (including its auto-open Icon Smoke
panel) IS included in the launcher's client-<rid>.zip release payload:
tools/publish-bin.ps1's New-PayloadZip zips App's entire publish
directory unfiltered, and AcDream.App.csproj's
CopySmokePluginToPublishOutput target runs unconditionally
AfterTargets="Publish". No behavior changed per instruction - flagged
for follow-up after the owner's connected gate.

Verification: dotnet build AcDream.slnx -c Release green; filtered
test command 108/108 passed (0 skipped) including the InstalledDat
lane; full AcDream.App.Tests suite 7362 passed / 97 skipped / 36
failed (all 36 pre-existing, same names, none touching
Markup/PluginSidePanel/RetailMarkupIconResolver/TextureCache/PluginIcons);
AcDream.Plugins.MossTank.Tests 337/337 passed.

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

786 lines
30 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_IconKindAttribute_ThrowsAtBuild()
{
// Residual round finding N7: unlike <button>/<list>, <icon> derives
// its kind from WHICH of did/spell/item is set — iconkind was
// previously silently ignored here (a plugin author's
// iconkind="spell" typo on an <icon did="..."> would never do what
// it looked like it did). Now it throws.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<icon x=\"0\" y=\"0\" did=\"1\" iconkind=\"did\"/>" +
"</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_MalformedBinding_ThrowsAtBuild_EvenWithNoResolverWired()
{
// Residual round finding N2: BindUintLiteralOrBinding used to run
// ONLY when icons was non-null, so icon="{Typo}" (no such property
// on the binding object) silently loaded on a resolver-less host
// instead of throwing FormatException at Build the way every other
// malformed markup attribute (including iconkind just above) does.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"{Typo}\"/>" +
"</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]);
}
private sealed class NegativeIntIconBinding
{
public int IconIdInt { get; set; } = -1;
}
[Fact]
public void ButtonIcon_NegativeIntProperty_MapsToZero_NotOverflowException()
{
// Residual round finding N3: Convert.ToUInt32(-1) used to throw
// OverflowException straight out of BindUintLiteralOrBinding's
// reader — every frame, from inside UiSimpleButton.OnDraw, since
// IconSource is invoked at draw time. DECIDED: a negative bound
// value (Decal's own "no icon" convention, e.g.
// HudPictureBox.Image = -1) maps to 0u instead of throwing.
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 NegativeIntIconBinding();
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
(uint tex, _, _) = button.IconSource!();
Assert.Equal(0u, tex);
Assert.Equal(("did", 0u), 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);
}
[Fact]
public void ListIcons_MalformedBinding_ThrowsAtBuild_EvenWithNoResolverWired()
{
// Residual round finding N2: BindUintList used to run ONLY when
// icons was non-null, so icons="notabinding" (list icons has no
// literal grammar at all — every non-empty value must be a
// {Binding}) silently loaded on a resolver-less host instead of
// throwing FormatException at Build.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" " +
"icons=\"notabinding\" selected=\"{Selected}\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new ListIconBinding(), Sprite));
}
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;
}
private sealed class NegativeIntListIconBinding
{
public IReadOnlyList<string> Items { get; } = new[] { "First" };
public IEnumerable<int> IconIds { get; } = new[] { -1 };
public int Selected { get; set; } = -1;
}
[Fact]
public void ListIcons_NegativeIntElement_MapsToZero_NotWraparound()
{
// Residual round finding N3: the per-element conversion used to be
// an unchecked reinterpret — -1 silently wrapped to 0xFFFFFFFF, a
// bogus id that could coincidentally resolve to something, rather
// than the "no icon" 0u the scalar (button/icon) path already used
// for the same negative value. DECIDED: both paths now agree.
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 NegativeIntListIconBinding();
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[] { 0u }, list.IconIdsSource!());
}
[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]})");
}
}