acdream/tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs
Erik 57ced0aff1 feat(vtank): slice 7 S7.3 — Monsters tab as VTank's own 23-column grid
Campaign VT slice 7 (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md),
S7.3 (Monsters). Owner's bar: looks basically the same as VTank and
behaves the same from the player's seat.

The pre-S7.3 Monsters tab was a select-then-edit adaptation (one
padded-text list + a separate expression/priority/damage-menu/equip
editor row + 14 standalone toggles) forced by acdream's earlier
single-column-only <list>. Campaign VT slice 1 Part B already closed
that gap (the <list><column> markup extension, docs/plugin-ui-markup.md
"Columns"), so this slice replaces the whole tab body with VTank's own
one 23-column HudList grid (docs/research/vtank-kb/08-ui-views.md §1
"Tab: Monsters"): 14 check columns (F B G I Y V A R S WC FC Cp DC Cs,
each 20px = VVS's forced 13px check width + WPadding per the markup
doc's PITCH convention), 7 text columns (Name 127/P 27/Dmg type 63/
Ex. Vuln 63/Weapon 87/Offhand 87/PetDmg 63, each pitch = fixedwidth+7),
2 icon columns (move up/down, DAT ids 0x060028FC/0x060028FD), 15
header <label>s carrying VTank's own decompiled tooltip text
(PluginCore.cs:1693-1707 — corrects "Broadside of a Tumerok" to the
decompile's actual "Broadside of a Barn"), and the txtNewMonster field
+ Add + Add Sel row at VTank's exact geometry (8,136,388,16 /
404,136,50,16 / 462,136,50,16). Selection is unused, matching VTank —
every real cell is its own click target.

Per-cell behavior ported from PluginCore.cs:7885-8105's `b(object,int,
int)` handler: Name click deletes the row (guarded on the rule's own
IsDefault rather than literal row 0, so the invariant holds even if a
caller reorders DEFAULT); P cycles -1->0->1->2->3->4->-1; Dmg type
cycles every eDamageElement 0..13; Ex. Vuln and PetDmg reproduce the
decompile's own non-contiguous jumps (6->8->98 and 6->101->8->98
respectively — eDamageElement.cs); move up/down reorder but never
displace DEFAULT (a deliberate symmetric guard — VTank's own case 21
keeps MoveUp off row 0 but case 22 has no matching MoveDown guard,
which reads as an authoring oversight, not intended asymmetric
behavior, so a literal port was not preserved here). Case 6's "A"
column write is inverted in VTank's own obfuscated field, but that
inversion is an artifact of VTank's internal storage, not the checkbox
the player sees — our Attack flag is already declared un-inverted, so
no inversion is needed for parity.

Deviation: cases 18/19's Weapon/Offhand cycle VTank's own runtime list
of owned weapon-TYPE ids via an opaque item-kind classifier the
decompile alone doesn't resolve. MossTank has no "weapon type"
abstraction — Weapon/Offhand are always concrete owned items — so
these columns instead cycle the same registered weapon roster the
Items tab already maintains (ordinal-sorted for a deterministic
order), preserving the click-to-cycle-through-<AUTO> contract without
inventing a type abstraction outside this slice's scope.

MossTankPanel: the whole select-then-edit surface (SelectedMonsterRule
selection, RefreshMonsterEditor's cached row formatting, Apply/Remove/
priority-stepper/damage-menu/equip-from-selection actions,
HasMonsterFlag/ToggleMonsterFlag) is retired in favor of per-row
column-array properties (14 flag columns, 7 text columns, 2 icon
columns) and per-row mutation actions, all still writing through the
unchanged CombatSettings.Rules/MonsterRule/MonsterRuleActions model.
EnsureDefaultMonsterRule replaces RefreshMonsterEditor's DEFAULT
invariant at the two remaining call sites (ctor init, profile reload,
and the /vt refresh command). MonsterEquipmentText is kept as a
read-only DEFAULT-rule readout for the (out-of-scope) Items tab label
that already depended on it.

Tests (every pin below shown to fail against a targeted mutation,
reverted after confirming the failure — mutations: wrong priority-wrap
boundary, dropped DEFAULT-delete guard, dropped DEFAULT-displacement
guard on move, spliced Harm into the Ex.Vuln cycle, flag toggle
targeting row 0 unconditionally, a wrong header tooltip string; the
xml column-count contract's 205->202 pin was independently shown to
fail against the pre-edit test file and the new markup):
MossTankMarkupContractTests gained
MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips
(column type/order + the 15 header tooltips) and updated the
205->202 interactive-control count plus the tooltip-check's <column>
skip (columns have no text/tooltip grammar). MossTankPanelTests
replaced the three old select-then-edit tests with
MonstersGridMutationsPersistAcrossSessions,
ToggleMonsterFlagAtWritesOnlyTheTargetedRow,
CycleMonsterPriorityAtWrapsExactlyNegativeOneThroughFour,
MonsterDamageColumnsCycleInTheExactRetailOrder (all three cycles),
DeleteMonsterRuleAtRemovesNonDefaultRowsButNeverDefault,
MoveMonsterRuleAtReordersButNeverDisplacesDefault,
AddMonsterRuleUsesTheDraftTextAndAddSelectedMonsterUsesTheWorldTarget,
and MonsterWeaponColumnCyclesTheRegisteredRosterAndPersistsByName
AcrossSessions, and updated MacroWieldsCasterEntersMagicBuffsThen
WieldsWeaponFightsThenIdlePeace for the new weapon-cycle API.

Full suite green: 651/651 MossTank tests (645 + 6 net), 109/109 App
markup-filtered tests.

Deviation not requiring a divergence-register row: this campaign ports
VTank plugin UI behavior, not retail AC client behavior, so
docs/architecture/retail-divergence-register.md's scope (acdream vs.
retail) does not apply here.

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

424 lines
18 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). Campaign VT
// slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals
// control-for-control from VTank's own tabs (docs/research/
// vtank-kb/08-ui-views.md §1) and repositioned the Advanced
// Options/Loot Editor popups to VTank's exact 392x300/268x300
// geometry — net 194 -> 205. The Options tab gained four direct
// checkboxes for settings that already existed but were only
// reachable through the generic Advanced Options key-value editor
// (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback
// Debuffs if Blocked); the Profiles tab gained a real Meta-profile
// combo/CopyTo row (previously a stub label) and per-type name
// fields/New buttons, and lost the Loot Priority Boost toggle as a
// duplicate of the Options-tab control of the same name; the Loot
// Editor popup lost LootEditorNotice (a status label, not an
// action — dropped to fit VTank's 268x300 footprint without
// clipping, see the group's own comment).
// S7.3 replaced the whole Monsters tab body with VTank's own
// 23-column grid (docs/research/vtank-kb/08-ui-views.md §1 "Tab:
// Monsters"): the select-then-edit surface (1 list + 1 field + 8
// buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls)
// is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons
// (27) — net 205 -> 202.
Assert.Equal(202, 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 MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips()
{
// Campaign VT S7.3: pins the transcription against
// docs/research/vtank-kb/08-ui-views.md §1 "Tab: Monsters" (column
// type/order) and the decompile's own tooltip strings
// (refs/vtank/decompiled/uTank2/PluginCore.cs:1693-1707).
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
XElement monstersGroup = root.Elements("group")
.Single(static g => (string?)g.Attribute("visible") == "{MonstersVisible}");
XElement list = Assert.Single(monstersGroup.Elements("list"));
XElement[] columns = list.Elements("column").ToArray();
Assert.Equal(23, columns.Length);
string[] expectedTypes =
[
"check", "check", "check", "check", "check", "check", "check",
"check", "check", "check", "check", "check", "check", "check",
"text", "text", "text", "text", "text", "text", "text",
"icon", "icon",
];
Assert.Equal(expectedTypes, columns.Select(c => (string?)c.Attribute("type")));
// The 14 check columns' header letters carry VTank's own decompiled
// tooltip text on the <label> placed above them (columns themselves
// have no tooltip grammar — docs/plugin-ui-markup.md's "Columns").
Dictionary<string, string?> tooltipsByHeaderText = monstersGroup.Elements("label")
.ToDictionary(
static l => (string?)l.Attribute("text") ?? string.Empty,
static l => (string?)l.Attribute("tooltip"));
Assert.Equal("Fester", tooltipsByHeaderText["F"]);
Assert.Equal("Broadside of a Barn", tooltipsByHeaderText["B"]);
Assert.Equal("Gravity Well", tooltipsByHeaderText["G"]);
Assert.Equal("Imperil", tooltipsByHeaderText["I"]);
Assert.Equal("Yield", tooltipsByHeaderText["Y"]);
Assert.Equal("Vuln (Element)", tooltipsByHeaderText["V"]);
Assert.Equal("Attack", tooltipsByHeaderText["A"]);
Assert.Equal("Ring Spell", tooltipsByHeaderText["R"]);
Assert.Equal("Streak", tooltipsByHeaderText["S"]);
Assert.Equal("Weakening Curse", tooltipsByHeaderText["WC"]);
Assert.Equal("Festering Curse", tooltipsByHeaderText["FC"]);
Assert.Equal("Corruption", tooltipsByHeaderText["Cp"]);
Assert.Equal("Destructive Curse", tooltipsByHeaderText["DC"]);
Assert.Equal("Corrosion", tooltipsByHeaderText["Cs"]);
Assert.Equal("Priority", tooltipsByHeaderText["P"]);
}
[Fact]
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
// Campaign VT slice 7 S7.1: 856 wide, matching VTank's own
// mainView.xml content (856x210, docs/research/vtank-kb/
// 08-ui-views.md §0). 350 tall (not 210) only because the
// Advanced Options / Loot Editor popups moved to VTank's own
// 392x300 / 268x300 popup geometry as separate in-panel groups —
// see mosstank.xml's own panel-level comment.
Assert.Equal(856f, Number(root, "w"));
Assert.Equal(350f, 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)))
{
// Campaign VT S7.3: <column> has no text/tooltip grammar at all
// (docs/plugin-ui-markup.md's "Columns" section — a column's
// attribute set is items/values/onclick/onchange/iconkind, never
// text or tooltip). Every column's "caption" is the header
// <label> placed above the <list> instead, which — being a
// plain label, not one of InteractiveElementNames — this loop
// never visits; the Monsters grid's own header labels carry
// real tooltip text for the abbreviated ones (see mosstank.xml).
if (element.Name.LocalName == "column")
continue;
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;
}
}