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
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal file
337
src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal file
470
src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal file
172
src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
Normal 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))];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal file
35
src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
Normal 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>
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue