acdream/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs
Erik 2040f2bcfb 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>
2026-09-06 23:03:04 +02:00

474 lines
16 KiB
C#

using System.Text.Json;
using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Tests.Plugins;
public sealed class PluginSessionTests
{
[Fact]
public void ScopedHostPrefixesStorageWithAuthenticatedManifestId()
{
var storage = new MemoryStorage();
using var alpha = new ScopedPluginHost(
new StubHost(storage),
"acdream.alpha",
"Alpha");
using var beta = new ScopedPluginHost(
new StubHost(storage),
"acdream.beta",
"Beta");
alpha.Storage.WriteText("profile.json", "alpha");
beta.Storage.WriteText("profile.json", "beta");
alpha.Storage.WriteText("imports/route.nav", "nav");
Assert.Equal("alpha", storage.Text[Path.Combine(
"acdream.alpha", "profile.json")]);
Assert.Equal("beta", storage.Text[Path.Combine(
"acdream.beta", "profile.json")]);
Assert.Equal(["imports/route.nav"], alpha.Storage.List("imports"));
Assert.Empty(beta.Storage.List("imports"));
Assert.Throws<ArgumentException>(() =>
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()
{
var global = new PluginLootClassifierRegistry();
var scope = new ScopedPluginHost(
new StubHost(lootClassifiers: global),
"acdream.looter",
"Looter");
scope.LootClassifiers.Register(
"main",
"My loot rules",
new KeepClassifier());
PluginLootClassifierInfo registered = Assert.Single(global.Available);
Assert.Equal("acdream.looter/main", registered.Id);
Assert.Equal("My loot rules", registered.DisplayName);
scope.Dispose();
Assert.Empty(global.Available);
}
[Fact]
public void AbsentAllowListLoadsEveryDiscoveredPlugin()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, "alpha", "acdream.test.alpha");
InstallFixture(temporary.Path, "beta", "acdream.test.beta");
var statuses = new List<PluginSessionStatus>();
var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(2, plugins.LoadedCount);
Assert.Equal(
["acdream.test.alpha", "acdream.test.beta"],
plugins.LoadedPluginIds);
Assert.All(
statuses,
status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind));
ReleaseAndCollect(plugins);
}
[Fact]
public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, "good", "acdream.test.good");
InstallBroken(temporary.Path, "broken", "acdream.test.broken");
var statuses = new List<PluginSessionStatus>();
var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start(
[temporary.Path],
[
"ACDREAM.TEST.BROKEN",
"ACDREAM.TEST.GOOD",
"acdream.test.missing",
]);
Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds);
Assert.Equal(
[
("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed),
("acdream.test.good", PluginSessionStatusKind.Loaded),
("acdream.test.missing", PluginSessionStatusKind.Failed),
],
statuses.Select(static status => (status.Plugin, status.Kind)));
Assert.All(
statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed),
status => Assert.False(string.IsNullOrWhiteSpace(status.Error)));
ReleaseAndCollect(plugins);
}
[Fact]
public void ExplicitEmptyAllowListLoadsNothing()
{
using var temporary = new TemporaryDirectory();
InstallFixture(temporary.Path, "fixture", "acdream.test.fixture");
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], []);
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(statuses);
}
[Fact]
public void GraphicalKindSet_RegistersRenderPackAndWithdrawsBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
var registry = new RecordingRenderPackRegistry();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(1, plugins.LoadedCount);
Assert.Equal(1, registry.ActiveCount);
Assert.Equal("acdream.test.noop-pack", registry.Descriptor?.Id);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Assert.Equal(0, registry.ActiveCount);
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_SkipsUnrequestedRenderPackBeforeDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Empty(statuses);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
[Fact]
public void RenderPackRegisterFailure_WithdrawsPartialRegistrationBeforeUnload()
{
using var temporary = new TemporaryDirectory();
InstallFixture(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
File.WriteAllText(
Path.Combine(temporary.Path, "render", "throw-after-render-register"),
string.Empty);
var registry = new RecordingRenderPackRegistry();
var statuses = new List<PluginSessionStatus>();
var plugins = new PluginSession(
new StubHost(),
statuses.Add,
registry,
[PluginKind.Gameplay, PluginKind.RenderPack]);
plugins.Start([temporary.Path], allowList: null);
Assert.Equal(0, plugins.LoadedCount);
Assert.Equal(0, registry.ActiveCount);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("failed after publishing", status.Error);
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
[Fact]
public void GameplayOnlyHost_ExplicitRenderPackReportsKindWithoutDllProbe()
{
using var temporary = new TemporaryDirectory();
InstallBroken(
temporary.Path,
"render",
"acdream.test.render",
[PluginKind.RenderPack]);
var statuses = new List<PluginSessionStatus>();
using var plugins = new PluginSession(new StubHost(), statuses.Add);
plugins.Start([temporary.Path], ["acdream.test.render"]);
PluginSessionStatus status = Assert.Single(statuses);
Assert.Equal(PluginSessionStatusKind.Failed, status.Kind);
Assert.Contains("does not support", status.Error);
Assert.DoesNotContain("entry dll", status.Error);
Assert.Empty(plugins.CaptureLoadContextWeakReferences());
}
private static void ReleaseAndCollect(PluginSession plugins)
{
IReadOnlyList<WeakReference> contexts =
plugins.CaptureLoadContextWeakReferences();
plugins.Dispose();
Collect(contexts);
}
private static void Collect(IReadOnlyList<WeakReference> contexts)
{
for (int attempt = 0;
attempt < 10 && contexts.Any(static context => context.IsAlive);
attempt++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Assert.All(contexts, static context => Assert.False(context.IsAlive));
}
private static void InstallFixture(string root, string folder, string id)
=> InstallFixture(root, folder, id, kinds: null);
private static void InstallFixture(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds)
{
string source = FixturePluginPath();
Assert.True(File.Exists(source), $"fixture DLL not found: {source}");
string pluginDirectory = Path.Combine(root, folder);
Directory.CreateDirectory(pluginDirectory);
string fileName = Path.GetFileName(source);
File.Copy(source, Path.Combine(pluginDirectory, fileName));
WriteManifest(pluginDirectory, id, fileName, kinds);
}
private static void InstallBroken(
string root,
string folder,
string id,
IReadOnlyList<PluginKind>? kinds = null)
{
string pluginDirectory = Path.Combine(root, folder);
Directory.CreateDirectory(pluginDirectory);
WriteManifest(pluginDirectory, id, "missing.dll", kinds);
}
private static void WriteManifest(
string directory,
string id,
string entryDll,
IReadOnlyList<PluginKind>? kinds = null) =>
File.WriteAllText(
Path.Combine(directory, "plugin.json"),
JsonSerializer.Serialize(new
{
id,
displayName = id,
version = "1.0.0",
entryDll,
apiVersion = 1,
kinds = kinds?.Select(static kind => kind.ToString()),
}));
private sealed class RecordingRenderPackRegistry : IRenderPackRegistry
{
private readonly List<Registration> _registrations = [];
internal int ActiveCount => _registrations.Count;
internal RenderPackDescriptor? Descriptor { get; private set; }
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
Descriptor = descriptor;
var registration = new Registration(this, assets);
_registrations.Add(registration);
return registration;
}
private void Remove(Registration registration) =>
_registrations.Remove(registration);
private sealed class Registration(
RecordingRenderPackRegistry owner,
IRenderPackAssets assets) : IDisposable
{
private RecordingRenderPackRegistry? _owner = owner;
private IRenderPackAssets? _assets = assets;
public void Dispose()
{
RecordingRenderPackRegistry? current =
Interlocked.Exchange(ref _owner, null);
_assets = null;
current?.Remove(this);
}
}
}
private static string FixturePluginPath()
{
string fileName = "AcDream.Core.Tests.Fixtures.HelloPlugin.dll";
string colocated = Path.Combine(AppContext.BaseDirectory, fileName);
if (File.Exists(colocated))
return colocated;
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent!.Name;
string root = FindRepoRoot(AppContext.BaseDirectory);
return Path.Combine(
root,
"tests",
"AcDream.Core.Tests.Fixtures.HelloPlugin",
"bin",
configuration,
"net10.0",
fileName);
}
private static string FindRepoRoot(string start)
{
DirectoryInfo? directory = new(start);
while (directory is not null
&& !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
directory = directory.Parent;
}
return directory?.FullName
?? throw new InvalidOperationException("Repository root not found.");
}
private sealed class StubHost(
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new SelectionState();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
public IPluginStorage Storage { get; } =
storage ?? NoOpPluginStorage.Instance;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? NoOpPluginStorage.Instance;
}
private sealed class KeepClassifier : IPluginLootClassifier
{
public PluginLootClassification Classify(
in PluginLootClassificationContext context) => new(
true,
PluginLootAction.Keep);
}
private sealed class MemoryStorage : IPluginStorage
{
public Dictionary<string, string> Text { get; } =
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 => key.StartsWith(prefix + Path.DirectorySeparatorChar,
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);
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class TemporaryDirectory : IDisposable
{
internal TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-plugin-session-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
internal string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
}
}
}