acdream/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Erik 8376105936 fix(vtank): slice 7 round F item 6 — Buffs gap, Loot Editor Back, Route count column
Three small resemblance-re-check nits.

Buffs: the extras strip (toggles/Difficulty/Rebuff/Buff button, no VTank
counterpart) stayed at its pre-Round-D x=332 while Round D item 4's own
+128 right-anchor shift moved the Blacklisted Buff Families list from
x=524 to x=652 — an 8px left margin against a 138px right margin.
Discovered a hard geometric ceiling while fixing this: Extra Buff
Spells never moves (anchor="left top bottom", pinned x=4..324) but
Blacklisted Buff Families tracks the growing right edge, sitting at
x=524 at the panel's own tested FLOOR (856x236) and x=652 at the
984-wide enlarged default — a 128px swing. A single fixed-position
strip can only be centered against ONE of those widths; centering
against the 984 default (tried x=397 first) put the strip's right edge
55px inside where the list sits at the floor — a real overlap the
existing ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize pin
(856x236 case) caught immediately. x=333 is the widest safe position
(9px margins against the floor's 324..524 gap) — 1px from where fix
round B item 2 already had it. The residual 984-default asymmetry
(~9/137, barely changed from ~8/138) is an accepted, unavoidable-
without-a-redesign limitation, the same "dead margin, polish later"
trade already accepted for six other tabs at this size. Coverage moved
from y=213 (anchored to the group's bottom edge, a real 55px empty
hole once the panel grew past its original 194-tall size) to sit
directly under the Buff button row (y=176, no anchor).

Loot Editor: dropped the leftover standalone "Back" button — the
window's own OS-level title bar already closes it, matching every
other plugin popup (Advanced Options included) since round D.
CloseLootEditor stays real, still called by ToggleLootEditorVisible.

Route: the waypoint grid's count column (declared width="30") was the
grid's LAST column, so docs/plugin-ui-markup.md's own "Width semantics"
rule made that declared width never validated/used — it silently
absorbed whatever remainder was left (33px normally, but only 17px once
the scrollbar's 16px reservation ate into the list's own 370px width,
since 337+30 already exceeded 370-16=354). A trailing filler column
alone wasn't sufficient — proved by a targeted mutation below — since
even non-last, a truly fixed 30px column still gets clamped by the
same 354px ceiling. The real text column narrows 337->324
(324+30=354, exactly matching the scrollbar-reduced width) so the
count column gets its full 30px whether or not the scrollbar shows.

Every new/changed pin shown to fail against a targeted mutation first:
LootEditorHasNoLeftoverBackButton failed with the button re-inserted,
then passed once removed. RouteShapedGrid_CountColumnStaysThirtyPxWhen
TheListScrolls (a synthetic list matching Route's real 370/324/30/*
shape, 9 rows forcing the scrollbar, reflecting into UiMarkupList's own
_cachedLayout) failed both against the original 2-column 337/30 shape
(17px, reproducing the reported squeeze) AND against filler-only-no-
narrowing (also 17px, proving the filler alone isn't the fix), then
passed at exactly 30px once both changes landed together.
BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow
and its App-side resolved-tree counterpart
BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor pin the
floor-centered position and the Coverage gap.

MossTank suite 725 -> 726 (one new pin); App markup/plugin filter
260 -> 262 (two new pins).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:28:30 +02:00

1623 lines
72 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);
Assert.Contains("column[0]", 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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("items/icons/colors", ex.Message);
}
[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>";
var ex = Assert.Throws<FormatException>(() => 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 =
"<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");
}
[Fact]
public void ColumnGrids_NeverDrawARowSelectionBand()
{
// Fix round B item 10 (owner/oracle: VVS's own HudList grids —
// Monsters/Meta/Route/Items and every other <list><column> grid —
// have no row-selection highlight at all; only the per-cell click
// callbacks survive). DrawColumns must never paint SelectedColor,
// even when SelectedIndexSource reports a real in-range row.
var list = new UiMarkupList
{
Width = 100f, Height = 60f, RowHeight = 18f,
SelectedIndexSource = () => 1,
BackgroundColor = new Vector4(0f, 0f, 0f, 1f),
BorderColor = default,
SelectedColor = new Vector4(1f, 0f, 0f, 1f), // distinct, unmistakable
Columns = new[]
{
UiMarkupListColumn.Text(100f, () => new[] { "Row0", "Row1", "Row2" }, null),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
bool anySelectedFill = renderer.DebugSpriteSegmentVerts.Any(s =>
{
if (s.Texture != 0u)
return false;
for (int q = 0; q + 48 <= s.Verts.Count; q += 48)
{
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
if (MathF.Abs(r - list.SelectedColor.X) < 0.01f
&& MathF.Abs(g - list.SelectedColor.Y) < 0.01f
&& MathF.Abs(b - list.SelectedColor.Z) < 0.01f
&& MathF.Abs(a - list.SelectedColor.W) < 0.01f)
{
return true;
}
}
return false;
});
Assert.False(anySelectedFill, "expected no SelectedColor fill in a column-based grid");
}
// ── 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_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<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);
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<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);
}
// ── 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<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.
///
/// <para>
/// Deliberately updated for the slice-7 resemblance re-check's
/// <see cref="UiMarkupList.SelectionBandEnabled"/> fix: <c>SelectedIndex
/// = 1</c> (a real selected row) previously meant this byte-for-byte
/// comparison implicitly included the <see cref="UiMarkupList.SelectedColor"/>
/// fill quad on BOTH sides. Neither the markup XML nor the hand-built
/// widget below sets <c>selectionband</c>/<see cref="UiMarkupList.SelectionBandEnabled"/>,
/// so that fill is now absent from both — the explicit assertion at the
/// end locks in that the new no-band default applies here too, rather
/// than leaving it to accidentally fall out of the byte-for-byte diff.
/// </para>
/// </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);
// Neither side opted into selectionband="true" — row 1 IS selected
// (LegacyBinding.SelectedIndex = 1) but the new default draws no
// SelectedColor fill for it, on either the markup or the hand-built
// path.
Assert.False(viaMarkup.SelectionBandEnabled);
Assert.False(handBuilt.SelectionBandEnabled);
Assert.DoesNotContain(markupVerts, s => s.Texture == 0u
&& Chunk(s.Verts).Any(v => ColorMatches(v, viaMarkup.SelectedColor)));
}
// ── Slice 7 resemblance re-check: selectionband default + opt-in ────────
private sealed class SelectionBandBinding
{
public IReadOnlyList<string> Choices => new[] { "First", "Second" };
public int Selected { get; set; } = 1;
public Action<int> SelectIndex => _ => { };
}
/// <summary>Any untextured (fill) quad in <paramref name="segs"/> whose vertex
/// color matches <paramref name="color"/> — used to detect the selection-band
/// fill regardless of its exact geometry.</summary>
private static bool HasFillOfColor(
IEnumerable<(uint Texture, IReadOnlyList<float> Verts)> segs, Vector4 color)
=> segs.Where(s => s.Texture == 0u)
.SelectMany(s => Chunk(s.Verts))
.Any(v => ColorMatches(v, color));
/// <summary>
/// Slice-7 resemblance re-check finding: VVS lists (VTank's real
/// <c>HudList</c>) draw no persistent row-selection fill, so a
/// column-less <c>&lt;list items="..."&gt;</c> must match that by
/// default — <see cref="UiMarkupList.SelectionBandEnabled"/> defaults
/// false. <c>selectionband="true"</c> opts a single list back into the
/// visible <see cref="UiMarkupList.SelectedColor"/> band.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SingleColumnList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled)
{
var binding = new SelectionBandBinding();
string attr = enabled ? " selectionband=\"true\"" : "";
string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
$"items=\"{{Choices}}\" selected=\"{{Selected}}\" onchange=\"{{SelectIndex}}\"{attr}/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Equal(enabled, list.SelectionBandEnabled);
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor));
}
/// <summary>Same fix, exercised through <c>&lt;column&gt;</c> mode
/// (<see cref="UiMarkupList.DrawColumns"/>) — the gate applies to both
/// draw branches of the shared widget, not just the legacy one.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ColumnModeList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled)
{
var binding = new SelectionBandBinding();
string attr = enabled ? " selectionband=\"true\"" : "";
string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
$"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" selected=\"{{Selected}}\" onchange=\"{{SelectIndex}}\"{attr}>" +
" <column type=\"text\" width=\"*\" items=\"{Choices}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Equal(enabled, list.SelectionBandEnabled);
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor));
}
/// <summary>Parse-only pin: <c>selectionband</c> follows the same silent
/// literal-bool convention as <c>openupward</c>/<c>clearonsubmit</c>
/// (<see cref="MarkupDocument"/>'s private <c>B</c> helper) — omitted
/// defaults false, and <c>"true"</c> sets the property, with no draw
/// involved at all.</summary>
[Fact]
public void ListSelectionBandAttribute_ParsesToProperty()
{
var binding = new SelectionBandBinding();
const string xmlDefault =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Choices}\" " +
"selected=\"{Selected}\" onchange=\"{SelectIndex}\"/>" +
"</panel>";
const string xmlEnabled =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Choices}\" " +
"selected=\"{Selected}\" onchange=\"{SelectIndex}\" selectionband=\"true\"/>" +
"</panel>";
var defaultList = Assert.IsType<UiMarkupList>(
MarkupDocument.Build(xmlDefault, binding, Sprite).Children[0]);
var enabledList = Assert.IsType<UiMarkupList>(
MarkupDocument.Build(xmlEnabled, binding, Sprite).Children[0]);
Assert.False(defaultList.SelectionBandEnabled);
Assert.True(enabledList.SelectionBandEnabled);
}
// ── Fix round: end-to-end MarkupDocument builds (fix item 10) ───────────
private static UiDatFont MakeAsciiFont()
{
var glyphs = new Dictionary<char, FontCharDesc>();
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<uint> Delete { get; } = new uint[] { 101u, 102u };
public IReadOnlyList<uint> MoveUp { get; } = new uint[] { 201u, 202u };
public IReadOnlyList<uint> MoveDown { get; } = new uint[] { 301u, 302u };
public IReadOnlyList<string> State { get; } = new[] { "A", "B" };
public IReadOnlyList<string> Condition { get; } = new[] { "C", "D" };
public IReadOnlyList<string> Action { get; } = new[] { "E", "F" };
public int Selected { get; set; } = -1;
public List<int> Selections { get; } = new();
public Action<int> SelectRow => row => Selections.Add(row);
public List<int> DeleteClicks { get; } = new();
public Action<int> ClickDelete => row => DeleteClicks.Add(row);
public List<int> MoveUpClicks { get; } = new();
public Action<int> ClickMoveUp => row => MoveUpClicks.Add(row);
public List<int> MoveDownClicks { get; } = new();
public Action<int> ClickMoveDown => row => MoveDownClicks.Add(row);
public List<int> ActionClicks { get; } = new();
public Action<int> ClickAction => row => ActionClicks.Add(row);
}
/// <summary>
/// VTank's Meta tab (<c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s
/// <c>lstMetaRules</c>, ~line 292): 3 icon columns (16px each), a 150px
/// text column, then TWO trailing 0-width (auto) text columns — the real
/// VVS shape our <c>width="*"</c> 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.
/// </summary>
[Fact]
public void EndToEnd_MetaShapedSixColumnList_AutoColumnsShareRemainder_EachKindRoutesCorrectly()
{
var resolver = new FakeIconResolver();
var binding = new MetaShapedBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"720\" h=\"140\">" +
"<list x=\"0\" y=\"0\" w=\"703\" h=\"60\" rowheight=\"18\" " +
"selected=\"{Selected}\" onchange=\"{SelectRow}\">" +
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Delete}\" onclick=\"{ClickDelete}\"/>" +
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{MoveUp}\" onclick=\"{ClickMoveUp}\"/>" +
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{MoveDown}\" onclick=\"{ClickMoveDown}\"/>" +
" <column type=\"text\" width=\"150\" items=\"{State}\"/>" +
" <column type=\"text\" width=\"*\" items=\"{Condition}\"/>" +
" <column type=\"text\" width=\"*\" items=\"{Action}\" onclick=\"{ClickAction}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
var list = Assert.IsType<UiMarkupList>(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 RouteShapedBinding
{
public IReadOnlyList<string> Names { get; } =
Enumerable.Range(0, 9).Select(static i => $"WP{i}").ToArray();
public IReadOnlyList<string> Counts { get; } =
Enumerable.Range(0, 9).Select(static i => i.ToString()).ToArray();
public IReadOnlyList<string> Filler { get; } = Array.Empty<string>();
public int Selected { get; set; } = -1;
public Action<int> Click => static _ => { };
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Route's count column
/// squeezes to 17 when the list scrolls"). Mirrors the Route tab's
/// EXACT real shape (mosstank.xml: a 370px-wide list, columns
/// 324/30/*) with enough rows (9, past the 151/17≈8 visible-row
/// ceiling) to force the scrollbar — the count column's declared
/// 30px was previously only honored while the scrollbar was hidden;
/// with it showing, 337(the OLD text width)+30 already exceeded
/// 370-16=354, clamping the count column to 17px. Reflects into
/// UiMarkupList's own cached column layout (the same one
/// DrawColumns/OnEventColumns use) rather than measuring glyphs —
/// this is a pure geometry question, no font involved.
/// </summary>
[Fact]
public void RouteShapedGrid_CountColumnStaysThirtyPxWhenTheListScrolls()
{
var binding = new RouteShapedBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"400\" h=\"200\">" +
"<list x=\"0\" y=\"0\" w=\"370\" h=\"151\" rowheight=\"17\" " +
"selected=\"{Selected}\">" +
" <column type=\"text\" width=\"324\" items=\"{Names}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"30\" items=\"{Counts}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"*\" items=\"{Filler}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
var (renderer, ctx) = MakeContext(800f, 400f);
list.DrawSelfAndChildren(ctx);
System.Reflection.FieldInfo layoutField = typeof(UiMarkupList).GetField(
"_cachedLayout",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var layout = ((float x, float w)[])layoutField.GetValue(list)!;
Assert.Equal(3, layout.Length);
Assert.Equal(30f, layout[1].w, 3);
}
private sealed class MonsterShapedBinding
{
public IReadOnlyList<bool> Checks { get; } = new[] { true, false };
public IReadOnlyList<string> Texts { get; } = new[] { "A", "B" };
public IReadOnlyList<uint> Icons0 { get; } = new uint[] { 501u, 502u };
public IReadOnlyList<uint> Icons1 { get; } = new uint[] { 601u, 602u };
public int Selected { get; set; } = -1;
public List<(int Column, int Row)> Check0Fires { get; } = new();
public Action<int> OnCheck0 => row => Check0Fires.Add((0, row));
public Action<int> OnCheckRest => _ => { };
public List<(int Column, int Row)> Text0Fires { get; } = new();
public Action<int> OnText0 => row => Text0Fires.Add((0, row));
public Action<int> OnTextRest => _ => { };
public List<(int Column, int Row)> Icon0Fires { get; } = new();
public Action<int> OnIcon0 => row => Icon0Fires.Add((0, row));
public Action<int> OnIcon1 => _ => { };
}
/// <summary>
/// VTank's Monsters tab (<c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s
/// <c>lstMonsters</c>, ~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
/// <c>"*"</c> 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.
/// </summary>
[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(
$"<column type=\"check\" width=\"16\" values=\"{{Checks}}\" onchange=\"{onchange}\"/>");
}
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(
$"<column type=\"text\" width=\"{textWidths[i]}\" items=\"{{Texts}}\" onclick=\"{onclick}\"/>");
}
columns.Append("<column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Icons0}\" onclick=\"{OnIcon0}\"/>");
columns.Append("<column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Icons1}\" onclick=\"{OnIcon1}\"/>");
string xml =
"<panel x=\"0\" y=\"0\" w=\"740\" h=\"140\">" +
"<list x=\"0\" y=\"0\" w=\"724\" h=\"60\" rowheight=\"18\" selected=\"{Selected}\">" +
columns +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
var list = Assert.IsType<UiMarkupList>(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);
}
}