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));
}
}
}