Round 2 step 5 gave the settings Profiles tab a Delete action; Meta, Route, and Loot never got the same verb. Added MossTankMetaProfileStore.Delete, MossTankRouteProfileStore.Delete, and MossTankLootProfileStore.Delete (same contract as Settings: remove the selected named profile's real file, fall back to By char; refuse for By char itself, which has nothing to delete — see each store's ClearCurrent for that case), wired through MossTankPanel.DeleteMetaProfile/DeleteRouteProfile/DeleteLootProfile to three new "Delete" buttons in mosstank.xml (Route tab row, the Meta tab's button row, and the Loot rule editor's button row). MossTankMarkupContractTests' interactive-control count moves 191 -> 194 for the three new buttons. Mutation: reverted all four .cs files and mosstank.xml to HEAD (keeping only the new/changed tests) — the test project failed to even COMPILE (DeleteRouteProfile/DeleteMetaProfile/DeleteLootProfile do not exist on MossTankPanel), confirming the six new behavioral tests (DeleteRouteProfile/DeleteMetaProfile/DeleteLootProfile, each with a successful-delete and a refuse-by-char case) and the markup-count update all depend on this commit's code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
264 lines
9.3 KiB
C#
264 lines
9.3 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class MossTankMarkupContractTests
|
|
{
|
|
[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())
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
[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(
|
|
[
|
|
"tab", "button", "toggle", "slider", "field", "menu", "list",
|
|
], 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 =
|
|
[
|
|
"tab", "button", "toggle", "slider", "field", "menu", "list",
|
|
];
|
|
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;
|
|
}
|
|
}
|