using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Views;
namespace OpenAC.MosswartMassacre.Tests.Ui;
///
/// Proves every {Binding} in the plugin's window markup 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.
///
public sealed class WindowMarkupContractTests
{
private static readonly string[] InteractiveElementNames =
[
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
];
public static TheoryData MarkupFiles => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
{ MainWindow.MarkupFile, typeof(MainWindow) },
};
/// The windows opened by a button of the plugin's own, whose visibility the plugin binds.
public static TheoryData OpenedWindows => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
};
[Theory]
[MemberData(nameof(MarkupFiles))]
public void EveryBindingResolvesToAPublicProperty(string file, Type bindingType)
{
XElement root = LoadRoot(file);
IReadOnlyDictionary 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 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 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(OpenedWindows))]
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}: {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}: 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 byName)
{
if (element.Name.LocalName == "column")
{
AssertBindingType(file, element, "onchange", typeof(Action), byName);
AssertBindingType(file, element, "onclick", typeof(Action), 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), 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),
"slider" => typeof(Action),
"list" => typeof(Action),
_ => 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 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 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(document.Root);
}
private static IReadOnlyDictionary PropertiesOf(Type type) =>
type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
}