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:
Erik 2026-09-06 23:03:04 +02:00
parent 8de69b9741
commit 2040f2bcfb
18 changed files with 318 additions and 164 deletions

View file

@ -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()
{

View file

@ -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

View file

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