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:
commit
2e5d768ca9
19 changed files with 3984 additions and 1 deletions
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
361
tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
Normal file
361
tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
235
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal file
235
tests/OpenAC.MosswartMassacre.Tests/Quests/QuestTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue