diff --git a/README.md b/README.md
index 9909ad1..70d0c4a 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,11 @@ Streaming is off until you turn it on. The shared secret is never compiled in:
`/mm help` lists every command.
+The plugin has one button on the plugin bar, MM. Its Metas tab downloads the
+club's metas and navs into the shared VTank profile folder the client
+configures (by default `%LOCALAPPDATA%\OpenAC\vtank`), under `mosstank\metas`
+and `mosstank\navs`, where MossTank loads them; the tab shows both folders.
+
## Testing against a stand-in backend
`tools/FrameRecorder` accepts the plugin's socket like the backend does,
diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
index 025d17e..eda77b6 100644
--- a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
+++ b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs
@@ -29,6 +29,13 @@ namespace OpenAC.MosswartMassacre.Backend;
/// ten seconds, and only then closes. Whatever that run still receives goes
/// nowhere, so a relog never hears the previous character's frames.
///
+///
+/// Every status and error line (connecting, connected, registered, errors,
+/// reconnects, disabled) goes to the host log and is also queued for the
+/// player's chat, worded as the original printed it there. The socket thread
+/// never writes to chat; the tick thread drains the queue through
+/// .
+///
///
internal sealed class BackendClient : IDisposable
{
@@ -39,6 +46,7 @@ internal sealed class BackendClient : IDisposable
private readonly IPluginLogger _log;
private readonly object _gate = new();
+ private readonly ConcurrentQueue _status = new();
private Run? _run;
public BackendClient(IPluginLogger log)
@@ -77,7 +85,7 @@ internal sealed class BackendClient : IDisposable
return;
var run = new Run();
_run = run;
- _log.Info("[WebSocket] connecting");
+ Status("[WebSocket] connecting…");
run.Loop = Task.Run(() => RunAsync(run, endpoint, secret, playerName, utcNow));
}
}
@@ -92,6 +100,8 @@ internal sealed class BackendClient : IDisposable
}
if (run is null)
return;
+ // Said at once, as the original did, while the run finishes behind it.
+ Status("[WebSocket] DISABLED");
Channel? outbound;
Task? sendLoop;
@@ -120,10 +130,16 @@ internal sealed class BackendClient : IDisposable
}
}
run.Cts.Dispose();
- _log.Info("[WebSocket] DISABLED");
});
}
+ ///
+ /// The next status or error line for the player's chat, oldest first. Lines
+ /// of a stopped run still come out, as the original printed them after it
+ /// said DISABLED.
+ ///
+ public bool TryTakeStatus(out string line) => _status.TryDequeue(out line!);
+
/// Queues a frame. Dropped when no socket is open.
public bool Send(string json)
{
@@ -173,12 +189,12 @@ internal sealed class BackendClient : IDisposable
{
socket.Options.SetRequestHeader(SecretHeader, secret);
await socket.ConnectAsync(endpoint, token).ConfigureAwait(false);
- _log.Info("[WebSocket] CONNECTED");
+ Status("[WebSocket] CONNECTED");
DateTime connectedAt = utcNow();
string register = Wire.Serialize(new { type = "register", player_name = playerName });
await SendFrameAsync(socket, register, token).ConfigureAwait(false);
- _log.Info("[WebSocket] REGISTERED");
+ Status("[WebSocket] REGISTERED");
outbound = Channel.CreateUnbounded(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
@@ -197,17 +213,27 @@ internal sealed class BackendClient : IDisposable
Task receive = ReceiveLoopAsync(socket, run.Inbound, token);
await Task.WhenAny(receive, send).ConfigureAwait(false);
+
+ // The original's telemetry loop ran for as long as the socket
+ // did, and said why it ended. A stop here is the stop flag
+ // first and the token later, so both count as cancelled.
+ bool cancelled = token.IsCancellationRequested || run.Stopping;
+ if (cancelled)
+ Status("[WebSocket] Telemetry loop cancelled");
+ Status($"[WebSocket] Telemetry loop ended - State: {socket.State}, Cancelled: {cancelled}");
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
+ Status("[WebSocket] Connection cancelled");
break;
}
catch (Exception ex)
{
- _log.Warn($"[WebSocket] Connection error: {ex.Message}");
+ Status($"[WebSocket] Connection error: {ex.Message}", warn: true);
}
finally
{
+ Status($"[WebSocket] Cleaning up connection - Final state: {socket.State}");
lock (run)
{
if (ReferenceEquals(run.Outbound, outbound))
@@ -220,7 +246,7 @@ internal sealed class BackendClient : IDisposable
if (token.IsCancellationRequested || run.Stopping)
break;
- _log.Info("[WebSocket] Reconnecting in 2 seconds...");
+ Status("[WebSocket] Reconnecting in 2 seconds...");
try
{
await Task.Delay(ReconnectDelay, token).ConfigureAwait(false);
@@ -249,7 +275,7 @@ internal sealed class BackendClient : IDisposable
}
catch (Exception ex)
{
- _log.Warn($"[WebSocket] receive error: {ex.Message}");
+ Status($"[WebSocket] receive error: {ex.Message}", warn: true);
return;
}
@@ -282,10 +308,20 @@ internal sealed class BackendClient : IDisposable
}
catch (Exception ex)
{
- _log.Warn($"[WebSocket] Send error: {ex.Message}");
+ Status($"[WebSocket] Send error: {ex.Message}", warn: true);
}
}
+ /// Queues a line for chat, then logs it; queued first so a line seen in the log is already drainable.
+ private void Status(string line, bool warn = false)
+ {
+ _status.Enqueue(line);
+ if (warn)
+ _log.Warn(line);
+ else
+ _log.Info(line);
+ }
+
private static Task SendFrameAsync(ClientWebSocket socket, string text, CancellationToken token)
{
byte[] bytes = Encoding.UTF8.GetBytes(text);
diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs
index d740abe..2699bce 100644
--- a/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs
+++ b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs
@@ -17,6 +17,12 @@ namespace OpenAC.MosswartMassacre.Backend;
/// stop_radar toggle the radar; any other text is run as if typed into
/// the chat bar. Commands run one per tick, as the original ran one per timer
/// tick.
+///
+/// The socket's status and error lines reach the chat here, on the tick
+/// thread: each tick drains them before anything else, and a start or stop
+/// made on this thread drains at once so its line comes out in the order the
+/// original printed it.
+///
///
internal sealed class BackendSession : IMmFeature
{
@@ -47,11 +53,13 @@ internal sealed class BackendSession : IMmFeature
{
if (_context.Settings.Current.WebSocketEnabled)
StartSocket();
+ DrainStatus();
}
public void OnLogoff()
{
_context.Backend.Stop();
+ DrainStatus();
_pendingCommands.Clear();
}
@@ -64,6 +72,8 @@ internal sealed class BackendSession : IMmFeature
/// Drains the socket. Public so tests can pump without a host tick.
public void Pump()
{
+ DrainStatus();
+
while (_context.Backend.TryTakeConnection(out BackendConnection connection))
OnConnected(connection.ConnectedAtUtc);
@@ -93,6 +103,13 @@ internal sealed class BackendSession : IMmFeature
}
}
+ /// Writes the socket's queued status lines to chat. Tick thread only.
+ private void DrainStatus()
+ {
+ while (_context.Backend.TryTakeStatus(out string line))
+ _context.Chat.Write(line);
+ }
+
/// Commands queued from the backend and not yet run.
internal int PendingCommandCount => _pendingCommands.Count;
@@ -188,6 +205,7 @@ internal sealed class BackendSession : IMmFeature
_context.Settings.Save();
if (_context.IsLoggedIn)
StartSocket();
+ DrainStatus();
_context.Chat.Write("WS streaming ENABLED.");
}
else if (sub.Equals("disable", StringComparison.OrdinalIgnoreCase))
@@ -195,6 +213,7 @@ internal sealed class BackendSession : IMmFeature
_context.Settings.Current.WebSocketEnabled = false;
_context.Settings.Save();
_context.Backend.Stop();
+ DrainStatus();
_context.Chat.Write("WS streaming DISABLED.");
}
else if (sub.Equals("url", StringComparison.OrdinalIgnoreCase) && args.Length > 2)
diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index 5ace75f..de2ebc8 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -79,14 +79,7 @@ internal static class FeatureCatalog
context.Host.Ui.ShowPanel("flagtracker");
};
}
- if (metas.Panel is { } metasPanel)
- {
- mainWindow.MetasOpener = () =>
- {
- metasPanel.IsVisible = true;
- context.Host.Ui.ShowPanel("metas");
- };
- }
+ mainWindow.MetasTab = metas.Panel;
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).
diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
index 4087620..6879694 100644
--- a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
+++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs
@@ -4,7 +4,8 @@ using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker;
///
-/// The "Mossy Tracker" window: augmentations, luminance auras, recalls,
+/// The "Mossy Tracker" window, opened from the main window's Mossy Tracker
+/// tab: augmentations, luminance auras, recalls,
/// cantrips, tracked weapons and quest timers for the character in the world.
/// Nothing here reaches the backend.
///
@@ -47,13 +48,12 @@ internal sealed class FlagTrackerFeature : IMmFeature
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("flagtracker", "Mossy Tracker")
{
- IconText = "MT",
- // The icon the original window carried (7735).
- IconSurfaceId = 0x06001E37,
// 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,
+ // No bar button: as in the original, the main window's
+ // Mossy Tracker tab opens it.
+ ShowInSidePanel = false,
},
Path.Combine(directory, MarkupFile),
Panel);
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
index 0fd528c..4031770 100644
--- a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
+++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs
@@ -6,7 +6,8 @@ namespace OpenAC.MosswartMassacre.Metas;
///
/// Meta and nav sync from the club's repository: the /mm metasrefresh,
/// metascheck, metaspull and metaspullfile verbs and, on a
-/// host with a window, the Metas window. Nothing here reaches the backend.
+/// host with a window, the state behind the main window's Metas tab. Nothing
+/// here reaches the backend.
///
///
/// Network work runs on the thread pool; every result, chat line and window
@@ -15,15 +16,11 @@ namespace OpenAC.MosswartMassacre.Metas;
///
internal sealed class MetaSyncFeature : IMmFeature
{
- /// The panel's markup file, beside the plugin assembly.
- public const string MarkupFile = "mosswartmassacre-metas.xml";
-
private readonly MmContext _context;
private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue _onTick = new();
private readonly CancellationTokenSource _shutdown = new();
private readonly Action _tick;
- private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask;
private bool _disposed;
@@ -55,28 +52,17 @@ internal sealed class MetaSyncFeature : IMmFeature
return;
Panel = new MetasViewModel();
- Panel.ShowPath(_manager.StorageRoot);
+ ShowFolders();
+ Panel.RepositoryText = RepositoryUrl;
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);
+ Panel.RepositoryChanged += OnRepositoryChanged;
}
///
- /// The window's binding object; set its IsVisible to open the
- /// window. Null on a host without a window.
+ /// The Metas tab's state, which the main window binds to. Null on a host
+ /// without a window.
///
public MetasViewModel? Panel { get; }
@@ -95,7 +81,14 @@ internal sealed class MetaSyncFeature : IMmFeature
}
}
- public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot);
+ public void OnLogin()
+ {
+ if (Panel is null)
+ return;
+ // The repository is a per-character setting, loaded at login.
+ ShowFolders();
+ Panel.RepositoryText = RepositoryUrl;
+ }
public void OnLogoff()
{
@@ -112,8 +105,8 @@ internal sealed class MetaSyncFeature : IMmFeature
{
Panel.ButtonPressed -= OnButton;
Panel.SearchRequested -= ShowFiles;
+ Panel.RepositoryChanged -= OnRepositoryChanged;
}
- _panel?.Dispose();
_onTick.Clear();
// Operations run one after another, so the manager goes once the
// last one, cancelled above, has finished.
@@ -239,7 +232,7 @@ internal sealed class MetaSyncFeature : IMmFeature
switch (button)
{
case nameof(MetasViewModel.RefreshList):
- panel.ShowPath(_manager.StorageRoot);
+ ShowFolders();
panel.StatusText = "Status: refreshing remote file list...";
RunRefresh(
count => panel.StatusText = $"Status: loaded {count} remote files",
@@ -251,7 +244,7 @@ internal sealed class MetaSyncFeature : IMmFeature
break;
case nameof(MetasViewModel.CheckUpdates):
- panel.ShowPath(_manager.StorageRoot);
+ ShowFolders();
panel.StatusText = "Status: checking remote updates...";
RunCheck(
summary => panel.StatusText =
@@ -299,6 +292,32 @@ internal sealed class MetaSyncFeature : IMmFeature
}
}
+ private void ShowFolders() =>
+ Panel?.ShowFolders(
+ _manager.FolderPath(MetaSyncManager.MetaFolder),
+ _manager.FolderPath(MetaSyncManager.NavFolder));
+
+ ///
+ /// Stores the address the player entered, or the default for an empty
+ /// one. The list already shown came from the old address, so the status
+ /// asks for a refresh.
+ ///
+ private void OnRepositoryChanged(string address)
+ {
+ if (Panel is null)
+ return;
+ string trimmed = address.Trim();
+ if (trimmed.Length > 0 && !Uri.TryCreate(trimmed, UriKind.Absolute, out _))
+ {
+ Panel.StatusText = "Status: not a repository address";
+ return;
+ }
+ _context.Settings.Current.MetasRepositoryUrl = trimmed;
+ _context.Settings.Save();
+ Panel.RepositoryText = RepositoryUrl;
+ Panel.StatusText = "Status: repository set; press Refresh List";
+ }
+
private void ShowFiles()
{
if (Panel is null)
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
index c84a798..b72fa7a 100644
--- a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
+++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs
@@ -105,9 +105,25 @@ internal sealed class MetaSyncManager : IDisposable
public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
- /// The storage root the files are written beneath, for the path label; null without storage.
+ /// The storage root the files are written beneath; null without storage.
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
+ ///
+ /// The folder on disk a storage folder ( or
+ /// ) sits in, for telling the player where the
+ /// files went: the storage key itself when the storage has no folder on
+ /// disk, null without storage.
+ ///
+ public string? FolderPath(string folder)
+ {
+ string? root = StorageRoot;
+ if (root is null)
+ return null;
+ return root.Length == 0
+ ? folder
+ : Path.Combine(root, folder.Replace('/', Path.DirectorySeparatorChar));
+ }
+
public IReadOnlyList GetCachedFiles()
{
lock (_sync)
diff --git a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
index a72beea..a38c417 100644
--- a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
+++ b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs
@@ -3,10 +3,11 @@ using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas;
///
-/// The binding object of the Metas window (mosswartmassacre-metas.xml),
-/// 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.
+/// The state behind the main window's Metas tab: the repository's files
+/// with their status, a live search, a pick list for one file, the refresh,
+/// check and download buttons, the repository address and the folders the
+/// files land in. The main window forwards its Metas bindings here.
+/// Assigned on the tick thread only.
///
internal sealed class MetasViewModel
{
@@ -30,6 +31,7 @@ internal sealed class MetasViewModel
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
+ SetRepository = address => RepositoryChanged?.Invoke(address ?? string.Empty);
}
/// A button was pressed; the argument is its binding name.
@@ -38,13 +40,17 @@ internal sealed class MetasViewModel
/// The search text changed or was cleared.
internal event Action? SearchRequested;
- ///
- /// 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.
- ///
- public bool IsVisible { get; set; }
+ /// The player entered a repository address; empty means the default.
+ internal event Action? RepositoryChanged;
- public string PathText { get; set; } = "VTank Path: unresolved";
+ public string MetaFolderText { get; set; } = UnresolvedMetaFolder;
+
+ public string NavFolderText { get; set; } = UnresolvedNavFolder;
+
+ /// The repository address the files come from.
+ public string RepositoryText { get; set; } = string.Empty;
+
+ public Action SetRepository { get; }
public string LastCheckText { get; set; } = "Last Check: never";
@@ -147,8 +153,16 @@ internal sealed class MetasViewModel
$"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}";
+ /// Names the folders the metas and navs land in; null when there is no profile storage.
+ public void ShowFolders(string? metaFolder, string? navFolder)
+ {
+ MetaFolderText = metaFolder is null ? UnresolvedMetaFolder : $"Metas: {metaFolder}";
+ NavFolderText = navFolder is null ? UnresolvedNavFolder : $"Navs: {navFolder}";
+ }
+
+ private const string UnresolvedMetaFolder = "Metas: no profile folder";
+
+ private const string UnresolvedNavFolder = "Navs: no profile folder";
/// The status text colour: green current, orange outdated, blue missing, red failed.
public static uint StatusColor(MetaFileSyncStatus status) => status switch
diff --git a/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
index 6141a54..6baf92b 100644
--- a/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
+++ b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs
@@ -140,16 +140,23 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
///
public void OnBackendConnected()
{
- if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn)
- return;
- _context.Send(new
+ try
{
- type = "share_subscribe",
- timestamp = Wire.Timestamp(_context.Clock),
- character_name = _context.CharacterName,
- tags = Tags,
- });
- _context.Verbose("[WebSocket] Vital sharing re-subscribed");
+ if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn)
+ return;
+ _context.Send(new
+ {
+ type = "share_subscribe",
+ timestamp = Wire.Timestamp(_context.Clock),
+ character_name = _context.CharacterName,
+ tags = Tags,
+ });
+ _context.Chat.Write("[WebSocket] Vital sharing re-subscribed");
+ }
+ catch (Exception ex)
+ {
+ _context.Chat.Write($"[WebSocket] Re-subscribe error: {ex.Message}");
+ }
}
// ── Outgoing ──
diff --git a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs
index 02f3a2d..a01a826 100644
--- a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs
+++ b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs
@@ -31,6 +31,7 @@ internal sealed class TelemetryStream : IMmFeature, IBackendConnectionListener
{
if (!_context.IsLoggedIn)
return;
+ _context.Chat.Write("[WebSocket] Starting telemetry loop");
SendNow();
_timer?.Dispose();
_timer = _context.Scheduler.Every(Interval, SendNow);
diff --git a/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs b/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
new file mode 100644
index 0000000..327ba28
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
@@ -0,0 +1,59 @@
+using OpenAC.MosswartMassacre.Metas;
+
+namespace OpenAC.MosswartMassacre.Views;
+
+///
+/// The Metas tab's bindings. The markup binds one object per window, so each
+/// is forwarded to the meta sync's ; an action
+/// looks the tab up when it runs, so it works whenever the tab is set.
+///
+internal sealed partial class MainWindow
+{
+ private static readonly IReadOnlyList NoItems = [];
+
+ private static readonly IReadOnlyList NoColors = [];
+
+ public string MetasFolderText => MetasTab?.MetaFolderText ?? string.Empty;
+
+ public string NavsFolderText => MetasTab?.NavFolderText ?? string.Empty;
+
+ public string MetasRepositoryText => MetasTab?.RepositoryText ?? string.Empty;
+
+ public Action SetMetasRepository => address => MetasTab?.SetRepository(address);
+
+ public Action MetasRefreshList => () => MetasTab?.RefreshList();
+
+ public Action MetasCheckUpdates => () => MetasTab?.CheckUpdates();
+
+ public Action MetasDownloadOutdated => () => MetasTab?.DownloadOutdated();
+
+ public Action MetasDownloadSelected => () => MetasTab?.DownloadSelected();
+
+ public string MetasSearchText => MetasTab?.SearchText ?? string.Empty;
+
+ public Action MetasSearchChanged => text => MetasTab?.SearchChanged(text);
+
+ public Action MetasClearSearch => () => MetasTab?.ClearSearch();
+
+ public int MetasNoRow => -1;
+
+ public Action MetasIgnoreRow => _ => { };
+
+ public IReadOnlyList MetasFileTypes => MetasTab?.FileTypes ?? NoItems;
+
+ public IReadOnlyList MetasFilePaths => MetasTab?.FilePaths ?? NoItems;
+
+ public IReadOnlyList MetasFileStatus => MetasTab?.FileStatus ?? NoItems;
+
+ public IReadOnlyList MetasFileStatusColors => MetasTab?.FileStatusColors ?? NoColors;
+
+ public IReadOnlyList MetasSelectionItems => MetasTab?.SelectionItems ?? NoItems;
+
+ public string MetasSelectedLabel => MetasTab?.SelectedLabel ?? string.Empty;
+
+ public Action MetasSelectionChanged => label => MetasTab?.SelectionChanged(label);
+
+ public string MetasLastCheckText => MetasTab?.LastCheckText ?? string.Empty;
+
+ public string MetasStatusText => MetasTab?.StatusText ?? string.Empty;
+}
diff --git a/src/OpenAC.MosswartMassacre/Views/MainWindow.cs b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs
index ee7a080..06b3275 100644
--- a/src/OpenAC.MosswartMassacre/Views/MainWindow.cs
+++ b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs
@@ -1,18 +1,23 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Streams;
namespace OpenAC.MosswartMassacre.Views;
///
-/// The main window (graphical host only) and its binding object. Every value
-/// is read live from the trackers each frame, so nothing here needs to be told
-/// that a number changed.
+/// The main window (graphical host only) and its binding object: the one
+/// plugin-bar button, "MM" with the original window's icon, and the
+/// original's tabbed window. Every value is read live each frame, so nothing
+/// here needs to be told that a number changed.
///
-internal sealed class MainWindow : IMmFeature
+internal sealed partial class MainWindow : IMmFeature
{
public const string MarkupFile = "mosswartmassacre-main.xml";
+ /// The original window's icon, 7735 in the image table.
+ public const uint OriginalIcon = 0x06001E37;
+
private readonly MmContext _context;
private readonly SessionStats _stats;
private IDisposable? _registration;
@@ -30,6 +35,8 @@ internal sealed class MainWindow : IMmFeature
new PluginPanelDescriptor("main", "Mosswart Massacre")
{
IconText = "MM",
+ // The icon the original window carried (7735).
+ IconSurfaceId = OriginalIcon,
StartVisible = false,
ShowInSidePanel = true,
},
@@ -42,24 +49,30 @@ internal sealed class MainWindow : IMmFeature
Main,
Settings,
Statistics,
+ MossyTracker,
+ Metas,
Tools,
}
/// Opens the flag tracker window; set by the feature that owns it.
public Action? FlagTrackerOpener { get; set; }
- /// Opens the metas window; set by the feature that owns it.
- public Action? MetasOpener { get; set; }
+ /// The Metas tab's state; set by the feature that owns it. Null on a host without a window.
+ public MetasViewModel? MetasTab { get; set; }
// ── Tabs ──
public bool MainSelected => _page == Page.Main;
public bool SettingsSelected => _page == Page.Settings;
public bool StatisticsSelected => _page == Page.Statistics;
+ public bool MossyTrackerSelected => _page == Page.MossyTracker;
+ public bool MetasSelected => _page == Page.Metas;
public bool ToolsSelected => _page == Page.Tools;
public Action ShowMain => () => _page = Page.Main;
public Action ShowSettings => () => _page = Page.Settings;
public Action ShowStatistics => () => _page = Page.Statistics;
+ public Action ShowMossyTracker => () => _page = Page.MossyTracker;
+ public Action ShowMetas => () => _page = Page.Metas;
public Action ShowTools => () => _page = Page.Tools;
// ── Main ──
@@ -173,7 +186,7 @@ internal sealed class MainWindow : IMmFeature
public Action ResetStats => () => _context.Commands.Dispatch("reset");
- // ── Tools ──
+ // ── Mossy Tracker ──
public Action OpenFlagTracker => () =>
{
@@ -183,13 +196,7 @@ internal sealed class MainWindow : IMmFeature
_context.Chat.Write("The Mossy Tracker is not available.");
};
- public Action OpenMetas => () =>
- {
- if (MetasOpener is { } open)
- open();
- else
- _context.Chat.Write("The metas window is not available.");
- };
+ // ── Tools ──
public Action SendInventory => () => _context.Commands.Dispatch("sendinventory");
diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml
index de4fcf0..d28ad0c 100644
--- a/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml
+++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml
@@ -1,21 +1,28 @@
-
-
+
+
-
-
-
-
+
+
-
+
@@ -27,7 +34,7 @@
-
+
@@ -50,7 +57,7 @@
-
+
@@ -69,10 +76,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
deleted file mode 100644
index 8acbffd..0000000
--- a/src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendChatStatusTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendChatStatusTests.cs
new file mode 100644
index 0000000..e9f8c39
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendChatStatusTests.cs
@@ -0,0 +1,122 @@
+using OpenAC.MosswartMassacre.Tests.Fakes;
+
+namespace OpenAC.MosswartMassacre.Tests.Backend;
+
+///
+/// The socket's status and error lines reach the player's chat, worded as the
+/// original worded them, and only through the tick: the socket thread never
+/// writes to chat itself.
+///
+public sealed class BackendChatStatusTests
+{
+ private const string P = "[Mosswart Massacre] ";
+
+ [Fact]
+ public async Task Enable_shows_connecting_then_connected_and_registered_on_the_next_tick()
+ {
+ using var server = new TestSocketServer();
+ using var harness = new PluginHarness("Mossy");
+ harness.Login();
+ harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
+
+ harness.Context.Commands.Dispatch("ws enable");
+
+ Assert.Equal([P + "[WebSocket] connecting…", P + "WS streaming ENABLED."], Tail(harness, 2));
+
+ server.NextFrame();
+ Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
+ Assert.DoesNotContain(P + "[WebSocket] CONNECTED", harness.ChatLines);
+
+ int before = harness.ChatLines.Count;
+ int tickThread = Environment.CurrentManagedThreadId;
+ harness.Tick(0.01);
+
+ Assert.Equal(
+ [P + "[WebSocket] CONNECTED", P + "[WebSocket] REGISTERED", P + "[WebSocket] Starting telemetry loop"],
+ Tail(harness, 3));
+ AssertPostedOn(harness, before, tickThread);
+ }
+
+ [Fact]
+ public async Task Streaming_saved_on_connects_at_login_and_says_so()
+ {
+ using var server = new TestSocketServer();
+ using var harness = new PluginHarness("Mossy");
+ harness.Context.Settings.Current.WebSocketEnabled = true;
+ harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
+ harness.Context.Settings.Save();
+
+ harness.Host.Automation.Character.IsInWorld = true;
+ harness.Plugin.OnLogin();
+
+ Assert.Contains(P + "[WebSocket] connecting…", harness.ChatLines);
+ Assert.Equal("{\"type\":\"register\",\"player_name\":\"Mossy\"}", server.NextFrame());
+ Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
+ harness.Tick(0.01);
+ Assert.Contains(P + "[WebSocket] REGISTERED", harness.ChatLines);
+ }
+
+ [Fact]
+ public async Task A_failed_connect_shows_the_error_cleanup_and_reconnect_on_the_next_tick()
+ {
+ using var harness = new PluginHarness("Mossy");
+ harness.Login();
+ // Nothing listens on port 1: the connect is refused.
+ harness.Context.Settings.Current.BackendUrl = "ws://127.0.0.1:1/websocket/";
+ var log = (RecordingLogger)harness.Host.Log;
+
+ harness.Context.Commands.Dispatch("ws enable");
+ Assert.True(await BackendClientTests.WaitAsync(
+ () => log.Any(line => line.Contains("[WebSocket] Reconnecting in 2 seconds...")), 15000));
+ Assert.DoesNotContain(harness.ChatLines, line => line.Contains("Connection error"));
+
+ int before = harness.ChatLines.Count;
+ int tickThread = Environment.CurrentManagedThreadId;
+ harness.Tick(0.01);
+
+ string[] tail = Tail(harness, 3);
+ Assert.StartsWith(P + "[WebSocket] Connection error: ", tail[0]);
+ Assert.StartsWith(P + "[WebSocket] Cleaning up connection - Final state: ", tail[1]);
+ Assert.Equal(P + "[WebSocket] Reconnecting in 2 seconds...", tail[2]);
+ AssertPostedOn(harness, before, tickThread);
+ }
+
+ [Fact]
+ public async Task Disable_shows_disabled_before_the_command_reply()
+ {
+ using var server = new TestSocketServer();
+ using var harness = new PluginHarness("Mossy");
+ harness.Login();
+ harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
+ harness.Context.Commands.Dispatch("ws enable");
+ server.NextFrame();
+ Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
+ harness.Tick(0.01);
+
+ harness.Context.Commands.Dispatch("ws disable");
+
+ Assert.Equal([P + "[WebSocket] DISABLED", P + "WS streaming DISABLED."], Tail(harness, 2));
+ }
+
+ [Fact]
+ public void A_reconnect_with_sharing_on_says_it_re_subscribed_even_without_verbose_logging()
+ {
+ using var harness = new PluginHarness("Mossy");
+ harness.Login();
+ harness.Context.Settings.Current.VitalSharingEnabled = true;
+ harness.Context.Settings.Current.VerboseLogging = false;
+
+ harness.SimulateConnected();
+
+ Assert.Equal(
+ [P + "[WebSocket] Vital sharing re-subscribed", P + "[WebSocket] Starting telemetry loop"],
+ Tail(harness, 2));
+ }
+
+ /// Every chat line from on was written on , the thread that ran the tick.
+ private static void AssertPostedOn(PluginHarness harness, int from, int threadId) =>
+ Assert.All(harness.Host.Automation.Chat.PostedThreadIds.Skip(from), id => Assert.Equal(threadId, id));
+
+ private static string[] Tail(PluginHarness harness, int count) =>
+ [.. harness.ChatLines.Skip(Math.Max(0, harness.ChatLines.Count - count))];
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
index 9315a5d..8817ef2 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
@@ -431,7 +431,14 @@ public sealed class RecordingChat : IPluginChat
return message;
}
- public void PostSystemMessage(string text) => Posted.Add(text);
+ /// The managed thread each ran on.
+ public List PostedThreadIds { get; } = [];
+
+ public void PostSystemMessage(string text)
+ {
+ Posted.Add(text);
+ PostedThreadIds.Add(Environment.CurrentManagedThreadId);
+ }
public void PostMessage(string text, int logTextType)
{
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
index 808dbc3..88e35ce 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
@@ -24,6 +24,9 @@ public sealed class FakeHost : IPluginHost
public MemoryStorage Storage { get; } = new();
+ /// The shared automation profile storage; its own root so a test can tell the two apart.
+ public MemoryStorage VtankProfiles { get; } = new() { RootPath = @"C:\Users\player\AppData\Local\OpenAC\vtank" };
+
public FakeAutomation Automation { get; } = new();
public FakeLootClassifierRegistry LootClassifiers { get; } = new();
@@ -48,6 +51,8 @@ public sealed class FakeHost : IPluginHost
IPluginStorage IPluginHost.Storage => Storage;
+ IPluginStorage IPluginHost.VtankProfiles => VtankProfiles;
+
IAutomationSurface IPluginHost.Automation => Automation;
IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers;
@@ -290,12 +295,25 @@ public sealed class RecordingLogger : IPluginLogger
{
public List Messages { get; } = [];
- public void Info(string message) => Messages.Add("info: " + message);
+ public void Info(string message) => Add("info: " + message);
- public void Warn(string message) => Messages.Add("warn: " + message);
+ public void Warn(string message) => Add("warn: " + message);
public void Error(string message, Exception? exception = null)
- => Messages.Add("error: " + message);
+ => Add("error: " + message);
+
+ /// True when a logged line matches; safe while a background thread logs.
+ public bool Any(Func match)
+ {
+ lock (Messages)
+ return Messages.Any(match);
+ }
+
+ private void Add(string line)
+ {
+ lock (Messages)
+ Messages.Add(line);
+ }
}
public sealed class FakeGameState : IGameState
@@ -444,6 +462,15 @@ public sealed class FakeUiRegistry : IUiRegistry
{
public List Panels { get; } = [];
+ /// Every call, in order.
+ public List ShowPanelCalls { get; } = [];
+
+ public bool ShowPanel(string viewName)
+ {
+ ShowPanelCalls.Add(viewName);
+ return Panels.Exists(panel => panel.Descriptor.WindowId == viewName || panel.Descriptor.Title == viewName);
+ }
+
/// Every call, in order -- a test sets to simulate the host refusing it.
public List ShowClientWindowCalls { get; } = [];
diff --git a/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
index 2f1664c..7684c9e 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/FlagTracker/FlagTrackerTests.cs
@@ -55,7 +55,8 @@ public sealed class FlagTrackerTests
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
- Assert.True(panel.Descriptor.ShowInSidePanel);
+ Assert.False(panel.Descriptor.ShowInSidePanel);
+ Assert.Equal(0u, panel.Descriptor.IconSurfaceId);
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
Assert.Same(Panel(harness), panel.Binding);
Assert.False(Panel(harness).IsVisible);
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
index 6de3e3b..0d9595e 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Metas/MetaSyncTests.cs
@@ -289,6 +289,35 @@ public sealed class MetaSyncTests
}
}
+ [Fact]
+ public async Task Downloads_land_in_the_shared_profile_storage_the_automation_plugin_loads_from()
+ {
+ using var harness = new PluginHarness();
+ harness.Login();
+ MemoryStorage profiles = harness.Host.VtankProfiles;
+
+ // The plugin's own sync writes to the shared profile storage, not to
+ // the plugin's private storage.
+ Assert.Equal(profiles.RootPath, harness.Feature().Manager.StorageRoot);
+ Assert.NotEqual(harness.Host.Storage.RootPath, harness.Feature().Manager.StorageRoot);
+
+ var server = new FakeGitServer();
+ server.Text(TreeUrl, TreeJson);
+ server.Text(RawBase + "/hunting/Colo.met", ColoMet);
+ server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
+ server.Text(RawBase + "/x.MET", XMet);
+ using var feature = new MetaSyncFeature(harness.Context, profiles, server);
+ harness.Context.Commands.Dispatch("metaspull");
+ await Settle(feature);
+
+ // Each file under its own name and extension, metas and navs apart,
+ // where a bare name in the automation plugin finds it.
+ Assert.Equal(ColoMet, profiles.ReadText("mosstank/metas/Colo.met"));
+ Assert.Equal(XMet, profiles.ReadText("mosstank/metas/x.MET"));
+ Assert.Equal(LoopNav, profiles.ReadText("mosstank/navs/Colo Loop.nav"));
+ Assert.Null(harness.Host.Storage.ReadText("mosstank/metas/Colo.met"));
+ }
+
[Fact]
public void A_headless_host_gets_the_verbs_but_no_window()
{
@@ -310,8 +339,8 @@ public sealed class MetaSyncTests
using (feature)
{
MetasViewModel panel = feature.Panel!;
- Assert.False(panel.IsVisible);
- Assert.Equal("VTank Path: C:/profiles", panel.PathText);
+ Assert.Equal($"Metas: {Path.Combine("C:/profiles", "mosstank", "metas")}", panel.MetaFolderText);
+ Assert.Equal($"Navs: {Path.Combine("C:/profiles", "mosstank", "navs")}", panel.NavFolderText);
Assert.Equal(["No files loaded"], panel.FilePaths);
storage.Seed("mosstank/metas/x.MET", "stale");
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Ui/WindowMarkupContractTests.cs
similarity index 94%
rename from tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
rename to tests/OpenAC.MosswartMassacre.Tests/Ui/WindowMarkupContractTests.cs
index 93c57ea..5510d8d 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Ui/TrackerAndMetasMarkupContractTests.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Ui/WindowMarkupContractTests.cs
@@ -1,16 +1,16 @@
using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker;
-using OpenAC.MosswartMassacre.Metas;
+using OpenAC.MosswartMassacre.Views;
namespace OpenAC.MosswartMassacre.Tests.Ui;
///
-/// Proves every {Binding} in this slice's markup files resolves to a
+/// Proves every {Binding} in the plugin's window markup resolves to a
/// public property of the right shape on its binding object, the check the
/// host would otherwise only make when the window is built in game.
///
-public sealed class TrackerAndMetasMarkupContractTests
+public sealed class WindowMarkupContractTests
{
private static readonly string[] InteractiveElementNames =
[
@@ -20,7 +20,13 @@ public sealed class TrackerAndMetasMarkupContractTests
public static TheoryData MarkupFiles => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
- { MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
+ { MainWindow.MarkupFile, typeof(MainWindow) },
+ };
+
+ /// The windows opened by a button of the plugin's own, whose visibility the plugin binds.
+ public static TheoryData OpenedWindows => new()
+ {
+ { FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
};
[Theory]
@@ -79,7 +85,7 @@ public sealed class TrackerAndMetasMarkupContractTests
}
[Theory]
- [MemberData(nameof(MarkupFiles))]
+ [MemberData(nameof(OpenedWindows))]
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
{
XElement root = LoadRoot(file);
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
new file mode 100644
index 0000000..2ceff2e
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
@@ -0,0 +1,164 @@
+using System.Reflection;
+using System.Xml.Linq;
+using OpenAC.MosswartMassacre.FlagTracker;
+using OpenAC.MosswartMassacre.Metas;
+using OpenAC.MosswartMassacre.Tests.Fakes;
+using OpenAC.MosswartMassacre.Views;
+
+namespace OpenAC.MosswartMassacre.Tests.Views;
+
+///
+/// The plugin shows as the original did: one button on the plugin bar, "MM"
+/// with the original window's icon, opening one tabbed window. The Mossy
+/// Tracker tab opens the tracker window, which has no bar button of its own,
+/// and the Metas tab holds the meta and nav downloads.
+///
+///
+/// The tabs are driven the way the host drives them: each binding is looked
+/// up by the name the markup gives it and read or invoked by reflection.
+///
+public sealed class OneWindowTests
+{
+ private const uint OriginalIcon = 0x06001E37;
+
+ private static PluginHarness WithUi()
+ {
+ var harness = new PluginHarness(arrange: host => host.HasUi = true);
+ harness.Login();
+ return harness;
+ }
+
+ [Fact]
+ public void The_plugin_bar_gets_one_button_MM_with_the_original_icon()
+ {
+ using PluginHarness harness = WithUi();
+
+ RegisteredPanel shelf = Assert.Single(harness.Host.Ui.Panels, panel => panel.Descriptor.ShowInSidePanel);
+ Assert.Equal("main", shelf.Descriptor.WindowId);
+ Assert.Equal("MM", shelf.Descriptor.IconText);
+ Assert.Equal(OriginalIcon, shelf.Descriptor.IconSurfaceId);
+ Assert.EndsWith(MainWindow.MarkupFile, shelf.MarkupPath, StringComparison.Ordinal);
+ Assert.IsType(shelf.Binding);
+
+ // The only other window is the tracker, opened from its tab.
+ RegisteredPanel other = Assert.Single(harness.Host.Ui.Panels, panel => !panel.Descriptor.ShowInSidePanel);
+ Assert.Equal("flagtracker", other.Descriptor.WindowId);
+ Assert.Equal(2, harness.Host.Ui.Panels.Count);
+ }
+
+ [Fact]
+ public void The_main_window_has_the_original_tabs_plus_metas()
+ {
+ XElement root = LoadMain();
+
+ string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
+ Assert.Equal(["Main", "Settings", "Statistics", "Mossy Tracker", "Metas", "Tools"], tabs);
+ foreach (string tab in tabs)
+ Assert.NotNull(PageOf(root, tab));
+ }
+
+ [Fact]
+ public void The_mossy_tracker_tab_opens_the_tracker_window()
+ {
+ using PluginHarness harness = WithUi();
+ MainWindow window = harness.Feature();
+ FlagTrackerViewModel tracker = harness.Feature().Panel!;
+ XElement page = PageOf(LoadMain(), "Mossy Tracker")!;
+
+ XElement tab = LoadMain().Elements("tab").Single(t => (string?)t.Attribute("text") == "Mossy Tracker");
+ Invoke(window, (string?)tab.Attribute("onclick"));
+ Assert.True(Read(window, (string?)page.Attribute("visible")));
+
+ XElement button = Assert.Single(page.Elements("button"), b => (string?)b.Attribute("text") == "Open Mossy Tracker");
+ Assert.False(tracker.IsVisible);
+ Invoke(window, (string?)button.Attribute("onclick"));
+
+ Assert.True(tracker.IsVisible);
+ Assert.Equal(["flagtracker"], harness.Host.Ui.ShowPanelCalls);
+ }
+
+ [Fact]
+ public void The_metas_tab_is_bound_to_the_meta_sync_and_names_the_real_folders()
+ {
+ using PluginHarness harness = WithUi();
+ MainWindow window = harness.Feature();
+ MetasViewModel metas = harness.Feature().Panel!;
+ XElement page = PageOf(LoadMain(), "Metas")!;
+
+ // The file list, its search and the download buttons are all here.
+ XElement list = Assert.Single(page.Elements("list"));
+ string[] columnItems = [.. list.Elements("column").Select(column => (string?)column.Attribute("items") ?? string.Empty)];
+ Assert.Equal(3, columnItems.Length);
+ Assert.Equal(["No files loaded"], Read>(window, columnItems[1]));
+ Assert.Single(page.Elements("field"), field => (string?)field.Attribute("onchange") is not null);
+ string[] buttons = [.. page.Elements("button").Select(b => (string?)b.Attribute("text") ?? string.Empty)];
+ Assert.Contains("Refresh List", buttons);
+ Assert.Contains("Check Updates", buttons);
+ Assert.Contains("Download Outdated", buttons);
+ Assert.Contains("Download", buttons);
+
+ // The labels name the folders the automation plugin loads from.
+ string vtankRoot = harness.Host.VtankProfiles.RootPath!;
+ string[] labels = [.. page.Elements("label").Select(label => Text(window, (string?)label.Attribute("text")))];
+ Assert.Contains(labels, text => text.EndsWith(Path.Combine(vtankRoot, "mosstank", "metas"), StringComparison.Ordinal));
+ Assert.Contains(labels, text => text.EndsWith(Path.Combine(vtankRoot, "mosstank", "navs"), StringComparison.Ordinal));
+
+ // Typing in the tab's search reaches the meta sync and redraws the list.
+ XElement search = page.Elements("field").Single(f => (string?)f.Attribute("onchange") is not null);
+ Invoke(window, (string?)search.Attribute("onchange"), "zzz");
+ Assert.Equal("zzz", metas.SearchText);
+ Assert.Equal(["No matches"], Read>(window, columnItems[1]));
+ Assert.Equal("zzz", Text(window, (string?)search.Attribute("text")));
+ }
+
+ [Fact]
+ public void The_metas_tab_changes_the_repository_address()
+ {
+ using PluginHarness harness = WithUi();
+ MainWindow window = harness.Feature();
+ XElement page = PageOf(LoadMain(), "Metas")!;
+ XElement field = Assert.Single(page.Elements("field"), f => (string?)f.Attribute("onsubmit") is not null);
+
+ Assert.Equal(MetaSyncManager.DefaultRepositoryUrl, Text(window, (string?)field.Attribute("text")));
+ Invoke(window, (string?)field.Attribute("onsubmit"), " https://example.org/club/metas ");
+ Assert.Equal("https://example.org/club/metas", harness.Context.Settings.Current.MetasRepositoryUrl);
+ Assert.Equal("https://example.org/club/metas", Text(window, (string?)field.Attribute("text")));
+ }
+
+ // ── markup helpers ──
+
+ private static XElement LoadMain() =>
+ XDocument.Load(Path.Combine(AppContext.BaseDirectory, MainWindow.MarkupFile)).Root!;
+
+ /// The group a tab shows: the one whose visibility is the tab's selected binding.
+ private static XElement? PageOf(XElement root, string tabText)
+ {
+ XElement? tab = root.Elements("tab").SingleOrDefault(t => (string?)t.Attribute("text") == tabText);
+ string? selected = (string?)tab?.Attribute("selected");
+ return selected is null
+ ? null
+ : root.Elements("group").SingleOrDefault(group => (string?)group.Attribute("visible") == selected);
+ }
+
+ private static PropertyInfo Property(object binding, string? expression)
+ {
+ Assert.NotNull(expression);
+ Assert.Matches("^\\{[^{}]+\\}$", expression);
+ PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
+ Assert.True(property is not null, $"{expression} names no public property on {binding.GetType().Name}.");
+ return property;
+ }
+
+ private static T Read(object binding, string? expression) =>
+ Assert.IsAssignableFrom(Property(binding, expression).GetValue(binding));
+
+ /// A label's text: the literal, or the bound value.
+ private static string Text(object binding, string? text) =>
+ text is not null && text.StartsWith('{') ? Read