diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 32c92ba..5db15b6 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -43,6 +43,11 @@ jobs:
working-directory: PluginManager
run: bash tools/Build/smoke.sh
+ # 作者测试包:内存上下文、手动调度与生命周期宿主。
+ - name: Testing 包冒烟
+ working-directory: PluginManager
+ run: dotnet run --project testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj -c Release
+
# 核心包本身也过一遍打包,避免发布时才暴露形状问题。
- name: 打包检查
working-directory: PluginManager
diff --git a/src/Hosihikari.PluginManager.csproj b/src/Hosihikari.PluginManager.csproj
index 8c70662..b86859e 100644
--- a/src/Hosihikari.PluginManager.csproj
+++ b/src/Hosihikari.PluginManager.csproj
@@ -10,8 +10,21 @@
true
true
+ Hosihikari.PluginManager
+ 1.1.0
+ HosihikariFramework
+ Managed plugin manager for the Hosihikari Framework.
+ LGPL-3.0-or-later
+ https://github.com/HosihikariFramework/PluginManager
+ git
+ hosihikari;minecraft;bedrock;plugin;plugin-manager
+ README.md
+
+
+
+
@@ -44,7 +57,7 @@
-
+
diff --git a/testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj b/testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj
new file mode 100644
index 0000000..3d40541
--- /dev/null
+++ b/testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ Hosihikari.Testing.Smoke
+ Hosihikari.Testing.Smoke
+
+
+
+
+
+
+
diff --git a/testing/Hosihikari.Testing.Smoke/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs
new file mode 100644
index 0000000..b8f6842
--- /dev/null
+++ b/testing/Hosihikari.Testing.Smoke/Program.cs
@@ -0,0 +1,268 @@
+using Hosihikari;
+using Hosihikari.Testing;
+
+var checks = new List<(string Name, bool Passed, string Detail)>();
+
+var invalidHostRejected = false;
+try
+{
+ _ = new PluginTestHost(new ProbePlugin(), "../escape");
+}
+catch (ArgumentException)
+{
+ invalidHostRejected = true;
+}
+
+var probe = new ProbePlugin();
+using (var host = new PluginTestHost(probe, "Probe"))
+{
+ host.RunLifecycle();
+ checks.Add(("测试宿主拒绝越界的插件名", invalidHostRejected, invalidHostRejected ? "已拒绝" : "(没有拒绝)"));
+ checks.Add((
+ "生命周期按 Load → Enable → Disable → Unload 驱动",
+ host.HookLog.SequenceEqual(["load", "enable", "disable", "unload"]),
+ string.Join(" → ", host.HookLog)));
+ checks.Add((
+ "上下文给出包目录与数据目录",
+ Directory.Exists(host.Context.PackageDirectory) && Directory.Exists(host.Context.DataDirectory),
+ host.Context.PackageDirectory));
+ checks.Add(("插件拿到上下文", probe.Context is not null, probe.Context?.Name ?? "(没有)"));
+
+ var seen = 0;
+ var subscription = host.Context.TestEvents.Subscribe(_ => seen++);
+ host.Context.TestEvents.Publish("first");
+ host.Context.TestEvents.Publish("second");
+ subscription.Dispose();
+ host.Context.TestEvents.Publish("after-dispose");
+ checks.Add(("事件总线订阅与退订", seen == 2, $"seen={seen}"));
+
+ var once = 0;
+ host.Context.TestScheduler.ScheduleOnce(TimeSpan.Zero, () => once++);
+ host.Context.TestScheduler.RunOnce();
+ checks.Add(("一次性调度手动执行", once == 1 && host.Context.TestScheduler.PendingOnce == 0, $"once={once}"));
+
+ var periodic = 0;
+ var cancel = host.Context.TestScheduler.SchedulePeriodic(TimeSpan.Zero, () => periodic++);
+ host.Context.TestScheduler.RunPeriodic();
+ host.Context.TestScheduler.RunPeriodic();
+ cancel.Dispose();
+ host.Context.TestScheduler.RunPeriodic();
+ checks.Add(("周期调度可取消", periodic == 2, $"periodic={periodic}"));
+
+ var services = host.Context.TestServices;
+ var first = services.Register(new ProbeService("first"));
+ var duplicate = services.Register(new ProbeService("second"));
+ checks.Add((
+ "服务注册先到先得",
+ first && !duplicate && services.Get()?.Name == "first",
+ services.Get()?.Name ?? "(没有)"));
+
+ host.Context.ReportFault("probe fault");
+ checks.Add(("故障报告可检查", host.Context.Faults.Contains("probe fault"), string.Join(";", host.Context.Faults)));
+}
+
+var refused = new RefusePlugin();
+using (var host = new PluginTestHost(refused))
+{
+ host.Load();
+ var result = host.Enable();
+ checks.Add((
+ "拒绝启用保留原因",
+ !result.Allowed && result.Reason == "not now",
+ result.Reason ?? "(没有原因)"));
+}
+
+var gate = new GatePlugin();
+using (var host = new PluginTestHost(gate))
+{
+ host.Load();
+ host.Context.Events.Publish("before-enable");
+ host.Enable();
+ host.Context.Events.Publish("enabled");
+ host.Context.TestScheduler.RunOnce();
+ host.Disable();
+ host.Context.Events.Publish("disabled");
+ host.Unload();
+ host.Context.Events.Publish("unloaded");
+
+ checks.Add((
+ "启用闸门拦住启用前、停用后与卸载后的回调",
+ gate.Seen == 1 && gate.Scheduled == 1,
+ $"seen={gate.Seen} scheduled={gate.Scheduled}"));
+ checks.Add((
+ "卸载释放插件登记的服务",
+ host.Context.TestServices.Get() is null,
+ host.Context.TestServices.Get()?.Name ?? "(已释放)"));
+}
+
+var thrower = new ThrowUnloadPlugin();
+using (var host = new PluginTestHost(thrower))
+{
+ host.Load();
+ host.Enable();
+ var threw = false;
+ try
+ {
+ host.Unload();
+ }
+ catch (InvalidOperationException)
+ {
+ threw = true;
+ }
+
+ checks.Add((
+ "OnUnload 抛出仍释放作用域",
+ threw && host.Context.TestServices.Get() is null,
+ $"threw={threw} service={host.Context.TestServices.Get()?.Name ?? "已释放"}"));
+}
+
+var unloadPublish = new UnloadPublishPlugin();
+using (var host = new PluginTestHost(unloadPublish))
+{
+ host.Load();
+ host.Enable();
+ host.Unload();
+ checks.Add((
+ "直接卸载时先关闸门再调 OnUnload",
+ unloadPublish.Seen == 0,
+ $"seen={unloadPublish.Seen}"));
+}
+
+using (var harness = new PluginManagerHarness())
+{
+ var escaped = false;
+ try
+ {
+ harness.CreatePackage("../escape", "{}");
+ }
+ catch (ArgumentException)
+ {
+ escaped = true;
+ }
+
+ checks.Add(("管理者辅助拒绝越界的包名", escaped, escaped ? "已拒绝" : "(没有拒绝)"));
+
+ harness.Manager.RegisterLoader(new ProbeLoader());
+ harness.CreatePackage(
+ "HarnessProbe",
+ """
+ {
+ "name": "HarnessProbe",
+ "entry": "HarnessProbe.dll",
+ "type": "hosihikari",
+ "version": "1.0.0",
+ "extraInfo": { "api_version": "2", "manifest_version": "1", "loader": "probe" }
+ }
+ """);
+ var loaded = harness.Load("HarnessProbe");
+ var enabled = harness.Manager.Enable("HarnessProbe");
+ var unloaded = harness.Manager.Unload("HarnessProbe");
+ checks.Add((
+ "真实管理者辅助加载、启用并卸载",
+ loaded && enabled && unloaded.Released,
+ $"loaded={loaded} enabled={enabled} unloaded={unloaded.Released} {unloaded.Error}"));
+}
+
+Console.WriteLine("=== Hosihikari.Testing 冒烟 ===");
+foreach (var (name, passed, detail) in checks)
+{
+ Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {name} ({detail})");
+}
+
+return checks.All(check => check.Passed) ? 0 : 1;
+
+interface IProbeService
+{
+ string Name { get; }
+}
+
+sealed class ProbeService(string name) : IProbeService
+{
+ public string Name { get; } = name;
+}
+
+sealed class ProbePlugin : IPlugin
+{
+ public IPluginContext? Context { get; private set; }
+
+ public void OnLoad(IPluginContext context) => Context = context;
+ public PluginHookResult OnEnable() => PluginHookResult.Allow();
+ public PluginHookResult OnDisable() => PluginHookResult.Allow();
+ public void OnUnload() { }
+}
+
+sealed class RefusePlugin : IPlugin
+{
+ public void OnLoad(IPluginContext context) { }
+ public PluginHookResult OnEnable() => PluginHookResult.Refuse("not now");
+ public PluginHookResult OnDisable() => PluginHookResult.Allow();
+ public void OnUnload() { }
+}
+
+sealed class GatePlugin : IPlugin
+{
+ public int Seen { get; private set; }
+ public int Scheduled { get; private set; }
+
+ public void OnLoad(IPluginContext context)
+ {
+ context.Events.Subscribe(_ => Seen++);
+ context.Scheduler.ScheduleOnce(TimeSpan.Zero, () => Scheduled++);
+ context.PluginServices.Register(new ProbeService("from-plugin"));
+ }
+
+ public PluginHookResult OnEnable() => PluginHookResult.Allow();
+ public PluginHookResult OnDisable() => PluginHookResult.Allow();
+ public void OnUnload() { }
+}
+
+sealed class UnloadPublishPlugin : IPlugin
+{
+ private IPluginContext? _context;
+ public int Seen { get; private set; }
+
+ public void OnLoad(IPluginContext context)
+ {
+ _context = context;
+ context.Events.Subscribe(_ => Seen++);
+ }
+
+ public PluginHookResult OnEnable() => PluginHookResult.Allow();
+ public PluginHookResult OnDisable() => PluginHookResult.Allow();
+ public void OnUnload() => _context?.Events.Publish("during-unload");
+}
+
+sealed class ThrowUnloadPlugin : IPlugin
+{
+ public void OnLoad(IPluginContext context) =>
+ context.PluginServices.Register(new ProbeService("throw-unload"));
+
+ public PluginHookResult OnEnable() => PluginHookResult.Allow();
+ public PluginHookResult OnDisable() => PluginHookResult.Allow();
+ public void OnUnload() => throw new InvalidOperationException("unload failed");
+}
+
+sealed class ProbeLoader : IPluginLoader
+{
+ public string Id => "probe";
+ public bool CanLoad(PluginManifest manifest) => manifest.Loader == Id;
+
+ public PluginHandle? Load(PluginManifest manifest, string packageDirectory, IPluginContext context) =>
+ new ProbeHandle(new ProbePlugin());
+}
+
+sealed class ProbeHandle(ProbePlugin plugin) : PluginHandle(plugin)
+{
+ private bool _unloaded;
+
+ public override bool IsUnloaded => _unloaded;
+
+ public override void Unload()
+ {
+ _unloaded = true;
+ Plugin = null;
+ }
+
+ public override Task WaitForUnloadAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(true);
+}
diff --git a/testing/Hosihikari.Testing/Hosihikari.Testing.csproj b/testing/Hosihikari.Testing/Hosihikari.Testing.csproj
new file mode 100644
index 0000000..33d75f4
--- /dev/null
+++ b/testing/Hosihikari.Testing/Hosihikari.Testing.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Hosihikari.Testing
+ Hosihikari.Testing
+ Hosihikari.Testing
+ 1.0.0
+ HosihikariFramework
+ Test host and in-memory host services for Hosihikari plugins.
+ MIT
+ https://github.com/HosihikariFramework/PluginManager
+ git
+ hosihikari;minecraft;bedrock;plugin;testing
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testing/Hosihikari.Testing/PluginManagerHarness.cs b/testing/Hosihikari.Testing/PluginManagerHarness.cs
new file mode 100644
index 0000000..2212a83
--- /dev/null
+++ b/testing/Hosihikari.Testing/PluginManagerHarness.cs
@@ -0,0 +1,113 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+///
+/// 临时目录 + 真实 的集成测试宿主。它负责准备 plugins/data/config
+/// 三个根,并把管理者报告与“已移除”回调收集起来;插件包与加载器由测试自己放进去。
+///
+public sealed class PluginManagerHarness : IDisposable
+{
+ private readonly string _root;
+ private readonly System.Collections.Concurrent.ConcurrentQueue _reports = new();
+ private readonly System.Collections.Concurrent.ConcurrentQueue _removed = new();
+
+ public PluginManagerHarness(PluginManagerOptions? options = null)
+ {
+ _root = Path.Combine(Path.GetTempPath(), "hosihikari-testing", Guid.NewGuid().ToString("N"));
+ PluginsRoot = Path.Combine(_root, "plugins");
+ DataRoot = Path.Combine(_root, "data");
+ ConfigRoot = Path.Combine(_root, "config", "hosihikari");
+ Directory.CreateDirectory(PluginsRoot);
+ Directory.CreateDirectory(DataRoot);
+ Directory.CreateDirectory(ConfigRoot);
+
+ Manager = new PluginManager(
+ PluginsRoot,
+ DataRoot,
+ ConfigRoot,
+ report => _reports.Enqueue(report),
+ carrierServices: null,
+ onRemoved: name => _removed.Enqueue(name),
+ options ?? new PluginManagerOptions());
+ }
+
+ public string Root => _root;
+ public string PluginsRoot { get; }
+ public string DataRoot { get; }
+ public string ConfigRoot { get; }
+ public PluginManager Manager { get; }
+ public IReadOnlyList Reports => _reports.ToArray();
+ public IReadOnlyList Removed => _removed.ToArray();
+
+ /// 在 plugins/<name> 下写清单与可选文件,返回包目录。
+ public string CreatePackage(
+ string name,
+ string manifestJson,
+ params (string Path, string Content)[] files)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(manifestJson);
+ if (Path.IsPathRooted(name)
+ || name is "." or ".."
+ || name.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0)
+ {
+ throw new ArgumentException("测试包名必须是单个目录名", nameof(name));
+ }
+
+ var directory = Path.GetFullPath(Path.Combine(PluginsRoot, name));
+ var pluginRoot = Path.GetFullPath(PluginsRoot) + Path.DirectorySeparatorChar;
+ if (!directory.StartsWith(pluginRoot, StringComparison.Ordinal))
+ {
+ throw new ArgumentException("测试包目录越界", nameof(name));
+ }
+
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(Path.Combine(directory, "manifest.json"), manifestJson);
+ foreach (var (path, content) in files)
+ {
+ var target = Path.GetFullPath(Path.Combine(directory, path));
+ if (!target.StartsWith(Path.GetFullPath(directory) + Path.DirectorySeparatorChar, StringComparison.Ordinal)
+ && !string.Equals(target, Path.GetFullPath(directory), StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"测试包路径越界:{path}", nameof(files));
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.WriteAllText(target, content);
+ }
+
+ return directory;
+ }
+
+ public bool Load(string name) => Manager.Load(Path.Combine(PluginsRoot, name));
+
+ public void Dispose()
+ {
+ foreach (var name in Manager.LoadedNames.Reverse())
+ {
+ try
+ {
+ Manager.Unload(name);
+ }
+ catch
+ {
+ // 测试清理不掩盖原来的失败。
+ }
+ }
+
+ try
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+}
diff --git a/testing/Hosihikari.Testing/PluginTestHost.cs b/testing/Hosihikari.Testing/PluginTestHost.cs
new file mode 100644
index 0000000..85ff532
--- /dev/null
+++ b/testing/Hosihikari.Testing/PluginTestHost.cs
@@ -0,0 +1,109 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+///
+/// 直接驱动 四个钩子的轻量测试宿主。它不创建 ALC、不读清单,也不模拟载体;
+/// 需要真实发现、依赖与卸载时用真实的 PluginManager。
+///
+public sealed class PluginTestHost : IDisposable
+{
+ private readonly string _root;
+ private readonly List _hooks = [];
+
+ public PluginTestHost(IPlugin plugin, string name = "TestPlugin")
+ {
+ ArgumentNullException.ThrowIfNull(plugin);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ Plugin = plugin;
+ _root = Path.Combine(Path.GetTempPath(), "hosihikari-testing", Guid.NewGuid().ToString("N"));
+ Context = new TestPluginContext(name, _root);
+ }
+
+ public IPlugin Plugin { get; }
+ public TestPluginContext Context { get; }
+ public IReadOnlyList HookLog => _hooks;
+
+ public void Load()
+ {
+ Plugin.OnLoad(Context);
+ _hooks.Add("load");
+ }
+
+ public PluginHookResult Enable()
+ {
+ var result = Plugin.OnEnable();
+ if (result.Allowed)
+ {
+ Context.Scope.Open();
+ }
+
+ _hooks.Add(result.Allowed ? "enable" : $"enable:refused({result.Reason ?? "未给原因"})");
+ return result;
+ }
+
+ public PluginHookResult Disable()
+ {
+ var wasEnabled = Context.Scope.Close();
+ var result = Plugin.OnDisable();
+ if (!result.Allowed && wasEnabled)
+ {
+ Context.Scope.Open();
+ }
+
+ _hooks.Add(result.Allowed ? "disable" : $"disable:refused({result.Reason ?? "未给原因"})");
+ return result;
+ }
+
+ public void Unload()
+ {
+ Context.Scope.Close();
+ try
+ {
+ Plugin.OnUnload();
+ }
+ finally
+ {
+ Context.Scope.ReleaseAll();
+ _hooks.Add("unload");
+ }
+ }
+
+ /// 按 Load → Enable → Disable → Unload 走一遍;任一钩子拒绝时抛出并带上原因。
+ public void RunLifecycle()
+ {
+ Load();
+ var enabled = Enable();
+ if (!enabled.Allowed)
+ {
+ throw new InvalidOperationException($"启用被拒绝:{enabled.Reason ?? "未给原因"}");
+ }
+
+ var disabled = Disable();
+ if (!disabled.Allowed)
+ {
+ throw new InvalidOperationException($"停用被拒绝:{disabled.Reason ?? "未给原因"}");
+ }
+
+ Unload();
+ }
+
+ public void Dispose()
+ {
+ Context.Scope.ReleaseAll();
+ try
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+}
diff --git a/testing/Hosihikari.Testing/README.md b/testing/Hosihikari.Testing/README.md
new file mode 100644
index 0000000..cf0fbea
--- /dev/null
+++ b/testing/Hosihikari.Testing/README.md
@@ -0,0 +1,36 @@
+# Hosihikari.Testing
+
+Test host and in-memory host services for Hosihikari plugins. It does not start BDS, create an
+assembly load context, or read a manifest; it drives the public `IPlugin` contract directly.
+
+```csharp
+using Hosihikari.Testing;
+
+using var host = new PluginTestHost(new MyPlugin());
+host.RunLifecycle();
+
+// host.HookLog: load → enable → disable → unload
+```
+
+The host mirrors the production scope: event and timer callbacks only fire while enabled, and
+`Unload` releases subscriptions, timers, and services. The context is a `TestPluginContext`:
+
+- `TestEvents` — synchronous event bus; handler exceptions land in `HandlerErrors`.
+- `TestScheduler` — manual scheduler; `RunOnce()` and `RunPeriodic()` decide when work runs.
+- `TestServices` — in-memory plugin service registry, first registration wins.
+- `Faults` — strings reported through `IPluginContext.ReportFault`.
+- `Logger`, `Configuration`, `CarrierServices` are replaceable for a test.
+
+For integration tests that need real discovery, dependency ordering, or unload verification, use
+`PluginManagerHarness`. It creates temporary `plugins/`, `data/`, and `config/` roots and exposes the
+real `PluginManager` plus captured reports:
+
+```csharp
+using var harness = new PluginManagerHarness();
+harness.CreatePackage("MyPlugin", manifestJson, ("main.cs", source));
+harness.Manager.RegisterLoader(new MyLoader());
+harness.Load("MyPlugin");
+```
+
+The core is distributed through lip and also as the `Hosihikari.PluginManager` NuGet package,
+starting at 1.1.0. `Hosihikari.Testing` is MIT; the core package is LGPL-3.0-or-later.
diff --git a/testing/Hosihikari.Testing/TestEventBus.cs b/testing/Hosihikari.Testing/TestEventBus.cs
new file mode 100644
index 0000000..14ef575
--- /dev/null
+++ b/testing/Hosihikari.Testing/TestEventBus.cs
@@ -0,0 +1,66 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+/// 同步事件总线,handler 异常被捕获到 ,不中断其余 handler。
+public sealed class TestEventBus : IEventBus
+{
+ private readonly Dictionary> _handlers = [];
+ private readonly List _errors = [];
+
+ public IReadOnlyList HandlerErrors => _errors;
+
+ public IDisposable Subscribe(Action handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ if (!_handlers.TryGetValue(typeof(TEvent), out var list))
+ {
+ _handlers[typeof(TEvent)] = list = [];
+ }
+
+ list.Add(handler);
+ return new Subscription(() =>
+ {
+ if (_handlers.TryGetValue(typeof(TEvent), out var current))
+ {
+ current.Remove(handler);
+ }
+ });
+ }
+
+ public void Publish(TEvent @event)
+ {
+ if (!_handlers.TryGetValue(typeof(TEvent), out var list))
+ {
+ return;
+ }
+
+ foreach (var handler in list.ToArray())
+ {
+ try
+ {
+ ((Action)handler)(@event);
+ }
+ catch (Exception ex)
+ {
+ _errors.Add(ex);
+ }
+ }
+ }
+
+ private sealed class Subscription(Action unsubscribe) : IDisposable
+ {
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ unsubscribe();
+ }
+ }
+}
diff --git a/testing/Hosihikari.Testing/TestPluginContext.cs b/testing/Hosihikari.Testing/TestPluginContext.cs
new file mode 100644
index 0000000..873124b
--- /dev/null
+++ b/testing/Hosihikari.Testing/TestPluginContext.cs
@@ -0,0 +1,74 @@
+using Hosihikari;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Hosihikari.Testing;
+
+/// 内存版 :日志、配置、数据目录与宿主服务都可直接检查或替换。
+public sealed class TestPluginContext : IPluginContext
+{
+ private readonly Dictionary _loaders = new(StringComparer.Ordinal);
+ private readonly List _faults = [];
+
+ public TestPluginContext(string name, string root)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(root);
+ if (Path.IsPathRooted(name)
+ || name is "." or ".."
+ || name.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0)
+ {
+ throw new ArgumentException("测试插件名必须是单个目录名", nameof(name));
+ }
+
+ Name = name;
+ PackageDirectory = Path.Combine(root, "plugins", name);
+ DataDirectory = Path.Combine(root, "data", name);
+ Directory.CreateDirectory(PackageDirectory);
+ Directory.CreateDirectory(DataDirectory);
+
+ Configuration = new ConfigurationBuilder().Build();
+ TestEvents = new TestEventBus();
+ TestScheduler = new TestScheduler();
+ TestServices = new TestServiceRegistry();
+ Scope = new TestPluginScope(TestEvents, TestScheduler, TestServices);
+ }
+
+ public string Name { get; }
+ public string PackageDirectory { get; }
+ public string DataDirectory { get; }
+
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+ public IConfiguration Configuration { get; set; }
+
+ public TestEventBus TestEvents { get; }
+ public IEventBus Events => Scope;
+
+ public TestScheduler TestScheduler { get; }
+ public IScheduler Scheduler => Scope;
+
+ public TestServiceRegistry TestServices { get; }
+ public IPluginServiceRegistry PluginServices => Scope;
+
+ internal TestPluginScope Scope { get; }
+
+ public IServiceProvider CarrierServices { get; set; } = EmptyServices.Instance;
+
+ public IReadOnlyList Faults => _faults;
+
+ public bool RegisterLoader(IPluginLoader loader)
+ {
+ ArgumentNullException.ThrowIfNull(loader);
+ return _loaders.TryAdd(loader.Id, loader);
+ }
+
+ public void ReportFault(string reason) => _faults.Add(reason);
+
+ private sealed class EmptyServices : IServiceProvider
+ {
+ public static readonly EmptyServices Instance = new();
+
+ public object? GetService(Type serviceType) => null;
+ }
+}
diff --git a/testing/Hosihikari.Testing/TestPluginScope.cs b/testing/Hosihikari.Testing/TestPluginScope.cs
new file mode 100644
index 0000000..8740c2c
--- /dev/null
+++ b/testing/Hosihikari.Testing/TestPluginScope.cs
@@ -0,0 +1,174 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+///
+/// 测试版的作用域:镜像生产 PluginScope 的启用闸门、订阅/定时器回收与服务反登记。
+/// 裸的 Test* 存储只用于观察;插件看到的 走这个作用域。
+///
+internal sealed class TestPluginScope(
+ TestEventBus events,
+ TestScheduler scheduler,
+ TestServiceRegistry registry) : IEventBus, IScheduler, IPluginServiceRegistry
+{
+ private readonly object _gate = new();
+ private readonly HashSet _owned = [];
+ private readonly List _registered = [];
+ private volatile bool _enabled;
+ private bool _released;
+
+ public IDisposable Subscribe(Action handler) =>
+ Track(() => events.Subscribe(@event =>
+ {
+ if (_enabled)
+ {
+ handler(@event);
+ }
+ }));
+
+ public void Publish(TEvent @event) => events.Publish(@event);
+
+ public IDisposable ScheduleOnce(TimeSpan delay, Action work)
+ {
+ var gate = new object();
+ var completed = false;
+ Handle? handle = null;
+
+ var tracked = Track(() => scheduler.ScheduleOnce(delay, () =>
+ {
+ try
+ {
+ if (_enabled)
+ {
+ work();
+ }
+ }
+ finally
+ {
+ lock (gate)
+ {
+ completed = true;
+ handle?.Dispose();
+ handle = null;
+ }
+ }
+ }));
+
+ lock (gate)
+ {
+ handle = tracked;
+ if (completed)
+ {
+ tracked.Dispose();
+ handle = null;
+ }
+ }
+
+ return tracked;
+ }
+
+ public IDisposable SchedulePeriodic(TimeSpan interval, Action work) =>
+ Track(() => scheduler.SchedulePeriodic(interval, () =>
+ {
+ if (_enabled)
+ {
+ work();
+ }
+ }));
+
+ public bool Register(T service) where T : class
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_released, this);
+ if (!registry.Register(service))
+ {
+ return false;
+ }
+
+ _registered.Add(typeof(T));
+ return true;
+ }
+ }
+
+ public T? Get() where T : class => registry.Get();
+
+ internal void Open()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_released, this);
+ _enabled = true;
+ }
+ }
+
+ internal bool Close()
+ {
+ lock (_gate)
+ {
+ var wasEnabled = _enabled;
+ _enabled = false;
+ return wasEnabled;
+ }
+ }
+
+ internal void ReleaseAll()
+ {
+ Handle[] owned;
+ lock (_gate)
+ {
+ if (_released)
+ {
+ return;
+ }
+
+ _released = true;
+ _enabled = false;
+ owned = [.. _owned];
+ _owned.Clear();
+ foreach (var type in _registered)
+ {
+ registry.Remove(type);
+ }
+
+ _registered.Clear();
+ }
+
+ foreach (var handle in owned)
+ {
+ handle.Dispose();
+ }
+ }
+
+ private Handle Track(Func create)
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_released, this);
+ var handle = new Handle(create(), this);
+ _owned.Add(handle);
+ return handle;
+ }
+ }
+
+ private sealed class Handle(IDisposable inner, TestPluginScope owner) : IDisposable
+ {
+ private IDisposable? _inner = inner;
+
+ public void Dispose()
+ {
+ var disposable = Interlocked.Exchange(ref _inner, null);
+ if (disposable is null)
+ {
+ return;
+ }
+
+ lock (owner._gate)
+ {
+ owner._owned.Remove(this);
+ }
+
+ disposable.Dispose();
+ }
+ }
+}
diff --git a/testing/Hosihikari.Testing/TestScheduler.cs b/testing/Hosihikari.Testing/TestScheduler.cs
new file mode 100644
index 0000000..78322d5
--- /dev/null
+++ b/testing/Hosihikari.Testing/TestScheduler.cs
@@ -0,0 +1,71 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+/// 手动调度器:测试决定何时执行一次性与周期性回调,不依赖真实时间。
+public sealed class TestScheduler : IScheduler
+{
+ private readonly List _once = [];
+ private readonly List _periodic = [];
+ private readonly List _errors = [];
+
+ public IReadOnlyList HandlerErrors => _errors;
+ public int PendingOnce => _once.Count;
+ public int PendingPeriodic => _periodic.Count;
+
+ public IDisposable ScheduleOnce(TimeSpan delay, Action work)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+ _once.Add(work);
+ return new Cancellation(() => _once.Remove(work));
+ }
+
+ public IDisposable SchedulePeriodic(TimeSpan interval, Action work)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+ _periodic.Add(work);
+ return new Cancellation(() => _periodic.Remove(work));
+ }
+
+ /// 执行并清空所有一次性回调。
+ public void RunOnce()
+ {
+ var pending = _once.ToArray();
+ _once.Clear();
+ Run(pending);
+ }
+
+ /// 执行一轮所有周期性回调;回调保留,供下一轮继续。
+ public void RunPeriodic() => Run(_periodic.ToArray());
+
+ private void Run(IEnumerable work)
+ {
+ foreach (var action in work)
+ {
+ try
+ {
+ action();
+ }
+ catch (Exception ex)
+ {
+ _errors.Add(ex);
+ }
+ }
+ }
+
+ private sealed class Cancellation(Action cancel) : IDisposable
+ {
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ cancel();
+ }
+ }
+}
diff --git a/testing/Hosihikari.Testing/TestServiceRegistry.cs b/testing/Hosihikari.Testing/TestServiceRegistry.cs
new file mode 100644
index 0000000..afaac5c
--- /dev/null
+++ b/testing/Hosihikari.Testing/TestServiceRegistry.cs
@@ -0,0 +1,20 @@
+using Hosihikari;
+
+namespace Hosihikari.Testing;
+
+/// 内存服务注册表:同类型先到先得,与框架契约一致。
+public sealed class TestServiceRegistry : IPluginServiceRegistry
+{
+ private readonly Dictionary _services = [];
+
+ public bool Register(T service) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(service);
+ return _services.TryAdd(typeof(T), service);
+ }
+
+ public T? Get() where T : class =>
+ _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
+
+ internal bool Remove(Type type) => _services.Remove(type);
+}