Merge slice/flagtracker-metas: quests, Mossy Tracker, meta sync

Resolved FeatureCatalog (both blocks kept; the main window's Tools buttons now
open the Mossy Tracker and Metas panels). Tests from both sides adjusted to
the combined plugin: quest tests look at quest frames only (chat is streamed
too), and the stream tests ignore the /myquests request sent at login.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:29:10 +02:00
commit 2e5d768ca9
19 changed files with 3984 additions and 1 deletions

View file

@ -9,6 +9,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Receive("{\"player_name\":\"mossy\",\"command\":\"/a hello\"}");
Assert.Empty(harness.Host.Automation.Chat.Submitted);
@ -23,6 +25,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Receive("{\"player_name\":\"Horan\",\"command\":\"/a hello\"}");
harness.Tick(0.01);
@ -35,6 +39,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/one\"}");
harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/two\"}");
@ -49,6 +55,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
var toggles = new List<bool>();
harness.Session.RadarToggle = on => toggles.Add(on);
@ -68,6 +76,8 @@ public sealed class BackendSessionTests
var seen = new List<string>();
harness.Session.AddShareHandler(new RecordingShareHandler(seen));
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\"}");
@ -81,6 +91,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Receive("not json");
harness.Receive("[1,2,3]");
@ -94,6 +106,8 @@ public sealed class BackendSessionTests
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Login itself submits /myquests; these tests count only what the backend ran.
harness.Host.Automation.Chat.Submitted.Clear();
harness.Session.OnConnected(new DateTime(2026, 9, 25, 8, 5, 9, DateTimeKind.Utc));

View file

@ -452,6 +452,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

View file

@ -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, registered => registered.Descriptor.WindowId == "flagtracker");
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);
}
}

View file

@ -0,0 +1,361 @@
using System.Net;
using System.Text;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests.Metas;
/// <summary>Answers requests from a table of URLs and records what was asked; no network.</summary>
internal sealed class FakeGitServer : HttpMessageHandler
{
public Dictionary<string, (HttpStatusCode Status, byte[] Body)> Responses { get; } = new(StringComparer.Ordinal);
public List<string> Requests { get; } = [];
public void Text(string url, string body) =>
Responses[url] = (HttpStatusCode.OK, Encoding.UTF8.GetBytes(body));
public void Bytes(string url, byte[] body) => Responses[url] = (HttpStatusCode.OK, body);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string url = request.RequestUri!.AbsoluteUri;
lock (Requests)
Requests.Add(url);
HttpResponseMessage response = Responses.TryGetValue(url, out var answer)
? new HttpResponseMessage(answer.Status) { Content = new ByteArrayContent(answer.Body) }
: new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("not found") };
return Task.FromResult(response);
}
}
public sealed class MetaSyncTests
{
// The original's endpoints, verbatim (MetaSyncManager: ApiBaseUrl + repos/{owner}/{repo}/git/trees/{branch}?recursive=1,
// RawBaseUrl + "/" + escaped path).
private const string TreeUrl =
"https://git.snakedesert.se/api/v1/repos/SawatoMosswartsEnjoyersClub/metas/git/trees/main?recursive=1";
private const string RawBase = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas/raw/branch/main";
// A Gitea tree answer: blobs and trees, only .met/.nav blobs count.
private const string TreeJson = """
{"sha":"abc","url":"x","tree":[
{"path":"README.md","mode":"100644","type":"blob","size":10,"sha":"1"},
{"path":"hunting","mode":"040000","type":"tree","sha":"2"},
{"path":"hunting/Colo.met","mode":"100644","type":"blob","size":10,"sha":"3"},
{"path":"navs/Colo Loop.nav","mode":"100644","type":"blob","size":10,"sha":"4"},
{"path":"x.MET","mode":"100644","type":"blob","size":10,"sha":"5"},
{"path":"folder.nav","mode":"040000","type":"tree","sha":"6"}
],"truncated":false,"page":1,"total_count":6}
""";
private const string ColoMet = "1\r\nCondition\r\nNever\r\n";
private const string LoopNav = "uTank2 NAV 1.2\r\n4\r\n2\r\n";
private const string XMet = "x-meta";
private static (MetaSyncManager Manager, FakeGitServer Server, MemoryStorage Storage, List<string> Notices) Build(
string repositoryUrl = "")
{
var server = new FakeGitServer();
server.Text(TreeUrl, TreeJson);
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
server.Text(RawBase + "/x.MET", XMet);
var storage = new MemoryStorage();
var notices = new List<string>();
var manager = new MetaSyncManager(
storage,
server,
() => repositoryUrl,
() => new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc),
notice => { lock (notices) notices.Add(notice); });
return (manager, server, storage, notices);
}
[Fact]
public void The_default_repository_gives_the_original_endpoints()
{
(string tree, string raw) = MetaSyncManager.ResolveEndpoints(string.Empty);
Assert.Equal(TreeUrl, tree);
Assert.Equal(RawBase, raw);
(tree, raw) = MetaSyncManager.ResolveEndpoints(MetaSyncManager.DefaultRepositoryUrl);
Assert.Equal(TreeUrl, tree);
Assert.Equal(RawBase, raw);
(tree, raw) = MetaSyncManager.ResolveEndpoints("https://git.example.org/me/metas.git/");
Assert.Equal("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", tree);
Assert.Equal("https://git.example.org/me/metas/raw/branch/main", raw);
}
[Fact]
public void Files_land_where_the_automation_plugin_reads_dropped_profiles()
{
Assert.Equal("mosstank/metas/Colo.met", MetaSyncManager.LocalKeyFor("hunting/Colo.met"));
Assert.Equal("mosstank/navs/Colo Loop.nav", MetaSyncManager.LocalKeyFor("navs/Colo Loop.nav"));
Assert.Equal("mosstank/metas/x.MET", MetaSyncManager.LocalKeyFor("x.MET"));
Assert.Null(MetaSyncManager.LocalKeyFor("README.md"));
}
[Fact]
public async Task Refresh_lists_the_met_and_nav_blobs_and_what_is_already_here()
{
var (manager, server, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", "old");
int count = await manager.RefreshCatalogAsync();
Assert.Equal(3, count);
Assert.Equal([TreeUrl], server.Requests);
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], files.Select(file => file.RelativePath));
Assert.Equal(["met", "nav", "met"], files.Select(file => file.Extension));
Assert.Equal(MetaFileSyncStatus.Unknown, files[0].Status);
Assert.Equal("Unknown", files[0].StatusText);
Assert.Equal(MetaFileSyncStatus.NotDownloaded, files[1].Status);
Assert.Equal("Not downloaded", files[1].StatusText);
Assert.Equal("mosstank/navs/Colo Loop.nav", files[1].LocalPath);
Assert.Equal(new DateTime(2026, 9, 25, 8, 0, 0, DateTimeKind.Utc), manager.LastCatalogRefreshUtc);
}
[Fact]
public async Task Check_compares_the_stored_text_with_the_remote_file()
{
var (manager, server, storage, notices) = Build();
storage.Seed("mosstank/metas/Colo.met", ColoMet);
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
storage.Seed("mosstank/metas/x.MET", XMet);
server.Responses.Remove(RawBase + "/x.MET");
var summary = await manager.CheckStatusesAsync();
Assert.Equal((1, 1, 0, 1), summary);
IReadOnlyList<MetaFileEntry> files = manager.GetCachedFiles();
Assert.Equal(
["Up to date", "Update available", "Error"],
files.Select(file => file.StatusText));
Assert.Contains(notices, notice => notice.StartsWith(
"[Metas] Status check failed for x.MET: ", StringComparison.Ordinal));
Assert.Contains(RawBase + "/navs/Colo%20Loop.nav", server.Requests);
}
[Fact]
public async Task Pull_downloads_missing_and_outdated_files_and_backs_up_the_old_copy()
{
var (manager, _, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", ColoMet);
storage.Seed("mosstank/navs/Colo Loop.nav", "an older route");
var result = await manager.DownloadOutdatedAsync();
Assert.Equal((2, 1, 0, 1), result);
Assert.Equal(LoopNav, storage.ReadText("mosstank/navs/Colo Loop.nav"));
Assert.Equal("an older route", storage.ReadText("mosstank/navs/Colo Loop.nav.bak"));
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
Assert.Null(storage.ReadText("mosstank/metas/Colo.met.bak"));
Assert.All(manager.GetCachedFiles(), file => Assert.Equal(MetaFileSyncStatus.UpToDate, file.Status));
// Nothing left to do the second time.
Assert.Equal((0, 0, 0, 0), await manager.DownloadOutdatedAsync());
}
[Fact]
public async Task A_failed_download_is_counted_and_marked()
{
var (manager, server, _, notices) = Build();
server.Responses.Remove(RawBase + "/x.MET");
await manager.RefreshCatalogAsync();
var result = await manager.DownloadOutdatedAsync();
Assert.Equal((2, 0, 1, 0), result);
Assert.Equal(MetaFileSyncStatus.Error, manager.GetCachedFiles().Single(file => file.RelativePath == "x.MET").Status);
Assert.Contains(notices, notice => notice.StartsWith("[Metas] Download failed for x.MET: ", StringComparison.Ordinal));
}
[Fact]
public async Task A_byte_order_mark_is_dropped_so_the_stored_copy_compares_equal()
{
var (manager, server, storage, _) = Build();
server.Bytes(RawBase + "/hunting/Colo.met", [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(ColoMet)]);
await manager.RefreshCatalogAsync();
MetaDownloadResult first = await manager.DownloadFileAsync("hunting/Colo.met");
MetaDownloadResult second = await manager.DownloadFileAsync("hunting/Colo.met");
Assert.Equal("Downloaded.", first.Message);
Assert.Equal(ColoMet, storage.ReadText("mosstank/metas/Colo.met"));
Assert.True(second.SkippedAsCurrent);
Assert.Equal("Already up to date.", second.Message);
Assert.Equal((1, 0, 2, 0), await manager.CheckStatusesAsync());
}
[Fact]
public async Task Pulling_one_file_reports_like_the_original()
{
var (manager, _, storage, _) = Build();
storage.Seed("mosstank/metas/Colo.met", "old");
Assert.Equal("No file selected.", (await manager.DownloadFileAsync(" ")).Message);
Assert.Equal("Only .met and .nav files can be pulled.", (await manager.DownloadFileAsync("README.md")).Message);
Assert.Equal("Downloaded and updated (.bak created).", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
storage.IsAvailable = false;
Assert.Equal("VTank path could not be resolved.", (await manager.DownloadFileAsync("hunting/Colo.met")).Message);
}
[Fact]
public async Task The_repository_setting_is_honoured()
{
var (manager, server, _, _) = Build("https://git.example.org/me/metas");
server.Text("https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1", TreeJson);
Assert.Equal(3, await manager.RefreshCatalogAsync());
Assert.Equal(["https://git.example.org/api/v1/repos/me/metas/git/trees/main?recursive=1"], server.Requests);
}
// ---- through the /mm verbs and the window ------------------------------
private static (PluginHarness Harness, MetaSyncFeature Feature, FakeGitServer Server, MemoryStorage Storage) BuildFeature(
bool hasUi = false)
{
var harness = new PluginHarness(arrange: host => host.HasUi = hasUi);
harness.Login();
var server = new FakeGitServer();
server.Text(TreeUrl, TreeJson);
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
server.Text(RawBase + "/x.MET", XMet);
var storage = new MemoryStorage { RootPath = "C:/profiles" };
var feature = new MetaSyncFeature(harness.Context, storage, server);
return (harness, feature, server, storage);
}
private static async Task Settle(MetaSyncFeature feature)
{
await feature.LastOperation;
feature.Pump();
}
[Fact]
public async Task The_mm_verbs_write_the_original_chat_lines_on_the_tick()
{
var (harness, feature, server, storage) = BuildFeature();
using (harness)
using (feature)
{
storage.Seed("mosstank/metas/Colo.met", ColoMet);
harness.Context.Commands.Dispatch("metasrefresh");
await Settle(feature);
harness.Context.Commands.Dispatch("metascheck");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspull");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspullfile navs/Colo Loop.nav");
await Settle(feature);
harness.Context.Commands.Dispatch("metaspullfile");
server.Responses.Remove(TreeUrl);
harness.Context.Commands.Dispatch("metasrefresh");
await Settle(feature);
Assert.Equal(
[
"[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files",
"[Mosswart Massacre] [Metas] Up-to-date: 1, Updates: 0, Missing: 2, Errors: 0",
"[Mosswart Massacre] [Metas] Downloaded: 2, Skipped: 0, Failed: 0, Backups: 0",
"[Mosswart Massacre] [Metas] navs/Colo Loop.nav: Already up to date.",
"[Mosswart Massacre] Usage: /mm metaspullfile <relative/path/file.met>",
"[Mosswart Massacre] [Metas] Refresh failed: Response status code does not indicate success: 404 (Not Found).",
],
harness.ChatLines.Where(line => line.Contains("[Metas]", StringComparison.Ordinal)
|| line.Contains("metaspullfile", StringComparison.Ordinal)));
}
}
[Fact]
public async Task Nothing_reaches_chat_before_the_tick_applies_it()
{
var (harness, feature, _, _) = BuildFeature();
using (harness)
using (feature)
{
harness.Context.Commands.Dispatch("metasrefresh");
await feature.LastOperation;
Assert.DoesNotContain(harness.ChatLines, line => line.Contains("[Metas]", StringComparison.Ordinal));
harness.Tick(0.01);
Assert.Contains("[Mosswart Massacre] [Metas] Loaded 3 remote .met/.nav files", harness.ChatLines);
}
}
[Fact]
public void A_headless_host_gets_the_verbs_but_no_window()
{
var (harness, feature, _, _) = BuildFeature();
using (harness)
using (feature)
{
Assert.Null(feature.Panel);
Assert.Null(harness.Feature<MetaSyncFeature>().Panel);
Assert.Empty(harness.Host.Ui.Panels);
}
}
[Fact]
public async Task The_window_lists_searches_and_downloads_the_picked_file()
{
var (harness, feature, _, storage) = BuildFeature(hasUi: true);
using (harness)
using (feature)
{
MetasViewModel panel = feature.Panel!;
Assert.False(panel.IsVisible);
Assert.Equal("VTank Path: C:/profiles", panel.PathText);
Assert.Equal(["No files loaded"], panel.FilePaths);
storage.Seed("mosstank/metas/x.MET", "stale");
panel.RefreshList();
Assert.Equal("Status: refreshing remote file list...", panel.StatusText);
await Settle(feature);
Assert.Equal("Status: loaded 3 remote files", panel.StatusText);
// Missing first, then the rest by path.
Assert.Equal(["hunting/Colo.met", "navs/Colo Loop.nav", "x.MET"], panel.FilePaths);
Assert.Equal(["MET", "NAV", "MET"], panel.FileTypes);
Assert.Equal(["Not downloaded", "Not downloaded", "Unknown"], panel.FileStatus);
Assert.Equal([0x0046A0u, 0x0046A0u, 0xFFFFFFu], panel.FileStatusColors);
Assert.Equal(["[met] hunting/Colo.met", "[nav] navs/Colo Loop.nav", "[met] x.MET"], panel.SelectionItems);
Assert.Equal("[met] hunting/Colo.met", panel.SelectedLabel);
Assert.StartsWith("Last Check: 2026-09-25 ", panel.LastCheckText, StringComparison.Ordinal);
panel.CheckUpdates();
await Settle(feature);
Assert.Equal("Status: 0 current, 1 updates, 2 missing, 0 errors", panel.StatusText);
Assert.Equal(["Not downloaded", "Not downloaded", "Update available"], panel.FileStatus);
Assert.Equal(0xB46E00u, panel.FileStatusColors[2]);
panel.SearchChanged("loop");
Assert.Equal(["navs/Colo Loop.nav"], panel.FilePaths);
Assert.Equal("[nav] navs/Colo Loop.nav", panel.SelectedLabel);
panel.SearchChanged("zzz");
Assert.Equal(["No matches"], panel.FilePaths);
Assert.Equal(["Try a different filter"], panel.FileStatus);
panel.ClearSearch();
Assert.Equal(3, panel.FilePaths.Count);
panel.SelectionChanged("[met] x.MET");
panel.DownloadSelected();
Assert.Equal("Status: downloading x.MET...", panel.StatusText);
await Settle(feature);
Assert.Equal("Status: Downloaded and updated (.bak created).", panel.StatusText);
Assert.Equal(XMet, storage.ReadText("mosstank/metas/x.MET"));
Assert.Equal("stale", storage.ReadText("mosstank/metas/x.MET.bak"));
Assert.Equal("[met] x.MET", panel.SelectedLabel);
panel.DownloadOutdated();
await Settle(feature);
Assert.Equal("Status: downloaded 2, skipped 0, failed 0, backups 0", panel.StatusText);
Assert.All(panel.FileStatus, status => Assert.Equal("Up to date", status));
}
}
}

View file

@ -0,0 +1,235 @@
using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.Tests.Quests;
public sealed class QuestTests
{
// Real answer lines in the shape the server writes them (ACE's /myquests:
// "stamp - N solves (last)\"message\" max delta"). The first is the example
// in the original's parser comment, the second the one UtilityBelt quotes.
private const string CommentSample = "SomeQuest - 5 solves (1640995200)\"Quest description here\" 10 3600";
private const string UbSample =
"regaliamaskuber - 1 solves (1522807913)\"Player has collected the mask from a Virinidi Profatrix.\" -1 72000";
private static long UnixNow(PluginHarness harness) =>
new DateTimeOffset(harness.Clock.UtcNow).ToUnixTimeSeconds();
private static string Line(string stamp, int solves, long lastSolved, int max, long delta) =>
$"{stamp} - {solves} solves ({lastSolved})\"A message\" {max} {delta}";
[Fact]
public void Login_asks_the_server_and_reads_the_answer_for_three_seconds()
{
using var harness = new PluginHarness();
harness.Login();
QuestManager quests = harness.Feature<QuestManager>();
Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted);
Assert.True(quests.IsRefreshing);
harness.Host.Automation.Chat.Deliver(CommentSample);
harness.Host.Automation.Chat.Deliver(UbSample);
harness.Host.Automation.Chat.Deliver("You say, \"hello - 3 solves\"");
harness.Run(3.1);
harness.Host.Automation.Chat.Deliver("late - 1 solves (1)\"x\" 1 1");
Assert.False(quests.IsRefreshing);
Assert.Equal(2, quests.QuestList.Count);
Quest first = quests.QuestList[0];
Assert.Equal("SomeQuest", first.Id);
Assert.Equal(5, first.Solves);
Assert.Equal(1640995200, first.Timestamp);
Assert.Equal("Quest description here", first.Description);
Assert.Equal(10, first.MaxSolves);
Assert.Equal(3600, first.Delta);
Assert.Equal(1640995200 + 3600, first.ExpireTime);
Quest second = quests.QuestList[1];
Assert.Equal("regaliamaskuber", second.Id);
Assert.Equal(-1, second.MaxSolves);
Assert.Equal(72000, second.Delta);
Assert.Equal("Player has collected the mask from a Virinidi Profatrix.", second.Description);
}
[Fact]
public void A_refresh_while_one_is_reading_is_ignored_and_the_answer_stays_in_chat()
{
using var harness = new PluginHarness();
harness.Login();
harness.Context.Commands.Dispatch("refreshquests");
Assert.Equal(["/myquests"], harness.Host.Automation.Chat.Submitted);
Assert.Contains("[Mosswart Massacre] [QUEST] Refreshing quest data...", harness.ChatLines);
harness.Host.Automation.Chat.Deliver(UbSample);
// The original never ate the answer lines.
Assert.Contains(harness.Host.Automation.Chat.Delivered, message => message.Text == UbSample);
harness.Run(3.1);
harness.Context.Commands.Dispatch("refreshquests");
Assert.Equal(["/myquests", "/myquests"], harness.Host.Automation.Chat.Submitted);
Assert.Empty(harness.Feature<QuestManager>().QuestList);
}
[Fact]
public void A_line_whose_number_overflows_is_reported_like_the_original()
{
using var harness = new PluginHarness();
harness.Login();
harness.Host.Automation.Chat.Deliver("big - 1 solves (1)\"x\" 1 99999999999");
Assert.Empty(harness.Feature<QuestManager>().QuestList);
Assert.Contains(harness.ChatLines, line => line.StartsWith(
"[Mosswart Massacre] Error parsing quest line 'big - 1 solves (1)\"x\" 1 99999999999': ",
StringComparison.Ordinal));
}
[Fact]
public void The_quest_frame_is_exactly_the_original_one()
{
// The original: WebSocket.SendQuestDataAsync serialized
// new { type = "quest", timestamp = DateTime.UtcNow.ToString("o"),
// character_name = CharacterFilter.Name, quest_name, countdown }
// with JsonConvert.SerializeObject, and QuestStreamingService passed
// QuestManager.GetFriendlyQuestName(quest.Id) and FormatCountdown.
using var harness = new PluginHarness();
harness.Login();
long now = UnixNow(harness);
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 3, now - 100, -1, 100 + 93600));
harness.Feature<QuestManager>().StreamPriorityQuests();
Assert.Equal(
["{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Stipend Collection Timer\",\"countdown\":\"1d 02h\"}"],
harness.Frames.Where(IsQuest));
}
[Fact]
public void Only_the_three_priority_quests_stream_once_each_in_answer_order()
{
using var harness = new PluginHarness();
harness.Login();
long now = UnixNow(harness);
harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 1, now - 10, -1, 5));
harness.Host.Automation.Chat.Deliver(Line("somethingelse", 1, now, -1, 500));
harness.Host.Automation.Chat.Deliver(Line("augmentationblankgemacquired", 1, now, -1, 3 * 3600 + 5 * 60 + 30));
harness.Host.Automation.Chat.Deliver(Line("insatiableeaterjaw", 9, now, -1, 99999));
harness.Feature<QuestManager>().StreamPriorityQuests();
// "insatiableeaterjaw" is in the name table twice; the later entry,
// "Insatiable Eater Jaw", is the one the original sent.
Assert.Equal(
[
"{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Insatiable Eater Jaw\",\"countdown\":\"READY\"}",
"{\"type\":\"quest\",\"timestamp\":\"2026-09-25T08:30:15.1230000Z\",\"character_name\":\"Mossy\",\"quest_name\":\"Blank Augmentation Gem Pickup Timer\",\"countdown\":\"3h 05m\"}",
],
harness.Frames.Where(IsQuest));
}
[Fact]
public void The_stream_runs_every_thirty_seconds_after_login()
{
using var harness = new PluginHarness();
harness.Login();
long now = UnixNow(harness);
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, now, -1, 3600 + 30));
harness.Run(29.5);
Assert.Empty(harness.FramesOfType("quest"));
harness.Run(1);
Assert.Single(harness.FramesOfType("quest"));
Assert.Equal("1h 00m", (string?)harness.FramesOfType("quest").Single()["countdown"]);
harness.Run(30);
Assert.Equal(2, harness.FramesOfType("quest").Count());
}
private static bool IsQuest(string frame) =>
frame.StartsWith("{\"type\":\"quest\"", StringComparison.Ordinal);
[Fact]
public void Nothing_streams_while_streaming_is_off_or_after_logoff()
{
using var off = new PluginHarness(webSocketEnabled: false);
off.Login();
off.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(off), -1, 60));
off.Feature<QuestManager>().StreamPriorityQuests();
Assert.Empty(off.Frames);
using var harness = new PluginHarness();
harness.Login();
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60));
harness.Plugin.OnLogoff();
harness.Run(31);
Assert.DoesNotContain(harness.Frames, IsQuest);
Assert.Equal(0, harness.Host.Automation.Chat.ReceivedSubscriberCount);
}
[Fact]
public void Quest_status_lists_the_priority_quests()
{
using var harness = new PluginHarness();
harness.Login();
harness.Host.Automation.Chat.Deliver(Line("stipendtimer_0812", 1, UnixNow(harness), -1, 60));
harness.Context.Commands.Dispatch("queststatus");
Assert.Contains("[Mosswart Massacre] Timer Active: True", harness.ChatLines);
Assert.Contains("[Mosswart Massacre] Quest Count: 1", harness.ChatLines);
Assert.Contains("[Mosswart Massacre] Priority Quests Found: 1", harness.ChatLines);
Assert.Contains("[Mosswart Massacre] - Stipend Collection Timer (stipendtimer_0812)", harness.ChatLines);
}
[Theory]
[InlineData(-5L, "READY")]
[InlineData(0L, "READY")]
[InlineData(1L, "1s")]
[InlineData(59L, "59s")]
[InlineData(60L, "1m 00s")]
[InlineData(69L, "1m 09s")]
[InlineData(3599L, "59m 59s")]
[InlineData(3600L, "1h 00m")]
[InlineData(3660L, "1h 01m")]
[InlineData(86399L, "23h 59m")]
[InlineData(86400L, "1d 00h")]
[InlineData(93600L, "1d 02h")]
[InlineData(10L * 86400 + 23 * 3600 + 3599, "10d 23h")]
public void Countdown_formats_like_the_original(long seconds, string expected)
{
Assert.Equal(expected, QuestStreamingService.FormatCountdown(seconds));
}
[Theory]
[InlineData(0, "0s")]
[InlineData(-1, "0s")]
[InlineData(59, "59s")]
[InlineData(60, "1m")]
[InlineData(3600, "1h")]
[InlineData(72000, "20h")]
[InlineData(90061, "1d 1h 1m 1s")]
[InlineData(1209600, "14d")]
public void Cooldown_formats_like_the_original(int seconds, string expected)
{
Assert.Equal(expected, QuestManager.FormatSeconds(seconds));
}
[Fact]
public void Last_solved_is_month_first_in_utc_whatever_the_culture()
{
System.Globalization.CultureInfo saved = System.Globalization.CultureInfo.CurrentCulture;
try
{
System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("sv-SE");
Assert.Equal("04/04/2018 05:31:53", QuestManager.FormatTimeStamp(1522819913));
}
finally
{
System.Globalization.CultureInfo.CurrentCulture = saved;
}
}
}

View file

@ -45,6 +45,8 @@ public sealed class StreamGoldenTests
arrange?.Invoke(harness);
harness.Login();
harness.Frames.Clear();
// Login itself submits /myquests; these tests count only their own commands.
harness.Host.Automation.Chat.Submitted.Clear();
return harness;
}

View file

@ -0,0 +1,222 @@
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);
}