The original kept VTank's profile folder in step with the club's metas repository; the port does the same against host.VtankProfiles, dropping .met files under mosstank/metas and .nav files under mosstank/navs where MossTank reads dropped profiles, flattened to their bare names as the original flattened them. The Gitea tree and raw endpoints are the original's; the repository address is the metas_repository_url setting, defaulting to the original repository. An overwritten file keeps its old copy as .bak (MossTank's pickers skip .bak side files). Storage is text-only, so the SHA-256 compare hashes the text a stored read gives back (UTF-8, BOM dropped); for the plain-text files in the repo that equals the original's byte compare. Network work runs on the thread pool, one operation after another; chat lines and window updates are applied on the tick. The four /mm verbs keep their chat lines, and the Metas tab is its own markup window with IsVisible for the main window to open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
222 lines
8.7 KiB
C#
222 lines
8.7 KiB
C#
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
using OpenAC.MosswartMassacre.FlagTracker;
|
|
using OpenAC.MosswartMassacre.Metas;
|
|
|
|
namespace OpenAC.MosswartMassacre.Tests.Ui;
|
|
|
|
/// <summary>
|
|
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a
|
|
/// public property of the right shape on its binding object, the check the
|
|
/// host would otherwise only make when the window is built in game.
|
|
/// </summary>
|
|
public sealed class TrackerAndMetasMarkupContractTests
|
|
{
|
|
private static readonly string[] InteractiveElementNames =
|
|
[
|
|
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
|
|
];
|
|
|
|
public static TheoryData<string, Type> MarkupFiles => new()
|
|
{
|
|
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
|
|
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MarkupFiles))]
|
|
public void EveryBindingResolvesToAPublicProperty(string file, Type bindingType)
|
|
{
|
|
XElement root = LoadRoot(file);
|
|
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
|
|
|
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),
|
|
$"{file}: binding {value} on <{attribute.Parent?.Name}> has no public {bindingType.Name} property.");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MarkupFiles))]
|
|
public void EveryBindingHasTheShapeTheHostReads(string file, Type bindingType)
|
|
{
|
|
XElement root = LoadRoot(file);
|
|
IReadOnlyDictionary<string, PropertyInfo> byName = PropertiesOf(bindingType);
|
|
|
|
foreach (XElement element in root.DescendantsAndSelf())
|
|
AssertElementBindings(file, element, byName);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MarkupFiles))]
|
|
public void EveryInteractiveControlDeclaresAHandler(string file, Type bindingType)
|
|
{
|
|
_ = bindingType;
|
|
XElement root = LoadRoot(file);
|
|
HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
|
|
|
|
XElement[] controls = [.. root.Descendants().Where(element => interactive.Contains(element.Name.LocalName))];
|
|
Assert.NotEmpty(controls);
|
|
|
|
foreach (XElement control in controls)
|
|
{
|
|
if (control.Name.LocalName == "column" && (string?)control.Attribute("type") == "text")
|
|
continue;
|
|
|
|
XAttribute? handler = control.Attribute("onclick")
|
|
?? control.Attribute("onchange")
|
|
?? control.Attribute("onsubmit");
|
|
Assert.True(handler is not null, $"{file}: <{control.Name.LocalName}> declares no handler.");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MarkupFiles))]
|
|
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
|
|
{
|
|
XElement root = LoadRoot(file);
|
|
Assert.Equal("panel", root.Name.LocalName);
|
|
Assert.Equal("{IsVisible}", (string?)root.Attribute("visible"));
|
|
PropertyInfo isVisible = PropertiesOf(bindingType)["IsVisible"];
|
|
Assert.Equal(typeof(bool), isVisible.PropertyType);
|
|
Assert.True(isVisible.CanWrite && isVisible.SetMethod!.IsPublic);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MarkupFiles))]
|
|
public void EveryListColumnHasASaneWidth(string file, Type bindingType)
|
|
{
|
|
_ = bindingType;
|
|
XElement root = LoadRoot(file);
|
|
|
|
foreach (XElement list in root.Descendants("list"))
|
|
{
|
|
XElement[] columns = [.. list.Elements("column")];
|
|
for (int index = 0; index < columns.Length; index++)
|
|
{
|
|
string? width = (string?)columns[index].Attribute("width");
|
|
Assert.True(width is not null, $"{file}: <column> {index} has no width.");
|
|
bool isStar = width == "*";
|
|
bool isPositive = double.TryParse(
|
|
width,
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
out double parsed)
|
|
&& parsed > 0d;
|
|
Assert.True(isStar || isPositive, $"{file}: <column width=\"{width}\"> at {index}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheFlagTrackerTabsAreTheOriginalPagesPlusWeapons()
|
|
{
|
|
XElement root = LoadRoot(FlagTrackerFeature.MarkupFile);
|
|
string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
|
|
|
|
Assert.Equal(["Augs", "Lum", "Recalls", "Cantrips", "Weapons", "Quests"], tabs);
|
|
Assert.Equal(6, root.Elements("group").Count());
|
|
Assert.Equal("Mossy Tracker", (string?)root.Attribute("title"));
|
|
}
|
|
|
|
private static void AssertElementBindings(string file, XElement element, IReadOnlyDictionary<string, PropertyInfo> byName)
|
|
{
|
|
if (element.Name.LocalName == "column")
|
|
{
|
|
AssertBindingType(file, element, "onchange", typeof(Action<int>), byName);
|
|
AssertBindingType(file, element, "onclick", typeof(Action<int>), byName);
|
|
AssertListBinding(file, element, "items", typeof(string), byName);
|
|
AssertListBinding(file, element, "colors", typeof(uint), byName);
|
|
switch ((string?)element.Attribute("type"))
|
|
{
|
|
case "check":
|
|
AssertListBinding(file, element, "values", typeof(bool), byName);
|
|
break;
|
|
case "icon":
|
|
AssertListBinding(file, element, "values", typeof(uint), byName);
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
AssertBindingType(file, element, "onclick", typeof(Action), byName);
|
|
AssertBindingType(file, element, "onsubmit", typeof(Action<string>), byName);
|
|
AssertBindingType(file, element, "visible", typeof(bool), byName);
|
|
AssertBindingType(file, element, "enabled", typeof(bool), byName);
|
|
AssertBindingType(file, element, "checked", typeof(bool), byName);
|
|
Type? selectedType = element.Name.LocalName switch
|
|
{
|
|
"tab" => typeof(bool),
|
|
"list" => typeof(int),
|
|
"menu" => null,
|
|
_ => null,
|
|
};
|
|
AssertBindingType(file, element, "selected", selectedType, byName);
|
|
|
|
Type? changeType = element.Name.LocalName switch
|
|
{
|
|
"field" or "menu" => typeof(Action<string>),
|
|
"slider" => typeof(Action<float>),
|
|
"list" => typeof(Action<int>),
|
|
_ => null,
|
|
};
|
|
AssertBindingType(file, element, "onchange", changeType, byName);
|
|
|
|
if (element.Name.LocalName is "list" or "menu")
|
|
AssertListBinding(file, element, "items", typeof(string), byName);
|
|
}
|
|
|
|
private static void AssertBindingType(
|
|
string file,
|
|
XElement element,
|
|
string attributeName,
|
|
Type? expected,
|
|
IReadOnlyDictionary<string, PropertyInfo> byName)
|
|
{
|
|
if (expected is null)
|
|
return;
|
|
string? value = (string?)element.Attribute(attributeName);
|
|
if (value is null || !value.StartsWith('{'))
|
|
return;
|
|
|
|
PropertyInfo property = byName[value[1..^1]];
|
|
Assert.True(
|
|
expected.IsAssignableFrom(property.PropertyType),
|
|
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {property.PropertyType.Name}, expected {expected.Name}.");
|
|
}
|
|
|
|
private static void AssertListBinding(
|
|
string file,
|
|
XElement element,
|
|
string attributeName,
|
|
Type elementType,
|
|
IReadOnlyDictionary<string, PropertyInfo> byName)
|
|
{
|
|
string? value = (string?)element.Attribute(attributeName);
|
|
if (value is null || !value.StartsWith('{'))
|
|
return;
|
|
|
|
Type type = byName[value[1..^1]].PropertyType;
|
|
Assert.True(
|
|
typeof(IReadOnlyList<>).MakeGenericType(elementType).IsAssignableFrom(type),
|
|
$"{file}: <{element.Name.LocalName} {attributeName}=\"{value}\"> is {type.Name}, expected IReadOnlyList<{elementType.Name}>.");
|
|
}
|
|
|
|
private static XElement LoadRoot(string file)
|
|
{
|
|
XDocument document = XDocument.Load(Path.Combine(AppContext.BaseDirectory, file));
|
|
return Assert.IsType<XElement>(document.Root);
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, PropertyInfo> PropertiesOf(Type type) =>
|
|
type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
|
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
|
|
}
|