Owner's third live look (2026-09-07): "Screenshot shows options under the buffing tab circled in red. Those are made up, remove them from here." VTank's real Buffs tab (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Buffs") is exactly two labels + two 320x116 lists + two "Add..." buttons — nothing in the middle strip. The MossTank-only extras block that used to live there (7 category toggles, the difficulty-margin/rebuff-under spinners, a second Force/Stop buffing button + status, and the "N attributes, N trained skills, N buff lines" coverage counts) is removed outright from mosstank.xml. The underlying settings are not lost: BuffSettings' seven category bools, SkillExcessOverDifficulty, and RebuffWhenUnderSeconds stay in the model and its JSON side-car, and the latter two are real, already-editable VtankOptionCatalog entries (SpellDiffExcessThreshold-Buff / RebuffTimeRemainingSeconds) reachable from Advanced Options. Force Buff / Cancel Force Buff stay on the Options tab, exactly where VTank has them. Orphaned XML-only bindings deleted from MossTankPanel.cs: the seven *Enabled/Toggle* pairs (TrainedOnlyEnabled/ToggleTrainedOnly, AttributesEnabled/ToggleAttributes, ProtectionsEnabled/ ToggleProtections, AurasEnabled/ToggleAuras, BanesEnabled/ToggleBanes, RegenerationEnabled/ToggleRegeneration, OtherEnabled/ToggleOther), DifficultyText/DifficultyUp/DifficultyDown, RebuffText/RebuffUp/ RebuffDown, BuffButtonText, and the Buff action property (StartOrStop itself stays — still reachable from the `/mt buff` slash command, MossTankCommands.cs). BuffStatus and Coverage stay: both are still exercised directly by MossTankPanelTests independent of any control reading them, and neither is listed among the settings the owner's report says must stay in the model, since neither backs a real BuffSettings field — they're the "force-buff status" and "coverage" strings the report never asked to keep alive as UI, only as state. Test changes: MossTankMarkupContractTests' old BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow (which asserted the removed strip's geometry) is replaced with BuffsTabIsVtankOnlySixControls, asserting the Buffs group is exactly two lists + two "Add..." buttons + two labels, with no toggle, no slider, and none of the deleted bindings anywhere in it. Shown to fail first against a targeted mutation (a stray toggle re-added to the group) before the fix was restored. MossTankMarkupBuildOverRealFilesTests' BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor (which proved the same now-deleted strip stayed centered at the panel's floor) is deleted outright; the two remaining lists' own non-overlap/ within-bounds behavior stays covered generically by ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize. MossTank suite: 726 -> 726 (one Fact replaced, same count). App markup/plugin filter: 262 -> 261 (one Fact removed, none added here). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
858 lines
39 KiB
C#
858 lines
39 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><list><column></c>)
|
|
/// joined this set here — mosstank.xml itself has no <c><column></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><column></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<int></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><column></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();
|
|
// Fix round C item F13: this used to also assert an exact control
|
|
// count, with ~60 lines of running commentary on every markup edit
|
|
// that bumped the number. That count was a pure change detector —
|
|
// it carried no signal EveryInteractiveControlDeclaresARealHandlerBinding
|
|
// couldn't already fail on its own, and it forced an edit to THIS
|
|
// test every time an unrelated tab gained or lost a control. The
|
|
// per-tab control-count history now lives in the slice 7 plan
|
|
// ledger (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md)
|
|
// instead. The loop below is the real assertion: every interactive
|
|
// control must have a real handler and must not be disabled.
|
|
Assert.NotEmpty(controls);
|
|
|
|
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).
|
|
//
|
|
// Round F item 5 appended a 24th column: a genuinely inert
|
|
// trailing filler (empty items, no onclick) so the icon column
|
|
// before it (MoveDownIcons) is no longer the grid's LAST column
|
|
// and keeps its own declared 23px width instead of silently
|
|
// absorbing every pixel of window growth (docs/plugin-ui-markup.md's
|
|
// "Width semantics" — the last column is ALWAYS auto). It has no
|
|
// VTank counterpart; VTank's own 23-column shape is unchanged.
|
|
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(24, 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", "text",
|
|
];
|
|
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"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round F item 3 (owner's second live look: "On items tab, we show
|
|
/// some options there right of the items we add. That should not be
|
|
/// there. Remove that."). VTank's own Items tab is exactly 6 controls
|
|
/// (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Items" — 2 labels,
|
|
/// the 2-column list, Add, Add (no buffs)) with nothing to the right
|
|
/// of the list. MossTank's pre-existing "Remove" button traces back
|
|
/// to the plugin's very first automation PoC commit (`4e6e9bc9d`,
|
|
/// long before Campaign VT slice 7), so it is the accepted
|
|
/// "slice-1 Delete" this round keeps; everything else that used to
|
|
/// sit right of the list (Weapon/Offhand readout, Refill Worn Mana
|
|
/// toggle/slider/status, the notice hint) is gone.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ItemsTabIsVtankOnlyPlusTheAcceptedSliceOneRemoveButton()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
XElement itemsGroup = root.Elements("group")
|
|
.Single(static g => (string?)g.Attribute("visible") == "{ItemsVisible}");
|
|
|
|
Assert.Single(itemsGroup.Elements("list"));
|
|
string[] buttonTexts = itemsGroup.Elements("button")
|
|
.Select(static b => (string?)b.Attribute("text") ?? string.Empty)
|
|
.ToArray();
|
|
Assert.Equal(["Add", "Add (no buffs)", "Remove"], buttonTexts);
|
|
|
|
// Nothing else — no toggle, no slider, and no label reads a
|
|
// MossTank-only status/notice property.
|
|
Assert.Empty(itemsGroup.Elements("toggle"));
|
|
Assert.Empty(itemsGroup.Elements("slider"));
|
|
string[] labelBindings = itemsGroup.Elements("label")
|
|
.Select(static l => (string?)l.Attribute("text") ?? string.Empty)
|
|
.ToArray();
|
|
Assert.DoesNotContain("{MonsterEquipmentText}", labelBindings);
|
|
Assert.DoesNotContain("{RefillWornManaText}", labelBindings);
|
|
Assert.DoesNotContain("{ItemManaRechargeStatus}", labelBindings);
|
|
Assert.DoesNotContain("{ProfileNotice}", labelBindings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner's third live look (2026-09-07): "Screenshot shows options
|
|
/// under the buffing tab circled in red. Those are made up, remove
|
|
/// them from here." VTank's real Buffs tab
|
|
/// (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Buffs") is exactly
|
|
/// two labels + two lists + two "Add..." buttons, nothing in the empty
|
|
/// middle strip between the lists. The MossTank-only extras block that
|
|
/// used to live there (7 category toggles, the difficulty/rebuff
|
|
/// spinners, the Force/Stop buffing button + status, the coverage
|
|
/// counts — added by fix round B item 2, repolished by round F item 6)
|
|
/// is gone outright, matching this same file's
|
|
/// <see cref="ItemsTabIsVtankOnlyPlusTheAcceptedSliceOneRemoveButton"/>
|
|
/// precedent for the Items tab's own round-F removal.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuffsTabIsVtankOnlySixControls()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
XElement buffsGroup = root.Elements("group")
|
|
.Single(static g => (string?)g.Attribute("visible") == "{BuffsVisible}");
|
|
|
|
Assert.Equal(2, buffsGroup.Elements("list").Count());
|
|
string[] buttonTexts = buffsGroup.Elements("button")
|
|
.Select(static b => (string?)b.Attribute("text") ?? string.Empty)
|
|
.ToArray();
|
|
Assert.Equal(["Add...", "Add..."], buttonTexts);
|
|
string[] labelTexts = buffsGroup.Elements("label")
|
|
.Select(static l => (string?)l.Attribute("text") ?? string.Empty)
|
|
.ToArray();
|
|
Assert.Equal(["Extra Buff Spells", "Blacklisted Buff Families"], labelTexts);
|
|
|
|
// Nothing else — no toggle, no slider, no MossTank-only status/
|
|
// difficulty/rebuff/coverage label ever made it back in.
|
|
Assert.Empty(buffsGroup.Elements("toggle"));
|
|
Assert.Empty(buffsGroup.Elements("slider"));
|
|
Assert.DoesNotContain("{BuffButtonText}", labelTexts);
|
|
Assert.DoesNotContain("{BuffStatus}", labelTexts);
|
|
Assert.DoesNotContain("{DifficultyText}", labelTexts);
|
|
Assert.DoesNotContain("{RebuffText}", labelTexts);
|
|
Assert.DoesNotContain("{Coverage}", labelTexts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round F item 6 (resemblance re-check: "Loot Editor: drop the
|
|
/// leftover 'Back' button — the window closes from its title bar
|
|
/// like the others"). The standalone button is gone;
|
|
/// CloseLootEditor stays a real, bound property (still called by
|
|
/// ToggleLootEditorVisible, the Profiles-tab checkbox's own close
|
|
/// path).
|
|
/// </summary>
|
|
[Fact]
|
|
public void LootEditorHasNoLeftoverBackButton()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank-loot-editor.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
Assert.DoesNotContain(
|
|
root.Elements("button"),
|
|
static b => (string?)b.Attribute("text") == "Back");
|
|
Assert.NotNull(typeof(MossTankPanel).GetProperty("CloseLootEditor"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
// Round D item 4 (owner: "The size of the entire window needs to
|
|
// be enlarged for default and should also be resizeable"): the
|
|
// authored default grew ~15% in both directions from the S7.1/
|
|
// fix-round-A proportion (856x236, still the panel's own
|
|
// minw/minh floor — see PanelIsResizableFlooredAtThePreRoundDAuthoredSize
|
|
// below) to 984x271. See mosstank.xml's own Round D item 4
|
|
// panel-level comment for the exact delta math (128 wide, 35
|
|
// tall) every tab group and stretching list shares.
|
|
Assert.Equal(984f, Number(root, "w"));
|
|
Assert.Equal(271f, Number(root, "h"));
|
|
AssertWithinParent(root);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 4: the panel itself is resizable, floored at the
|
|
/// PRE-round-D authored size (856x236) — a drag-resize or a
|
|
/// persisted-layout restore must never shrink it past what every tab
|
|
/// was actually authored/tested at.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PanelIsResizableFlooredAtThePreRoundDAuthoredSize()
|
|
{
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
Assert.Equal("true", (string?)root.Attribute("resizable"));
|
|
Assert.Equal(856f, Number(root, "minw"));
|
|
Assert.Equal(236f, Number(root, "minh"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 4's own contract pin: every list this round calls out
|
|
/// as "should grow" (Monsters, Items, Consumables x2, Buffs x2, Route,
|
|
/// Meta) declares a real anchor — not the silent "left top" default,
|
|
/// which would leave it a fixed size forever regardless of how far the
|
|
/// user drags the window. Doesn't prescribe WHICH edges (Monsters/Meta
|
|
/// stretch both axes; the others grow height-only, pinned to whichever
|
|
/// horizontal edge a sibling list/button column doesn't block — see
|
|
/// mosstank.xml's own Round D item 4 comment for why per list).
|
|
/// </summary>
|
|
[Fact]
|
|
public void EveryStretchingListDeclaresARealAnchor()
|
|
{
|
|
(string GroupVisible, int ExpectedListCount)[] stretchingListGroups =
|
|
[
|
|
("MonstersVisible", 1),
|
|
("ItemsVisible", 1),
|
|
("ConsumablesVisible", 2),
|
|
("BuffsVisible", 2),
|
|
("RouteVisible", 1),
|
|
("MetaVisible", 1),
|
|
];
|
|
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
foreach ((string groupVisible, int expectedListCount) in stretchingListGroups)
|
|
{
|
|
XElement group = root.Elements("group")
|
|
.Single(g => (string?)g.Attribute("visible") == $"{{{groupVisible}}}");
|
|
XElement[] lists = group.Elements("list").ToArray();
|
|
Assert.Equal(expectedListCount, lists.Length);
|
|
foreach (XElement list in lists)
|
|
{
|
|
string? anchor = (string?)list.Attribute("anchor");
|
|
Assert.False(
|
|
string.IsNullOrEmpty(anchor),
|
|
$"A <list> in the {groupVisible} tab has no anchor attribute — "
|
|
+ "it will never grow with the window.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round C item D3: direct pin on <see cref="AssertWithinParent"/>'s
|
|
/// height check, independent of any real markup file. A
|
|
/// <c><label y="190"></c> with no declared <c>h</c> inside a
|
|
/// 194-tall group clips 12px past the bottom edge once the label's
|
|
/// real 16px default row height is counted (190 + 16 = 206 > 194) —
|
|
/// before this fix, an absent <c>h</c> read as zero height and the
|
|
/// check silently passed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AssertWithinParent_CatchesAnUnsizedLabelNearTheBottomEdge()
|
|
{
|
|
var group = new XElement("group",
|
|
new XAttribute("w", "848"), new XAttribute("h", "194"),
|
|
new XElement("label", new XAttribute("x", "4"), new XAttribute("y", "190"),
|
|
new XAttribute("text", "Notice")));
|
|
|
|
Assert.Throws<Xunit.Sdk.TrueException>(() => AssertWithinParent(group));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round B item 2: <see cref="AssertWithinParent"/> only ever proved
|
|
/// a control fits inside its OWN parent's declared bounds — it never
|
|
/// caught two SIBLINGS overlapping each other while each individually
|
|
/// still fit. That's exactly how the Buffs tab's Difficulty "+" button
|
|
/// (338,108,26,20) ended up drawn on top of the Extra Buff Spells
|
|
/// "Add..." button (336,102,120,16) before this fix round, and how the
|
|
/// Options tab's "Pet Min. Monsters:" label (614,48,125,16) overlapped
|
|
/// the very field it labels (734,48,40,16) by 5px. This walks every
|
|
/// container in every plugin panel file and asserts no two positioned
|
|
/// children overlap — except two <c><group></c> elements (VTank
|
|
/// tab pages are mutually exclusive via their own <c>visible="{...}"</c>
|
|
/// binding, so sharing the same rectangle is by design) and
|
|
/// <c><column></c> (Campaign VT slice 1's <c><list><column></c>
|
|
/// children have no <c>x</c>/<c>y</c> grammar at all — see
|
|
/// docs/plugin-ui-markup.md's "Columns" — so every column reads as
|
|
/// (0,0) and would trivially "overlap" every other column). Fix round B
|
|
/// item 13: the file list is discovered (Directory.GetFiles) rather than
|
|
/// named one by one, so a new mosstank-*.xml popup is covered for free.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AuthoredControlsInTheSameContainerNeverOverlapASibling()
|
|
{
|
|
foreach (string path in Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml"))
|
|
{
|
|
XDocument document = XDocument.Load(path);
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
AssertNoSiblingOverlap(root);
|
|
}
|
|
}
|
|
|
|
/// <summary>Direct pin on the overlap PREDICATE itself (independent of
|
|
/// any real markup file): two rectangles that truly overlap must be
|
|
/// caught, touching-but-not-overlapping edges must not be a false
|
|
/// positive, and two sibling <group>s sharing one rectangle (the
|
|
/// normal VTank-tab-page shape) must be ignored.</summary>
|
|
[Fact]
|
|
public void AssertNoSiblingOverlap_CatchesARealOverlapAndIgnoresGroupPagesAndTouchingEdges()
|
|
{
|
|
var overlapping = new XElement("panel",
|
|
new XAttribute("w", "848"), new XAttribute("h", "194"),
|
|
new XElement("button", new XAttribute("x", "336"), new XAttribute("y", "102"),
|
|
new XAttribute("w", "120"), new XAttribute("h", "16")),
|
|
new XElement("button", new XAttribute("x", "338"), new XAttribute("y", "108"),
|
|
new XAttribute("w", "26"), new XAttribute("h", "20")));
|
|
Assert.Throws<Xunit.Sdk.TrueException>(() => AssertNoSiblingOverlap(overlapping));
|
|
|
|
var touchingEdges = new XElement("panel",
|
|
new XAttribute("w", "848"), new XAttribute("h", "194"),
|
|
new XElement("button", new XAttribute("x", "0"), new XAttribute("y", "0"),
|
|
new XAttribute("w", "100"), new XAttribute("h", "20")),
|
|
new XElement("button", new XAttribute("x", "100"), new XAttribute("y", "0"),
|
|
new XAttribute("w", "100"), new XAttribute("h", "20")));
|
|
AssertNoSiblingOverlap(touchingEdges); // must not throw
|
|
|
|
var twoGroupPages = new XElement("panel",
|
|
new XAttribute("w", "848"), new XAttribute("h", "236"),
|
|
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
|
|
new XAttribute("w", "848"), new XAttribute("h", "194")),
|
|
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
|
|
new XAttribute("w", "848"), new XAttribute("h", "194")));
|
|
AssertNoSiblingOverlap(twoGroupPages); // must not throw
|
|
}
|
|
|
|
private static void AssertNoSiblingOverlap(XElement container)
|
|
{
|
|
XElement[] children = container.Elements()
|
|
.Where(static child => child.Name.LocalName != "column")
|
|
.ToArray();
|
|
for (int i = 0; i < children.Length; i++)
|
|
{
|
|
for (int j = i + 1; j < children.Length; j++)
|
|
{
|
|
XElement a = children[i], b = children[j];
|
|
if (a.Name.LocalName == "group" && b.Name.LocalName == "group")
|
|
continue;
|
|
Assert.True(
|
|
!RectanglesOverlap(a, b),
|
|
$"<{a.Name}> text='{(string?)a.Attribute("text")}' @ "
|
|
+ $"({Number(a, "x")},{Number(a, "y")},{Number(a, "w")},{Number(a, "h")}) "
|
|
+ $"overlaps sibling <{b.Name}> text='{(string?)b.Attribute("text")}' @ "
|
|
+ $"({Number(b, "x")},{Number(b, "y")},{Number(b, "w")},{Number(b, "h")}).");
|
|
}
|
|
}
|
|
foreach (XElement child in children)
|
|
AssertNoSiblingOverlap(child);
|
|
}
|
|
|
|
private static bool RectanglesOverlap(XElement a, XElement b)
|
|
{
|
|
float aw = Number(a, "w"), ah = Number(a, "h");
|
|
float bw = Number(b, "w"), bh = Number(b, "h");
|
|
if (aw <= 0f || ah <= 0f || bw <= 0f || bh <= 0f)
|
|
return false; // an element with no declared size never "occupies" space
|
|
float ax = Number(a, "x"), ay = Number(a, "y");
|
|
float bx = Number(b, "x"), by = Number(b, "y");
|
|
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round A (2026-09-07): the Advanced Options and Loot Editor popups
|
|
/// moved out of mosstank.xml into their own plugin panel files. This
|
|
/// pins that every secondary popup file still parses, still declares
|
|
/// VTank's own popup footprint (docs/research/vtank-kb/08-ui-views.md
|
|
/// §1's secondary-view table), every child fits its own declared bounds,
|
|
/// and every markup binding still resolves against a real
|
|
/// <see cref="MossTankPanel"/> property — the same three guarantees
|
|
/// <see cref="VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel"/>
|
|
/// and <see cref="AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent"/>
|
|
/// give the main panel, now split across multiple files instead of one.
|
|
/// Fix round C item F3: the file list used to be a hardcoded
|
|
/// <c>[InlineData]</c> set that drifted the moment a popup was added or
|
|
/// renamed without a matching row here. It is now discovered with the
|
|
/// exact same glob the two "covers a new popup for free" tests already
|
|
/// use (<see cref="AuthoredControlsInTheSameContainerNeverOverlapASibling"/>,
|
|
/// <see cref="NoButtonAnywhereUsesTheUnrenderableArrowGlyphs"/>),
|
|
/// excluding mosstank.xml itself (the main panel, covered by the two
|
|
/// tests cited above). A newly discovered popup with no entry in
|
|
/// <see cref="ExpectedPopupBounds"/> fails loudly, naming the file,
|
|
/// instead of being silently skipped.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, (float Width, float Height)> ExpectedPopupBounds =
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
// Round D item 2: removing the Apply/Back button row (retail
|
|
// has neither) reclaimed 26px, shrinking the panel 476->450.
|
|
// Round F item 1 (owner's second live look: "The whole bottom
|
|
// window of advanced options should not be there") deleted the
|
|
// "Editing X." notice label and the entire "MossTank Extras"
|
|
// section below it, shrinking the popup back to VTank's real
|
|
// AdvancedOptionsView footprint (392x300,
|
|
// docs/research/vtank-kb/08-ui-views.md §1) — 450->300.
|
|
["mosstank-advanced.xml"] = (392f, 300f),
|
|
["mosstank-loot-editor.xml"] = (268f, 300f),
|
|
["mosstank-buffpicker.xml"] = (268f, 236f),
|
|
["mosstank-metaeditor.xml"] = (630f, 160f),
|
|
};
|
|
|
|
[Fact]
|
|
public void SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves()
|
|
{
|
|
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
|
|
BindingFlags.Instance | BindingFlags.Public);
|
|
var byName = properties.ToDictionary(
|
|
static property => property.Name,
|
|
StringComparer.Ordinal);
|
|
|
|
string[] popupFileNames = Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml")
|
|
.Select(static path => Path.GetFileName(path)!)
|
|
.Where(static name => !string.Equals(
|
|
name, "mosstank.xml", StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
Assert.NotEmpty(popupFileNames);
|
|
|
|
foreach (string fileName in popupFileNames)
|
|
{
|
|
Assert.True(
|
|
ExpectedPopupBounds.TryGetValue(fileName, out (float Width, float Height) expected),
|
|
$"{fileName} has no expected width/height entry in "
|
|
+ $"{nameof(ExpectedPopupBounds)} — add one (VTank's own "
|
|
+ "secondary-view footprint from docs/research/vtank-kb/"
|
|
+ "08-ui-views.md §1) before this popup can be trusted.");
|
|
|
|
XDocument document = XDocument.Load(
|
|
Path.Combine(AppContext.BaseDirectory, fileName));
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
|
|
Assert.Equal(expected.Width, Number(root, "w"));
|
|
Assert.Equal(expected.Height, Number(root, "h"));
|
|
AssertWithinParent(root);
|
|
|
|
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}> in "
|
|
+ $"{fileName} has no public MossTankPanel property.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round A: neither popup's own "↑"/"↓" reorder buttons (Loot
|
|
/// Editor's MoveLootRuleUp/Down) render as blank slots — the retail
|
|
/// default UI font (0x40000000) has no glyph for those Unicode
|
|
/// characters, so they now carry the same DAT move-up/move-down icons
|
|
/// (0x060028FC/0x060028FD) the Monsters grid already uses, and no
|
|
/// <c><button></c> anywhere is left with the literal arrow text.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NoButtonAnywhereUsesTheUnrenderableArrowGlyphs()
|
|
{
|
|
// Fix round B item 13: discovered file list (Directory.GetFiles)
|
|
// instead of a named-one-by-one array, so a new mosstank-*.xml
|
|
// popup is covered for free.
|
|
foreach (string path in Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml"))
|
|
{
|
|
string fileName = Path.GetFileName(path);
|
|
XDocument document = XDocument.Load(path);
|
|
XElement root = Assert.IsType<XElement>(document.Root);
|
|
foreach (XElement button in root.Descendants("button"))
|
|
{
|
|
string? text = (string?)button.Attribute("text");
|
|
Assert.False(
|
|
text is "↑" or "↓",
|
|
$"<button> in {fileName} still uses the unrenderable "
|
|
+ $"'{text}' glyph instead of a DAT icon.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[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 = EffectiveHeight(child);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round C item D3: an element's declared <c>h</c> only tells the
|
|
/// whole story for widget kinds that never rely on an implicit
|
|
/// default. <c><label></c>/<c><field></c> without <c>h</c>
|
|
/// still occupy one DAT-font text row (16px) at runtime, and
|
|
/// <c><toggle></c> still occupies the checkbox lamp's own 20px
|
|
/// footprint — <see cref="AssertWithinParent"/> used to treat any of
|
|
/// those as zero-height (h absent) and silently pass an unsized notice
|
|
/// label positioned right up against a group's bottom edge, which
|
|
/// actually clips in the running client. <c><button></c> has no
|
|
/// single canonical default (real buttons in this markup range from
|
|
/// 16 to 25px and always declare h explicitly), so 16 — the same
|
|
/// floor as label/field — is used defensively; <c><list></c>/
|
|
/// <c><menu></c> have no implicit default at all and keep the
|
|
/// old "0 when absent" behavior.
|
|
/// </summary>
|
|
private static float EffectiveHeight(XElement element)
|
|
{
|
|
float declared = Number(element, "h");
|
|
if (declared > 0f)
|
|
return declared;
|
|
|
|
return element.Name.LocalName switch
|
|
{
|
|
"label" => 16f,
|
|
"field" => 16f,
|
|
"toggle" => 20f,
|
|
"button" => 16f,
|
|
_ => 0f,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|