fix(vt): F host-composed VtankProfiles storage replaces raw path string
Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the plugin a raw string path and told it to fall back to its own System.IO-based portable default when null — a plugin reading and resolving filesystem paths itself, which is exactly the seam the rest of IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost scopes/validates every key; the plugin never sees a path). - IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles (IPluginStorage, defaults to NoOpPluginStorage) added — a second, UNSCOPED storage instance (unlike Storage, which Core scopes per plugin manifest id) rooted at a host-composed VTank-compatible directory. - ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly (no scoping — it names one shared external location, not per-plugin data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped proves the forwarded instance is the exact same object (Assert.Same), not a wrapper. - AppPluginHost/Program.cs: new vtankProfiles constructor parameter, composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride ?? Path.Combine(applicationPaths.DataDirectory, "vtank")). - RuntimeOptions.VtankProfileDirectoryOverride: new init-only property parsed from ACDREAM_VTANK_PROFILE_DIR (row added to docs/launch-options.md, side-effects column states the redirect is the only effect and documents the NullIfEmpty whitespace-not-special-cased quirk it shares with every other path-override flag). New RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet. - FilePluginStorage.List(prefix): empty prefix now means "the storage root itself" instead of throwing (Resolve() rejects empty/whitespace keys, which is correct for every OTHER caller but wrong for "list everything" — VtankProfileDirectory needs exactly that). - Headless: HeadlessPluginHost gained the same VtankProfiles property/constructor param, threaded through HeadlessPluginSession.Create -> HeadlessSessionHost -> HeadlessProcessHost, composed from the new HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides are HeadlessPathOverrides/CLI flags, not env vars). A small AcDream.Headless.Plugins.FilePluginStorage duplicates the App implementation byte-for-byte (Headless does not reference AcDream.App and no shared "platform plugins" library exists yet to host one copy; documented as a reasonable future consolidation, not required here). - VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted outright (no more System.IO, no plugin-owned portable-default fallback); ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take IPluginStorage and enumerate through EnumerateFileNames, which calls storage.List(string.Empty) and skips any key containing '/' (VTank's profile directory is flat; a nested key from some other IPluginStorage implementation is not a profile file). VtankProfileDirectoryTests rewritten against an in-memory IPluginStorage fake instead of real temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins that skip. The prior Resolve/PortableDefault-specific tests (Linux-path guarantee, host-override-vs-portable-default) are superseded by RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus the RuntimeOptions.FromEnvironment Path.Combine-only composition in Program.cs. - docs/architecture/acdream-architecture.md: one sentence in the Storage/List(prefix) paragraph naming VtankProfiles as the second, unscoped storage. No production caller of VtankProfileDirectory's listing methods exists yet (A2's foundation is not wired into MossTankProfileStore/ MossTankMetaProfileStore/MossTankRouteProfileStore's own selection — per that slice's own ledger note), so this is a contract + plumbing change with no MossTank runtime behavior change. MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests (Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests: 173/174 (the one failure, HeadlessCredentialResolverTests. LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing Linux-only lane gate that throws PlatformNotSupportedException on this Windows host — unrelated to this change). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
8de69b9741
commit
2040f2bcfb
18 changed files with 318 additions and 164 deletions
|
|
@ -162,6 +162,12 @@ namespace nor a machine-specific path. Hosts without durable storage expose
|
|||
The additive `List(prefix)` operation enumerates only keys inside that same
|
||||
authenticated namespace, allowing plugins to discover explicit import/export
|
||||
files without receiving a filesystem path or crossing plugin ownership.
|
||||
`IPluginHost.VtankProfiles` is a second, unscoped `IPluginStorage` — one
|
||||
shared external location (a real installed VTank's own profile folder, or a
|
||||
host-composed portable default) rather than per-plugin data — so a
|
||||
VTank-compatible plugin (`AcDream.Plugins.MossTank.VtankProfileDirectory`)
|
||||
can enumerate real `.usd`/`.ast`/`.af` files through the same BCL-only
|
||||
contract without ever touching `System.IO` or resolving its own path.
|
||||
|
||||
`IPluginHost.Automation` is the additive gameplay-automation projection. Its
|
||||
character, spell, magic, chat, combat, equipment, item, loot, fellowship,
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
|
|||
| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=<feature-name>` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` |
|
||||
| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` |
|
||||
| `ACDREAM_VULKAN_PROBE_FRAMES` | `=<int>` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` |
|
||||
| `ACDREAM_VTANK_PROFILE_DIR` | `=<path>` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. | unset → `<ApplicationPathSet.DataDirectory>/vtank` | `RuntimeOptions.VtankProfileDirectoryOverride` → `Program.cs` (composes `AppPluginHost`'s `vtankProfiles` argument). The Headless host has no equivalent override (its path overrides are `HeadlessPathOverrides`, not env vars) and always uses `HeadlessPathSet.VtankProfilesDirectory` (`<DataDirectory>/vtank`). |
|
||||
| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` |
|
||||
|
||||
## Permanent diagnostics
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ public sealed class AppPluginHost : IPluginHost
|
|||
IAutomationSurface automation,
|
||||
IPluginStorage? storage = null,
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null)
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
|
|
@ -25,6 +26,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
LootClassifiers = lootClassifiers
|
||||
?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
|
|
@ -37,4 +39,5 @@ public sealed class AppPluginHost : IPluginHost
|
|||
public IPluginStorage Storage { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; }
|
||||
public IPluginStorage VtankProfiles { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage
|
|||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
string directory = Resolve(prefix);
|
||||
ArgumentNullException.ThrowIfNull(prefix);
|
||||
// An empty prefix means "the storage root itself" — Resolve()
|
||||
// rejects an empty/whitespace key (every other caller of it means
|
||||
// one specific file or sub-directory), so this is handled directly
|
||||
// rather than relaxing that guard for every other use.
|
||||
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@ var host = new AppPluginHost(
|
|||
new FilePluginStorage(
|
||||
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
|
||||
automation.PluginCommands,
|
||||
lootClassifiers);
|
||||
lootClassifiers,
|
||||
new FilePluginStorage(
|
||||
runtimeOptions.VtankProfileDirectoryOverride
|
||||
?? Path.Combine(applicationPaths.DataDirectory, "vtank")));
|
||||
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,17 @@ public sealed record RuntimeOptions(
|
|||
/// process configuration directly.</summary>
|
||||
public IReadOnlyList<string> PluginTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// <c>ACDREAM_VTANK_PROFILE_DIR</c> override for the directory a
|
||||
/// VTank-compatible plugin's <c>IPluginHost.VtankProfiles</c> storage is
|
||||
/// rooted at (real <c>.usd</c>/<c>.ast</c>/<c>.af</c> files — e.g. a real
|
||||
/// installed VTank's own profile folder for direct interop).
|
||||
/// <see langword="null"/> (the default) means "no opinion": the host
|
||||
/// composes <c>applicationPaths.DataDirectory/vtank</c> instead. See
|
||||
/// <c>docs/launch-options.md</c>.
|
||||
/// </summary>
|
||||
public string? VtankProfileDirectoryOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
/// <c>Program.cs</c> at startup.
|
||||
|
|
@ -271,6 +282,7 @@ public sealed record RuntimeOptions(
|
|||
LoginCommandDelayMs: 500)
|
||||
{
|
||||
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
|
||||
VtankProfileDirectoryOverride = NullIfEmpty(env("ACDREAM_VTANK_PROFILE_DIR")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
public ISelectionService Selection => _selection;
|
||||
public IUiRegistry Ui => _ui;
|
||||
public IPluginStorage Storage => _storage;
|
||||
/// <summary>
|
||||
/// Forwarded, not scoped, unlike <see cref="Storage"/>: the VTank
|
||||
/// profile folder is one shared external location (real VTank's own
|
||||
/// files, or a host-composed portable default), not per-plugin data —
|
||||
/// scoping it under this plugin's manifest id would defeat the whole
|
||||
/// point of pointing it at a real installed VTank profile directory.
|
||||
/// </summary>
|
||||
public IPluginStorage VtankProfiles => _inner.VtankProfiles;
|
||||
public IPluginCommandRegistry Commands => _commands;
|
||||
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
];
|
||||
var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage(
|
||||
paths.VtankProfilesDirectory);
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
|
|
@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
timeProvider,
|
||||
contentLease: contentLease,
|
||||
gateCoordinator: gateCoordinator,
|
||||
pluginRoots: pluginRoots));
|
||||
pluginRoots: pluginRoots,
|
||||
vtankProfiles: vtankProfiles));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
|
@ -266,7 +267,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
IHeadlessBotPolicy? policyOverride = null,
|
||||
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
|
||||
IEnumerable<string>? pluginRoots = null)
|
||||
IEnumerable<string>? pluginRoots = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -367,7 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
descriptor.Id,
|
||||
pluginRoots ?? [],
|
||||
descriptor.Plugins,
|
||||
pluginCommands);
|
||||
pluginCommands,
|
||||
vtankProfiles);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet(
|
|||
internal string PluginsDirectory =>
|
||||
Path.Combine(DataDirectory, "plugins");
|
||||
|
||||
/// <summary>
|
||||
/// Default root for <see cref="AcDream.Plugin.Abstractions.IPluginHost.VtankProfiles"/>
|
||||
/// (Campaign VT slice-1 fix round, item F). No override mechanism of its
|
||||
/// own yet — unlike the graphical host's <c>ACDREAM_VTANK_PROFILE_DIR</c>,
|
||||
/// which only exists on <c>AcDream.App.RuntimeOptions</c> — because
|
||||
/// headless path overrides already go through <c>HeadlessPathOverrides</c>
|
||||
/// (config file / <c>--data-dir</c>), not environment variables.
|
||||
/// </summary>
|
||||
internal string VtankProfilesDirectory =>
|
||||
Path.Combine(DataDirectory, "vtank");
|
||||
|
||||
internal static HeadlessPathSet Resolve(
|
||||
HeadlessPathOverrides overrides,
|
||||
IHeadlessPlatformEnvironment? platform = null)
|
||||
|
|
|
|||
99
src/AcDream.Headless/Plugins/FilePluginStorage.cs
Normal file
99
src/AcDream.Headless/Plugins/FilePluginStorage.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Text;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-safe filesystem <see cref="IPluginStorage"/> — byte-identical
|
||||
/// contract to <c>AcDream.App.Plugins.FilePluginStorage</c>. Duplicated
|
||||
/// rather than shared: <c>AcDream.Headless</c> does not (and per the
|
||||
/// no-window/graphical layer split should not) reference <c>AcDream.App</c>,
|
||||
/// and no shared "platform plugins" library exists yet to host one copy of
|
||||
/// this ~70-line class for both hosts. Promoting it there is a reasonable
|
||||
/// future cleanup, not required for Campaign VT slice-1 item F.
|
||||
/// </summary>
|
||||
internal sealed class FilePluginStorage : IPluginStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
internal FilePluginStorage(string root)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(root);
|
||||
_root = Path.GetFullPath(root);
|
||||
}
|
||||
|
||||
public bool IsAvailable => true;
|
||||
|
||||
public string? ReadText(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
return File.Exists(path)
|
||||
? File.ReadAllText(path, Encoding.UTF8)
|
||||
: null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> List(string prefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(prefix);
|
||||
// An empty prefix means "the storage root itself" — Resolve()
|
||||
// rejects an empty/whitespace key (every other caller of it means
|
||||
// one specific file or sub-directory), so this is handled directly
|
||||
// rather than relaxing that guard for every other use.
|
||||
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
|
||||
if (!Directory.Exists(directory))
|
||||
return Array.Empty<string>();
|
||||
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(_root, path)
|
||||
.Replace(Path.DirectorySeparatorChar, '/'))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void WriteText(string key, string content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
string path = Resolve(key);
|
||||
string directory = Path.GetDirectoryName(path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporary = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, content, new UTF8Encoding(false));
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(string key)
|
||||
{
|
||||
string path = Resolve(key);
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Resolve(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
if (Path.IsPathRooted(key))
|
||||
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
|
||||
string path = Path.GetFullPath(Path.Combine(_root, key));
|
||||
string relative = Path.GetRelativePath(_root, path);
|
||||
if (Path.IsPathRooted(relative)
|
||||
|| relative.Equals("..", StringComparison.Ordinal)
|
||||
|| relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,17 +39,20 @@ internal sealed class HeadlessPluginHost
|
|||
internal HeadlessPluginHost(
|
||||
GameRuntime runtime,
|
||||
IPluginLogger logger,
|
||||
IPluginCommandRegistry? commands = null)
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
|
||||
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
|
||||
_eventSubscription = runtime.Subscribe(this);
|
||||
}
|
||||
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; }
|
||||
public IPluginCommandRegistry Commands { get; }
|
||||
public IPluginStorage VtankProfiles { get; }
|
||||
public IGameState State => this;
|
||||
public IEvents Events => this;
|
||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
|||
string sessionId,
|
||||
IEnumerable<string> roots,
|
||||
IReadOnlyList<string>? allowList,
|
||||
IPluginCommandRegistry? commands = null)
|
||||
IPluginCommandRegistry? commands = null,
|
||||
IPluginStorage? vtankProfiles = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||
|
|
@ -59,7 +60,8 @@ internal sealed class HeadlessPluginSession : IDisposable
|
|||
diagnostics,
|
||||
sessionId,
|
||||
() => runtime.Generation.Value),
|
||||
commands);
|
||||
commands,
|
||||
vtankProfiles);
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status),
|
||||
|
|
|
|||
|
|
@ -44,16 +44,20 @@ public interface IPluginHost
|
|||
IAutomationSurface Automation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute filesystem directory a VTank-compatible plugin should treat
|
||||
/// as the VTank profile folder (real <c>.usd</c>/<c>.ast</c>/<c>.af</c>
|
||||
/// files, VTank's own naming rules). <see langword="null"/> when the
|
||||
/// host has no opinion, in which case the plugin falls back to its own
|
||||
/// portable per-OS default (never a hard-coded Windows path) — see
|
||||
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>. A graphical
|
||||
/// host may point this at a real installed VTank's own profile
|
||||
/// directory for direct interop; that platform-specific discovery
|
||||
/// belongs entirely to the host composing this property, never to the
|
||||
/// plugin reading it.
|
||||
/// Storage rooted at the VTank profile folder (real <c>.usd</c>/
|
||||
/// <c>.ast</c>/<c>.af</c> files, VTank's own naming rules) rather than
|
||||
/// this plugin's own scoped <see cref="Storage"/> directory — see
|
||||
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>, which
|
||||
/// enumerates through this property exclusively (no <c>System.IO</c>,
|
||||
/// no per-OS portable-default fallback of its own) so directory
|
||||
/// discovery stays entirely host-composed. Defaults to the inert
|
||||
/// <see cref="NoOpPluginStorage"/> (<c>IsAvailable</c> false) when the
|
||||
/// host has no opinion. A graphical host may root this at a real
|
||||
/// installed VTank's own profile directory for direct interop, or at
|
||||
/// its own portable per-OS default under
|
||||
/// <c>ApplicationPathSet.DataDirectory</c>; that discovery belongs
|
||||
/// entirely to the host composing this property, never to the plugin
|
||||
/// reading it.
|
||||
/// </summary>
|
||||
string? VtankProfileDirectory => null;
|
||||
IPluginStorage VtankProfiles => NoOpPluginStorage.Instance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,27 +3,24 @@ using AcDream.Plugin.Abstractions;
|
|||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the on-disk VTank profile directory and implements VTank's real
|
||||
/// naming/selection rules (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
|
||||
/// section 3): the per-character auto file, the longer <c>--Name_Server_</c>
|
||||
/// Implements VTank's real profile-directory naming/selection rules
|
||||
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3):
|
||||
/// the per-character auto file, the longer <c>--Name_Server_</c>
|
||||
/// sub-profile prefix, and which filenames a given character can see.
|
||||
///
|
||||
/// The directory itself is never hard-coded here: <see cref="Resolve"/>
|
||||
/// prefers <see cref="IPluginHost.VtankProfileDirectory"/> (an App-composed
|
||||
/// path — on Windows that may be a real installed VTank's own profile
|
||||
/// folder for direct interop; that discovery belongs to the host, not this
|
||||
/// plugin) and only falls back to a portable, cross-platform default under
|
||||
/// the user's own local-app-data directory when the host has no opinion.
|
||||
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> resolves to
|
||||
/// <c>%LOCALAPPDATA%</c> on Windows and (via .NET's XDG-aware base-directory
|
||||
/// resolution) <c>$XDG_DATA_HOME</c> (or <c>~/.local/share</c>) on Linux —
|
||||
/// built exclusively with <see cref="Path.Combine(string, string)"/>, so it
|
||||
/// never contains a literal backslash.
|
||||
/// The directory itself is never resolved here: every listing method reads
|
||||
/// through <see cref="IPluginHost.VtankProfiles"/> (an <see cref="IPluginStorage"/>
|
||||
/// the App/Headless host composes — real installed VTank's own profile
|
||||
/// folder for direct interop, or a portable per-OS default under
|
||||
/// <c>ApplicationPathSet.DataDirectory</c>). This class has no
|
||||
/// <c>System.IO</c> dependency and no portable-default fallback of its
|
||||
/// own: when the host has no opinion, <see cref="IPluginHost.VtankProfiles"/>
|
||||
/// defaults to the inert <c>NoOpPluginStorage</c> (<c>IsAvailable</c>
|
||||
/// false), and every listing here degrades to just its built-in entries,
|
||||
/// exactly like a missing directory used to.
|
||||
/// </summary>
|
||||
internal static class VtankProfileDirectory
|
||||
{
|
||||
private const string PortableFolderName = "vtank";
|
||||
|
||||
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
|
||||
internal const string HiddenPrefix = "--";
|
||||
|
||||
|
|
@ -38,19 +35,6 @@ internal static class VtankProfileDirectory
|
|||
internal const string DefaultLabel = "[Default]";
|
||||
internal const string NoneLabel = "[None]";
|
||||
|
||||
public static string Resolve(IPluginHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
return !string.IsNullOrWhiteSpace(host.VtankProfileDirectory)
|
||||
? host.VtankProfileDirectory
|
||||
: PortableDefault();
|
||||
}
|
||||
|
||||
internal static string PortableDefault() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"acdream",
|
||||
PortableFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// VTank's single per-character default filename
|
||||
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
|
||||
|
|
@ -123,7 +107,7 @@ internal static class VtankProfileDirectory
|
|||
/// <c>[Char] suffix</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
|
||||
string directory,
|
||||
IPluginStorage storage,
|
||||
string characterName,
|
||||
string server,
|
||||
bool mineOnly)
|
||||
|
|
@ -133,7 +117,7 @@ internal static class VtankProfileDirectory
|
|||
new(string.Empty, DefaultLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(directory, "*.usd"))
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".usd"))
|
||||
{
|
||||
string? subProfileDisplay = TryDisplayName(fileName, characterName, server);
|
||||
if (subProfileDisplay is not null)
|
||||
|
|
@ -156,14 +140,14 @@ internal static class VtankProfileDirectory
|
|||
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
|
||||
/// <c>.af</c> file that starts with neither <c>--</c> nor <c>~~</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(string directory)
|
||||
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|
||||
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
|
||||
|
|
@ -185,14 +169,14 @@ internal static class VtankProfileDirectory
|
|||
/// itself distinguished by the separate <c>.met</c>/<c>.nav</c>
|
||||
/// extensions).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(string directory)
|
||||
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".af"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
|
@ -201,14 +185,24 @@ internal static class VtankProfileDirectory
|
|||
return entries;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFileNames(string directory, string searchPattern)
|
||||
/// <summary>
|
||||
/// Lists root-level file names matching <paramref name="extension"/>
|
||||
/// through <see cref="IPluginStorage.List"/> alone — no
|
||||
/// <c>System.IO</c>. VTank's own profile directory is flat, so a key
|
||||
/// containing '/' (meaning it lives in some deeper storage
|
||||
/// implementation's sub-directory) is not one of these files and is
|
||||
/// skipped rather than surfaced as a bogus profile name.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> EnumerateFileNames(IPluginStorage storage, string extension)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
if (!storage.IsAvailable)
|
||||
yield break;
|
||||
foreach (string path in Directory.EnumerateFiles(directory, searchPattern)
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (string key in storage.List(string.Empty)
|
||||
.Where(key => !key.Contains('/', StringComparison.Ordinal))
|
||||
.Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return Path.GetFileName(path);
|
||||
yield return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(opts.UiProbeEnabled);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
Assert.Empty(opts.PluginTags);
|
||||
Assert.Null(opts.VtankProfileDirectoryOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -186,6 +187,26 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Equal(["healer", "Leader", "scout"], options.PluginTags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VtankProfileDirectoryOverrideIsNullUnlessSet()
|
||||
{
|
||||
RuntimeOptions blank = RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VTANK_PROFILE_DIR"] = "" }));
|
||||
Assert.Null(blank.VtankProfileDirectoryOverride);
|
||||
|
||||
RuntimeOptions set = RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new()
|
||||
{
|
||||
["ACDREAM_VTANK_PROFILE_DIR"] =
|
||||
"C:/Games/VirindiPlugins/VirindiTank",
|
||||
}));
|
||||
Assert.Equal(
|
||||
"C:/Games/VirindiPlugins/VirindiTank",
|
||||
set.VtankProfileDirectoryOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,6 +35,26 @@ public sealed class PluginSessionTests
|
|||
alpha.Storage.WriteText("../escape.json", "bad"));
|
||||
}
|
||||
|
||||
// Item F (Campaign VT slice-1 fix round): VtankProfiles must be
|
||||
// forwarded UNSCOPED, unlike Storage — it names one shared external
|
||||
// location (a real VTank profile folder, or a host-composed portable
|
||||
// default), not per-plugin data, so prefixing it by manifest id would
|
||||
// defeat the point of pointing it at a real installed VTank directory.
|
||||
[Fact]
|
||||
public void ScopedHostForwardsVtankProfilesUnscoped()
|
||||
{
|
||||
var vtankProfiles = new MemoryStorage();
|
||||
using var scope = new ScopedPluginHost(
|
||||
new StubHost(vtankProfiles: vtankProfiles),
|
||||
"acdream.alpha",
|
||||
"Alpha");
|
||||
|
||||
scope.VtankProfiles.WriteText("Shared.usd", "content");
|
||||
|
||||
Assert.Same(vtankProfiles, scope.VtankProfiles);
|
||||
Assert.Equal("content", vtankProfiles.Text["Shared.usd"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
|
||||
{
|
||||
|
|
@ -364,7 +384,8 @@ public sealed class PluginSessionTests
|
|||
|
||||
private sealed class StubHost(
|
||||
IPluginStorage? storage = null,
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
|
||||
IPluginLootClassifierRegistry? lootClassifiers = null,
|
||||
IPluginStorage? vtankProfiles = null) : IPluginHost
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
|
|
@ -377,6 +398,8 @@ public sealed class PluginSessionTests
|
|||
storage ?? NoOpPluginStorage.Instance;
|
||||
public IPluginLootClassifierRegistry LootClassifiers { get; } =
|
||||
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
|
||||
public IPluginStorage VtankProfiles { get; } =
|
||||
vtankProfiles ?? NoOpPluginStorage.Instance;
|
||||
}
|
||||
|
||||
private sealed class KeepClassifier : IPluginLootClassifier
|
||||
|
|
|
|||
|
|
@ -4,51 +4,6 @@ namespace AcDream.Plugins.MossTank.Tests;
|
|||
|
||||
public sealed class VtankProfileDirectoryTests
|
||||
{
|
||||
// Linux-path test: the portable default must be built with Path.Combine
|
||||
// only (never a hard-coded Windows-style backslash path), so it stays
|
||||
// correct on Linux CI. This does not merely check for backslashes (a
|
||||
// Windows machine's own LocalApplicationData root may legitimately
|
||||
// contain one) — it asserts the *plugin-owned suffix* is combined with
|
||||
// forward-slash-safe path segments by re-deriving it the same way and
|
||||
// comparing, so a future hard-coded "acdream\vtank" typo would fail this
|
||||
// test on any OS.
|
||||
[Fact]
|
||||
public void PortableDefaultIsBuiltWithPathCombineOnly()
|
||||
{
|
||||
string expected = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"acdream",
|
||||
"vtank");
|
||||
Assert.Equal(expected, VtankProfileDirectory.PortableDefault());
|
||||
Assert.DoesNotContain("acdream\\vtank", VtankProfileDirectory.PortableDefault()
|
||||
.Replace(Path.DirectorySeparatorChar, '/'), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePrefersHostSuppliedDirectoryOverPortableDefault()
|
||||
{
|
||||
var host = new FakeHost("C:/Games/VirindiPlugins/VirindiTank");
|
||||
Assert.Equal(
|
||||
"C:/Games/VirindiPlugins/VirindiTank",
|
||||
VtankProfileDirectory.Resolve(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFallsBackToPortableDefaultWhenHostHasNoOpinion()
|
||||
{
|
||||
var host = new FakeHost(null);
|
||||
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void ResolveTreatsBlankHostDirectoryAsNoOpinion(string? value, bool _)
|
||||
{
|
||||
var host = new FakeHost(value);
|
||||
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoCharacterFileNameMatchesRealInstalledConvention()
|
||||
{
|
||||
|
|
@ -97,84 +52,82 @@ public sealed class VtankProfileDirectoryTests
|
|||
[Fact]
|
||||
public void ListSettingsProfilesSeedsDefaultAndByCharFirst()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(directory, "Shared.usd"), "1\r\n");
|
||||
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.usd"), "1\r\n");
|
||||
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve_Base.usd"), "1\r\n");
|
||||
File.WriteAllText(Path.Combine(directory, "--Someone_Coldeve.usd"), "1\r\n");
|
||||
var storage = new MemoryStorage();
|
||||
storage.WriteText("Shared.usd", "1\r\n");
|
||||
storage.WriteText("--Barris_Coldeve.usd", "1\r\n");
|
||||
storage.WriteText("--Barris_Coldeve_Base.usd", "1\r\n");
|
||||
storage.WriteText("--Someone_Coldeve.usd", "1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListSettingsProfiles(
|
||||
directory, "Barris", "Coldeve", mineOnly: false);
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListSettingsProfiles(
|
||||
storage, "Barris", "Coldeve", mineOnly: false);
|
||||
|
||||
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
|
||||
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
|
||||
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
|
||||
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
|
||||
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListNavigationProfilesFiltersBothReservedPrefixes()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(directory, "Hunt.af"), "1\r\n");
|
||||
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.af"), "1\r\n");
|
||||
File.WriteAllText(Path.Combine(directory, "~~backup.af"), "1\r\n");
|
||||
var storage = new MemoryStorage();
|
||||
storage.WriteText("Hunt.af", "1\r\n");
|
||||
storage.WriteText("--Barris_Coldeve.af", "1\r\n");
|
||||
storage.WriteText("~~backup.af", "1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListNavigationProfiles(directory);
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListNavigationProfiles(storage);
|
||||
|
||||
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
|
||||
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
|
||||
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
|
||||
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListingsOnMissingDirectoryOnlySeedTheBuiltInEntries()
|
||||
public void ListingsOnUnavailableStorageOnlySeedTheBuiltInEntries()
|
||||
{
|
||||
string missing = Path.Combine(Path.GetTempPath(), "acdream-vt-missing-" + Guid.NewGuid());
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListSettingsProfiles(missing, "Barris", "Coldeve", mineOnly: false);
|
||||
VtankProfileDirectory.ListSettingsProfiles(
|
||||
NoOpPluginStorage.Instance, "Barris", "Coldeve", mineOnly: false);
|
||||
Assert.Equal(2, entries.Count);
|
||||
}
|
||||
|
||||
private sealed class FakeHost(string? vtankProfileDirectory) : IPluginHost
|
||||
// Item F (Campaign VT slice-1 fix round): a nested-path key (as a real
|
||||
// IPluginStorage implementation might return for a sub-directory) must
|
||||
// never surface as a bogus profile name — VTank's own profile
|
||||
// directory is flat.
|
||||
[Fact]
|
||||
public void NestedPathKeysAreNotTreatedAsProfileFiles()
|
||||
{
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log => NoOpLogger.Instance;
|
||||
public IGameState State => throw new NotSupportedException();
|
||||
public IEvents Events => throw new NotSupportedException();
|
||||
public ISelectionService Selection => throw new NotSupportedException();
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
|
||||
public string? VtankProfileDirectory => vtankProfileDirectory;
|
||||
var storage = new MemoryStorage();
|
||||
storage.WriteText("subdir/Nested.usd", "1\r\n");
|
||||
storage.WriteText("Flat.usd", "1\r\n");
|
||||
|
||||
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
|
||||
VtankProfileDirectory.ListSettingsProfiles(
|
||||
storage, "Barris", "Coldeve", mineOnly: false);
|
||||
|
||||
Assert.Contains(entries, static e => e.FileName == "Flat.usd");
|
||||
Assert.DoesNotContain(entries, static e => e.FileName.Contains('/'));
|
||||
}
|
||||
|
||||
private sealed class NoOpLogger : IPluginLogger
|
||||
private sealed class MemoryStorage : IPluginStorage
|
||||
{
|
||||
public static NoOpLogger Instance { get; } = new();
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message, Exception? exception = null) { }
|
||||
private readonly Dictionary<string, string> _text = new(StringComparer.Ordinal);
|
||||
public bool IsAvailable => true;
|
||||
public string? ReadText(string key) =>
|
||||
_text.TryGetValue(key, out string? value) ? value : null;
|
||||
public IReadOnlyList<string> List(string prefix) => _text.Keys
|
||||
.Where(key => prefix.Length == 0
|
||||
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
|
||||
.OrderBy(static key => key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
public void WriteText(string key, string content) => _text[key] = content;
|
||||
public bool Delete(string key) => _text.Remove(key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue