Flag tracker: the Mossy Tracker window as a markup panel
The owner calls the flag tracker important, so the original's pages come across with their tables unchanged: augmentations and luminance auras from the player's own int properties (the object table carries them), recalls from the spellbook with composited spell icons, cantrips matched from the active enchantments to trained skills, attributes and wards, the tracked weapons the original kept data for but never showed, and the quest timers. Read on the tick thread at login and on each page's Refresh; the quest page redraws every five seconds instead of from a thread-pool timer. The root panel's visibility is the settable IsVisible so the main window can open it; the three sections the original left as stubs stay out, and a headless host registers nothing. The fake spell catalog learns IsKnown. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b5da797e0
commit
714fbfa2e8
8 changed files with 1735 additions and 0 deletions
|
|
@ -446,6 +446,11 @@ public sealed class FakeSpellCatalog : ISpellCatalog
|
|||
/// <summary>The full spell table, independent of what the character knows.</summary>
|
||||
public IReadOnlyList<PluginSpellInfo> All { get; set; } = [];
|
||||
|
||||
/// <summary>The spells the character knows, for <see cref="IsKnown"/>.</summary>
|
||||
public HashSet<uint> KnownSpellIds { get; } = [];
|
||||
|
||||
public bool IsKnown(uint spellId) => KnownSpellIds.Contains(spellId);
|
||||
|
||||
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
||||
{
|
||||
foreach (PluginSpellInfo spell in KnownSelfBuffs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Tests.Fakes;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.FlagTracker;
|
||||
|
||||
public sealed class FlagTrackerTests
|
||||
{
|
||||
private const uint Player = 0x50000001;
|
||||
|
||||
private static PluginHarness WithUi(Action<FakeHost>? arrange = null) =>
|
||||
new(arrange: host =>
|
||||
{
|
||||
host.HasUi = true;
|
||||
arrange?.Invoke(host);
|
||||
});
|
||||
|
||||
private static void SetPlayerInts(FakeHost host, Dictionary<uint, int> ints) =>
|
||||
host.Automation.Objects.Properties[Player] = new PluginItemProperties(
|
||||
ints,
|
||||
new Dictionary<uint, long>(),
|
||||
new Dictionary<uint, bool>(),
|
||||
new Dictionary<uint, double>(),
|
||||
new Dictionary<uint, string>(),
|
||||
new Dictionary<uint, uint>(),
|
||||
new Dictionary<uint, uint>());
|
||||
|
||||
private static FlagTrackerViewModel Panel(PluginHarness harness) =>
|
||||
harness.Feature<FlagTrackerFeature>().Panel!;
|
||||
|
||||
private static int Row(IReadOnlyList<string> names, string name)
|
||||
{
|
||||
int index = names.ToList().IndexOf(name);
|
||||
Assert.True(index >= 0, $"no row {name}");
|
||||
return index;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_headless_host_gets_no_window_and_login_does_nothing()
|
||||
{
|
||||
using var harness = new PluginHarness();
|
||||
harness.Login();
|
||||
harness.Run(6);
|
||||
|
||||
FlagTrackerFeature feature = harness.Feature<FlagTrackerFeature>();
|
||||
Assert.Null(feature.Panel);
|
||||
Assert.Empty(harness.Host.Ui.Panels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_graphical_host_gets_the_mossy_tracker_window_closed()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
|
||||
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels);
|
||||
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
|
||||
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
|
||||
Assert.True(panel.Descriptor.ShowInSidePanel);
|
||||
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
|
||||
Assert.Same(Panel(harness), panel.Binding);
|
||||
Assert.False(Panel(harness).IsVisible);
|
||||
Assert.True(Panel(harness).AugsSelected);
|
||||
|
||||
Panel(harness).ShowQuests();
|
||||
Assert.True(Panel(harness).QuestsSelected);
|
||||
Assert.False(Panel(harness).AugsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Augmentations_come_from_the_players_own_properties()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [231] = 3, [230] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal("--- Death Augs ---", panel.AugNames[0]);
|
||||
int keepItems = Row(panel.AugNames, "Keep Items");
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepItems]);
|
||||
Assert.Equal(0x008000u, panel.AugProgressColors[keepItems]);
|
||||
Assert.Equal("Rohula bint Ludun", panel.AugTrainers[keepItems]);
|
||||
Assert.Equal("Ayan Baqur", panel.AugLocations[keepItems]);
|
||||
|
||||
int keepSpells = Row(panel.AugNames, "Keep Spells");
|
||||
Assert.Equal("[0/1]", panel.AugProgress[keepSpells]);
|
||||
Assert.Equal(0xFF0000u, panel.AugProgressColors[keepSpells]);
|
||||
|
||||
int carrying = Row(panel.AugNames, "Extra Carrying Capacity");
|
||||
Assert.Equal("[2/5]", panel.AugProgress[carrying]);
|
||||
Assert.Equal(0xFFFF00u, panel.AugProgressColors[carrying]);
|
||||
|
||||
// A bought augmentation shows after Refresh.
|
||||
SetPlayerInts(harness.Host, new() { [232] = 1 });
|
||||
panel.RefreshAugs();
|
||||
Assert.Equal("[MAX]", panel.AugProgress[keepSpells]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Luminance_auras_show_progress_and_the_seer_they_belong_to()
|
||||
{
|
||||
using var harness = WithUi(host => SetPlayerInts(host, new() { [333] = 5, [344] = 2 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int damage = Row(panel.LumNames, "+1 Damage Rating");
|
||||
Assert.Equal("5/5", panel.LumProgress[damage]);
|
||||
Assert.Equal(0x008000u, panel.LumProgressColors[damage]);
|
||||
Assert.Equal("Nalicana Auras", panel.LumCategories[damage]);
|
||||
|
||||
int kahiri = Row(panel.LumNames, "(Ka'hiri) +2 Specialized Skills");
|
||||
Assert.Equal("2/5", panel.LumProgress[kahiri]);
|
||||
Assert.Equal(0xFFFF00u, panel.LumProgressColors[kahiri]);
|
||||
Assert.Equal("LoyalToKahiri", panel.LumCategories[kahiri]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recalls_show_known_and_unknown_with_their_spell_icons()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Spells.KnownSpellIds.Add(2023));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(17, panel.RecallNames.Count);
|
||||
Assert.Equal("Recall the Sanctuary", panel.RecallNames[0]);
|
||||
Assert.Equal(2023u, panel.RecallSpellIds[0]);
|
||||
Assert.Equal("Known", panel.RecallStatus[0]);
|
||||
Assert.Equal(0x008000u, panel.RecallStatusColors[0]);
|
||||
Assert.Equal("Unknown", panel.RecallStatus[1]);
|
||||
Assert.Equal(0xFF0000u, panel.RecallStatusColors[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cantrips_are_matched_to_trained_skills_attributes_and_wards()
|
||||
{
|
||||
using var harness = WithUi(host =>
|
||||
{
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(34, "War Magic", PluginSkillTraining.Specialized, 400));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(6, "Melee Defense", PluginSkillTraining.Trained, 300));
|
||||
host.Automation.Character.Skills.Add(new PluginSkillInfo(21, "Healing", PluginSkillTraining.Untrained, 10));
|
||||
host.Automation.Spells.All =
|
||||
[
|
||||
FakeSpellCatalog.Spell(4715, "Epic War Magic Aptitude"),
|
||||
FakeSpellCatalog.Spell(2248, "Major Invulnerability"),
|
||||
FakeSpellCatalog.Spell(1, "Minor Invulnerability"),
|
||||
FakeSpellCatalog.Spell(6099, "Legendary Flame Ward"),
|
||||
FakeSpellCatalog.Spell(2091, "Major Strength"),
|
||||
FakeSpellCatalog.Spell(2, "Strength Self VIII"),
|
||||
];
|
||||
host.Automation.Character.ActiveEnchantments =
|
||||
[
|
||||
new PluginActiveEnchantment(4715, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2248, 0, 0, -1),
|
||||
new PluginActiveEnchantment(1, 0, 0, -1),
|
||||
new PluginActiveEnchantment(6099, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2091, 0, 0, -1),
|
||||
new PluginActiveEnchantment(2, 0, 0, 3600),
|
||||
];
|
||||
});
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"--- Specialized Skills ---", "War Magic",
|
||||
"--- Trained Skills ---", "Invulnerability",
|
||||
"--- Attributes ---", "Strength",
|
||||
"--- Protection Auras ---", "Armor", "Bludgeoning Ward", "Piercing Ward",
|
||||
"Slashing Ward", "Flame Ward", "Frost Ward", "Acid Ward", "Storm Ward",
|
||||
],
|
||||
panel.CantripNames);
|
||||
|
||||
Assert.Equal(FlagTrackerData.Icons.CategoryStar, panel.CantripIcons[0]);
|
||||
Assert.Equal("Epic", panel.CantripLevels[1]);
|
||||
Assert.Equal(0x800080u, panel.CantripLevelColors[1]);
|
||||
Assert.Equal(FlagTrackerData.Icons.GreenCircle, panel.CantripIcons[1]);
|
||||
// The higher of two cantrips on one skill wins.
|
||||
Assert.Equal("Major", panel.CantripLevels[3]);
|
||||
Assert.Equal(0x0000FFu, panel.CantripLevelColors[3]);
|
||||
Assert.Equal("Major", panel.CantripLevels[5]);
|
||||
Assert.Equal("N/A", panel.CantripLevels[7]);
|
||||
Assert.Equal(FlagTrackerData.Icons.RedCircle, panel.CantripIcons[7]);
|
||||
int flame = Row(panel.CantripNames, "Flame Ward");
|
||||
Assert.Equal("Legendary", panel.CantripLevels[flame]);
|
||||
Assert.Equal(0xFFA500u, panel.CantripLevelColors[flame]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracked_weapons_are_found_in_the_inventory_by_name()
|
||||
{
|
||||
using var harness = WithUi(host => host.Automation.Items.Owned.Add(
|
||||
FakeItems.Item(0x80000001, "Atlan Sword") with { IconId = 0x06001234 }));
|
||||
harness.Login();
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
|
||||
int atlan = Row(panel.WeaponNames, "Atlan Sword");
|
||||
Assert.Equal("Acquired", panel.WeaponStatus[atlan]);
|
||||
Assert.Equal(0x06001234u, panel.WeaponIcons[atlan]);
|
||||
int axe = Row(panel.WeaponNames, "Atlan Axe");
|
||||
Assert.Equal("Not Acquired", panel.WeaponStatus[axe]);
|
||||
Assert.Equal(FlagTrackerData.Icons.Portal, panel.WeaponIcons[axe]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_quest_page_hides_maxed_quests_and_sorts_by_solves()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
long now = new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
|
||||
var chat = harness.Host.Automation.Chat;
|
||||
chat.Deliver($"stipendtimer_0812 - 2 solves ({now - 10})\"x\" -1 3700");
|
||||
chat.Deliver($"maxed - 5 solves ({now})\"x\" 5 60");
|
||||
chat.Deliver($"aaa - 7 solves ({now - 100})\"x\" -1 50");
|
||||
|
||||
FlagTrackerViewModel panel = Panel(harness);
|
||||
Assert.Equal(["aaa", "Stipend Collection Timer"], panel.QuestRowNames);
|
||||
Assert.Equal(["READY", "1h 01m"], panel.QuestCountdowns);
|
||||
Assert.Equal([0x008000u, 0xFFFF00u], panel.QuestCountdownColors);
|
||||
Assert.Equal(["50s", "1h 1m 40s"], panel.QuestCooldowns);
|
||||
Assert.Equal(["7", "2"], panel.QuestSolves);
|
||||
|
||||
// The countdowns move on every five seconds.
|
||||
harness.Run(5.1);
|
||||
Assert.Equal("1h 01m", panel.QuestCountdowns[1]);
|
||||
harness.Run(120);
|
||||
Assert.StartsWith("59m ", panel.QuestCountdowns[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logoff_empties_the_pages()
|
||||
{
|
||||
using var harness = WithUi();
|
||||
harness.Login();
|
||||
Assert.NotEmpty(Panel(harness).AugNames);
|
||||
|
||||
harness.Plugin.OnLogoff();
|
||||
|
||||
Assert.Empty(Panel(harness).AugNames);
|
||||
Assert.Empty(Panel(harness).QuestRowNames);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
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) },
|
||||
};
|
||||
|
||||
[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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue