From c0cb94353e956874b50c66b0077d1cf9f8e10f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 13:22:59 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Hosihikari.Tes?= =?UTF-8?q?ting=20=E5=86=85=E5=AD=98=E6=B5=8B=E8=AF=95=E5=AE=BF=E4=B8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 5 + .../Hosihikari.Testing.Smoke.csproj | 16 +++ testing/Hosihikari.Testing.Smoke/Program.cs | 98 +++++++++++++++++++ .../Hosihikari.Testing.csproj | 33 +++++++ testing/Hosihikari.Testing/PluginTestHost.cs | 89 +++++++++++++++++ testing/Hosihikari.Testing/README.md | 27 +++++ testing/Hosihikari.Testing/TestEventBus.cs | 66 +++++++++++++ .../Hosihikari.Testing/TestPluginContext.cs | 65 ++++++++++++ testing/Hosihikari.Testing/TestScheduler.cs | 71 ++++++++++++++ .../Hosihikari.Testing/TestServiceRegistry.cs | 18 ++++ 10 files changed, 488 insertions(+) create mode 100644 testing/Hosihikari.Testing.Smoke/Hosihikari.Testing.Smoke.csproj create mode 100644 testing/Hosihikari.Testing.Smoke/Program.cs create mode 100644 testing/Hosihikari.Testing/Hosihikari.Testing.csproj create mode 100644 testing/Hosihikari.Testing/PluginTestHost.cs create mode 100644 testing/Hosihikari.Testing/README.md create mode 100644 testing/Hosihikari.Testing/TestEventBus.cs create mode 100644 testing/Hosihikari.Testing/TestPluginContext.cs create mode 100644 testing/Hosihikari.Testing/TestScheduler.cs create mode 100644 testing/Hosihikari.Testing/TestServiceRegistry.cs 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/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..2135158 --- /dev/null +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -0,0 +1,98 @@ +using Hosihikari; +using Hosihikari.Testing; + +var checks = new List<(string Name, bool Passed, string Detail)>(); + +var probe = new ProbePlugin(); +using (var host = new PluginTestHost(probe, "Probe")) +{ + host.RunLifecycle(); + 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 ?? "(没有原因)")); +} + +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() { } +} diff --git a/testing/Hosihikari.Testing/Hosihikari.Testing.csproj b/testing/Hosihikari.Testing/Hosihikari.Testing.csproj new file mode 100644 index 0000000..e8146d7 --- /dev/null +++ b/testing/Hosihikari.Testing/Hosihikari.Testing.csproj @@ -0,0 +1,33 @@ + + + + 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/PluginTestHost.cs b/testing/Hosihikari.Testing/PluginTestHost.cs new file mode 100644 index 0000000..f9745d0 --- /dev/null +++ b/testing/Hosihikari.Testing/PluginTestHost.cs @@ -0,0 +1,89 @@ +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(); + _hooks.Add(result.Allowed ? "enable" : $"enable:refused({result.Reason ?? "未给原因"})"); + return result; + } + + public PluginHookResult Disable() + { + var result = Plugin.OnDisable(); + _hooks.Add(result.Allowed ? "disable" : $"disable:refused({result.Reason ?? "未给原因"})"); + return result; + } + + public void Unload() + { + Plugin.OnUnload(); + _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() + { + 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..4343538 --- /dev/null +++ b/testing/Hosihikari.Testing/README.md @@ -0,0 +1,27 @@ +# 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 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 a +real `PluginManager` with a temporary plugin root. The core is distributed through lip, so this +package keeps its dependency to `Hosihikari.Plugin.Abstractions` only. + +The package is MIT. 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..b301332 --- /dev/null +++ b/testing/Hosihikari.Testing/TestPluginContext.cs @@ -0,0 +1,65 @@ +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); + + 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(); + } + + 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 => TestEvents; + + public TestScheduler TestScheduler { get; } + public IScheduler Scheduler => TestScheduler; + + public TestServiceRegistry TestServices { get; } + public IPluginServiceRegistry PluginServices => TestServices; + + 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/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..ef28086 --- /dev/null +++ b/testing/Hosihikari.Testing/TestServiceRegistry.cs @@ -0,0 +1,18 @@ +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; +} From 2c657274ba89d0ec4410e49a6dfe24f1ffc4c709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 13:33:32 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20Testing=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E8=80=85=E8=BE=85=E5=8A=A9=E4=B8=8E=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F=E9=97=B8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Hosihikari.PluginManager.csproj | 15 +- testing/Hosihikari.Testing.Smoke/Program.cs | 88 +++++++++ .../Hosihikari.Testing.csproj | 16 +- .../PluginManagerHarness.cs | 101 ++++++++++ testing/Hosihikari.Testing/PluginTestHost.cs | 13 ++ testing/Hosihikari.Testing/README.md | 16 +- .../Hosihikari.Testing/TestPluginContext.cs | 9 +- testing/Hosihikari.Testing/TestPluginScope.cs | 174 ++++++++++++++++++ .../Hosihikari.Testing/TestServiceRegistry.cs | 2 + 9 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 testing/Hosihikari.Testing/PluginManagerHarness.cs create mode 100644 testing/Hosihikari.Testing/TestPluginScope.cs 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/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs index 2135158..e4f9ed7 100644 --- a/testing/Hosihikari.Testing.Smoke/Program.cs +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -61,6 +61,52 @@ 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 ?? "(已释放)")); +} + +using (var harness = new PluginManagerHarness()) +{ + 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) { @@ -96,3 +142,45 @@ public void OnLoad(IPluginContext context) { } 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 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 index e8146d7..33d75f4 100644 --- a/testing/Hosihikari.Testing/Hosihikari.Testing.csproj +++ b/testing/Hosihikari.Testing/Hosihikari.Testing.csproj @@ -22,12 +22,20 @@ - - + + - - + + + + + + + + + + diff --git a/testing/Hosihikari.Testing/PluginManagerHarness.cs b/testing/Hosihikari.Testing/PluginManagerHarness.cs new file mode 100644 index 0000000..bfa5eb3 --- /dev/null +++ b/testing/Hosihikari.Testing/PluginManagerHarness.cs @@ -0,0 +1,101 @@ +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); + + var directory = Path.Combine(PluginsRoot, 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 index f9745d0..8fb1160 100644 --- a/testing/Hosihikari.Testing/PluginTestHost.cs +++ b/testing/Hosihikari.Testing/PluginTestHost.cs @@ -34,13 +34,24 @@ public void 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; } @@ -48,6 +59,7 @@ public PluginHookResult Disable() public void Unload() { Plugin.OnUnload(); + Context.Scope.ReleaseAll(); _hooks.Add("unload"); } @@ -72,6 +84,7 @@ public void RunLifecycle() public void Dispose() { + Context.Scope.ReleaseAll(); try { if (Directory.Exists(_root)) diff --git a/testing/Hosihikari.Testing/README.md b/testing/Hosihikari.Testing/README.md index 4343538..e11b5f4 100644 --- a/testing/Hosihikari.Testing/README.md +++ b/testing/Hosihikari.Testing/README.md @@ -20,8 +20,16 @@ The context is a `TestPluginContext`: - `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 a -real `PluginManager` with a temporary plugin root. The core is distributed through lip, so this -package keeps its dependency to `Hosihikari.Plugin.Abstractions` only. +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: -The package is MIT. +```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. The package is MIT. diff --git a/testing/Hosihikari.Testing/TestPluginContext.cs b/testing/Hosihikari.Testing/TestPluginContext.cs index b301332..ac2c029 100644 --- a/testing/Hosihikari.Testing/TestPluginContext.cs +++ b/testing/Hosihikari.Testing/TestPluginContext.cs @@ -26,6 +26,7 @@ public TestPluginContext(string name, string root) TestEvents = new TestEventBus(); TestScheduler = new TestScheduler(); TestServices = new TestServiceRegistry(); + Scope = new TestPluginScope(TestEvents, TestScheduler, TestServices); } public string Name { get; } @@ -36,13 +37,15 @@ public TestPluginContext(string name, string root) public IConfiguration Configuration { get; set; } public TestEventBus TestEvents { get; } - public IEventBus Events => TestEvents; + public IEventBus Events => Scope; public TestScheduler TestScheduler { get; } - public IScheduler Scheduler => TestScheduler; + public IScheduler Scheduler => Scope; public TestServiceRegistry TestServices { get; } - public IPluginServiceRegistry PluginServices => TestServices; + public IPluginServiceRegistry PluginServices => Scope; + + internal TestPluginScope Scope { get; } public IServiceProvider CarrierServices { get; set; } = EmptyServices.Instance; 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/TestServiceRegistry.cs b/testing/Hosihikari.Testing/TestServiceRegistry.cs index ef28086..afaac5c 100644 --- a/testing/Hosihikari.Testing/TestServiceRegistry.cs +++ b/testing/Hosihikari.Testing/TestServiceRegistry.cs @@ -15,4 +15,6 @@ public bool Register(T service) where T : class public T? Get() where T : class => _services.TryGetValue(typeof(T), out var service) ? (T)service : null; + + internal bool Remove(Type type) => _services.Remove(type); } From 95328145e66e5d1963ccbdef57353883e759690d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 14:09:58 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E8=AF=B4=E6=98=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=AE=BF=E4=B8=BB=E7=9A=84=E5=90=AF=E7=94=A8=E9=97=B8?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/Hosihikari.Testing/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/Hosihikari.Testing/README.md b/testing/Hosihikari.Testing/README.md index e11b5f4..9a69e87 100644 --- a/testing/Hosihikari.Testing/README.md +++ b/testing/Hosihikari.Testing/README.md @@ -12,7 +12,8 @@ host.RunLifecycle(); // host.HookLog: load → enable → disable → unload ``` -The context is a `TestPluginContext`: +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. From ecbb736a5983e2c3c31a1ca8ba580b06b1d966ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 14:18:22 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20OnUnload=20=E6=8A=9B=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E4=BB=8D=E9=87=8A=E6=94=BE=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=98=8E=E7=A1=AE=E8=AE=B8=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/Hosihikari.Testing.Smoke/Program.cs | 31 ++++++++++++++++++++ testing/Hosihikari.Testing/PluginTestHost.cs | 12 ++++++-- testing/Hosihikari.Testing/README.md | 2 +- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/testing/Hosihikari.Testing.Smoke/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs index e4f9ed7..e064fa5 100644 --- a/testing/Hosihikari.Testing.Smoke/Program.cs +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -84,6 +84,27 @@ 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 ?? "已释放"}")); +} + using (var harness = new PluginManagerHarness()) { harness.Manager.RegisterLoader(new ProbeLoader()); @@ -160,6 +181,16 @@ public void OnLoad(IPluginContext context) public void OnUnload() { } } +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"; diff --git a/testing/Hosihikari.Testing/PluginTestHost.cs b/testing/Hosihikari.Testing/PluginTestHost.cs index 8fb1160..2091f7f 100644 --- a/testing/Hosihikari.Testing/PluginTestHost.cs +++ b/testing/Hosihikari.Testing/PluginTestHost.cs @@ -58,9 +58,15 @@ public PluginHookResult Disable() public void Unload() { - Plugin.OnUnload(); - Context.Scope.ReleaseAll(); - _hooks.Add("unload"); + try + { + Plugin.OnUnload(); + } + finally + { + Context.Scope.ReleaseAll(); + _hooks.Add("unload"); + } } /// 按 Load → Enable → Disable → Unload 走一遍;任一钩子拒绝时抛出并带上原因。 diff --git a/testing/Hosihikari.Testing/README.md b/testing/Hosihikari.Testing/README.md index 9a69e87..cf0fbea 100644 --- a/testing/Hosihikari.Testing/README.md +++ b/testing/Hosihikari.Testing/README.md @@ -33,4 +33,4 @@ harness.Load("MyPlugin"); ``` The core is distributed through lip and also as the `Hosihikari.PluginManager` NuGet package, -starting at 1.1.0. The package is MIT. +starting at 1.1.0. `Hosihikari.Testing` is MIT; the core package is LGPL-3.0-or-later. From eafadc2615a9f775573c923b4615ab5b8d1f7767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 14:24:19 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E8=80=85=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E6=8B=92=E7=BB=9D=E8=B6=8A=E7=95=8C=E5=8C=85=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/Hosihikari.Testing.Smoke/Program.cs | 12 ++++++++++++ testing/Hosihikari.Testing/PluginManagerHarness.cs | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/testing/Hosihikari.Testing.Smoke/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs index e064fa5..f86508b 100644 --- a/testing/Hosihikari.Testing.Smoke/Program.cs +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -107,6 +107,18 @@ 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", diff --git a/testing/Hosihikari.Testing/PluginManagerHarness.cs b/testing/Hosihikari.Testing/PluginManagerHarness.cs index bfa5eb3..2212a83 100644 --- a/testing/Hosihikari.Testing/PluginManagerHarness.cs +++ b/testing/Hosihikari.Testing/PluginManagerHarness.cs @@ -48,8 +48,20 @@ public string CreatePackage( { 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)); + } - var directory = Path.Combine(PluginsRoot, name); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "manifest.json"), manifestJson); foreach (var (path, content) in files) From 459d2662baaef175acf57f04886a2e85f4650464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 14:29:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E6=B5=8B=E8=AF=95=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E8=B6=8A=E7=95=8C=E6=8F=92=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/Hosihikari.Testing.Smoke/Program.cs | 11 +++++++++++ testing/Hosihikari.Testing/TestPluginContext.cs | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/testing/Hosihikari.Testing.Smoke/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs index f86508b..aae4bf3 100644 --- a/testing/Hosihikari.Testing.Smoke/Program.cs +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -3,10 +3,21 @@ 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"]), diff --git a/testing/Hosihikari.Testing/TestPluginContext.cs b/testing/Hosihikari.Testing/TestPluginContext.cs index ac2c029..873124b 100644 --- a/testing/Hosihikari.Testing/TestPluginContext.cs +++ b/testing/Hosihikari.Testing/TestPluginContext.cs @@ -15,6 +15,12 @@ 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); From c85cf24b0d6809489c07a4175f1bfdda026f5350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=88=E7=BA=BE?= Date: Thu, 22 Oct 2026 14:32:47 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E7=9B=B4=E6=8E=A5=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E5=85=88=E5=85=B3=E9=97=AD=E4=BD=9C=E7=94=A8=E5=9F=9F=E9=97=B8?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- testing/Hosihikari.Testing.Smoke/Program.cs | 28 ++++++++++++++++++++ testing/Hosihikari.Testing/PluginTestHost.cs | 1 + 2 files changed, 29 insertions(+) diff --git a/testing/Hosihikari.Testing.Smoke/Program.cs b/testing/Hosihikari.Testing.Smoke/Program.cs index aae4bf3..b8f6842 100644 --- a/testing/Hosihikari.Testing.Smoke/Program.cs +++ b/testing/Hosihikari.Testing.Smoke/Program.cs @@ -116,6 +116,18 @@ $"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; @@ -204,6 +216,22 @@ public void OnLoad(IPluginContext context) 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) => diff --git a/testing/Hosihikari.Testing/PluginTestHost.cs b/testing/Hosihikari.Testing/PluginTestHost.cs index 2091f7f..85ff532 100644 --- a/testing/Hosihikari.Testing/PluginTestHost.cs +++ b/testing/Hosihikari.Testing/PluginTestHost.cs @@ -58,6 +58,7 @@ public PluginHookResult Disable() public void Unload() { + Context.Scope.Close(); try { Plugin.OnUnload();