diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs
index eddad0027..09b405cb5 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs
@@ -6,6 +6,29 @@ namespace AcDream.Plugins.MossTank.Tests;
public sealed class MossTankMarkupContractTests
{
+ ///
+ /// Fix round item 9: every element name whose interactive attributes
+ /// (onclick/onchange/onsubmit) this contract validates and requires a
+ /// real handler for — shared by
+ /// and
+ /// .
+ /// column (Campaign VT slice 1 Part B's <list><column>)
+ /// joined this set here — mosstank.xml itself has no <column>
+ /// elements yet, so this is a zero-behavior-change addition against the
+ /// current file (see
+ /// for the direct pin).
+ ///
+ private static readonly string[] InteractiveElementNames =
+ [
+ "tab", "button", "toggle", "slider", "field", "menu", "list", "column",
+ ];
+
+ [Fact]
+ public void InteractiveElementNames_IncludesColumn()
+ {
+ Assert.Contains("column", InteractiveElementNames);
+ }
+
[Fact]
public void VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel()
{
@@ -54,24 +77,80 @@ public sealed class MossTankMarkupContractTests
StringComparer.Ordinal);
foreach (XElement element in root.DescendantsAndSelf())
- {
- AssertBindingType(element, "onclick", typeof(Action), byName);
- AssertBindingType(
- element,
- "onsubmit",
- typeof(Action),
- byName);
+ AssertElementBindingsMatchRetainedUiDelegateShape(element, byName);
+ }
- Type? changeType = element.Name.LocalName switch
- {
- "field" or "menu" => typeof(Action),
- "slider" => typeof(Action),
- "list" => typeof(Action),
- _ => null,
- };
- if (changeType is not null)
- AssertBindingType(element, "onchange", changeType, byName);
+ ///
+ /// Fix round item 9: <column>'s own onchange (a
+ /// type="check" column) and onclick (type="icon",
+ /// or a type="text" column's fix-item-1 optional onclick) are
+ /// BOTH Action<int> (the row index) — never the plain
+ /// Action every other element's onclick resolves to.
+ /// Extracted out of
+ /// so can drive
+ /// it directly against a synthetic <column> element —
+ /// mosstank.xml itself has none yet.
+ ///
+ private static void AssertElementBindingsMatchRetainedUiDelegateShape(
+ XElement element,
+ IReadOnlyDictionary byName)
+ {
+ if (element.Name.LocalName == "column")
+ {
+ AssertBindingType(element, "onchange", typeof(Action), byName);
+ AssertBindingType(element, "onclick", typeof(Action), byName);
+ return;
}
+
+ AssertBindingType(element, "onclick", typeof(Action), byName);
+ AssertBindingType(
+ element,
+ "onsubmit",
+ typeof(Action),
+ byName);
+
+ Type? changeType = element.Name.LocalName switch
+ {
+ "field" or "menu" => typeof(Action),
+ "slider" => typeof(Action),
+ "list" => typeof(Action),
+ _ => null,
+ };
+ if (changeType is not null)
+ AssertBindingType(element, "onchange", changeType, byName);
+ }
+
+ private sealed class ColumnBindingProbe
+ {
+ public Action RowAction { get; } = _ => { };
+ public Action PlainAction { get; } = () => { };
+ }
+
+ [Fact]
+ public void Column_OnchangeAndOnclick_MustBeActionOfInt()
+ {
+ var byName = typeof(ColumnBindingProbe)
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(static property => property.Name, StringComparer.Ordinal);
+
+ // Correctly typed Action — must not throw.
+ var goodColumn = new XElement(
+ "column",
+ new XAttribute("type", "check"),
+ new XAttribute("onchange", "{RowAction}"));
+ AssertElementBindingsMatchRetainedUiDelegateShape(goodColumn, byName);
+
+ // A column's onclick bound to a PLAIN Action (the shape every other
+ // element's onclick uses) must be rejected — proves the dispatch
+ // actually enforces Action for specifically, rather
+ // than silently accepting whatever the generic non-column path
+ // would have allowed.
+ var badColumn = new XElement(
+ "column",
+ new XAttribute("type", "icon"),
+ new XAttribute("onclick", "{PlainAction}"));
+ Assert.Throws(
+ () => AssertElementBindingsMatchRetainedUiDelegateShape(badColumn, byName));
}
[Fact]
@@ -80,10 +159,7 @@ public sealed class MossTankMarkupContractTests
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType(document.Root);
- HashSet interactive = new(
- [
- "tab", "button", "toggle", "slider", "field", "menu", "list",
- ], StringComparer.Ordinal);
+ HashSet interactive = new(InteractiveElementNames, StringComparer.Ordinal);
XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName))
@@ -134,10 +210,7 @@ public sealed class MossTankMarkupContractTests
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType(document.Root);
- string[] interactive =
- [
- "tab", "button", "toggle", "slider", "field", "menu", "list",
- ];
+ string[] interactive = InteractiveElementNames;
HashSet terse = new(
[
"+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A",