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;
///
/// Campaign VT slice 1 Part B (docs/plans/2026-09-06-campaign-vt-slice1-files-and-columns.md):
/// multi-column <list><column> markup, VVS HudList
/// parity per docs/research/vtank-kb/08-ui-views.md §2-3.
///
///
/// 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 MarkupIconTests
/// 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
/// <list> is unaffected.
///
///
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 Names { get; } = new[] { "Mosswart", "Drudge", "Rat" };
public IReadOnlyList Fester { get; } = new[] { true, false };
public IReadOnlyList 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 ToggleFester => row => FesterToggled = row;
public Action ClickIcon => row => IconClickedRow = row;
}
private const string ThreeColumnXml =
"" +
"" +
" " +
" " +
" " +
"
" +
"";
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(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 =
"" +
"" +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType(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 =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(
() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("bogus", ex.Message);
Assert.Contains("column[0]", ex.Message);
}
[Fact]
public void TextColumn_MissingItems_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" items", ex.Message);
}
[Fact]
public void CheckColumn_MissingValues_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"check\" values", ex.Message);
}
[Fact]
public void CheckColumn_MissingOnchange_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"check\" onchange", ex.Message);
}
[Fact]
public void IconColumn_MissingValues_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"icon\" values", ex.Message);
}
[Fact]
public void IconColumn_MissingOnclick_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"icon\" onclick", ex.Message);
}
[Fact]
public void IconColumn_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"icon\" iconkind", ex.Message);
}
[Fact]
public void IconColumn_NoResolverWired_IconResolveStaysNull_ButValuesStillBind()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite); // no `icons:` resolver
var list = Assert.IsType(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 =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("items/icons/colors", ex.Message);
}
[Fact]
public void Columns_CombinedWithLegacyColorsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("items/icons/colors", ex.Message);
}
[Fact]
public void Columns_CombinedWithLegacyIconsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("items/icons/colors", ex.Message);
}
[Fact]
public void NonColumnChildOfList_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
"
";
var ex = Assert.Throws(
() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("label", ex.Message);
}
[Fact]
public void TextColumn_OptionalColorsAttribute_BindsWhenPresent_NullWhenAbsent()
{
var binding = new ThreeColumnBinding();
const string withColors =
"" +
"" +
" " +
"
";
var panelWith = MarkupDocument.Build(withColors, binding, Sprite);
var listWith = Assert.IsType(panelWith.Children[0]);
Assert.NotNull(listWith.Columns![0].ColorsSource);
Assert.Equal(binding.Icons, listWith.Columns[0].ColorsSource!());
const string withoutColors =
"" +
"" +
" " +
"
";
var panelWithout = MarkupDocument.Build(withoutColors, binding, Sprite);
var listWithout = Assert.IsType(panelWithout.Children[0]);
Assert.Null(listWithout.Columns![0].ColorsSource);
}
[Fact]
public void TextColumn_MalformedColorsAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" colors", ex.Message);
}
// ── Fix round: text column optional onclick (fix item 1) ────────────────
[Fact]
public void TextColumn_OptionalOnclickAttribute_BindsAndDoesNotBreakWithoutIt()
{
var binding = new ThreeColumnBinding();
const string withOnclick =
"" +
"" +
" " +
"
";
var panelWith = MarkupDocument.Build(withOnclick, binding, Sprite);
var listWith = Assert.IsType(panelWith.Children[0]);
Assert.NotNull(listWith.Columns![0].TextClicked);
const string withoutOnclick =
"" +
"" +
" " +
"
";
var panelWithout = MarkupDocument.Build(withoutOnclick, binding, Sprite);
var listWithout = Assert.IsType(panelWithout.Children[0]);
Assert.Null(listWithout.Columns![0].TextClicked);
}
[Fact]
public void TextColumn_MalformedOnclickAttribute_ThrowsAtBuild()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
"
";
var ex = Assert.Throws(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" onclick", ex.Message);
}
[Fact]
public void ClickInTextColumn_WithOnclick_FiresItInsteadOfSelecting()
{
var clicked = new List();
var selections = new List();
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 =
"" +
"" +
" " +
" " +
"
";
var ex = Assert.Throws(() => 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 =
"" +
"" +
" " +
" " +
"
";
var ex = Assert.Throws(() => 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 =
"" +
"" +
" " +
" " +
"
";
var ex = Assert.Throws(() => 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 =
"" +
"" +
" " +
" " +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType(panel.Children[0]);
Assert.NotNull(list.Columns);
}
[Fact]
public void LastColumn_MissingOrInvalidWidth_DoesNotThrow_StillAbsorbsRemainder()
{
var binding = new ThreeColumnBinding();
const string xml =
"" +
"" +
" " +
" " +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType(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(() => list.DrawSelfAndChildren(ctx));
Assert.Equal(depthBefore, ctx.ClipStackDepth);
}
[Fact]
public void Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds()
{
// col0 = icon, width 20; col1 (last) = check, absorbs the remainder
// of a 100-wide list (80px). Fix round item 8: the check column is
// now SECOND (not first, as the original draft had it) — a check
// glyph in col0's [0,20) position would satisfy a ">=0 and <20"
// assertion trivially even if the column-offset math were completely
// broken (e.g. everything drawing at x=0); putting it second makes
// the assertion a REAL pin — it can only pass if the glyph actually
// moved to col1's [20,100) cell.
var list = new UiMarkupList
{
Width = 100f, Height = 40f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Icon(
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
},
};
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, UiCheckLamp.CheckedInner));
Assert.True(checkVertex[0] >= 20f - 0.01f && checkVertex[0] < 100f,
$"expected the check glyph inside [20,100), got x={checkVertex[0]}");
// The icon column drew a real (non-zero-width) sprite quad on its
// own resolved texture (9u), entirely inside col0's [0,20) cell.
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
Assert.True(iconQuad.Verts[0] < 20f,
$"expected the icon column's sprite inside [0,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, UiCheckLamp.CheckedInner));
Assert.Contains(quads, v => ColorMatches(v, UiCheckLamp.UncheckedInner));
// Row 0's checked glyph sits above row 1's unchecked glyph.
var checkedY = quads.First(v => ColorMatches(v, UiCheckLamp.CheckedInner))[1];
var uncheckedY = quads.First(v => ColorMatches(v, UiCheckLamp.UncheckedInner))[1];
Assert.True(uncheckedY > checkedY,
$"expected row 1's glyph ({uncheckedY}) below row 0's ({checkedY})");
}
// ── Fix round: short check columns draw the unchecked lamp past their
// own row count (fix item 7) ────────────────────────────────────────────
[Fact]
public void CheckColumn_DrawsUncheckedLamp_ForRowsPastItsOwnRowCount()
{
// col0 (text) has 3 rows, driving the overall row count. col1 (last,
// check) only binds 1 row of its own — VVS materializes every cell
// regardless (docs/plugin-ui-markup.md already documented this), so
// rows 1 and 2 must STILL draw an unchecked lamp, not nothing.
var list = new UiMarkupList
{
Width = 60f, Height = 60f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var uncheckedQuads = renderer.DebugSpriteSegmentVerts
.SelectMany(s => Chunk(s.Verts))
.Where(v => ColorMatches(v, UiCheckLamp.UncheckedInner))
.ToList();
// One unchecked lamp for row 1 (bool value false — not this test's
// case) never applies here since col1's only row (0) is TRUE; rows 1
// and 2 have no bound value at all and must still draw UNCHECKED.
Assert.True(uncheckedQuads.Count >= 2,
$"expected an unchecked lamp for both row 1 and row 2 (past col1's own "
+ $"1-row data), got {uncheckedQuads.Count} unchecked lamp quad(s)");
}
// ── Fix round: shared UiCheckLamp, centered check glyph (fix item 8) ────
[Fact]
public void CheckColumn_GlyphIsHorizontallyCenteredInItsCell_LikeTheIconCellAlreadyIs()
{
// A single (last) check column absorbing a 50px-wide list: the old
// flush-left placement put the lamp's inner fill at x=4 regardless
// of cell width; centered (like DrawIconCell already centers its
// sprite) it must sit near the cell's midpoint instead.
var list = new UiMarkupList
{
Width = 50f, Height = 20f, RowHeight = 18f,
SelectedIndexSource = () => -1,
BackgroundColor = default, BorderColor = default,
Columns = new[]
{
UiMarkupListColumn.Check(50f, () => new[] { true }, _ => { }),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
var innerVertex = renderer.DebugSpriteSegmentVerts
.SelectMany(s => Chunk(s.Verts))
.First(v => ColorMatches(v, UiCheckLamp.CheckedInner));
// Flush-left would put this at x=4; centered in a 50px cell (extent
// 48, lamp size 11) it lands at 1 + (48-11)/2 + 3 = 22.5.
Assert.True(innerVertex[0] > 15f,
$"expected the check glyph centered in its 50px cell (~22.5), got x={innerVertex[0]}");
}
[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
{
['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}");
}
}
/// Split a flat 8-floats/vertex, 6-vertices/quad buffer into per-vertex arrays.
private static IEnumerable Chunk(IReadOnlyList 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 selections, out List checkFires, out List iconFires,
int rowCount = 3, float width = 60f)
{
var sel = new List();
var chk = new List();
var icn = new List();
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);
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx); // frame order: draw, then handle input
// 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);
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// 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);
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// 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);
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// 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();
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();
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();
var selections = new List();
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);
}
// ── Fix round: kill per-frame/per-event allocations (fix item 6) ────────
[Fact]
public void ClickEvent_DoesNotReinvokeColumnSourceFunctions_ReusesDrawSideMaterialization()
{
// Each column's source Func increments its own counter every time
// it's invoked. One Draw call must invoke each exactly once
// (materializing the frame's rows); any number of SUBSEQUENT click
// events must invoke NONE of them again — the click path reuses the
// draw-side materialized rows/row-count/layout rather than
// re-invoking every source and re-laying-out on every event.
int textCalls = 0, checkCalls = 0, iconCalls = 0;
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
Columns = new[]
{
UiMarkupListColumn.Text(20f, () => { textCalls++; return new[] { "a", "b", "c" }; }, null),
UiMarkupListColumn.Check(
20f,
() => { checkCalls++; return new[] { true, false, true }; },
_ => { }),
UiMarkupListColumn.Icon(
20f,
() => { iconCalls++; return new uint[] { 1u, 2u, 3u }; },
id => (id, 16, 16),
_ => { }),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Equal(1, textCalls);
Assert.Equal(1, checkCalls);
Assert.Equal(1, iconCalls);
for (int i = 0; i < 5; i++)
{
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 });
}
Assert.Equal(1, textCalls);
Assert.Equal(1, checkCalls);
Assert.Equal(1, iconCalls);
}
[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 Choices => new[] { "First", "Second" };
public IReadOnlyList ChoiceColors => new[] { 0xFF0000u, 0x00FF00u };
public int SelectedIndex { get; set; } = 1;
public Action SelectIndex => _ => { };
}
///
/// Regression pin for "without <column> children the element is
/// byte-for-byte the old single-text-column list": builds the SAME
/// column-less markup through (which now
/// routes through the new if/else split in the "list" case) and compares
/// its draw output against a constructed BY
/// HAND with equivalent fields, bypassing
/// entirely — i.e. against the widget's own unmodified
/// OnDraw/legacy branch, not a snapshot captured from a different
/// commit. Every vertex float must match exactly.
///
[Fact]
public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget()
{
var glyphs = new Dictionary
{
['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 =
"" +
"" +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var viaMarkup = Assert.IsType(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);
}
// ── Fix round: end-to-end MarkupDocument builds (fix item 10) ───────────
private static UiDatFont MakeAsciiFont()
{
var glyphs = new Dictionary();
foreach (char c in "ABCDEFGH")
glyphs[c] = new FontCharDesc { Unicode = c, Width = 6, Height = 8 };
return new UiDatFont(
fgTex: 1u, fgW: 32, fgH: 32,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs);
}
private sealed class MetaShapedBinding
{
public IReadOnlyList Delete { get; } = new uint[] { 101u, 102u };
public IReadOnlyList MoveUp { get; } = new uint[] { 201u, 202u };
public IReadOnlyList MoveDown { get; } = new uint[] { 301u, 302u };
public IReadOnlyList State { get; } = new[] { "A", "B" };
public IReadOnlyList Condition { get; } = new[] { "C", "D" };
public IReadOnlyList Action { get; } = new[] { "E", "F" };
public int Selected { get; set; } = -1;
public List Selections { get; } = new();
public Action SelectRow => row => Selections.Add(row);
public List DeleteClicks { get; } = new();
public Action ClickDelete => row => DeleteClicks.Add(row);
public List MoveUpClicks { get; } = new();
public Action ClickMoveUp => row => MoveUpClicks.Add(row);
public List MoveDownClicks { get; } = new();
public Action ClickMoveDown => row => MoveDownClicks.Add(row);
public List ActionClicks { get; } = new();
public Action ClickAction => row => ActionClicks.Add(row);
}
///
/// VTank's Meta tab (refs/vtank/uTank2.ViewXML.mainView.xml's
/// lstMetaRules, ~line 292): 3 icon columns (16px each), a 150px
/// text column, then TWO trailing 0-width (auto) text columns — the real
/// VVS shape our width="*" convention exists to express. The list
/// width (703) is chosen so the two auto columns' shares DON'T divide
/// evenly (505/2 = 252 remainder 1), proving the last one absorbs the
/// rounding slack rather than both getting an identical share.
///
[Fact]
public void EndToEnd_MetaShapedSixColumnList_AutoColumnsShareRemainder_EachKindRoutesCorrectly()
{
var resolver = new FakeIconResolver();
var binding = new MetaShapedBinding();
const string xml =
"" +
"" +
" " +
" " +
" " +
" " +
" " +
" " +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
var list = Assert.IsType(panel.Children[0]);
Assert.Equal(6, list.Columns!.Count);
Assert.Equal(
new[]
{
UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon,
UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text,
},
list.Columns.Select(c => c.Kind));
var (renderer, ctx) = MakeContext(800f, 200f);
list.DrawSelfAndChildren(ctx);
// Column ranges: [0,16) [16,32) [32,48) [48,198) [198,450) [450,703).
// The two auto text columns split 505 leftover px as 252/253 — the
// LAST one (Action) absorbs the extra pixel.
var deleteQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 101u);
Assert.True(deleteQuad.Verts[0] < 16f);
var moveUpQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 201u);
Assert.True(moveUpQuad.Verts[0] >= 16f && moveUpQuad.Verts[0] < 32f);
var moveDownQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 301u);
Assert.True(moveDownQuad.Verts[0] >= 32f && moveDownQuad.Verts[0] < 48f);
var glyphs = renderer.DebugSpriteSegmentVerts
.Where(s => s.Texture == 1u)
.SelectMany(s => Chunk(s.Verts))
.ToList();
Assert.Contains(glyphs, v => v[0] >= 48f && v[0] < 198f); // State
Assert.Contains(glyphs, v => v[0] >= 198f && v[0] < 450f); // Condition (auto, 252px)
Assert.Contains(glyphs, v => v[0] >= 450f && v[0] < 703f); // Action (auto, 253px — the slack)
// Click routing: each column kind reaches its own callback with the
// right row; the plain text column (State) still selects.
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 0 }); // col0 row0
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 24, Data2 = 18 }); // col1 row1
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 40, Data2 = 0 }); // col2 row0
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 60, Data2 = 18 }); // col3 (State) row1
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 600, Data2 = 0 }); // col5 (Action) row0
Assert.Equal(new[] { 0 }, binding.DeleteClicks);
Assert.Equal(new[] { 1 }, binding.MoveUpClicks);
Assert.Equal(new[] { 0 }, binding.MoveDownClicks);
Assert.Equal(new[] { 1 }, binding.Selections); // col3 click selected row1
Assert.Equal(new[] { 0 }, binding.ActionClicks);
}
private sealed class MonsterShapedBinding
{
public IReadOnlyList Checks { get; } = new[] { true, false };
public IReadOnlyList Texts { get; } = new[] { "A", "B" };
public IReadOnlyList Icons0 { get; } = new uint[] { 501u, 502u };
public IReadOnlyList Icons1 { get; } = new uint[] { 601u, 602u };
public int Selected { get; set; } = -1;
public List<(int Column, int Row)> Check0Fires { get; } = new();
public Action OnCheck0 => row => Check0Fires.Add((0, row));
public Action OnCheckRest => _ => { };
public List<(int Column, int Row)> Text0Fires { get; } = new();
public Action OnText0 => row => Text0Fires.Add((0, row));
public Action OnTextRest => _ => { };
public List<(int Column, int Row)> Icon0Fires { get; } = new();
public Action OnIcon0 => row => Icon0Fires.Add((0, row));
public Action OnIcon1 => _ => { };
}
///
/// VTank's Monsters tab (refs/vtank/uTank2.ViewXML.mainView.xml's
/// lstMonsters, ~lines 111-140): 14 check columns (16px each,
/// clFester..clCorrosion), 7 text columns (clMonName 120, clPriority 20,
/// clDamageType 56, clExVulnType 56, clWeaponToUse 80, clOffHand 80,
/// clPetDamageType 56), then 2 icon columns (clMoveUp/clMoveDown, 16px
/// each). Every declared width is a plain positive number — no
/// "*" anywhere, matching the real file — so the LAST column
/// (clMoveDown) still absorbs the remainder "as today", which this test
/// pins by sizing the list to the EXACT sum of every column's declared
/// width (724px): the last column ends up with exactly its own declared
/// 16px, not stretched or starved.
///
[Fact]
public void EndToEnd_MonstersShapedTwentyThreeColumnList_EachKindRoutesCorrectly()
{
var resolver = new FakeIconResolver();
var binding = new MonsterShapedBinding();
var columns = new System.Text.StringBuilder();
for (int i = 0; i < 14; i++)
{
string onchange = i == 0 ? "{OnCheck0}" : "{OnCheckRest}";
columns.Append(
$"");
}
int[] textWidths = { 120, 20, 56, 56, 80, 80, 56 };
for (int i = 0; i < textWidths.Length; i++)
{
string onclick = i == 0 ? "{OnText0}" : "{OnTextRest}";
columns.Append(
$"");
}
columns.Append("");
columns.Append("");
string xml =
"" +
"" +
columns +
"
";
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
var list = Assert.IsType(panel.Children[0]);
Assert.Equal(23, list.Columns!.Count);
Assert.Equal(14, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Check));
Assert.Equal(7, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Text));
Assert.Equal(2, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Icon));
var (renderer, ctx) = MakeContext(800f, 200f);
list.DrawSelfAndChildren(ctx);
// col0 (check): [0,16). col14 (first text, clMonName): [224,344).
// col21 (first icon, clMoveUp): [692,708). col22 (LAST, clMoveDown):
// [708,724) — exactly its own declared 16px since every width here
// is a plain fixedwidth number (no "*"), the "no auto column"
// no-op-through-ColumnLayout case.
var checkGlyph = renderer.DebugSpriteSegmentVerts
.SelectMany(s => Chunk(s.Verts))
.First(v => ColorMatches(v, UiCheckLamp.CheckedInner));
Assert.True(checkGlyph[0] < 16f);
var textGlyphs = renderer.DebugSpriteSegmentVerts
.Where(s => s.Texture == 1u)
.SelectMany(s => Chunk(s.Verts))
.ToList();
Assert.Contains(textGlyphs, v => v[0] >= 224f && v[0] < 344f);
var icon0Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 501u);
// Both icon columns share texture 501u/502u pairs per row, but only
// ONE quad per texture id total across both columns (each row binds
// the SAME Icons source to both columns) — assert its x falls in
// EITHER icon column's range, proving the icon kind draws there and
// nowhere else.
Assert.True(icon0Quad.Verts[0] >= 692f && icon0Quad.Verts[0] < 724f);
// Click routing: one representative column per kind reaches its own
// callback with the right row; the OTHER same-kind columns (sharing
// a dummy callback) stay untouched.
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 18 }); // col0 (check) row1
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 260, Data2 = 0 }); // col14 (text) row0
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 700, Data2 = 18 }); // col21 (icon) row1
Assert.Equal(new[] { (0, 1) }, binding.Check0Fires);
Assert.Equal(new[] { (0, 0) }, binding.Text0Fires);
Assert.Equal(new[] { (0, 1) }, binding.Icon0Fires);
}
}