New internal static UiCheckLamp (LampSize=11f, the four checked/ unchecked colors, Draw) is the ONE definition of the five-band lamp glyph, promoted out of UiMarkupToggle's private/internal fields — UiMarkupToggle.OnDraw and UiMarkupList.DrawCheckCell both call UiCheckLamp.Draw now instead of each carrying (or one exposing to the other) its own copy of the same five DrawFill calls and four colors. DrawCheckCell also centers the lamp horizontally in its cell instead of drawing it flush left at cellX+1 — matching DrawIconCell, which already centers its sprite. A check column declared wider than the glyph itself (routine under fix item 11's PITCH-based authoring convention) no longer strands the glyph in the cell's left edge. UiMarkupListColumn's settable members are now internal init (Kind/Width lost their `required` modifier — C# forbids `required` pairing with a setter less visible than the containing public type, CS9032 — every factory already sets both unconditionally, so this is a compiler-level demotion, not a behavior change) — the type is constructible only through its Text/Check/Icon factories from any external assembly (a plugin) with no InternalsVisibleTo grant, so it can never assemble an inconsistent instance via object-initializer syntax. Replaced the inert draw-offset assertion in Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds (check column FIRST, so its own-cell assertion held trivially even with completely broken column offsets) with Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds — check column now SECOND, so the assertion can only pass if the glyph actually moved into its own [20,100) cell. New test: a single 50px-wide check column's glyph lands near the cell's midpoint (~22.5) rather than the old flush-left x=4 — shown to fail first before the centering change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1185 lines
50 KiB
C#
1185 lines
50 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><list><column></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><list></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");
|
|
}
|
|
|
|
// ── 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 <column> children the element is
|
|
/// byte-for-byte the old single-text-column list": builds the SAME
|
|
/// column-less markup through <see cref="MarkupDocument"/> (which now
|
|
/// routes through the new if/else split in the "list" case) and compares
|
|
/// its draw output against a <see cref="UiMarkupList"/> constructed BY
|
|
/// HAND with equivalent fields, bypassing <see cref="MarkupDocument"/>
|
|
/// entirely — i.e. against the widget's own unmodified
|
|
/// <c>OnDraw</c>/legacy branch, not a snapshot captured from a different
|
|
/// commit. Every vertex float must match exactly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget()
|
|
{
|
|
var glyphs = new Dictionary<char, FontCharDesc>
|
|
{
|
|
['F'] = new FontCharDesc { Unicode = 'F', Width = 8, Height = 8 },
|
|
};
|
|
var font = new UiDatFont(
|
|
fgTex: 1u, fgW: 32, fgH: 32,
|
|
bgTex: 0, bgW: 0, bgH: 0,
|
|
lineHeight: 16f, baselineOffset: 12f,
|
|
glyphs);
|
|
|
|
var binding = new LegacyBinding();
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
|
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
|
|
"items=\"{Choices}\" colors=\"{ChoiceColors}\" " +
|
|
"selected=\"{SelectedIndex}\" onchange=\"{SelectIndex}\"/>" +
|
|
"</panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite);
|
|
var viaMarkup = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
viaMarkup.DatFont = font; // markup panels always get a host font; irrelevant to the pin itself
|
|
viaMarkup.BackgroundColor = default;
|
|
viaMarkup.BorderColor = default;
|
|
|
|
var handBuilt = new UiMarkupList
|
|
{
|
|
Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font,
|
|
ItemsSource = () => binding.Choices,
|
|
ItemColorsSource = () => binding.ChoiceColors,
|
|
SelectedIndexSource = () => binding.SelectedIndex,
|
|
BackgroundColor = default,
|
|
BorderColor = default,
|
|
};
|
|
|
|
var (rendererMarkup, ctxMarkup) = MakeContext(200f, 200f);
|
|
viaMarkup.DrawSelfAndChildren(ctxMarkup);
|
|
var (rendererHand, ctxHand) = MakeContext(200f, 200f);
|
|
handBuilt.DrawSelfAndChildren(ctxHand);
|
|
|
|
var markupVerts = rendererMarkup.DebugSpriteSegmentVerts.ToList();
|
|
var handVerts = rendererHand.DebugSpriteSegmentVerts.ToList();
|
|
Assert.Equal(handVerts.Count, markupVerts.Count);
|
|
for (int i = 0; i < handVerts.Count; i++)
|
|
{
|
|
Assert.Equal(handVerts[i].Texture, markupVerts[i].Texture);
|
|
Assert.Equal(handVerts[i].Verts.Count, markupVerts[i].Verts.Count);
|
|
for (int j = 0; j < handVerts[i].Verts.Count; j++)
|
|
Assert.Equal(handVerts[i].Verts[j], markupVerts[i].Verts[j], 4);
|
|
}
|
|
|
|
Assert.Null(viaMarkup.Columns);
|
|
}
|
|
}
|