Live in 0.1.19 the plugin put three buttons on the plugin bar (MM, Mossy Tracker, Metas), the tracker carried the icon that belongs to the main window, and the metas downloads were in a window of their own. Now only the main window has a bar button: "MM" with the original window's icon (7735, 0x06001E37). Its tabs follow the original's notebook: Main, Settings, Statistics, Mossy Tracker, Metas, Tools. The Mossy Tracker tab is the original page, one "Open Mossy Tracker" button that opens the tracker window; that window is registered without a bar button and opened through the host's ShowPanel. The Metas tab holds everything the metas window had (list with search, refresh, check, download outdated, download one file, last check, status), plus the repository address and the two folders the files land in. The main window forwards those bindings to the meta sync's view model, since markup binds one object. Downloads already went to the host's VTank profile storage, the one MossTank reads, under mosstank/metas and mosstank/navs with each file's own name and extension; the tab now shows those full folder paths instead of the storage root. Backend frames are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
18 KiB
C#
390 lines
18 KiB
C#
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 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<MetaSyncFeature>().Manager.StorageRoot);
|
|
Assert.NotEqual(harness.Host.Storage.RootPath, harness.Feature<MetaSyncFeature>().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<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.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));
|
|
}
|
|
}
|
|
}
|