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