acdream/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs
Erik f680bf234a merge(vt): slice 1 Part A — VTank .usd/.utl drop-in and metaf .af for metas and routes (review-closed)
Campaign VT slice 1 Part A: the .usd document model + 137-setting
serializer with declared type tags and exact compare, metaf .af reader/
writer for metas and nav routes with real byte identity against the
owner's fixtures, .utl gate fixes, the VtankProfiles host storage
(ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores
to real VTank files with one-time JSON migration. Two Opus lenses, three
fix rounds, two narrow re-reviews, final re-check: MERGE-READY.
Contract-doc ledger conflict resolved by keeping the campaign branch.

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

337 lines
13 KiB
C#

using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Tests;
public sealed class MossTankMarkupContractTests
{
/// <summary>
/// Fix round item 9: every element name whose interactive attributes
/// (onclick/onchange/onsubmit) this contract validates and requires a
/// real handler for — shared by
/// <see cref="EveryInteractiveControlDeclaresARealHandlerBinding"/> and
/// <see cref="TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips"/>.
/// <c>column</c> (Campaign VT slice 1 Part B's <c>&lt;list&gt;&lt;column&gt;</c>)
/// joined this set here — mosstank.xml itself has no <c>&lt;column&gt;</c>
/// elements yet, so this is a zero-behavior-change addition against the
/// current file (see <see cref="InteractiveElementNames_IncludesColumn"/>
/// for the direct pin).
/// </summary>
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()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(
[
"Options", "Profiles", "Vitals", "Monsters", "Items",
"Consumables", "Buffs", "Route", "Meta",
],
root.Elements("tab")
.Select(static tab => (string?)tab.Attribute("text")));
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var byName = properties.ToDictionary(
static property => property.Name,
StringComparer.Ordinal);
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
{
string value = attribute.Value;
if (!value.Contains('{', StringComparison.Ordinal))
continue;
Assert.Matches("^\\{[^{}]+\\}$", value);
string name = value[1..^1];
Assert.True(
byName.ContainsKey(name),
$"Markup binding {value} on <{attribute.Parent?.Name}> has no "
+ $"public MossTankPanel property.");
}
}
[Fact]
public void EveryInteractiveBindingMatchesTheRetainedUiDelegateShape()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var byName = properties.ToDictionary(
static property => property.Name,
StringComparer.Ordinal);
foreach (XElement element in root.DescendantsAndSelf())
AssertElementBindingsMatchRetainedUiDelegateShape(element, byName);
}
/// <summary>
/// Fix round item 9: <c>&lt;column&gt;</c>'s own <c>onchange</c> (a
/// <c>type="check"</c> column) and <c>onclick</c> (<c>type="icon"</c>,
/// or a <c>type="text"</c> column's fix-item-1 optional onclick) are
/// BOTH <c>Action&lt;int&gt;</c> (the row index) — never the plain
/// <c>Action</c> every other element's <c>onclick</c> resolves to.
/// Extracted out of <see cref="EveryInteractiveBindingMatchesTheRetainedUiDelegateShape"/>
/// so <see cref="Column_OnchangeAndOnclick_MustBeActionOfInt"/> can drive
/// it directly against a synthetic <c>&lt;column&gt;</c> element —
/// mosstank.xml itself has none yet.
/// </summary>
private static void AssertElementBindingsMatchRetainedUiDelegateShape(
XElement element,
IReadOnlyDictionary<string, PropertyInfo> byName)
{
if (element.Name.LocalName == "column")
{
AssertBindingType(element, "onchange", typeof(Action<int>), byName);
AssertBindingType(element, "onclick", typeof(Action<int>), byName);
return;
}
AssertBindingType(element, "onclick", typeof(Action), byName);
AssertBindingType(
element,
"onsubmit",
typeof(Action<string>),
byName);
Type? changeType = element.Name.LocalName switch
{
"field" or "menu" => typeof(Action<string>),
"slider" => typeof(Action<float>),
"list" => typeof(Action<int>),
_ => null,
};
if (changeType is not null)
AssertBindingType(element, "onchange", changeType, byName);
}
private sealed class ColumnBindingProbe
{
public Action<int> 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<int> — 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<int> for <column> 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<Xunit.Sdk.EqualException>(
() => AssertElementBindingsMatchRetainedUiDelegateShape(badColumn, byName));
}
[Fact]
public void EveryInteractiveControlDeclaresARealHandlerBinding()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName))
.ToArray();
// Round 3 item 10: +3 for the Route/Loot/Meta Delete buttons
// (Settings already had one from round 2 step 5).
Assert.Equal(194, controls.Length);
foreach (XElement control in controls)
{
XAttribute? handler = control.Attribute("onclick")
?? control.Attribute("onchange")
?? control.Attribute("onsubmit");
Assert.NotNull(handler);
Assert.NotEqual("false", (string?)control.Attribute("enabled"));
}
}
[Fact]
public void EveryVtankTabIsBackedByALivePanelSurface()
{
var panel = new MossTankPanel(new StubHost());
Assert.True(panel.OptionsTabEnabled);
Assert.True(panel.VitalsTabEnabled);
Assert.True(panel.MonstersTabEnabled);
Assert.True(panel.BuffsTabEnabled);
Assert.True(panel.ProfilesTabEnabled);
Assert.True(panel.ItemsTabEnabled);
Assert.True(panel.ConsumablesTabEnabled);
Assert.True(panel.RouteTabEnabled);
Assert.True(panel.MetaTabEnabled);
}
[Fact]
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(800f, Number(root, "w"));
Assert.Equal(244f, Number(root, "h"));
AssertWithinParent(root);
}
[Fact]
public void TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
string[] interactive = InteractiveElementNames;
HashSet<string> terse = new(
[
"+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A",
"R", "S", "W", "FC", "Cp", "DC", "Cs",
], StringComparer.Ordinal);
foreach (XElement element in root.Descendants()
.Where(element => interactive.Contains(
element.Name.LocalName,
StringComparer.Ordinal)))
{
string? text = (string?)element.Attribute("text");
if (!string.IsNullOrWhiteSpace(text) && !terse.Contains(text))
continue;
Assert.False(
string.IsNullOrWhiteSpace((string?)element.Attribute("tooltip")),
$"<{element.Name}> text='{text}' needs a tooltip.");
}
}
private static void AssertBindingType(
XElement element,
string attributeName,
Type expectedType,
IReadOnlyDictionary<string, PropertyInfo> properties)
{
string? expression = (string?)element.Attribute(attributeName);
if (expression is null)
return;
Assert.StartsWith("{", expression, StringComparison.Ordinal);
Assert.EndsWith("}", expression, StringComparison.Ordinal);
string name = expression[1..^1];
Assert.True(
properties.TryGetValue(name, out PropertyInfo? property),
$"Markup binding {expression} on <{element.Name}> has no public "
+ "MossTankPanel property.");
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertWithinParent(XElement parent)
{
float parentWidth = Number(parent, "w");
float parentHeight = Number(parent, "h");
foreach (XElement child in parent.Elements())
{
float width = Number(child, "w");
float height = Number(child, "h");
if (width > 0f)
{
Assert.True(
Number(child, "x") + width <= parentWidth,
$"<{child.Name}> crosses the right edge of <{parent.Name}>.");
}
if (height > 0f)
{
Assert.True(
Number(child, "y") + height <= parentHeight,
$"<{child.Name}> crosses the bottom edge of <{parent.Name}>.");
}
AssertWithinParent(child);
}
}
private static float Number(XElement element, string attribute) =>
float.TryParse(
(string?)element.Attribute(attribute),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out float value)
? value
: 0f;
private sealed class StubHost : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}