feat(cordis): 接入上下文组合与模组事务热替换

This commit is contained in:
2026-08-16 23:20:40 +08:00
commit ad256f109b
676 changed files with 52168 additions and 0 deletions
@@ -0,0 +1,100 @@
#nullable enable
using NUnit.Framework;
using ShrinkApp.Starter.Basic;
using ShrinkCommand.Integration.App;
using ShrinkContext;
using ShrinkDataSaver.Integration.App;
using ShrinkNetwork;
using ShrinkNetwork.Integration.App;
using ShrinkApp;
using UnityEngine;
using Object = UnityEngine.Object;
namespace ShrinkContext.AppAdapter.Tests
{
/// <summary>
/// Basic Starter 默认装配表的端到端验证(阶段 3):
/// 三个功能模块走原生 Cordis 组件,四个跨包集成走按键注入的薄组件;
/// 提供者停用时依赖集成先退出,兼容服务门面也作为可逆效应注销。
/// </summary>
public class NativeWiringTests
{
[Test]
public void BasicComposition_UsesNativeComponents_AndRetiresDependentsReversibly()
{
var settings = ScriptableObject.CreateInstance<ShrinkAppSettings>();
ShrinkAppLoaderHost? host = null;
try
{
host = new ShrinkAppLoaderHost(settings);
ShrinkAppBasicContextComposition.Configure(host);
TestAwait.Run(host.StartAsync());
Assert.AreEqual(7, host.ModuleIds.Count);
Assert.AreEqual(7, host.ActiveModuleIds.Count);
Assert.IsTrue(host.IsModuleActive("shrink.network"));
Assert.IsTrue(host.IsModuleActive("shrink.command"));
Assert.IsTrue(host.IsModuleActive("shrink.datasaver"));
Assert.IsTrue(host.IsModuleActive("shrink.integration.command-network"));
Assert.IsTrue(host.IsModuleActive("shrink.integration.command-eventbus"));
Assert.IsTrue(host.IsModuleActive("shrink.integration.datasaver-eventbus"));
Assert.IsTrue(host.IsModuleActive("shrink.integration.network-eventbus"));
var root = host.Context.RootContext;
Assert.IsTrue(host.Context.TryGetRaw<ShrinkNetworkService>(root,
ShrinkNetworkAppComponent.ServiceKey, out _), "网络服务键由原生组件提供");
Assert.IsTrue(host.Context.TryGetRaw<ShrinkCommand.ShrinkCommandService>(root,
ShrinkCommandAppComponent.ServiceKey, out _), "命令服务键由原生组件提供");
Assert.IsTrue(host.Context.TryGetRaw<object>(root,
ShrinkDataSaverAppComponent.ServiceKey, out _), "存档服务键由原生组件提供");
Assert.IsTrue(host.Context.TryGetRaw<object>(root,
"shrink.integration.command-network", out _));
Assert.IsTrue(host.Context.TryGetRaw<object>(root,
"shrink.integration.command-eventbus", out _));
Assert.IsTrue(host.Context.TryGetRaw<object>(root,
"shrink.integration.datasaver-eventbus", out _));
Assert.IsTrue(host.Context.TryGetRaw<object>(root,
"shrink.integration.network-eventbus", out _));
Assert.IsTrue(host.Services.TryGet<ShrinkNetworkAppService>(out _));
Assert.IsTrue(host.Services.TryGet<ShrinkCommandAppService>(out _));
Assert.IsTrue(host.Services.TryGet<ShrinkDataSaverService>(out _));
TestAwait.Run(host.SetModuleDisabledAsync("shrink.command", true));
Assert.IsFalse(host.IsModuleActive("shrink.command"));
Assert.IsFalse(host.IsModuleActive("shrink.integration.command-network"));
Assert.IsFalse(host.IsModuleActive("shrink.integration.command-eventbus"));
Assert.IsTrue(host.IsModuleActive("shrink.network"), "无关提供者不得重载");
Assert.IsTrue(host.IsModuleActive("shrink.datasaver"), "无关提供者不得重载");
Assert.IsTrue(host.IsModuleActive("shrink.integration.network-eventbus"));
Assert.IsTrue(host.IsModuleActive("shrink.integration.datasaver-eventbus"));
Assert.IsFalse(host.Context.TryGetRaw<object>(root,
ShrinkCommandAppComponent.ServiceKey, out _));
Assert.IsFalse(host.Context.TryGetRaw<object>(root,
"shrink.integration.command-network", out _));
Assert.IsFalse(host.Context.TryGetRaw<object>(root,
"shrink.integration.command-eventbus", out _));
Assert.IsFalse(host.Services.TryGet<ShrinkCommandAppService>(out _),
"兼容门面应随组件退役注销");
TestAwait.Run(host.SetModuleDisabledAsync("shrink.command", false));
Assert.AreEqual(7, host.ActiveModuleIds.Count);
Assert.IsTrue(host.Services.TryGet<ShrinkCommandAppService>(out _));
TestAwait.Run(host.ShutdownAsync());
Assert.AreEqual(0, host.ActiveModuleIds.Count);
Assert.IsFalse(host.Services.TryGet<ShrinkNetworkAppService>(out _));
Assert.IsFalse(host.Services.TryGet<ShrinkCommandAppService>(out _));
Assert.IsFalse(host.Services.TryGet<ShrinkDataSaverService>(out _));
}
finally
{
if (host?.IsRunning == true)
TestAwait.Run(host.ShutdownAsync());
Object.DestroyImmediate(settings);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 92ef45fe59147f445bbb2ae4760732b7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,174 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkApp;
using ShrinkContext;
using UnityEngine;
using Object = UnityEngine.Object;
namespace ShrinkContext.AppAdapter.Tests
{
/// <summary>
/// ShrinkApp 安装器适配器:
/// 依赖缺失等待而非抛错(对照 ShrinkAppHost.SortInstallers 的行为差异)、
/// 依赖就绪顺序保证、重复 ModuleId 退化为供给冲突而非宿主级失败、服务注册可见性。
/// </summary>
public class ShrinkAppInstallerComponentTests
{
private ShrinkContextRuntime _runtime = null!;
private ShrinkAppServices _services = null!;
private ShrinkAppSettings _settings = null!;
private static long _sequence;
[SetUp]
public void SetUp()
{
_runtime = new ShrinkContextRuntime();
_services = new ShrinkAppServices();
_settings = ScriptableObject.CreateInstance<ShrinkAppSettings>();
}
[TearDown]
public void TearDown()
{
if (_settings != null)
Object.DestroyImmediate(_settings);
}
private ShrinkFiber UseInstaller(FakeInstaller installer)
{
return _runtime.Use(new ShrinkAppInstallerComponent(installer, _settings), _services);
}
[Test]
public void MissingDependency_WaitsInsteadOfThrowing()
{
var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } };
var fiber = UseInstaller(dependent);
Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State,
"依赖模块未就绪时安装器保持非活动(对照:ShrinkAppHost 在排序期直接抛依赖缺失)");
Assert.AreEqual(0, dependent.RegisterCount);
Assert.AreEqual(0, dependent.InitCount);
}
[Test]
public void DependencyArrives_DependentActivates_AfterProvider()
{
var provider = new FakeInstaller("app.a", "a-service");
var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } };
var dependentFiber = UseInstaller(dependent);
var providerFiber = UseInstaller(provider);
Assert.AreEqual(ShrinkFiberState.Active, providerFiber.State);
Assert.AreEqual(ShrinkFiberState.Active, dependentFiber.State,
"依赖键由提供者发布后,依赖安装器被响应式激活");
// 激活次序由依赖关系保证:提供者先完成 Register+Init
Assert.AreEqual(1, provider.InitCount);
Assert.AreEqual(1, dependent.InitCount);
Assert.Less(provider.CompletedAt, dependent.CompletedAt,
"依赖者的初始化必须晚于其依赖的提供者(空间可组合性排序)");
Assert.IsTrue(_services.TryGet<FakeService>(out var service));
Assert.AreEqual("b-service", service!.Value);
}
[Test]
public void DuplicateModuleId_SecondFailsAsSupplyConflict_FirstStaysActive()
{
var first = new FakeInstaller("app.dup", "first");
var second = new FakeInstaller("app.dup", "second");
var firstFiber = UseInstaller(first);
var secondFiber = UseInstaller(second);
Assert.AreEqual(ShrinkFiberState.Active, firstFiber.State);
Assert.AreEqual(ShrinkFiberState.Inactive, secondFiber.State);
Assert.IsInstanceOf<ShrinkSupplyConflictException>(secondFiber.LastError,
"重复 ModuleId 退化为该纤程的供给冲突失败,而不是宿主级异常(对照 ShrinkHost 的 Duplicate installer 抛错)");
Assert.AreEqual(0, second.InitCount);
}
[Test]
public void RetireProvider_DependentInstallerDeactivates()
{
var provider = new FakeInstaller("app.a", "a-service");
var dependent = new FakeInstaller("app.b", "b-service") { DependsOn = new[] { "app.a" } };
var providerFiber = UseInstaller(provider);
var dependentFiber = UseInstaller(dependent);
Assert.AreEqual(ShrinkFiberState.Active, dependentFiber.State);
TestAwait.Run(_runtime.RetireAsync(providerFiber));
Assert.AreEqual(ShrinkFiberState.Inactive, dependentFiber.State,
"提供者退役后依赖安装器自动停用(服务注销为已知边界,见组件注释)");
}
[Test]
public void MissingServicesConfig_FailsWithClearError()
{
var installer = new FakeInstaller("app.x", "x");
var component = new ShrinkAppInstallerComponent(installer, _settings);
var fiber = _runtime.Use(component, config: null!);
Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State);
Assert.IsInstanceOf<InvalidOperationException>(fiber.LastError);
StringAssert.Contains("ShrinkAppServices", fiber.LastError!.Message);
}
// ---- 测试替身 ----
private sealed class FakeInstaller : IShrinkAppModuleInstaller
{
public FakeInstaller(string moduleId, string serviceValue)
{
ModuleId = moduleId;
ServiceValue = serviceValue;
}
public string ModuleId { get; }
public int Order => 0;
public IReadOnlyList<string> DependsOn { get; set; } = Array.Empty<string>();
public int RegisterCount { get; private set; }
public int InitCount { get; private set; }
public long CompletedAt { get; private set; }
private string ServiceValue { get; }
public void RegisterServices(ShrinkAppContext context)
{
RegisterCount++;
context.Services.Register(new FakeService(ServiceValue));
}
public UniTask InitializeAsync(ShrinkAppContext context)
{
InitCount++;
CompletedAt = ++_sequence;
return UniTask.CompletedTask;
}
}
private sealed class FakeService
{
public FakeService(string value)
{
Value = value;
}
public string Value { get; }
}
}
/// <summary>跨程序集测试辅助(与 ShrinkContext.Core.Tests.TestAwait 同语义)。</summary>
public static class TestAwait
{
public static void Run(UniTask task)
{
task.GetAwaiter().GetResult();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4351747efa1ed6242a67b18f60388935
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,205 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkApp;
using ShrinkContext;
using ShrinkEventBus;
using UnityEngine;
using Object = UnityEngine.Object;
namespace ShrinkContext.AppAdapter.Tests
{
/// <summary>
/// ShrinkAppLoaderHost 端到端:
/// 启动激活与 Started 事件、运行中 disable/enable 不重启宿主、设置级初始禁用、
/// 未知模块报错、重复 ModuleId 构造期失败、Shutdown。
/// </summary>
public class ShrinkAppLoaderHostTests
{
private ShrinkAppSettings _settings = null!;
[SetUp]
public void SetUp()
{
_settings = ScriptableObject.CreateInstance<ShrinkAppSettings>();
}
[TearDown]
public void TearDown()
{
if (_settings != null)
Object.DestroyImmediate(_settings);
}
private ShrinkAppLoaderHost CreateHost(params FakeInstaller[] installers)
{
return new ShrinkAppLoaderHost(_settings, installers);
}
[Test]
public void StartAsync_ActivatesModules_AndPublishesStartedEvent()
{
var host = CreateHost(
new FakeInstaller("app.a", "a"),
new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } });
ShrinkAppStartedEvent? started = null;
using (var subscription = EventBus.SubscribeEvent<ShrinkAppStartedEvent>(e => started = e))
{
TestAwait.Run(host.StartAsync());
}
Assert.IsTrue(host.IsRunning);
Assert.IsTrue(host.IsModuleActive("app.a"));
Assert.IsTrue(host.IsModuleActive("app.b"), "依赖模块在提供者激活后自动激活");
Assert.IsNotNull(started);
CollectionAssert.AreEquivalent(new[] { "app.a", "app.b" }, started!.ModuleIds);
}
[Test]
public void SetModuleDisabled_RetiresModuleAndDependents_EnableReloadsWithoutRestart()
{
var provider = new FakeInstaller("app.a", "a");
var dependent = new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } };
var bystander = new FakeInstaller("app.c", "c");
var host = CreateHost(provider, dependent, bystander);
TestAwait.Run(host.StartAsync());
Assert.AreEqual(3, host.ActiveModuleIds.Count);
TestAwait.Run(host.SetModuleDisabledAsync("app.a", true));
Assert.IsTrue(host.IsModuleDisabled("app.a"));
Assert.IsFalse(host.IsModuleActive("app.a"));
Assert.IsFalse(host.IsModuleActive("app.b"), "依赖者随提供者停用");
Assert.IsTrue(host.IsModuleActive("app.c"), "无关模块不受影响,宿主不重启");
Assert.IsTrue(host.IsRunning);
TestAwait.Run(host.SetModuleDisabledAsync("app.a", false));
Assert.IsTrue(host.IsModuleActive("app.a"));
Assert.IsTrue(host.IsModuleActive("app.b"), "重新启用后依赖链整体复活");
Assert.AreEqual(2, provider.InitCount, "重启用会重新执行安装器初始化(重建语义)");
Assert.AreEqual(1, bystander.InitCount, "未受影响的模块不重复初始化");
}
[Test]
public void DisabledViaSettings_ExcludedInitially_CanEnableAtRuntime()
{
_settings.disabledModuleIds = new[] { "app.a" };
var provider = new FakeInstaller("app.a", "a");
var dependent = new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } };
var host = CreateHost(provider, dependent);
TestAwait.Run(host.StartAsync());
Assert.IsTrue(host.IsModuleDisabled("app.a"));
Assert.IsFalse(host.TryGetModuleFiber("app.a", out _));
Assert.IsTrue(host.IsModuleWaiting("app.b"), "依赖被禁用模块的安装器保持等待而非报错");
CollectionAssert.AreEqual(new[] { "app.b" }, host.WaitingModuleIds);
TestAwait.Run(host.SetModuleDisabledAsync("app.a", false));
Assert.IsTrue(host.IsModuleActive("app.a"));
Assert.IsTrue(host.IsModuleActive("app.b"));
}
[Test]
public void UnknownModuleId_Throws()
{
var host = CreateHost(new FakeInstaller("app.a", "a"));
TestAwait.Run(host.StartAsync());
Assert.Throws<InvalidOperationException>(() =>
TestAwait.Run(host.SetModuleDisabledAsync("app.no-such", true)));
}
[Test]
public void DuplicateModuleIds_ThrowAtConstruction()
{
Assert.Throws<InvalidOperationException>(() =>
CreateHost(new FakeInstaller("app.dup", "a"), new FakeInstaller("app.dup", "b")));
}
[Test]
public void ShutdownAsync_RetiresEverything()
{
var provider = new FakeInstaller("app.a", "a");
var host = CreateHost(provider);
TestAwait.Run(host.StartAsync());
Assert.IsTrue(host.IsModuleActive("app.a"));
TestAwait.Run(host.ShutdownAsync());
Assert.IsFalse(host.IsRunning);
Assert.IsFalse(host.IsModuleActive("app.a"));
}
[Test]
public void OverrideModuleComponent_ReplacesInstallerWrapper()
{
var installer = new FakeInstaller("app.a", "a");
var host = CreateHost(installer);
host.OverrideModuleComponent("app.a", () => new NativeReplacementComponent());
TestAwait.Run(host.StartAsync());
Assert.IsTrue(host.IsModuleActive("app.a"));
Assert.AreEqual(0, installer.InitCount, "原生组件替换后安装器不再被包装执行");
Assert.IsTrue(host.TryGetModuleFiber("app.a", out var fiber));
Assert.IsInstanceOf<NativeReplacementComponent>(fiber.Component);
}
/// <summary>替换安装器的原生测试组件(发布同一模块键)。</summary>
private sealed class NativeReplacementComponent : IShrinkComponent
{
public string Name => "app.a";
public System.Collections.Generic.IReadOnlyList<string> Inject => System.Array.Empty<string>();
public System.Collections.Generic.IReadOnlyList<string> Provide =>
new[] { ShrinkAppInstallerComponent.ModuleKeyPrefix + "app.a" };
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
ctx.Set(Provide[0], Name);
return UniTask.CompletedTask;
}
}
private sealed class FakeInstaller : IShrinkAppModuleInstaller
{
public FakeInstaller(string moduleId, string serviceValue)
{
ModuleId = moduleId;
ServiceValue = serviceValue;
}
public string ModuleId { get; }
public int Order => 0;
public IReadOnlyList<string> DependsOn { get; set; } = Array.Empty<string>();
public int InitCount { get; private set; }
private string ServiceValue { get; }
public void RegisterServices(ShrinkAppContext context)
{
context.Services.Register(new FakeService(ServiceValue));
}
public UniTask InitializeAsync(ShrinkAppContext context)
{
InitCount++;
return UniTask.CompletedTask;
}
}
private sealed class FakeService
{
public FakeService(string value)
{
Value = value;
}
public string Value { get; }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4aa1105e16f405b428d2c5a841521479
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
{
"name": "ShrinkContext.AppAdapter.Tests",
"rootNamespace": "ShrinkContext.AppAdapter.Tests",
"references": [
"ShrinkContext.AppAdapter.Runtime",
"ShrinkContext.Core.Runtime",
"ShrinkApp.Core.Runtime",
"ShrinkApp.Starter.Basic.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkNetwork.Integration.App",
"ShrinkCommand.Integration.App",
"ShrinkDataSaver.Integration.App",
"ShrinkNetwork.Runtime",
"ShrinkCommand.Runtime",
"UniTask",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 907201a93f40ce44781eaa942ed9dbac
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: