One plugin-bar button and one tabbed window, as the original
Live in 0.1.19 the plugin put three buttons on the plugin bar (MM, Mossy Tracker, Metas), the tracker carried the icon that belongs to the main window, and the metas downloads were in a window of their own. Now only the main window has a bar button: "MM" with the original window's icon (7735, 0x06001E37). Its tabs follow the original's notebook: Main, Settings, Statistics, Mossy Tracker, Metas, Tools. The Mossy Tracker tab is the original page, one "Open Mossy Tracker" button that opens the tracker window; that window is registered without a bar button and opened through the host's ShowPanel. The Metas tab holds everything the metas window had (list with search, refresh, check, download outdated, download one file, last check, status), plus the repository address and the two folders the files land in. The main window forwards those bindings to the meta sync's view model, since markup binds one object. Downloads already went to the host's VTank profile storage, the one MossTank reads, under mosstank/metas and mosstank/navs with each file's own name and extension; the tab now shows those full folder paths instead of the storage root. Backend frames are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
360300a1ee
commit
336c5cfdf5
15 changed files with 459 additions and 124 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ using OpenAC.MosswartMassacre.Quests;
|
|||
namespace OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ 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.
|
||||
/// host with a window, the state behind the main window's Metas tab. Nothing
|
||||
/// here reaches the backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Network work runs on the thread pool; every result, chat line and window
|
||||
|
|
@ -15,15 +16,11 @@ namespace OpenAC.MosswartMassacre.Metas;
|
|||
/// </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;
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window's binding object; set its <c>IsVisible</c> 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.
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -105,9 +105,25 @@ internal sealed class MetaSyncManager : IDisposable
|
|||
|
||||
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>
|
||||
/// <summary>The storage root the files are written beneath; null without storage.</summary>
|
||||
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
|
||||
|
||||
/// <summary>
|
||||
/// The folder on disk a storage folder (<see cref="MetaFolder"/> or
|
||||
/// <see cref="NavFolder"/>) 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.
|
||||
/// </summary>
|
||||
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<MetaFileEntry> GetCachedFiles()
|
||||
{
|
||||
lock (_sync)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A button was pressed; the argument is its binding name.</summary>
|
||||
|
|
@ -38,13 +40,17 @@ internal sealed class MetasViewModel
|
|||
/// <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; }
|
||||
/// <summary>The player entered a repository address; empty means the default.</summary>
|
||||
internal event Action<string>? RepositoryChanged;
|
||||
|
||||
public string PathText { get; set; } = "VTank Path: unresolved";
|
||||
public string MetaFolderText { get; set; } = UnresolvedMetaFolder;
|
||||
|
||||
public string NavFolderText { get; set; } = UnresolvedNavFolder;
|
||||
|
||||
/// <summary>The repository address the files come from.</summary>
|
||||
public string RepositoryText { get; set; } = string.Empty;
|
||||
|
||||
public Action<string> 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}";
|
||||
/// <summary>Names the folders the metas and navs land in; null when there is no profile storage.</summary>
|
||||
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";
|
||||
|
||||
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
|
||||
public static uint StatusColor(MetaFileSyncStatus status) => status switch
|
||||
|
|
|
|||
59
src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
Normal file
59
src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using OpenAC.MosswartMassacre.Metas;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The Metas tab's bindings. The markup binds one object per window, so each
|
||||
/// is forwarded to the meta sync's <see cref="MetasViewModel"/>; an action
|
||||
/// looks the tab up when it runs, so it works whenever the tab is set.
|
||||
/// </summary>
|
||||
internal sealed partial class MainWindow
|
||||
{
|
||||
private static readonly IReadOnlyList<string> NoItems = [];
|
||||
|
||||
private static readonly IReadOnlyList<uint> NoColors = [];
|
||||
|
||||
public string MetasFolderText => MetasTab?.MetaFolderText ?? string.Empty;
|
||||
|
||||
public string NavsFolderText => MetasTab?.NavFolderText ?? string.Empty;
|
||||
|
||||
public string MetasRepositoryText => MetasTab?.RepositoryText ?? string.Empty;
|
||||
|
||||
public Action<string> 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<string> MetasSearchChanged => text => MetasTab?.SearchChanged(text);
|
||||
|
||||
public Action MetasClearSearch => () => MetasTab?.ClearSearch();
|
||||
|
||||
public int MetasNoRow => -1;
|
||||
|
||||
public Action<int> MetasIgnoreRow => _ => { };
|
||||
|
||||
public IReadOnlyList<string> MetasFileTypes => MetasTab?.FileTypes ?? NoItems;
|
||||
|
||||
public IReadOnlyList<string> MetasFilePaths => MetasTab?.FilePaths ?? NoItems;
|
||||
|
||||
public IReadOnlyList<string> MetasFileStatus => MetasTab?.FileStatus ?? NoItems;
|
||||
|
||||
public IReadOnlyList<uint> MetasFileStatusColors => MetasTab?.FileStatusColors ?? NoColors;
|
||||
|
||||
public IReadOnlyList<string> MetasSelectionItems => MetasTab?.SelectionItems ?? NoItems;
|
||||
|
||||
public string MetasSelectedLabel => MetasTab?.SelectedLabel ?? string.Empty;
|
||||
|
||||
public Action<string> MetasSelectionChanged => label => MetasTab?.SelectionChanged(label);
|
||||
|
||||
public string MetasLastCheckText => MetasTab?.LastCheckText ?? string.Empty;
|
||||
|
||||
public string MetasStatusText => MetasTab?.StatusText ?? string.Empty;
|
||||
}
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Metas;
|
||||
using OpenAC.MosswartMassacre.Streams;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class MainWindow : IMmFeature
|
||||
internal sealed partial class MainWindow : IMmFeature
|
||||
{
|
||||
public const string MarkupFile = "mosswartmassacre-main.xml";
|
||||
|
||||
/// <summary>The original window's icon, 7735 in the image table.</summary>
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>Opens the flag tracker window; set by the feature that owns it.</summary>
|
||||
public Action? FlagTrackerOpener { get; set; }
|
||||
|
||||
/// <summary>Opens the metas window; set by the feature that owns it.</summary>
|
||||
public Action? MetasOpener { get; set; }
|
||||
/// <summary>The Metas tab's state; set by the feature that owns it. Null on a host without a window.</summary>
|
||||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Mosswart Massacre main window. The original's notebook, minus what OpenAC
|
||||
or another plugin now owns: the updater (the launcher updates), the VTank
|
||||
path (the host keeps the profile folder), route visualisation (MossTank
|
||||
draws routes) and the chest looter (a looter comes later). -->
|
||||
<panel x="60" y="60" w="420" h="320" title="Mosswart Massacre">
|
||||
<!-- Mosswart Massacre main window, the plugin's one bar button. The original's
|
||||
notebook, minus what OpenAC or another plugin now owns: the updater (the
|
||||
launcher updates), the VTank path (the host keeps the profile folder),
|
||||
route visualisation (MossTank draws routes) and the chest looter (a looter
|
||||
comes later). Mossy Tracker opens the tracker window as the original did;
|
||||
Metas downloads metas and navs into the folders MossTank loads them from;
|
||||
Tools holds what has no page of its own. -->
|
||||
<panel x="60" y="60" w="420" h="360" title="Mosswart Massacre">
|
||||
|
||||
<tab x="6" y="22" w="56" h="18" text="Main"
|
||||
<tab x="6" y="22" w="50" h="18" text="Main"
|
||||
selected="{MainSelected}" onclick="{ShowMain}" />
|
||||
<tab x="62" y="22" w="62" h="18" text="Settings"
|
||||
<tab x="56" y="22" w="60" h="18" text="Settings"
|
||||
selected="{SettingsSelected}" onclick="{ShowSettings}" />
|
||||
<tab x="124" y="22" w="70" h="18" text="Statistics"
|
||||
<tab x="116" y="22" w="66" h="18" text="Statistics"
|
||||
selected="{StatisticsSelected}" onclick="{ShowStatistics}" />
|
||||
<tab x="194" y="22" w="62" h="18" text="Tools"
|
||||
<tab x="182" y="22" w="92" h="18" text="Mossy Tracker"
|
||||
selected="{MossyTrackerSelected}" onclick="{ShowMossyTracker}" />
|
||||
<tab x="274" y="22" w="48" h="18" text="Metas"
|
||||
selected="{MetasSelected}" onclick="{ShowMetas}" />
|
||||
<tab x="322" y="22" w="46" h="18" text="Tools"
|
||||
selected="{ToolsSelected}" onclick="{ShowTools}" />
|
||||
|
||||
<!-- Main -->
|
||||
<group x="6" y="44" w="408" h="270" visible="{MainSelected}">
|
||||
<group x="6" y="44" w="408" h="310" visible="{MainSelected}">
|
||||
<label x="4" y="4" text="{TotalKillsText}" />
|
||||
<label x="4" y="24" text="{KillsPer5MinText}" />
|
||||
<label x="4" y="44" text="{KillsPerHourText}" />
|
||||
|
|
@ -27,7 +34,7 @@
|
|||
</group>
|
||||
|
||||
<!-- Settings -->
|
||||
<group x="6" y="44" w="408" h="270" visible="{SettingsSelected}">
|
||||
<group x="6" y="44" w="408" h="310" visible="{SettingsSelected}">
|
||||
<label x="4" y="4" text="Plugin Configuration" color="#FFE8D8B0" />
|
||||
<toggle x="14" y="24" w="300" h="20" text="WebSocket streaming"
|
||||
checked="{WebSocketEnabled}" onclick="{ToggleWebSocket}" />
|
||||
|
|
@ -50,7 +57,7 @@
|
|||
</group>
|
||||
|
||||
<!-- Statistics -->
|
||||
<group x="6" y="44" w="408" h="270" visible="{StatisticsSelected}">
|
||||
<group x="6" y="44" w="408" h="310" visible="{StatisticsSelected}">
|
||||
<label x="4" y="4" text="Detailed Statistics" color="#FFE8D8B0" />
|
||||
<label x="4" y="28" text="Total Kills This Session:" />
|
||||
<label x="194" y="28" text="{DetailedKillsValue}" />
|
||||
|
|
@ -69,10 +76,46 @@
|
|||
<button x="4" y="198" w="80" h="22" text="Reset All" onclick="{ResetStats}" />
|
||||
</group>
|
||||
|
||||
<!-- Mossy Tracker: the original page, one button that opens the tracker window. -->
|
||||
<group x="6" y="44" w="408" h="310" visible="{MossyTrackerSelected}">
|
||||
<button x="4" y="4" w="150" h="30" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" />
|
||||
</group>
|
||||
|
||||
<!-- Metas: the repository's .met/.nav files with their status, a live
|
||||
search, a pick list for one file, refresh, check and download. -->
|
||||
<group x="6" y="44" w="408" h="310" visible="{MetasSelected}">
|
||||
<label x="4" y="0" text="Remote .met/.nav files from the metas repository" color="#FFE8D8B0" />
|
||||
<label x="4" y="18" text="Repository:" />
|
||||
<field x="80" y="16" w="324" h="20" text="{MetasRepositoryText}" onsubmit="{SetMetasRepository}" />
|
||||
<label x="4" y="40" text="{MetasFolderText}" color="#FFC7B98F" />
|
||||
<label x="4" y="56" text="{NavsFolderText}" color="#FFC7B98F" />
|
||||
|
||||
<button x="4" y="74" w="95" h="22" text="Refresh List" onclick="{MetasRefreshList}" />
|
||||
<button x="104" y="74" w="95" h="22" text="Check Updates" onclick="{MetasCheckUpdates}" />
|
||||
<button x="204" y="74" w="120" h="22" text="Download Outdated" onclick="{MetasDownloadOutdated}" />
|
||||
|
||||
<label x="4" y="102" text="Search:" />
|
||||
<field x="52" y="100" w="212" h="20" text="{MetasSearchText}" onchange="{MetasSearchChanged}" />
|
||||
<button x="269" y="99" w="55" h="22" text="Clear" onclick="{MetasClearSearch}" />
|
||||
|
||||
<list x="4" y="124" w="400" h="112" rowheight="16"
|
||||
selected="{MetasNoRow}" onchange="{MetasIgnoreRow}">
|
||||
<column type="text" width="45" items="{MetasFileTypes}" />
|
||||
<column type="text" width="230" items="{MetasFilePaths}" />
|
||||
<column type="text" width="*" items="{MetasFileStatus}" colors="{MetasFileStatusColors}" />
|
||||
</list>
|
||||
|
||||
<label x="4" y="244" text="Selected file:" />
|
||||
<menu x="89" y="242" w="225" h="20"
|
||||
items="{MetasSelectionItems}" selected="{MetasSelectedLabel}" onchange="{MetasSelectionChanged}" />
|
||||
<button x="319" y="241" w="85" h="22" text="Download" onclick="{MetasDownloadSelected}" />
|
||||
|
||||
<label x="4" y="268" text="{MetasLastCheckText}" />
|
||||
<label x="4" y="286" text="{MetasStatusText}" />
|
||||
</group>
|
||||
|
||||
<!-- Tools -->
|
||||
<group x="6" y="44" w="408" h="270" visible="{ToolsSelected}">
|
||||
<button x="4" y="4" w="150" h="26" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" />
|
||||
<button x="4" y="36" w="150" h="26" text="Open Metas" onclick="{OpenMetas}" />
|
||||
<button x="4" y="68" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
|
||||
<group x="6" y="44" w="408" h="310" visible="{ToolsSelected}">
|
||||
<button x="4" y="4" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
|
||||
</group>
|
||||
</panel>
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -24,6 +24,9 @@ public sealed class FakeHost : IPluginHost
|
|||
|
||||
public MemoryStorage Storage { get; } = new();
|
||||
|
||||
/// <summary>The shared automation profile storage; its own root so a test can tell the two apart.</summary>
|
||||
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;
|
||||
|
|
@ -444,6 +449,15 @@ public sealed class FakeUiRegistry : IUiRegistry
|
|||
{
|
||||
public List<RegisteredPanel> Panels { get; } = [];
|
||||
|
||||
/// <summary>Every <see cref="ShowPanel"/> call, in order.</summary>
|
||||
public List<string> ShowPanelCalls { get; } = [];
|
||||
|
||||
public bool ShowPanel(string viewName)
|
||||
{
|
||||
ShowPanelCalls.Add(viewName);
|
||||
return Panels.Exists(panel => panel.Descriptor.WindowId == viewName || panel.Descriptor.Title == viewName);
|
||||
}
|
||||
|
||||
/// <summary>Every <see cref="ShowClientWindow"/> call, in order -- a test sets <see cref="ShowClientWindowResult"/> to simulate the host refusing it.</summary>
|
||||
public List<PluginClientWindow> ShowClientWindowCalls { get; } = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<MetaSyncFeature>().Manager.StorageRoot);
|
||||
Assert.NotEqual(harness.Host.Storage.RootPath, harness.Feature<MetaSyncFeature>().Manager.StorageRoot);
|
||||
|
||||
var server = new FakeGitServer();
|
||||
server.Text(TreeUrl, TreeJson);
|
||||
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
|
||||
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
|
||||
server.Text(RawBase + "/x.MET", XMet);
|
||||
using var feature = new MetaSyncFeature(harness.Context, profiles, server);
|
||||
harness.Context.Commands.Dispatch("metaspull");
|
||||
await Settle(feature);
|
||||
|
||||
// Each file under its own name and extension, metas and navs apart,
|
||||
// where a bare name in the automation plugin finds it.
|
||||
Assert.Equal(ColoMet, profiles.ReadText("mosstank/metas/Colo.met"));
|
||||
Assert.Equal(XMet, profiles.ReadText("mosstank/metas/x.MET"));
|
||||
Assert.Equal(LoopNav, profiles.ReadText("mosstank/navs/Colo Loop.nav"));
|
||||
Assert.Null(harness.Host.Storage.ReadText("mosstank/metas/Colo.met"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_headless_host_gets_the_verbs_but_no_window()
|
||||
{
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a
|
||||
/// Proves every <c>{Binding}</c> 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.
|
||||
/// </summary>
|
||||
public sealed class TrackerAndMetasMarkupContractTests
|
||||
public sealed class WindowMarkupContractTests
|
||||
{
|
||||
private static readonly string[] InteractiveElementNames =
|
||||
[
|
||||
|
|
@ -20,7 +20,13 @@ public sealed class TrackerAndMetasMarkupContractTests
|
|||
public static TheoryData<string, Type> MarkupFiles => new()
|
||||
{
|
||||
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
|
||||
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) },
|
||||
{ MainWindow.MarkupFile, typeof(MainWindow) },
|
||||
};
|
||||
|
||||
/// <summary>The windows opened by a button of the plugin's own, whose visibility the plugin binds.</summary>
|
||||
public static TheoryData<string, Type> 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);
|
||||
164
tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
Normal file
164
tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<MainWindow>(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<MainWindow>();
|
||||
FlagTrackerViewModel tracker = harness.Feature<FlagTrackerFeature>().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<bool>(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<MainWindow>();
|
||||
MetasViewModel metas = harness.Feature<MetaSyncFeature>().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<IReadOnlyList<string>>(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<IReadOnlyList<string>>(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<MainWindow>();
|
||||
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!;
|
||||
|
||||
/// <summary>The group a tab shows: the one whose visibility is the tab's selected binding.</summary>
|
||||
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<T>(object binding, string? expression) =>
|
||||
Assert.IsAssignableFrom<T>(Property(binding, expression).GetValue(binding));
|
||||
|
||||
/// <summary>A label's text: the literal, or the bound value.</summary>
|
||||
private static string Text(object binding, string? text) =>
|
||||
text is not null && text.StartsWith('{') ? Read<object>(binding, text).ToString() ?? string.Empty : text ?? string.Empty;
|
||||
|
||||
private static void Invoke(object binding, string? expression) =>
|
||||
Read<Action>(binding, expression)();
|
||||
|
||||
private static void Invoke(object binding, string? expression, string argument) =>
|
||||
Read<Action<string>>(binding, expression)(argument);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue