Meta sync: pull the club's metas and navs into the shared profiles
The original kept VTank's profile folder in step with the club's metas repository; the port does the same against host.VtankProfiles, dropping .met files under mosstank/metas and .nav files under mosstank/navs where MossTank reads dropped profiles, flattened to their bare names as the original flattened them. The Gitea tree and raw endpoints are the original's; the repository address is the metas_repository_url setting, defaulting to the original repository. An overwritten file keeps its old copy as .bak (MossTank's pickers skip .bak side files). Storage is text-only, so the SHA-256 compare hashes the text a stored read gives back (UTF-8, BOM dropped); for the plain-text files in the repo that equals the original's byte compare. Network work runs on the thread pool, one operation after another; chat lines and window updates are applied on the tick. The four /mm verbs keep their chat lines, and the Metas tab is its own markup window with IsVisible for the main window to open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
714fbfa2e8
commit
162d242c06
8 changed files with 1380 additions and 1 deletions
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue