acdream/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Erik b58668c6cd fix(vt): list column fix round 4/11 — try/finally around per-cell clip
Matches UiButton.cs:896-906 / UiElement.cs:683-720's own clip
discipline: every per-cell PushClip in DrawColumns now has its matching
PopClip in a finally, so a cell draw that throws (e.g. a plugin's icon
resolver misbehaving) doesn't leak that PushClip onto the context's
clip stack. Without this, the leaked entry combines badly with
UiElement.DrawSelfAndChildren's own outer clip pop on the way out
(it pops the wrong stack entry), permanently corrupting the shared
UiRenderContext's clip state for every draw that follows in the frame.

Added UiRenderContext.ClipStackDepth (internal, InternalsVisibleTo
AcDream.App.Tests) purely to make this provable from a test — the
number of PushClip calls not yet matched by PopClip.

New test: a column whose icon resolver throws mid-draw still leaves
the clip stack at its pre-draw depth after the exception propagates —
shown to fail first (leaked to depth 1 instead of 0, reproducing
exactly the "outer pop consumes the wrong stack entry" mechanism
described above) before the try/finally was added.

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

1046 lines
44 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>
/// Campaign VT slice 1 Part B (<c>docs/plans/2026-09-06-campaign-vt-slice1-files-and-columns.md</c>):
/// multi-column <c>&lt;list&gt;&lt;column&gt;</c> markup, VVS <c>HudList</c>
/// parity per <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3.
///
/// <para>
/// Covers: parse + per-column binding-type validation (good markup binds;
/// every documented throw case throws with the expected message), draw-level
/// pins against the same recording-renderer apparatus <c>MarkupIconTests</c>
/// uses (column x-offsets, the check glyph, the icon column, per-cell
/// clipping), hit-test routing (text selects, check/icon fire their own
/// per-row callback and do NOT change selection, past-the-end and scroll
/// offset), and a backward-compatibility proof that a column-less
/// <c>&lt;list&gt;</c> is unaffected.
/// </para>
/// </summary>
public sealed class MarkupListColumnsTests
{
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
// ── B1: parse + per-column binding-type validation ──────────────────────
private sealed class ThreeColumnBinding
{
public IReadOnlyList<string> Names { get; } = new[] { "Mosswart", "Drudge", "Rat" };
public IReadOnlyList<bool> Fester { get; } = new[] { true, false };
public IReadOnlyList<uint> Icons { get; } = new[] { 7735u, 0u, 42u };
public int Selected { get; set; } = -1;
public int FesterToggled { get; private set; } = -1;
public int IconClickedRow { get; private set; } = -1;
public Action<int> ToggleFester => row => FesterToggled = row;
public Action<int> ClickIcon => row => IconClickedRow = row;
}
private const string ThreeColumnXml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
" <column type=\"check\" width=\"20\" values=\"{Fester}\" onchange=\"{ToggleFester}\"/>" +
" <column type=\"icon\" width=\"20\" iconkind=\"did\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
"</list>" +
"</panel>";
private sealed class FakeIconResolver : IMarkupIconResolver
{
public readonly List<(string Method, uint Id)> Calls = new();
public (uint tex, int w, int h) ResolveDid(uint did)
{
Calls.Add(("did", did));
return did == 0u ? (0u, 0, 0) : (did, 16, 16);
}
public (uint tex, int w, int h) ResolveSpell(uint spellId)
{
Calls.Add(("spell", spellId));
return spellId == 0u ? (0u, 0, 0) : (spellId, 16, 16);
}
public (uint tex, int w, int h) ResolveItem(uint objectId)
{
Calls.Add(("item", objectId));
return objectId == 0u ? (0u, 0, 0) : (objectId, 16, 16);
}
}
[Fact]
public void Columns_TextCheckIcon_BindEachColumnsOwnPerRowSource()
{
var resolver = new FakeIconResolver();
var binding = new ThreeColumnBinding();
var panel = MarkupDocument.Build(ThreeColumnXml, binding, Sprite, icons: resolver);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.Columns);
Assert.Equal(3, list.Columns!.Count);
Assert.Equal(UiMarkupListColumnKind.Text, list.Columns[0].Kind);
Assert.Equal(UiMarkupListColumnKind.Check, list.Columns[1].Kind);
Assert.Equal(UiMarkupListColumnKind.Icon, list.Columns[2].Kind);
Assert.Equal(binding.Names, list.Columns[0].TextSource!());
Assert.Equal(binding.Fester, list.Columns[1].CheckSource!());
Assert.Equal(binding.Icons, list.Columns[2].IconValuesSource!());
// Row count is the longest bound column (Names has 3 rows; Fester
// only 2) — short columns simply have nothing to draw for row 2, they
// don't truncate the whole list.
Assert.Equal(3, list.Columns.Max(c => c.RowCount()));
// The legacy single-column fields stay at their untouched defaults —
// proves MarkupDocument never populates both surfaces at once.
Assert.Empty(list.ItemsSource());
Assert.Null(list.IconIdsSource);
list.Columns[1].CheckChanged!(1);
Assert.Equal(1, binding.FesterToggled);
list.Columns[2].IconClicked!(0);
Assert.Equal(0, binding.IconClickedRow);
}
[Fact]
public void ColumnLessList_ColumnsPropertyStaysNull_LegacyPathUntouched()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Names}\" selected=\"{Selected}\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Null(list.Columns);
Assert.Equal(binding.Names, list.ItemsSource());
}
[Fact]
public void Column_UnknownType_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"bogus\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("bogus", ex.Message);
}
[Fact]
public void TextColumn_MissingItems_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void CheckColumn_MissingValues_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"check\" width=\"20\" onchange=\"{ToggleFester}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void CheckColumn_MissingOnchange_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"check\" width=\"20\" values=\"{Fester}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void IconColumn_MissingValues_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"icon\" width=\"20\" onclick=\"{ClickIcon}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void IconColumn_MissingOnclick_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"icon\" width=\"20\" values=\"{Icons}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void IconColumn_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"icon\" width=\"20\" iconkind=\"spel\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void IconColumn_NoResolverWired_IconResolveStaysNull_ButValuesStillBind()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"icon\" width=\"20\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite); // no `icons:` resolver
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.Columns);
Assert.Null(list.Columns![0].IconResolve);
Assert.Equal(binding.Icons, list.Columns[0].IconValuesSource!());
}
[Fact]
public void Columns_CombinedWithLegacyItemsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Names}\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void Columns_CombinedWithLegacyColorsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" colors=\"{Icons}\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void Columns_CombinedWithLegacyIconsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" icons=\"{Icons}\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
[Fact]
public void NonColumnChildOfList_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <label x=\"0\" y=\"0\" text=\"stray\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("label", ex.Message);
}
[Fact]
public void TextColumn_OptionalColorsAttribute_BindsWhenPresent_NullWhenAbsent()
{
var binding = new ThreeColumnBinding();
const string withColors =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\" colors=\"{Icons}\"/>" +
"</list></panel>";
var panelWith = MarkupDocument.Build(withColors, binding, Sprite);
var listWith = Assert.IsType<UiMarkupList>(panelWith.Children[0]);
Assert.NotNull(listWith.Columns![0].ColorsSource);
Assert.Equal(binding.Icons, listWith.Columns[0].ColorsSource!());
const string withoutColors =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
var panelWithout = MarkupDocument.Build(withoutColors, binding, Sprite);
var listWithout = Assert.IsType<UiMarkupList>(panelWithout.Children[0]);
Assert.Null(listWithout.Columns![0].ColorsSource);
}
[Fact]
public void TextColumn_MalformedColorsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\" colors=\"notabinding\"/>" +
"</list></panel>";
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
// ── Fix round: text column optional onclick (fix item 1) ────────────────
[Fact]
public void TextColumn_OptionalOnclickAttribute_BindsAndDoesNotBreakWithoutIt()
{
var binding = new ThreeColumnBinding();
const string withOnclick =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\" onclick=\"{ClickIcon}\"/>" +
"</list></panel>";
var panelWith = MarkupDocument.Build(withOnclick, binding, Sprite);
var listWith = Assert.IsType<UiMarkupList>(panelWith.Children[0]);
Assert.NotNull(listWith.Columns![0].TextClicked);
const string withoutOnclick =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
"</list></panel>";
var panelWithout = MarkupDocument.Build(withoutOnclick, binding, Sprite);
var listWithout = Assert.IsType<UiMarkupList>(panelWithout.Children[0]);
Assert.Null(listWithout.Columns![0].TextClicked);
}
[Fact]
public void TextColumn_MalformedOnclickAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"80\" items=\"{Names}\" onclick=\"notabinding\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" onclick", ex.Message);
}
[Fact]
public void ClickInTextColumn_WithOnclick_FiresItInsteadOfSelecting()
{
var clicked = new List<int>();
var selections = new List<int>();
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
SelectionChanged = row => selections.Add(row),
Columns = new[]
{
UiMarkupListColumn.Text(
60f, () => new[] { "a", "b", "c" }, null, row => clicked.Add(row)),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 1 (y = 20..40).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
Assert.Equal(new[] { 1 }, clicked);
Assert.Empty(selections);
}
// ── Fix round: width semantics (fix item 2) ──────────────────────────────
[Fact]
public void NonLastColumn_MissingWidth_ThrowsAtBuild_NamingColumnIndexAndType()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" items=\"{Names}\"/>" +
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" width", ex.Message);
}
[Fact]
public void NonLastColumn_UnparseableWidth_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"wide\" items=\"{Names}\"/>" +
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" width", ex.Message);
}
[Fact]
public void NonLastColumn_NonPositiveWidth_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"check\" width=\"0\" values=\"{Fester}\" onchange=\"{ToggleFester}\"/>" +
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"check\" width", ex.Message);
}
[Fact]
public void NonLastColumn_WidthStar_DoesNotThrow_ParsesAsAutoWidth()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"*\" items=\"{Names}\"/>" +
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.Columns);
}
[Fact]
public void LastColumn_MissingOrInvalidWidth_DoesNotThrow_StillAbsorbsRemainder()
{
var binding = new ThreeColumnBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
" <column type=\"text\" items=\"{Names}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.NotNull(list.Columns);
}
[Fact]
public void AutoWidthColumns_ShareRemainingWidthEqually_LastAbsorbsRoundingSlack()
{
// 3 columns, list width 100: col0 fixed 10, col1 auto ("*"), col2
// (last) auto too. Remaining = 90, split between 2 auto columns:
// floor(90/2)=45 each with no slack to prove the interesting case,
// so use 101 instead — remaining=91, share=floor(91/2)=45, and the
// LAST auto column must absorb the odd pixel (46), not col1.
var list = new UiMarkupList
{
Width = 101f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Text(10f, () => new[] { "" }, null),
UiMarkupListColumn.Icon(
0f, () => new uint[] { 20u }, id => (id, 16, 16), _ => { }, isAutoWidth: true),
UiMarkupListColumn.Icon(
0f, () => new uint[] { 21u }, id => (id, 16, 16), _ => { }, isAutoWidth: true),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var col1Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 20u);
var col2Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 21u);
// col1 (non-last auto): cell [10,55) — a 16x16 icon centered in a
// 45px-wide cell must start at/after x=10 and stay inside the cell.
Assert.True(col1Quad.Verts[0] >= 10f - 0.01f && col1Quad.Verts[8] <= 55f + 0.01f,
$"expected col1's icon inside [10,55), got x=[{col1Quad.Verts[0]},{col1Quad.Verts[8]}]");
// col2 (last auto): cell [55,101) — the rounding slack (46px, not
// 45px) landed here, so its full 16px icon must start at/after x=55.
Assert.True(col2Quad.Verts[0] >= 55f - 0.01f,
$"expected col2's icon at/after x=55 (10 + 45), got x={col2Quad.Verts[0]}");
}
[Fact]
public void DeclaredWidthsExceedingListWidth_ClampTheOverflowingColumn_LaterColumnsGetZero()
{
// col0 declares 80px in a 50px-wide list — clamps to 50. col1 (a
// fixed, non-last, non-auto column) and col2 (last) both get 0: col1
// never draws (cellW<=0 skip), col2 (last, always-auto) absorbs
// whatever's left, which is also 0.
var list = new UiMarkupList
{
Width = 50f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Icon(
80f, () => new uint[] { 7u }, id => (id, 16, 16), _ => { }),
UiMarkupListColumn.Icon(
20f, () => new uint[] { 8u }, id => (id, 16, 16), _ => { }),
UiMarkupListColumn.Icon(
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 8u);
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
var clampedQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
Assert.True(clampedQuad.Verts[8] - clampedQuad.Verts[0] <= 50f + 0.01f,
"expected col0's icon clamped inside the 50px list width");
}
// ── Draw-level: column x-offsets, check glyph, icon, clipping ────────────
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);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
// ── Fix round: try/finally around every per-cell clip (fix item 4) ──────
[Fact]
public void PerCellDrawException_StillBalancesTheClipStack()
{
// col0's icon resolver throws mid-draw (a hostile/buggy plugin
// resolver) — the per-cell PushClip for that cell must still be
// popped before the exception propagates, matching UiButton.cs
// /UiElement.cs's own try/finally clip pattern. Without it, the one
// leaked PushClip (plus UiElement's own outer ambient-clip pop
// consuming the wrong stack entry on the way out) leaves the
// context's clip stack permanently off by one.
var list = new UiMarkupList
{
Width = 60f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
Columns = new[]
{
UiMarkupListColumn.Icon(
60f,
() => new uint[] { 99u },
_ => throw new InvalidOperationException("boom"),
_ => { }),
},
};
var (_, ctx) = MakeContext(200f, 200f);
int depthBefore = ctx.ClipStackDepth;
Assert.Throws<InvalidOperationException>(() => list.DrawSelfAndChildren(ctx));
Assert.Equal(depthBefore, ctx.ClipStackDepth);
}
[Fact]
public void Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds()
{
// col0 = check, width 20; col1 (last) = icon, absorbs the remainder
// of a 100-wide list (80px). Both columns render row 0; the check
// glyph's inner-lamp fill (a distinct, deliberately non-default color)
// and the icon sprite must each stay within their own [cellX, cellX+w)
// window — proving the column offsets actually took effect, not just
// that both draw somewhere.
var list = new UiMarkupList
{
Width = 100f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
UiMarkupListColumn.Icon(
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// The check glyph's inner-lamp fill is a distinctive color (not the
// default black/zero used by every other fill in this test) —
// isolate its vertices by color match. One quad emits 6 vertices
// sharing the same color, so take the first rather than requiring
// exactly one match.
var checkVertex = renderer.DebugSpriteSegmentVerts
.SelectMany(s => Chunk(s.Verts))
.First(v => ColorMatches(v, UiMarkupToggle.CheckedInner));
Assert.True(checkVertex[0] >= 0f && checkVertex[0] < 20f,
$"expected the check glyph inside [0,20), got x={checkVertex[0]}");
// The icon column drew a real (non-zero-width) sprite quad on its
// own resolved texture (9u), entirely at or past x=20 (col1's start).
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
Assert.True(iconQuad.Verts[0] >= 20f - 0.01f,
$"expected the icon column's sprite at or past x=20, got x={iconQuad.Verts[0]}");
float iconWidth = iconQuad.Verts[8] - iconQuad.Verts[0];
Assert.True(iconWidth > 0f, $"expected a non-zero icon quad width, got {iconWidth}");
}
[Fact]
public void CheckColumn_DrawsCheckedAndUncheckedGlyphsPerRow()
{
var list = new UiMarkupList
{
Width = 60f, Height = 60f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Check(60f, () => new[] { true, false }, _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var quads = renderer.DebugSpriteSegmentVerts.SelectMany(s => Chunk(s.Verts)).ToList();
Assert.Contains(quads, v => ColorMatches(v, UiMarkupToggle.CheckedInner));
Assert.Contains(quads, v => ColorMatches(v, UiMarkupToggle.UncheckedInner));
// Row 0's checked glyph sits above row 1's unchecked glyph.
var checkedY = quads.First(v => ColorMatches(v, UiMarkupToggle.CheckedInner))[1];
var uncheckedY = quads.First(v => ColorMatches(v, UiMarkupToggle.UncheckedInner))[1];
Assert.True(uncheckedY > checkedY,
$"expected row 1's glyph ({uncheckedY}) below row 0's ({checkedY})");
}
[Fact]
public void IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
{
var list = new UiMarkupList
{
Width = 60f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Icon(
60f, () => new uint[] { 9u, 0u }, id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
_ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
// Row 1's id (0u) resolves to nothing — there is exactly one drawn
// icon quad total (6 vertices; AppendQuad's two-triangle layout).
int iconVertexCount = renderer.DebugSpriteSegmentVerts
.Where(s => s.Texture == 9u)
.Sum(s => s.Verts.Count / 8);
Assert.Equal(6, iconVertexCount);
}
[Fact]
public void LastColumn_AbsorbsRemainingWidth_RegardlessOfItsOwnDeclaredWidth()
{
// col0 (text) declares 30px; col1 (icon, LAST) declares only 5px, but
// being last it must absorb the true remainder of a 100px-wide list
// (70px), not its own tiny declared value — a 16x16 icon fit into a
// genuine 70px-wide cell scales up to its full 16px size, whereas a
// 5px cell would force it down to ~3px (extentW=3, scale=3/16).
var list = new UiMarkupList
{
Width = 100f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Text(30f, () => new[] { "" }, null),
UiMarkupListColumn.Icon(5f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
float drawnWidth = iconQuad.Verts[8] - iconQuad.Verts[0];
Assert.True(drawnWidth > 10f,
$"expected the icon to render near its full 16px size in the ~70px remainder "
+ $"cell (not squeezed into its declared 5px), got width {drawnWidth}");
// Also at/after col0's 30px boundary — never inside col0's own cell.
Assert.True(iconQuad.Verts[0] >= 30f - 0.01f);
}
[Fact]
public void TextColumn_OverLongText_ClipsToItsOwnCellWidth()
{
var glyphs = new Dictionary<char, FontCharDesc>
{
['W'] = new FontCharDesc { Unicode = 'W', 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);
// A narrow (10px) first column with a long run of wide glyphs must
// never draw past x=10 — the SECOND column starts immediately after
// and must never see any of the first column's glyph geometry.
var list = new UiMarkupList
{
Width = 100f, Height = 40f, RowHeight = 18f, DatFont = font,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Text(10f, () => new[] { "WWWWWWWWWW" }, null),
UiMarkupListColumn.Text(90f, () => new[] { "" }, null),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var glyphVertices = renderer.DebugSpriteSegmentVerts
.Where(s => s.Texture == 1u)
.SelectMany(s => Chunk(s.Verts))
.ToList();
Assert.NotEmpty(glyphVertices);
foreach (var vertex in glyphVertices)
{
float x = vertex[0];
Assert.True(x <= 10f + 0.01f,
$"expected every glyph vertex clipped inside the 10px column, got x={x}");
}
}
/// <summary>Split a flat 8-floats/vertex, 6-vertices/quad buffer into per-vertex arrays.</summary>
private static IEnumerable<float[]> Chunk(IReadOnlyList<float> verts)
{
for (int i = 0; i + 8 <= verts.Count; i += 8)
yield return verts.Skip(i).Take(8).ToArray();
}
private static bool ColorMatches(float[] vertex, Vector4 color) =>
MathF.Abs(vertex[4] - color.X) < 0.001f
&& MathF.Abs(vertex[5] - color.Y) < 0.001f
&& MathF.Abs(vertex[6] - color.Z) < 0.001f
&& MathF.Abs(vertex[7] - color.W) < 0.001f;
// ── Hit-test: text selects, check/icon fire their own callback ───────────
private static UiMarkupList MakeHitTestList(
out List<int> selections, out List<int> checkFires, out List<int> iconFires,
int rowCount = 3, float width = 60f)
{
var sel = new List<int>();
var chk = new List<int>();
var icn = new List<int>();
selections = sel; checkFires = chk; iconFires = icn;
var textRows = Enumerable.Range(0, rowCount).Select(i => $"row{i}").ToArray();
var checkRows = Enumerable.Range(0, rowCount).Select(i => i % 2 == 0).ToArray();
var iconRows = Enumerable.Range(0, rowCount).Select(i => (uint)(i + 1)).ToArray();
return new UiMarkupList
{
Width = width, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
SelectionChanged = row => sel.Add(row),
Columns = new[]
{
UiMarkupListColumn.Text(20f, () => textRows, null),
UiMarkupListColumn.Check(20f, () => checkRows, row => chk.Add(row)),
UiMarkupListColumn.Icon(20f, () => iconRows, id => (id, 16, 16), row => icn.Add(row)),
},
};
}
[Fact]
public void ClickInTextColumn_SelectsAndDoesNotFireCheckOrIcon()
{
var list = MakeHitTestList(out var sel, out var chk, out var icn);
// Row 1 (y = 20..40), x = 10 (inside the text column [0,20)).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
Assert.Equal(new[] { 1 }, sel);
Assert.Empty(chk);
Assert.Empty(icn);
}
[Fact]
public void ClickInCheckColumn_FiresCheckCallbackWithRowIndex_AndDoesNotSelect()
{
var list = MakeHitTestList(out var sel, out var chk, out var icn);
// Row 2 (y = 40..60), x = 30 (inside the check column [20,40)).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
Assert.Equal(new[] { 2 }, chk);
Assert.Empty(sel);
Assert.Empty(icn);
}
[Fact]
public void ClickInIconColumn_FiresIconCallbackWithRowIndex_AndDoesNotSelect()
{
var list = MakeHitTestList(out var sel, out var chk, out var icn);
// Row 0 (y = 0..20), x = 50 (inside the icon column [40,60)).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 });
Assert.Equal(new[] { 0 }, icn);
Assert.Empty(sel);
Assert.Empty(chk);
}
[Fact]
public void ClickPastTheLastRow_DoesNothing_ButStillSwallowsThePress()
{
var list = MakeHitTestList(out var sel, out var chk, out var icn, rowCount: 2);
// Only 2 rows (0..40); click at y=100 lands well past the data.
bool handled = list.OnEvent(
new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 100 });
Assert.True(handled);
Assert.Empty(sel);
Assert.Empty(chk);
Assert.Empty(icn);
}
// ── Fix round: per-column row-bound guard on callbacks (fix item 3) ─────
[Fact]
public void ClickInShortCheckColumn_PastItsOwnRowCount_FiresNothing()
{
// col0 = text, 3 rows (drives the overall row count). col1 (last) =
// check, only 1 row of its own. A click on row 2 (index 2) lands
// within the OVERALL row count (0..3) and inside col1's cell, but
// col1's own CheckSource only covers row 0 — the click must fire
// nothing, not throw and not fall back to some default value.
var fired = new List<int>();
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
Columns = new[]
{
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
UiMarkupListColumn.Check(20f, () => new[] { true }, row => fired.Add(row)),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 2 (y = 40..60), x = 30 (inside col1's [20,60) cell).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
Assert.Empty(fired);
}
[Fact]
public void ClickInShortIconColumn_PastItsOwnRowCount_FiresNothing()
{
var fired = new List<int>();
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
Columns = new[]
{
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
UiMarkupListColumn.Icon(
20f, () => new uint[] { 9u }, id => (id, 16, 16), row => fired.Add(row)),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
Assert.Empty(fired);
}
[Fact]
public void ClickInShortTextColumn_WithOnclick_PastItsOwnRowCount_FiresNothing()
{
var fired = new List<int>();
var selections = new List<int>();
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
SelectionChanged = row => selections.Add(row),
Columns = new[]
{
UiMarkupListColumn.Text(
20f, () => new[] { "only-row-0" }, null, row => fired.Add(row)),
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 2, x=10 (inside col0's onclick-bearing cell) — col0 only has
// row 0, so nothing fires; the click must NOT fall back to
// SelectionChanged either (that's a different column's affordance).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 45 });
Assert.Empty(fired);
Assert.Empty(selections);
}
[Fact]
public void Scroll_OffsetIsRespectedBySubsequentHitTests()
{
var list = MakeHitTestList(out var sel, out _, out _, rowCount: 10, width: 60f);
list.Height = 40f; // 2 visible rows of a 10-row list — forces real scrolling
// Force a draw so the widget has computed VisibleRows/topRow state at
// least once (mirrors normal frame order: draw, then handle input).
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Scroll down 3 rows (negative wheel data = scroll down, matching the
// legacy single-column list's own convention: _topRow -= Sign(Data0)).
for (int i = 0; i < 3; i++)
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
list.DrawSelfAndChildren(ctx); // clamp/re-settle topRow like a real frame would
// Click the FIRST visible row after scrolling 3 rows down — should
// resolve to absolute row 3, not row 0.
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 5 });
Assert.Equal(new[] { 3 }, sel);
}
// ── Backward compatibility: column-less list is unaffected ──────────────
private sealed class LegacyBinding
{
public IReadOnlyList<string> Choices => new[] { "First", "Second" };
public IReadOnlyList<uint> ChoiceColors => new[] { 0xFF0000u, 0x00FF00u };
public int SelectedIndex { get; set; } = 1;
public Action<int> SelectIndex => _ => { };
}
/// <summary>
/// Regression pin for "without &lt;column&gt; children the element is
/// byte-for-byte the old single-text-column list": builds the SAME
/// column-less markup through <see cref="MarkupDocument"/> (which now
/// routes through the new if/else split in the "list" case) and compares
/// its draw output against a <see cref="UiMarkupList"/> constructed BY
/// HAND with equivalent fields, bypassing <see cref="MarkupDocument"/>
/// entirely — i.e. against the widget's own unmodified
/// <c>OnDraw</c>/legacy branch, not a snapshot captured from a different
/// commit. Every vertex float must match exactly.
/// </summary>
[Fact]
public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget()
{
var glyphs = new Dictionary<char, FontCharDesc>
{
['F'] = new FontCharDesc { Unicode = 'F', 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 binding = new LegacyBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
"items=\"{Choices}\" colors=\"{ChoiceColors}\" " +
"selected=\"{SelectedIndex}\" onchange=\"{SelectIndex}\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var viaMarkup = Assert.IsType<UiMarkupList>(panel.Children[0]);
viaMarkup.DatFont = font; // markup panels always get a host font; irrelevant to the pin itself
viaMarkup.BackgroundColor = default;
viaMarkup.BorderColor = default;
var handBuilt = new UiMarkupList
{
Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font,
ItemsSource = () => binding.Choices,
ItemColorsSource = () => binding.ChoiceColors,
SelectedIndexSource = () => binding.SelectedIndex,
BackgroundColor = default,
BorderColor = default,
};
var (rendererMarkup, ctxMarkup) = MakeContext(200f, 200f);
viaMarkup.DrawSelfAndChildren(ctxMarkup);
var (rendererHand, ctxHand) = MakeContext(200f, 200f);
handBuilt.DrawSelfAndChildren(ctxHand);
var markupVerts = rendererMarkup.DebugSpriteSegmentVerts.ToList();
var handVerts = rendererHand.DebugSpriteSegmentVerts.ToList();
Assert.Equal(handVerts.Count, markupVerts.Count);
for (int i = 0; i < handVerts.Count; i++)
{
Assert.Equal(handVerts[i].Texture, markupVerts[i].Texture);
Assert.Equal(handVerts[i].Verts.Count, markupVerts[i].Verts.Count);
for (int j = 0; j < handVerts[i].Verts.Count; j++)
Assert.Equal(handVerts[i].Verts[j], markupVerts[i].Verts[j], 4);
}
Assert.Null(viaMarkup.Columns);
}
}