acdream/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
Erik cd6eefd0ba feat(plugins): enforce apiVersion; launcher plugins default ON with "none" opt-out
Two gaps from the MossTank shipment review.

**apiVersion was declared in every manifest and checked by nothing.** The
loader now refuses an unsupported contract BEFORE loading any code from the
plugin — checking after the fact is not equivalent, because by then the
assembly is in a collectible context and the mismatch surfaces as a type-load
or missing-member failure from inside the plugin, which reads like the plugin
is broken rather than built for a different host. PluginApi (Current /
MinimumSupported) lives in Plugin.Abstractions beside the contract it
versions, and the refusal is a distinct PluginApiVersionException so callers
can tell "update the client or the plugin" from "this plugin is broken". The
tests pin the ordering too: a manifest with a future apiVersion AND a missing
dll must fail on the version, a supported one on the dll.

**A launcher-launched client loaded no plugins until the user typed ids.**
LA5 distinguishes an omitted allow-list (load all) from an explicit empty one
(load none); a fresh character profile's list is empty, so it composed to
load-none. Direct launches pass null and load everything -- which is why the
gap never showed in development: the two launch paths disagreed and the
launcher was the one users get. This REVERSES the LA5 default deliberately:
"nothing configured" now composes to the omitted list, so plugins are on by
default, including ones installed later. The opt-out is kept -- losing it
would be a real regression for stripped sessions -- respelled as the literal
id "none", and the launcher's plugin box says so.

The cross-host shared fixture composes its explicit-load-none case through
the new spelling, keeping the reader-side contract tests (App and Headless
both preserve an explicit empty list) exactly as they were.

Complete Release suite: 14,469 tests pass on the standard hermetic lane
filter, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:28:04 +02:00

181 lines
6 KiB
C#

using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Plugins;
public class PluginLoaderTests
{
private static string FixturePluginPath()
{
// walk up from the test bin dir to the repo root, then into the fixture's build output
var baseDir = AppContext.BaseDirectory;
var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release
var repoRoot = FindRepoRoot(baseDir);
return Path.Combine(
repoRoot,
"tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0",
"AcDream.Core.Tests.Fixtures.HelloPlugin.dll");
}
private static string FindRepoRoot(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AcDream.slnx")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("repo root not found");
}
private sealed class StubHost : IPluginHost
{
public bool HasUi => true;
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 { get; } = new StubUiRegistry();
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubUiRegistry : IUiRegistry
{
public void AddMarkupPanel(string markupPath, object binding) { }
}
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 { get; } = Array.Empty<WorldEntitySnapshot>();
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
[Fact]
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
{
var dllPath = FixturePluginPath();
Assert.True(File.Exists(dllPath), $"fixture dll not found: {dllPath}");
var host = new StubHost();
var manifest = new PluginManifest(
Id: "acdream.test.hello",
DisplayName: "Hello",
Version: "0.0.1",
EntryDll: Path.GetFileName(dllPath),
ApiVersion: 1,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load(
pluginDirectory: Path.GetDirectoryName(dllPath)!,
manifest: manifest,
host: host);
Assert.True(loaded.Success);
Assert.NotNull(loaded.Plugin);
Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name);
loaded.Plugin.Disable();
loaded.LoadContext!.Unload();
}
[Fact]
public void Load_UnsupportedApiVersion_IsRefusedBeforeAnyCodeLoads()
{
var host = new StubHost();
var manifest = new PluginManifest(
Id: "future.plugin",
DisplayName: "Future",
Version: "1.0.0",
EntryDll: "nope.dll", // deliberately nonexistent:
ApiVersion: PluginApi.Current + 1,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
Assert.False(loaded.Success);
// ...the version gate must fire FIRST, before the dll is even probed,
// so the failure names the real remedy (update the client or the
// plugin) instead of a file-not-found or a type-load error from
// half-loaded plugin code.
var mismatch = Assert.IsType<PluginApiVersionException>(loaded.Error);
Assert.Contains("future.plugin", mismatch.Message);
Assert.Null(loaded.LoadContext);
}
[Fact]
public void Load_MinimumSupportedApiVersion_PassesTheGate()
{
var host = new StubHost();
var manifest = new PluginManifest(
Id: "old.plugin",
DisplayName: "Old",
Version: "1.0.0",
EntryDll: "nope.dll",
ApiVersion: PluginApi.MinimumSupported,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
// Fails on the missing dll, NOT on the version gate.
Assert.False(loaded.Success);
Assert.IsType<FileNotFoundException>(loaded.Error);
}
[Fact]
public void Load_MissingDll_ReturnsFailure()
{
var host = new StubHost();
var manifest = new PluginManifest(
Id: "x",
DisplayName: "X",
Version: "0.0.1",
EntryDll: "nope.dll",
ApiVersion: 1,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load("/does/not/exist", manifest, host);
Assert.False(loaded.Success);
Assert.NotNull(loaded.Error);
}
[Fact]
public void Load_DllWithNoPluginImpl_ReturnsFailure()
{
// Use AcDream.Core.dll itself — it has no IAcDreamPlugin impl
var coreDllDir = AppContext.BaseDirectory;
var host = new StubHost();
var manifest = new PluginManifest(
Id: "x",
DisplayName: "X",
Version: "0.0.1",
EntryDll: "AcDream.Core.dll",
ApiVersion: 1,
Dependencies: Array.Empty<string>());
var loaded = PluginLoader.Load(coreDllDir, manifest, host);
Assert.False(loaded.Success);
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
Assert.NotNull(loaded.LoadContext);
loaded.LoadContext!.Unload();
}
}