From 0f79ee03ee693cfb22b9f74a07e55fa95cb11149 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 21:20:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(vt):=20list=20column=20fix=20round=209/11?= =?UTF-8?q?=20=E2=80=94=20MossTankMarkupContractTests=20learns=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract test suite's per-element binding-shape check treated EVERY element's onclick as a plain Action, and its interactive-element set (used both for "declares a real handler" and "has an accessible tooltip") had no entry for at all — both would misvalidate a future mosstank.xml the way Campaign VT slice 1 Part B and this fix round's item 1 actually shipped it (onchange/onclick as Action, the row index). Extracted the per-element dispatch out of EveryInteractiveBindingMatchesTheRetainedUiDelegateShape into AssertElementBindingsMatchRetainedUiDelegateShape, which now special-cases "column": both onchange and onclick must resolve to Action, never plain Action. The interactive-element name list is now the single shared InteractiveElementNames array (was duplicated inline in EveryInteractiveControlDeclaresARealHandlerBinding and TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips), with "column" added — zero behavior change against mosstank.xml today (it has no elements yet), confirmed by the existing 190-control count assertion staying green. New tests: InteractiveElementNames_IncludesColumn pins the addition directly; Column_OnchangeAndOnclick_MustBeActionOfInt builds a synthetic (not loaded from mosstank.xml, which has none) and proves both a correctly-typed Action onchange passes and a column onclick bound to a plain Action (the shape every OTHER element's onclick uses) is rejected — confirmed by temporarily removing the "column" special case and rerunning: the plain-Action onclick then passed silently ("No exception was thrown", the generic Action check being satisfied by a real Action property), proving the test only passes because the fix's column-specific Action enforcement is present. Co-Authored-By: Claude Fable 5.1 --- .../MossTankMarkupContractTests.cs | 121 ++++++++++++++---- 1 file changed, 97 insertions(+), 24 deletions(-) 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",