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:
Erik 2026-09-25 12:26:14 +02:00
parent 714fbfa2e8
commit 162d242c06
8 changed files with 1380 additions and 1 deletions

View file

@ -1,5 +1,6 @@
using OpenAC.MosswartMassacre.Backend; using OpenAC.MosswartMassacre.Backend;
using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Quests; using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre; namespace OpenAC.MosswartMassacre;
@ -18,5 +19,6 @@ internal static class FeatureCatalog
var quests = new QuestManager(context); var quests = new QuestManager(context);
features.Add(quests); features.Add(quests);
features.Add(new FlagTrackerFeature(context, quests)); features.Add(new FlagTrackerFeature(context, quests));
features.Add(new MetaSyncFeature(context));
} }
} }

View file

@ -0,0 +1,337 @@
using System.Collections.Concurrent;
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>,
/// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a
/// host with a window, the Metas window. Nothing here reaches the backend.
/// </summary>
/// <remarks>
/// Network work runs on the thread pool; every result, chat line and window
/// update is queued and applied on the next tick. The file list is not tied
/// to a character, so it survives a relog.
/// </remarks>
internal sealed class MetaSyncFeature : IMmFeature
{
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-metas.xml";
private readonly MmContext _context;
private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue<Action> _onTick = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly Action<double> _tick;
private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask;
private bool _disposed;
public MetaSyncFeature(MmContext context)
: this(context, context.Host.VtankProfiles, handler: null)
{
}
/// <summary>Builds the feature on a given profile storage and HTTP handler; tests pass fakes.</summary>
internal MetaSyncFeature(MmContext context, IPluginStorage profiles, HttpMessageHandler? handler)
{
_context = context;
_manager = new MetaSyncManager(
profiles,
handler,
() => RepositoryUrl,
() => _context.Clock.UtcNow,
message => _onTick.Enqueue(() => _context.Chat.Write(message)));
_tick = _ => Pump();
context.Host.Events.Tick += _tick;
context.Commands.Register("metasrefresh", _ => RunRefresh(), "Refresh remote metas list");
context.Commands.Register("metascheck", _ => RunCheck(), "Check local metas against remote");
context.Commands.Register("metaspull", _ => RunPullOutdated(), "Download outdated metas/nav files");
context.Commands.Register("metaspullfile", HandlePullFile, "Download one metas/nav file by relative path");
if (!context.Host.HasUi)
return;
Panel = new MetasViewModel();
Panel.ShowPath(_manager.StorageRoot);
Panel.ShowFiles([]);
Panel.ButtonPressed += OnButton;
Panel.SearchRequested += ShowFiles;
string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? ".";
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("metas", "Metas")
{
IconText = "ME",
// Shown while the binding's IsVisible is set; the host's own
// shown state starts true so that flag alone opens it.
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(directory, MarkupFile),
Panel);
}
/// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the
/// window. Null on a host without a window.
/// </summary>
public MetasViewModel? Panel { get; }
internal MetaSyncManager Manager => _manager;
/// <summary>The operation started last; tests await it before pumping the tick.</summary>
internal Task LastOperation => _lastOperation;
/// <summary>The repository setting, or the original's repository when it is empty.</summary>
private string RepositoryUrl
{
get
{
string? url = _context.Settings.Current.MetasRepositoryUrl;
return string.IsNullOrWhiteSpace(url) ? MetaSyncManager.DefaultRepositoryUrl : url;
}
}
public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot);
public void OnLogoff()
{
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_context.Host.Events.Tick -= _tick;
_shutdown.Cancel();
if (Panel is not null)
{
Panel.ButtonPressed -= OnButton;
Panel.SearchRequested -= ShowFiles;
}
_panel?.Dispose();
_onTick.Clear();
// Operations run one after another, so the manager goes once the
// last one, cancelled above, has finished.
_lastOperation.ContinueWith(
_ => { _manager.Dispose(); _shutdown.Dispose(); },
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default);
}
/// <summary>Applies finished work on the tick thread. Internal so tests can pump without a host tick.</summary>
internal void Pump()
{
while (_onTick.TryDequeue(out Action? action))
{
try
{
action();
}
catch (Exception ex)
{
_context.Host.Log.Error("A metas update failed.", ex);
}
}
}
private void RunRefresh(Action<int>? done = null, Action<Exception>? failed = null) =>
Start(
token => _manager.RefreshCatalogAsync(token),
count =>
{
ShowFiles();
if (done is null)
_context.Chat.Write($"[Metas] Loaded {count} remote .met/.nav files");
else
done(count);
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
else
failed(ex);
});
private void RunCheck(
Action<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.CheckStatusesAsync(token),
summary =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Up-to-date: {summary.UpToDate}, Updates: {summary.NeedsUpdate}, Missing: {summary.NotDownloaded}, Errors: {summary.Errors}");
}
else
{
done(summary);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Check failed: {ex.Message}");
else
failed(ex);
});
private void RunPullOutdated(
Action<(int Downloaded, int Skipped, int Failed, int Backups)>? done = null,
Action<Exception>? failed = null) =>
Start(
token => _manager.DownloadOutdatedAsync(token),
result =>
{
ShowFiles();
if (done is null)
{
_context.Chat.Write(
$"[Metas] Downloaded: {result.Downloaded}, Skipped: {result.Skipped}, Failed: {result.Failed}, Backups: {result.Backups}");
}
else
{
done(result);
}
},
ex =>
{
if (failed is null)
_context.Chat.Write($"[Metas] Pull failed: {ex.Message}");
else
failed(ex);
});
private void HandlePullFile(string[] args)
{
if (args.Length < 2)
{
_context.Chat.Write("Usage: /mm metaspullfile <relative/path/file.met>");
return;
}
string relativePath = string.Join(" ", args.Skip(1));
Start(
token => _manager.DownloadFileAsync(relativePath, token),
result =>
{
ShowFiles();
_context.Chat.Write($"[Metas] {relativePath}: {result.Message}");
},
ex => _context.Chat.Write($"[Metas] Pull file failed: {ex.Message}"));
}
private void OnButton(string button)
{
MetasViewModel? panel = Panel;
if (panel is null)
return;
switch (button)
{
case nameof(MetasViewModel.RefreshList):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: refreshing remote file list...";
RunRefresh(
count => panel.StatusText = $"Status: loaded {count} remote files",
ex =>
{
panel.StatusText = "Status: refresh failed";
_context.Chat.Write($"[Metas] Refresh failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.CheckUpdates):
panel.ShowPath(_manager.StorageRoot);
panel.StatusText = "Status: checking remote updates...";
RunCheck(
summary => panel.StatusText =
$"Status: {summary.UpToDate} current, {summary.NeedsUpdate} updates, {summary.NotDownloaded} missing, {summary.Errors} errors",
ex =>
{
panel.StatusText = "Status: update check failed";
_context.Chat.Write($"[Metas] Update check failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadOutdated):
panel.StatusText = "Status: downloading outdated files...";
RunPullOutdated(
result => panel.StatusText =
$"Status: downloaded {result.Downloaded}, skipped {result.Skipped}, failed {result.Failed}, backups {result.Backups}",
ex =>
{
panel.StatusText = "Status: pull outdated failed";
_context.Chat.Write($"[Metas] Pull outdated failed: {ex.Message}");
});
break;
case nameof(MetasViewModel.DownloadSelected):
string selection = panel.SelectedRelativePath;
if (string.IsNullOrWhiteSpace(selection))
{
_context.Chat.Write("[Metas] Select a file first");
return;
}
panel.StatusText = $"Status: downloading {selection}...";
Start(
token => _manager.DownloadFileAsync(selection, token),
result =>
{
ShowFiles();
panel.StatusText = $"Status: {result.Message}";
},
ex =>
{
panel.StatusText = "Status: download failed";
_context.Chat.Write($"[Metas] Download failed: {ex.Message}");
});
break;
}
}
private void ShowFiles()
{
if (Panel is null)
return;
Panel.ShowFiles(_manager.GetCachedFiles());
Panel.ShowLastCheck(_manager.LastStatusCheckUtc, _manager.LastCatalogRefreshUtc);
}
/// <summary>Runs <paramref name="work"/> on the thread pool and applies its outcome on the next tick.</summary>
private void Start<T>(Func<CancellationToken, Task<T>> work, Action<T> done, Action<Exception> failed)
{
if (_disposed)
return;
CancellationToken token = _shutdown.Token;
Task previous = _lastOperation;
// Each operation starts when the one before it has finished, the
// order the player asked for them in.
_lastOperation = Task.Run(async () =>
{
await previous.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
try
{
T result = await work(token).ConfigureAwait(false);
_onTick.Enqueue(() => done(result));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Shutting down: nobody is left to tell.
}
catch (Exception ex)
{
_onTick.Enqueue(() => failed(ex));
}
}, CancellationToken.None);
}
}

View file

@ -0,0 +1,470 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json.Linq;
namespace OpenAC.MosswartMassacre.Metas;
internal enum MetaFileSyncStatus
{
Unknown,
NotDownloaded,
UpToDate,
UpdateAvailable,
Error,
}
/// <summary>One <c>.met</c> or <c>.nav</c> file of the repository and how the local copy compares.</summary>
internal sealed class MetaFileEntry
{
public string RelativePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
/// <summary>The storage key the file is kept under, or empty when there is no storage.</summary>
public string LocalPath { get; set; } = string.Empty;
public bool LocalExists { get; set; }
public string LocalSha256 { get; set; } = string.Empty;
public string RemoteSha256 { get; set; } = string.Empty;
public MetaFileSyncStatus Status { get; set; }
public string StatusText { get; set; } = string.Empty;
public MetaFileEntry Clone() => (MetaFileEntry)MemberwiseClone();
}
internal sealed class MetaDownloadResult
{
public string RelativePath { get; set; } = string.Empty;
public bool Downloaded { get; set; }
public bool SkippedAsCurrent { get; set; }
public bool BackupCreated { get; set; }
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// Keeps the shared automation profiles in step with the metas repository on
/// the club's git server: lists its <c>.met</c> and <c>.nav</c> files through
/// the server's API, compares each with the local copy by SHA-256, and
/// downloads the ones that differ, keeping the old copy as <c>.bak</c>.
/// </summary>
/// <remarks>
/// <para>The endpoints are the original's: the tree comes from
/// <c>{server}/api/v1/repos/{owner}/{repo}/git/trees/main?recursive=1</c> and each
/// file from <c>{repository}/raw/branch/main/{path}</c>. The repository address is a
/// setting that defaults to the original's repository.</para>
/// <para>Files land where the automation plugin reads dropped profiles, flattened
/// to their bare names as the original flattened them into its profile
/// folder: metas under <c>mosstank/metas</c>, navs under <c>mosstank/navs</c>.
/// The shared storage holds text, so both sides are compared as the text a
/// read of the stored file gives back (UTF-8, byte-order mark dropped),
/// hashed as UTF-8; for the plain-text files in the repository that is the
/// same as the original's byte comparison.</para>
/// <para>The methods run off the tick thread: they touch only the network, the
/// shared storage and this object's own list, which is locked. One operation
/// runs at a time; a second waits for the first. Notices for the chat
/// window go through the callback, which the owner marshals to the tick.</para>
/// </remarks>
internal sealed class MetaSyncManager : IDisposable
{
public const string DefaultRepositoryUrl = "https://git.snakedesert.se/SawatoMosswartsEnjoyersClub/metas";
public const string BranchName = "main";
/// <summary>Where the automation plugin looks for dropped metas.</summary>
public const string MetaFolder = "mosstank/metas";
/// <summary>Where the automation plugin looks for dropped navs.</summary>
public const string NavFolder = "mosstank/navs";
private readonly IPluginStorage _storage;
private readonly HttpClient _http;
private readonly Func<string> _repositoryUrl;
private readonly Func<DateTime> _utcNow;
private readonly Action<string> _notice;
private readonly object _sync = new();
private readonly SemaphoreSlim _oneAtATime = new(1, 1);
private List<MetaFileEntry> _cachedFiles = [];
public MetaSyncManager(
IPluginStorage storage,
HttpMessageHandler? handler,
Func<string> repositoryUrl,
Func<DateTime> utcNow,
Action<string> notice)
{
_storage = storage;
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = TimeSpan.FromSeconds(30);
_repositoryUrl = repositoryUrl;
_utcNow = utcNow;
_notice = notice;
}
public DateTime LastCatalogRefreshUtc { get; private set; } = DateTime.MinValue;
public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
/// <summary>The storage root the files are written beneath, for the path label; null without storage.</summary>
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
public IReadOnlyList<MetaFileEntry> GetCachedFiles()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
/// <summary>The API and raw-file addresses for a repository address.</summary>
public static (string TreeUrl, string RawBaseUrl) ResolveEndpoints(string repositoryUrl)
{
string address = string.IsNullOrWhiteSpace(repositoryUrl) ? DefaultRepositoryUrl : repositoryUrl.Trim();
var uri = new Uri(address, UriKind.Absolute);
string server = uri.GetLeftPart(UriPartial.Authority);
string path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
return (
$"{server}/api/v1/repos/{path}/git/trees/{BranchName}?recursive=1",
$"{server}/{path}/raw/branch/{BranchName}");
}
/// <summary>The storage key a repository file is kept under, or null for a file that is neither a meta nor a nav.</summary>
public static string? LocalKeyFor(string relativePath)
{
string fileName = Path.GetFileName((relativePath ?? string.Empty).Replace('\\', '/'));
if (fileName.Length == 0)
return null;
if (fileName.EndsWith(".met", StringComparison.OrdinalIgnoreCase))
return $"{MetaFolder}/{fileName}";
if (fileName.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
return $"{NavFolder}/{fileName}";
return null;
}
public async Task<int> RefreshCatalogAsync(CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int UpToDate, int NeedsUpdate, int NotDownloaded, int Errors)> CheckStatusesAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> workingSet = CloneCache();
if (workingSet.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
workingSet = CloneCache();
}
foreach (MetaFileEntry entry in workingSet)
{
try
{
string? localText = entry.LocalPath.Length == 0 ? null : _storage.ReadText(entry.LocalPath);
if (localText is null)
{
entry.LocalExists = false;
entry.LocalSha256 = string.Empty;
entry.Status = MetaFileSyncStatus.NotDownloaded;
entry.StatusText = "Not downloaded";
continue;
}
entry.LocalExists = true;
entry.LocalSha256 = Sha256(localText);
string remoteText = await DownloadRemoteTextAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
entry.RemoteSha256 = Sha256(remoteText);
if (string.Equals(entry.LocalSha256, entry.RemoteSha256, StringComparison.OrdinalIgnoreCase))
{
entry.Status = MetaFileSyncStatus.UpToDate;
entry.StatusText = "Up to date";
}
else
{
entry.Status = MetaFileSyncStatus.UpdateAvailable;
entry.StatusText = "Update available";
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
entry.Status = MetaFileSyncStatus.Error;
entry.StatusText = "Error";
_notice($"[Metas] Status check failed for {entry.RelativePath}: {ex.Message}");
}
}
lock (_sync)
{
_cachedFiles = workingSet;
LastStatusCheckUtc = _utcNow();
}
return (
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpToDate),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.UpdateAvailable),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.NotDownloaded),
workingSet.Count(static entry => entry.Status == MetaFileSyncStatus.Error));
}
finally
{
_oneAtATime.Release();
}
}
public async Task<MetaDownloadResult> DownloadFileAsync(string relativePath, CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await DownloadFileCoreAsync(relativePath, cancellationToken).ConfigureAwait(false);
}
finally
{
_oneAtATime.Release();
}
}
public async Task<(int Downloaded, int Skipped, int Failed, int Backups)> DownloadOutdatedAsync(
CancellationToken cancellationToken = default)
{
await _oneAtATime.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<MetaFileEntry> current = CloneCache();
if (current.Count == 0)
{
await RefreshCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
current = CloneCache();
}
List<MetaFileEntry> candidates = [.. current
.Where(static entry => entry.Status is MetaFileSyncStatus.UpdateAvailable
or MetaFileSyncStatus.NotDownloaded
or MetaFileSyncStatus.Unknown)
.OrderBy(static entry => entry.RelativePath, StringComparer.OrdinalIgnoreCase)];
int downloaded = 0;
int skipped = 0;
int failed = 0;
int backups = 0;
foreach (MetaFileEntry entry in candidates)
{
try
{
MetaDownloadResult result = await DownloadFileCoreAsync(entry.RelativePath, cancellationToken)
.ConfigureAwait(false);
if (result.Downloaded)
downloaded++;
if (result.SkippedAsCurrent)
skipped++;
if (result.BackupCreated)
backups++;
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
failed++;
_notice($"[Metas] Download failed for {entry.RelativePath}: {ex.Message}");
UpdateSingleCachedEntry(
entry.RelativePath, entry.LocalPath, entry.LocalSha256, entry.RemoteSha256,
MetaFileSyncStatus.Error, "Error");
}
}
return (downloaded, skipped, failed, backups);
}
finally
{
_oneAtATime.Release();
}
}
public void Dispose()
{
_http.Dispose();
_oneAtATime.Dispose();
}
private async Task<int> RefreshCatalogCoreAsync(CancellationToken cancellationToken)
{
List<string> remoteEntries = await FetchRemoteTreeEntriesAsync(cancellationToken).ConfigureAwait(false);
HashSet<string> localKeys = ListLocalKeys();
List<MetaFileEntry> result = [.. remoteEntries
.Where(static path => path.EndsWith(".met", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".nav", StringComparison.OrdinalIgnoreCase))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(path =>
{
string localPath = _storage.IsAvailable ? LocalKeyFor(path) ?? string.Empty : string.Empty;
bool exists = localPath.Length > 0 && localKeys.Contains(localPath);
return new MetaFileEntry
{
RelativePath = path,
FileName = Path.GetFileName(path),
Extension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(),
LocalPath = localPath,
LocalExists = exists,
Status = exists ? MetaFileSyncStatus.Unknown : MetaFileSyncStatus.NotDownloaded,
StatusText = exists ? "Unknown" : "Not downloaded",
};
})];
lock (_sync)
{
_cachedFiles = result;
LastCatalogRefreshUtc = _utcNow();
}
return result.Count;
}
private async Task<MetaDownloadResult> DownloadFileCoreAsync(string relativePath, CancellationToken cancellationToken)
{
var result = new MetaDownloadResult { RelativePath = relativePath };
if (string.IsNullOrWhiteSpace(relativePath))
{
result.Message = "No file selected.";
return result;
}
if (!_storage.IsAvailable)
{
result.Message = "VTank path could not be resolved.";
return result;
}
// The original dropped any file into the profile folder; only the
// two kinds the automation plugin reads have a folder here.
string? localPath = LocalKeyFor(relativePath);
if (localPath is null)
{
result.Message = "Only .met and .nav files can be pulled.";
return result;
}
string remoteText = await DownloadRemoteTextAsync(relativePath, cancellationToken).ConfigureAwait(false);
string remoteHash = Sha256(remoteText);
string? localText = _storage.ReadText(localPath);
if (localText is not null)
{
string localHash = Sha256(localText);
if (string.Equals(localHash, remoteHash, StringComparison.OrdinalIgnoreCase))
{
result.SkippedAsCurrent = true;
result.Message = "Already up to date.";
UpdateSingleCachedEntry(relativePath, localPath, localHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
_storage.WriteText(localPath + ".bak", localText);
result.BackupCreated = true;
}
_storage.WriteText(localPath, remoteText);
result.Downloaded = true;
result.Message = result.BackupCreated ? "Downloaded and updated (.bak created)." : "Downloaded.";
UpdateSingleCachedEntry(relativePath, localPath, remoteHash, remoteHash, MetaFileSyncStatus.UpToDate, "Up to date");
return result;
}
private async Task<List<string>> FetchRemoteTreeEntriesAsync(CancellationToken cancellationToken)
{
(string treeUrl, _) = ResolveEndpoints(_repositoryUrl());
using HttpResponseMessage response = await _http.GetAsync(treeUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (JObject.Parse(json)["tree"] is not JArray tree)
return [];
return [.. tree
.Where(static node => string.Equals(node["type"]?.ToString(), "blob", StringComparison.OrdinalIgnoreCase))
.Select(static node => node["path"]?.ToString())
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Select(static path => path!)];
}
private async Task<string> DownloadRemoteTextAsync(string relativePath, CancellationToken cancellationToken)
{
(_, string rawBaseUrl) = ResolveEndpoints(_repositoryUrl());
string escapedPath = string.Join(
"/",
(relativePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
byte[] bytes = await _http.GetByteArrayAsync($"{rawBaseUrl}/{escapedPath}", cancellationToken)
.ConfigureAwait(false);
return DecodeAsStored(bytes);
}
/// <summary>
/// The text a read of this file from storage would give back: UTF-8,
/// with a leading byte-order mark taken as the encoding it names and
/// dropped, as the host's own text read does.
/// </summary>
internal static string DecodeAsStored(byte[] bytes)
{
using var reader = new StreamReader(new MemoryStream(bytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
internal static string Sha256(string text) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(CultureInfo.InvariantCulture);
private HashSet<string> ListLocalKeys()
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!_storage.IsAvailable)
return keys;
foreach (string key in _storage.List(MetaFolder))
keys.Add(key);
foreach (string key in _storage.List(NavFolder))
keys.Add(key);
return keys;
}
private List<MetaFileEntry> CloneCache()
{
lock (_sync)
return [.. _cachedFiles.Select(static entry => entry.Clone())];
}
private void UpdateSingleCachedEntry(
string relativePath,
string localPath,
string localHash,
string remoteHash,
MetaFileSyncStatus status,
string statusText)
{
lock (_sync)
{
MetaFileEntry? target = _cachedFiles.FirstOrDefault(entry =>
string.Equals(entry.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase));
if (target is null)
return;
target.LocalPath = localPath;
target.LocalExists = status == MetaFileSyncStatus.UpToDate
|| (localPath.Length > 0 && _storage.ReadText(localPath) is not null);
target.LocalSha256 = localHash ?? string.Empty;
target.RemoteSha256 = remoteHash ?? string.Empty;
target.Status = status;
target.StatusText = statusText ?? "Unknown";
}
}
}

View file

@ -0,0 +1,172 @@
using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas;
/// <summary>
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>),
/// the original's Metas tab: the repository's files with their status, a
/// live search, a pick list for one file, and the refresh, check and
/// download buttons. Assigned on the tick thread only.
/// </summary>
internal sealed class MetasViewModel
{
private readonly Dictionary<string, string> _pathByLabel = new(StringComparer.Ordinal);
public MetasViewModel()
{
IgnoreRow = _ => { };
SearchChanged = text =>
{
SearchText = text ?? string.Empty;
SearchRequested?.Invoke();
};
ClearSearch = () =>
{
SearchText = string.Empty;
SearchRequested?.Invoke();
};
SelectionChanged = label => SelectedLabel = label ?? string.Empty;
RefreshList = () => ButtonPressed?.Invoke(nameof(RefreshList));
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
}
/// <summary>A button was pressed; the argument is its binding name.</summary>
internal event Action<string>? ButtonPressed;
/// <summary>The search text changed or was cleared.</summary>
internal event Action? SearchRequested;
/// <summary>
/// Whether the window is open. The root panel's visibility is bound to it,
/// so the main window opens this one by setting it; it starts closed.
/// </summary>
public bool IsVisible { get; set; }
public string PathText { get; set; } = "VTank Path: unresolved";
public string LastCheckText { get; set; } = "Last Check: never";
public string StatusText { get; set; } = "Status: idle";
public string SearchText { get; set; } = string.Empty;
public Action<string> SearchChanged { get; }
public Action ClearSearch { get; }
public Action RefreshList { get; }
public Action CheckUpdates { get; }
public Action DownloadOutdated { get; }
public Action DownloadSelected { get; }
public int NoRow => -1;
public Action<int> IgnoreRow { get; }
public IReadOnlyList<string> FileTypes { get; private set; } = [];
public IReadOnlyList<string> FilePaths { get; private set; } = [];
public IReadOnlyList<string> FileStatus { get; private set; } = [];
public IReadOnlyList<uint> FileStatusColors { get; private set; } = [];
public IReadOnlyList<string> SelectionItems { get; private set; } = [];
public string SelectedLabel { get; set; } = string.Empty;
public Action<string> SelectionChanged { get; }
/// <summary>The repository path of the file picked in the list, or empty.</summary>
public string SelectedRelativePath =>
_pathByLabel.TryGetValue(SelectedLabel, out string? path) ? path : string.Empty;
/// <summary>
/// Redraws the list and the pick list from the manager's files, filtered
/// by the search text on path or name. The list puts the files that need
/// attention first: missing, then outdated, then failed, then current.
/// </summary>
public void ShowFiles(IReadOnlyList<MetaFileEntry> all)
{
IReadOnlyList<MetaFileEntry> files = ApplySearch(all);
List<MetaFileEntry> ordered = [.. files
.OrderBy(static file => file.Status switch
{
MetaFileSyncStatus.NotDownloaded => 0,
MetaFileSyncStatus.UpdateAvailable => 1,
MetaFileSyncStatus.Error => 2,
_ => 3,
})
.ThenBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase)];
if (ordered.Count == 0)
{
bool searching = !string.IsNullOrWhiteSpace(SearchText);
FileTypes = ["-"];
FilePaths = [searching ? "No matches" : "No files loaded"];
FileStatus = [searching ? "Try a different filter" : "Click Refresh List"];
FileStatusColors = [0xFFFFFFu];
}
else
{
FileTypes = [.. ordered.Select(static file => file.Extension.ToUpperInvariant())];
FilePaths = [.. ordered.Select(static file => file.RelativePath)];
FileStatus = [.. ordered.Select(static file => file.StatusText)];
FileStatusColors = [.. ordered.Select(static file => StatusColor(file.Status))];
}
string previous = SelectedRelativePath;
_pathByLabel.Clear();
var labels = new List<string>();
foreach (MetaFileEntry file in files.OrderBy(static file => file.RelativePath, StringComparer.OrdinalIgnoreCase))
{
string label = $"[{file.Extension}] {file.RelativePath}";
labels.Add(label);
_pathByLabel[label] = file.RelativePath;
}
SelectionItems = labels;
SelectedLabel = labels.FirstOrDefault(label =>
previous.Length > 0 && _pathByLabel[label].Equals(previous, StringComparison.OrdinalIgnoreCase))
?? labels.FirstOrDefault()
?? string.Empty;
}
public void ShowLastCheck(DateTime lastStatusCheckUtc, DateTime lastCatalogRefreshUtc)
{
DateTime stamp = lastStatusCheckUtc != DateTime.MinValue ? lastStatusCheckUtc : lastCatalogRefreshUtc;
LastCheckText = stamp == DateTime.MinValue
? "Last Check: never"
: string.Create(
CultureInfo.InvariantCulture,
$"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}");
}
public void ShowPath(string? storageRoot) =>
PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}";
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
public static uint StatusColor(MetaFileSyncStatus status) => status switch
{
MetaFileSyncStatus.UpToDate => 0x008000u,
MetaFileSyncStatus.UpdateAvailable => 0xB46E00u,
MetaFileSyncStatus.NotDownloaded => 0x0046A0u,
MetaFileSyncStatus.Error => 0xB40000u,
_ => 0xFFFFFFu,
};
private IReadOnlyList<MetaFileEntry> ApplySearch(IReadOnlyList<MetaFileEntry> files)
{
string filter = SearchText.Trim();
if (filter.Length == 0)
return files;
return [.. files.Where(file =>
file.RelativePath.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| file.FileName.Contains(filter, StringComparison.OrdinalIgnoreCase))];
}
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Metas window: the original's Metas tab in a window of its own. The
repository's .met/.nav files with their status, a live search, a pick list
for one file, and refresh, check and download. -->
<panel x="80" y="80" w="420" h="320" title="Metas"
visible="{IsVisible}" resizable="true" minw="420" minh="320">
<label x="10" y="24" text="Remote .met/.nav files from metas repo" />
<label x="10" y="40" text="{PathText}" />
<button x="10" y="58" w="95" h="22" text="Refresh List" onclick="{RefreshList}" />
<button x="110" y="58" w="95" h="22" text="Check Updates" onclick="{CheckUpdates}" />
<button x="210" y="58" w="120" h="22" text="Download Outdated" onclick="{DownloadOutdated}" />
<label x="10" y="86" text="Search:" />
<field x="58" y="84" w="212" h="20" text="{SearchText}" onchange="{SearchChanged}" />
<button x="275" y="83" w="55" h="22" text="Clear" onclick="{ClearSearch}" />
<list x="10" y="110" w="400" h="128" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="45" items="{FileTypes}" />
<column type="text" width="230" items="{FilePaths}" />
<column type="text" width="*" items="{FileStatus}" colors="{FileStatusColors}" />
</list>
<label x="10" y="246" text="Selected file:" anchor="left bottom" />
<menu x="95" y="244" w="225" h="20" anchor="left bottom"
items="{SelectionItems}" selected="{SelectedLabel}" onchange="{SelectionChanged}" />
<button x="325" y="243" w="85" h="22" text="Download" anchor="left bottom"
onclick="{DownloadSelected}" />
<label x="10" y="272" text="{LastCheckText}" anchor="left bottom" />
<label x="10" y="290" text="{StatusText}" anchor="left bottom" />
</panel>

View file

@ -52,7 +52,7 @@ public sealed class FlagTrackerTests
{ {
using var harness = WithUi(); using var harness = WithUi();
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels); RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
Assert.Equal("flagtracker", panel.Descriptor.WindowId); Assert.Equal("flagtracker", panel.Descriptor.WindowId);
Assert.Equal("Mossy Tracker", panel.Descriptor.Title); Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
Assert.True(panel.Descriptor.ShowInSidePanel); Assert.True(panel.Descriptor.ShowInSidePanel);

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

View file

@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using System.Xml.Linq; using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Metas;
namespace OpenAC.MosswartMassacre.Tests.Ui; namespace OpenAC.MosswartMassacre.Tests.Ui;
@ -19,6 +20,7 @@ public sealed class TrackerAndMetasMarkupContractTests
public static TheoryData<string, Type> MarkupFiles => new() public static TheoryData<string, Type> MarkupFiles => new()
{ {
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) }, { FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
}; };
[Theory] [Theory]