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); +}