using System.Net; using System.Text; using OpenAC.MosswartMassacre.Metas; using OpenAC.MosswartMassacre.Tests.Fakes; namespace OpenAC.MosswartMassacre.Tests.Metas; /// Answers requests from a table of URLs and records what was asked; no network. internal sealed class FakeGitServer : HttpMessageHandler { public Dictionary Responses { get; } = new(StringComparer.Ordinal); public List 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 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 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(); 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 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 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 ", "[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 async Task Downloads_land_in_the_shared_profile_storage_the_automation_plugin_loads_from() { using var harness = new PluginHarness(); harness.Login(); MemoryStorage profiles = harness.Host.VtankProfiles; // The plugin's own sync writes to the shared profile storage, not to // the plugin's private storage. Assert.Equal(profiles.RootPath, harness.Feature().Manager.StorageRoot); Assert.NotEqual(harness.Host.Storage.RootPath, harness.Feature().Manager.StorageRoot); 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); using var feature = new MetaSyncFeature(harness.Context, profiles, server); harness.Context.Commands.Dispatch("metaspull"); await Settle(feature); // Each file under its own name and extension, metas and navs apart, // where a bare name in the automation plugin finds it. Assert.Equal(ColoMet, profiles.ReadText("mosstank/metas/Colo.met")); Assert.Equal(XMet, profiles.ReadText("mosstank/metas/x.MET")); Assert.Equal(LoopNav, profiles.ReadText("mosstank/navs/Colo Loop.nav")); Assert.Null(harness.Host.Storage.ReadText("mosstank/metas/Colo.met")); } [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().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.Equal($"Metas: {Path.Combine("C:/profiles", "mosstank", "metas")}", panel.MetaFolderText); Assert.Equal($"Navs: {Path.Combine("C:/profiles", "mosstank", "navs")}", panel.NavFolderText); 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)); } } }