From 6eebb8e8e45273d33ce2daff0fd5206e9b39b7cb Mon Sep 17 00:00:00 2001 From: cneicy Date: Wed, 26 Aug 2026 02:49:58 +0800 Subject: [PATCH] chore: initialize standalone UPM package --- .gitea/workflows/publish.yml | 49 ++ .gitea/workflows/unity-verify.yml | 39 ++ .gitignore | 10 + .npmignore | 8 + Development~/UnityProject/.gitignore | 6 + Development~/UnityProject/Assets/.gitkeep | 0 .../UnityProject/Packages/manifest.json | 15 + .../ProjectSettings/ProjectVersion.txt | 2 + README.md | 69 +++ README.md.meta | 7 + Runtime.meta | 8 + Runtime/Coeffects.meta | 8 + Runtime/Coeffects/ShrinkCoeffectTypes.cs | 154 +++++ Runtime/Coeffects/ShrinkCoeffectTypes.cs.meta | 11 + Runtime/Context.meta | 8 + Runtime/Context/ShrinkCtx.cs | 300 ++++++++++ Runtime/Context/ShrinkCtx.cs.meta | 11 + Runtime/Context/ShrinkEffect.cs | 61 ++ Runtime/Context/ShrinkEffect.cs.meta | 11 + Runtime/Fibers.meta | 8 + Runtime/Fibers/IShrinkComponent.cs | 47 ++ Runtime/Fibers/IShrinkComponent.cs.meta | 11 + Runtime/Fibers/ShrinkFiber.cs | 68 +++ Runtime/Fibers/ShrinkFiber.cs.meta | 11 + Runtime/Loader.meta | 8 + Runtime/Loader/ShrinkLoader.cs | 412 ++++++++++++++ Runtime/Loader/ShrinkLoader.cs.meta | 11 + Runtime/ShrinkContext.Core.Runtime.asmdef | 12 + .../ShrinkContext.Core.Runtime.asmdef.meta | 7 + Runtime/ShrinkContextDefaults.cs | 28 + Runtime/ShrinkContextDefaults.cs.meta | 11 + Runtime/ShrinkContextDiagnostics.cs | 112 ++++ Runtime/ShrinkContextDiagnostics.cs.meta | 11 + Runtime/ShrinkContextRuntime.cs | 528 ++++++++++++++++++ Runtime/ShrinkContextRuntime.cs.meta | 11 + Tests.meta | 8 + Tests/CoeffectAccessTests.cs | 156 ++++++ Tests/CoeffectAccessTests.cs.meta | 11 + Tests/EffectTrackingTests.cs | 131 +++++ Tests/EffectTrackingTests.cs.meta | 11 + Tests/FiberLifecycleTests.cs | 323 +++++++++++ Tests/FiberLifecycleTests.cs.meta | 11 + Tests/LoaderTests.cs | 312 +++++++++++ Tests/LoaderTests.cs.meta | 11 + Tests/ShrinkContext.Core.Tests.asmdef | 22 + Tests/ShrinkContext.Core.Tests.asmdef.meta | 7 + Tests/TestComponents.cs | 341 +++++++++++ Tests/TestComponents.cs.meta | 11 + package.json | 23 + package.json.meta | 7 + 50 files changed, 3458 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/unity-verify.yml create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 Development~/UnityProject/.gitignore create mode 100644 Development~/UnityProject/Assets/.gitkeep create mode 100644 Development~/UnityProject/Packages/manifest.json create mode 100644 Development~/UnityProject/ProjectSettings/ProjectVersion.txt create mode 100644 README.md create mode 100644 README.md.meta create mode 100644 Runtime.meta create mode 100644 Runtime/Coeffects.meta create mode 100644 Runtime/Coeffects/ShrinkCoeffectTypes.cs create mode 100644 Runtime/Coeffects/ShrinkCoeffectTypes.cs.meta create mode 100644 Runtime/Context.meta create mode 100644 Runtime/Context/ShrinkCtx.cs create mode 100644 Runtime/Context/ShrinkCtx.cs.meta create mode 100644 Runtime/Context/ShrinkEffect.cs create mode 100644 Runtime/Context/ShrinkEffect.cs.meta create mode 100644 Runtime/Fibers.meta create mode 100644 Runtime/Fibers/IShrinkComponent.cs create mode 100644 Runtime/Fibers/IShrinkComponent.cs.meta create mode 100644 Runtime/Fibers/ShrinkFiber.cs create mode 100644 Runtime/Fibers/ShrinkFiber.cs.meta create mode 100644 Runtime/Loader.meta create mode 100644 Runtime/Loader/ShrinkLoader.cs create mode 100644 Runtime/Loader/ShrinkLoader.cs.meta create mode 100644 Runtime/ShrinkContext.Core.Runtime.asmdef create mode 100644 Runtime/ShrinkContext.Core.Runtime.asmdef.meta create mode 100644 Runtime/ShrinkContextDefaults.cs create mode 100644 Runtime/ShrinkContextDefaults.cs.meta create mode 100644 Runtime/ShrinkContextDiagnostics.cs create mode 100644 Runtime/ShrinkContextDiagnostics.cs.meta create mode 100644 Runtime/ShrinkContextRuntime.cs create mode 100644 Runtime/ShrinkContextRuntime.cs.meta create mode 100644 Tests.meta create mode 100644 Tests/CoeffectAccessTests.cs create mode 100644 Tests/CoeffectAccessTests.cs.meta create mode 100644 Tests/EffectTrackingTests.cs create mode 100644 Tests/EffectTrackingTests.cs.meta create mode 100644 Tests/FiberLifecycleTests.cs create mode 100644 Tests/FiberLifecycleTests.cs.meta create mode 100644 Tests/LoaderTests.cs create mode 100644 Tests/LoaderTests.cs.meta create mode 100644 Tests/ShrinkContext.Core.Tests.asmdef create mode 100644 Tests/ShrinkContext.Core.Tests.asmdef.meta create mode 100644 Tests/TestComponents.cs create mode 100644 Tests/TestComponents.cs.meta create mode 100644 package.json create mode 100644 package.json.meta diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..f78b8b8 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish UPM package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }} + steps: + - name: Fetch tagged revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + test -n "$ref" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkContext.Core.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Validate immutable release version + shell: bash + run: | + set -eu + tag="$(git describe --exact-match --tags HEAD)" + version="$(node -p "require('./package.json').version")" + test "$tag" = "v$version" + npm pack --dry-run + + - name: Publish to ShrinkSDK registry + shell: bash + run: | + set -eu + : "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}" + npmrc="$HOME/.npmrc" + cleanup() { rm -f "$npmrc"; } + trap cleanup EXIT + printf '%s\n' \ + 'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \ + '//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc" + npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/ \ No newline at end of file diff --git a/.gitea/workflows/unity-verify.yml b/.gitea/workflows/unity-verify.yml new file mode 100644 index 0000000..af5b2a1 --- /dev/null +++ b/.gitea/workflows/unity-verify.yml @@ -0,0 +1,39 @@ +name: Verify standalone Unity package + +on: + workflow_dispatch: + +jobs: + editmode: + runs-on: unity-2022.3.62f3 + container: + image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3 + volumes: + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity + - /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses + steps: + - name: Fetch selected revision + shell: bash + run: | + set -eu + ref="${{ gitea.sha }}" + git init . + git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkContext.Core.git" + git fetch --depth=1 origin "$ref" + git checkout --detach FETCH_HEAD + + - name: Run package EditMode tests + shell: bash + run: | + set -eu + unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)" + test -n "$unity_bin" + "$unity_bin" \ + -batchmode \ + -nographics \ + -quit \ + -projectPath "$PWD/Development~/UnityProject" \ + -runTests \ + -testPlatform EditMode \ + -testResults "$PWD/TestResults/editmode.xml" \ + -logFile "$PWD/TestResults/unity.log" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/Development~/UnityProject/[Ll]ibrary/ +/Development~/UnityProject/[Tt]emp/ +/Development~/UnityProject/[Oo]bj/ +/Development~/UnityProject/[Ll]ogs/ +/Development~/UnityProject/[Uu]ser[Ss]ettings/ +/Development~/UnityProject/TestResults/ +/Tools~/**/[Bb]in/ +/Tools~/**/[Oo]bj/ +*.user +*.DotSettings.user diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..700f03d --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +.git/ +.gitea/ +Development~/ +Tools~/ +*.csproj +*.sln +*.user +*.DotSettings.user diff --git a/Development~/UnityProject/.gitignore b/Development~/UnityProject/.gitignore new file mode 100644 index 0000000..aa2eb00 --- /dev/null +++ b/Development~/UnityProject/.gitignore @@ -0,0 +1,6 @@ +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ +TestResults/ \ No newline at end of file diff --git a/Development~/UnityProject/Assets/.gitkeep b/Development~/UnityProject/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Development~/UnityProject/Packages/manifest.json b/Development~/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..f286e04 --- /dev/null +++ b/Development~/UnityProject/Packages/manifest.json @@ -0,0 +1,15 @@ +{ + "scopedRegistries": [ + { + "name": "ShrinkSDK", + "url": "https://git.crash.work/api/packages/ShrinkSDK/npm/", + "scopes": [ + "com.cneicy" + ] + } + ], + "dependencies": { + "com.unity.test-framework": "1.1.33", + "com.cneicy.shrink-context-core": "file:../../.." + } +} diff --git a/Development~/UnityProject/ProjectSettings/ProjectVersion.txt b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000..587f809 --- /dev/null +++ b/Development~/UnityProject/ProjectSettings/ProjectVersion.txt @@ -0,0 +1,2 @@ +m_EditorVersion: 2022.3.62f3 +m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7) diff --git a/README.md b/README.md new file mode 100644 index 0000000..023e2a4 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# ShrinkContext.Core + +《Cordis: A Programming Paradigm for Spatiotemporal Composability》核心机制的 Unity/C# 实现。当前架构与边界见仓库根 `DESIGN.md`;已完成的迁移过程归档于 `Docs/Archive/CORDIS_MIGRATION.completed.md`。 + +## 定位 + +时空可组合性的**元框架核心库**:不规定任何领域,只提供动态组合的两个通用原语—— + +- **可逆效应**(时间可组合性,论文 3.1 / 算法 1):`ctx.Effect / EffectAsync`,前向执行时产出逆操作,运行时按 LIFO 累积,卸载即回滚; +- **响应式余效应**(空间可组合性,论文 3.2 / 算法 2、3):`ctx.Set / Get`,依赖键的安装/撤回自动通知依赖者,组件在依赖全部可用时激活、失效时停用、换提供者时重载; +- **纤程生命周期**(论文 4 / 算法 4、5):`runtime.Use(component)` 实例化组件为纤程,惯性状态机 `Loading → Active → Unloading → Inactive`,卸载先等待依赖者排空再回滚自身效应,父卸载级联子卸载。 +- **声明式协调与访问介导**:加载器以事务更新期望组合,失败时恢复旧组合;`isolate / intercept` 控制解析域与服务视图,组件供给受 `Provide` 声明约束。 + +## 目录结构 + +| 目录 | 说明 | +|---|---| +| `Runtime/Context/` | `ShrinkCtx`(一等上下文、隔离派生、介导访问)与 `ShrinkEffectHandle`(算法 1) | +| `Runtime/Coeffects/` | 绑定、强类型版本键、访问策略与异常类型 | +| `Runtime/Fibers/` | `IShrinkComponent`(inject/provide/apply)、`ShrinkFiber`、分步效应接口 | +| `Runtime/Loader/` | 声明式加载器:`ShrinkLoaderEntry` / `ShrinkComponentCatalog` / `ShrinkContextLoader` 增量协调、事务恢复与条目诊断 | +| `Runtime/` | `ShrinkContextRuntime`(注册表 + 算法 2-5 + notify 倒排索引)、诊断快照与 Domain Reload 重置 | +| `Tests/` | EditMode 测试:对照论文定理 7/16/20/63/64 与算法 1/2/3/4/5/6 的可观察行为 + 加载器协调行为 | + +## 论文 → API 对照 + +| 论文概念 | 本实现 | +|---|---| +| `ctx.effect(callback)` → dispose | `ShrinkCtx.Effect / EffectAsync` → `ShrinkEffectHandle.DisposeAsync`(armed 单次执行,等待进行中前向) | +| `ctx.get/set`(算法 2) | `ShrinkCtx.Set`(可逆,安装+通知 / 撤回+通知) | +| `ctx.isolate(key, realm)` | `ShrinkCtx.Isolate`(派生子上下文,丢弃即恢复) | +| `ctx.intercept(key, metadata)` | `ShrinkCtx.Intercept`(派生访问元数据;provider 通过 `IShrinkCoeffectAccessPolicy` 返回受限视图或拒绝访问) | +| 代理介导访问(算法 6) | `ShrinkCtx.Get/TryGet`(沿纤程链解析已提交视图;未声明/未激活/策略拒绝分别抛出不同异常) | +| `ctx.use(component)`(算法 4) | `ShrinkContextRuntime.Use`(实例化是父上下文的可逆效应 → 级联卸载;支持条目级 isolate 叠加) | +| 声明式配置 + 协调(论文 5.2) | `ShrinkContextLoader.ApplyAsync`(预校验期望组合;条目消失→退役;组件/配置变化→重建;intercept 变化→原位更新;失败→恢复旧组合) | +| refresh/reload/unload(算法 5) | `RunTransitionAsync`(惯性链式转换;卸载先 drain 依赖者) | +| notify(算法 3) | `ShrinkContextRuntime.Notify`(`key → inject fibers` 倒排索引选候选,再按 realm 过滤) | +| target = 按 inject 顺序解析的提供者 uid | `ShrinkFiber.Target`(保留键顺序与重复依赖;uid 永不复用,因此同值换提供者也会重载) | +| `𝔈iterΓ` 分步效应 | `IShrinkIterativeComponent.ApplySteps`(自定义 UniTask 步进器,步进边界守卫 → 部分回滚;不用 IAsyncEnumerable,规避 Mono ValueTask 迭代器的非内联调度) | +| fiber.committed 提交视图 | `ShrinkFiber.Committed`(装载前提交,拆除完才丢弃) | + +## 测试覆盖(对照论文元理论) + +- 定理 7(恢复初始上下文)/ 定理 16(LIFO)/ 选择性撤销 / armed 幂等 / dispose 等待进行中前向 +- set 可逆性、介导访问纪律(UNDECLARED / INACTIVE)、隔离域派生与丢弃恢复 +- 未声明供给回滚、访问策略拒绝、intercept 原位更新与只读服务视图 +- 依赖驱动激活、缺依赖静默非活动、晚到提供者激活 +- 退役次序:依赖者先于提供者逆操作排空(drain-before-inverse) +- 提供者替换重载(含同值替换,uid 语义) +- 父子级联、供给冲突、循环依赖静止(论文 6.5) +- 惯性:装载中途退役永不 Active、链式卸载(论文 4.3.3) +- 分步效应部分回滚(定理 64) +- apply 失败的部分效应回滚(L-Raise) +- 加载器失败恢复旧组合、跨 realm 替换隔离、notify 倒排索引候选计数与运行时诊断 +- Domain Reload 重置 + +## 当前边界 + +- `intercept` 是经 `ShrinkCtx.Get` 的能力介导,不是对不可信 DLL 的内存、反射、文件或网络沙箱。 +- loader 的 isolate 变化仍通过重建条目生效;尚未提供运行中 fiber 的原位 realm 迁移。 +- 强类型版本键是新增契约,现有字符串键不会在阶段 5 被一次性重写。 +- Core 保持不依赖具体配置资产;`ShrinkContext.AppAdapter` 提供 ScriptableObject/JSON 组合、Editor 诊断窗和可重复容量基准。 +- 主线程宿主与 Unity PlayerLoop 调度仍由上层应用负责。 +- 当前上下文只协调单进程内组件;跨进程/独立服务器使用 Network 合同,不共享 fiber 或事务。 + +## 约定 + +- 仅主线程使用;异步转换在同步可完成路径上内联跑完(测试无需 PlayerLoop 泵即可确定性断言)。 +- 测试中的 `TestAwait.Run` 同步阻塞等待,仅用于保证延续同步完成的测试代码。 diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..804162f --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1d4324f03a9bc5241a5021b5ae5bfd06 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..1ec67e5 --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ba47b2c9b35184a4c89870601cdaa5e8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Coeffects.meta b/Runtime/Coeffects.meta new file mode 100644 index 0000000..d721c9e --- /dev/null +++ b/Runtime/Coeffects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 40e4fc95f69522e46b43fbb4dade4bb9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Coeffects/ShrinkCoeffectTypes.cs b/Runtime/Coeffects/ShrinkCoeffectTypes.cs new file mode 100644 index 0000000..5904587 --- /dev/null +++ b/Runtime/Coeffects/ShrinkCoeffectTypes.cs @@ -0,0 +1,154 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace ShrinkContext +{ + /// 共享存储中的一条依赖绑定:键解析到 realm 后对应的值与提供者纤程。 + public sealed class ShrinkBinding + { + public ShrinkBinding(object? value, ShrinkFiber? provider, string key, + IShrinkCoeffectAccessPolicy? accessPolicy = null) + { + Value = value; + Provider = provider; + Key = key; + AccessPolicy = accessPolicy; + } + + public object? Value { get; } + public ShrinkFiber? Provider { get; } + public string Key { get; } + public IShrinkCoeffectAccessPolicy? AccessPolicy { get; } + } + + /// + /// 带命名空间与主版本的类型化余效应键。现有字符串声明可继续使用 , + /// Set/Get 重载则在调用点固定值类型,逐步收口字符串键的碰撞与接口漂移。 + /// + public sealed class ShrinkKey + { + public ShrinkKey(string packageName, string name, int majorVersion = 1) + { + if (string.IsNullOrWhiteSpace(packageName)) + throw new ArgumentException("Package name must not be empty.", nameof(packageName)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Key name must not be empty.", nameof(name)); + if (majorVersion <= 0) + throw new ArgumentOutOfRangeException(nameof(majorVersion), "Major version must be positive."); + + PackageName = packageName.Trim(); + Name = name.Trim(); + MajorVersion = majorVersion; + Id = $"{PackageName}/{Name}@v{MajorVersion}"; + } + + public string PackageName { get; } + public string Name { get; } + public int MajorVersion { get; } + public string Id { get; } + public override string ToString() => Id; + } + + /// 依赖值被读取时的访问上下文;metadata 来自访问方上下文上的 intercept 合并结果。 + public sealed class ShrinkCoeffectAccessContext + { + internal ShrinkCoeffectAccessContext(string key, Type requestedType, ShrinkFiber? consumer, + IReadOnlyDictionary metadata) + { + Key = key; + RequestedType = requestedType; + Consumer = consumer; + Metadata = metadata; + } + + public string Key { get; } + public Type RequestedType { get; } + public ShrinkFiber? Consumer { get; } + public IReadOnlyDictionary Metadata { get; } + + public bool TryGetMetadata(string name, out T value) + { + if (Metadata.TryGetValue(name, out var raw) && raw is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + } + + /// + /// provider 侧访问策略。它可以依据 intercept metadata 返回降权包装或拒绝访问, + /// 但不会改变依赖满足关系,也不会构成不可信代码沙箱。 + /// + public interface IShrinkCoeffectAccessPolicy + { + object? Resolve(ShrinkCoeffectAccessContext context, object? value); + } + + /// 访问了纤程链上没有任何一方声明的依赖键(论文算法 6 的 UNDECLARED_ACCESS)。 + public sealed class ShrinkUndeclaredAccessException : Exception + { + public ShrinkUndeclaredAccessException(string key) + : base($"Undeclared coeffect access: '{key}'. Declare it in the component's Inject list.") + { + Key = key; + } + + public string Key { get; } + } + + /// 纤程声明了依赖键但尚未激活就访问(论文算法 6 的 INACTIVE_ACCESS)。 + public sealed class ShrinkInactiveAccessException : Exception + { + public ShrinkInactiveAccessException(string key, string fiberName) + : base($"Inactive coeffect access: '{key}' declared by fiber '{fiberName}' but not committed yet.") + { + Key = key; + } + + public string Key { get; } + } + + /// 组件写入了未在 Provide 中声明的键。 + public sealed class ShrinkUndeclaredSupplyException : Exception + { + public ShrinkUndeclaredSupplyException(string key, string fiberName) + : base($"Undeclared coeffect supply: '{key}' is not listed in fiber '{fiberName}' Provide keys.") + { + Key = key; + FiberName = fiberName; + } + + public string Key { get; } + public string FiberName { get; } + } + + /// intercept 访问策略拒绝了本次依赖读取。 + public sealed class ShrinkCoeffectAccessDeniedException : Exception + { + public ShrinkCoeffectAccessDeniedException(string key, string message) + : base($"Coeffect access denied for '{key}': {message}") + { + Key = key; + } + + public string Key { get; } + } + + /// 两个活跃纤程试图供给同一个 realm(论文定义 45 的供给不相交约束)。 + public sealed class ShrinkSupplyConflictException : Exception + { + public ShrinkSupplyConflictException(string key, ShrinkFiber? existing, ShrinkFiber? setter) + : base($"Supply conflict on key '{key}': existing provider " + + $"'{existing?.Name ?? ""}' conflicts with '{setter?.Name ?? ""}'.") + { + Key = key; + } + + public string Key { get; } + } +} diff --git a/Runtime/Coeffects/ShrinkCoeffectTypes.cs.meta b/Runtime/Coeffects/ShrinkCoeffectTypes.cs.meta new file mode 100644 index 0000000..49dc6f5 --- /dev/null +++ b/Runtime/Coeffects/ShrinkCoeffectTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dcad9bdadb1607498dc233cfc1b5a06 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context.meta b/Runtime/Context.meta new file mode 100644 index 0000000..1d503de --- /dev/null +++ b/Runtime/Context.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4906e85b1734b944ca870fa011dd3a98 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkCtx.cs b/Runtime/Context/ShrinkCtx.cs new file mode 100644 index 0000000..6d83f2c --- /dev/null +++ b/Runtime/Context/ShrinkCtx.cs @@ -0,0 +1,300 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + /// + /// 一等上下文(论文 Γ∞ 的运行时对应物)。 + /// - 树结构:由 派生隔离子上下文,或由运行时为纤程派生子上下文; + /// - 效应:本上下文注册的可逆效应按注册逆序(LIFO)在 中回滚; + /// - 余效应:Set/Get 委托给运行时共享存储,键先经隔离表解析为 realm 再查表。 + /// + public sealed class ShrinkCtx + { + private readonly List _effects = new(); + private readonly Dictionary? _isolateOverlay; + private Dictionary>? _interceptOverlay; + + internal ShrinkCtx(ShrinkContextRuntime runtime, ShrinkCtx? parent, ShrinkFiber? ownerFiber, + Dictionary? isolateOverlay, + IReadOnlyDictionary>? interceptOverlay = null) + { + Runtime = runtime; + Parent = parent; + OwnerFiber = ownerFiber; + _isolateOverlay = isolateOverlay; + ReplaceIntercept(interceptOverlay); + } + + public ShrinkContextRuntime Runtime { get; } + public ShrinkCtx? Parent { get; } + public ShrinkFiber? OwnerFiber { get; } + + // ---------------- 效应(论文 3.1 / 算法 1) ---------------- + + /// 同步前向 + 可选同步逆操作的可逆效应;前向立即执行。 + public ShrinkEffectHandle Effect(Action forward, Action? inverse = null) + { + if (forward == null) + throw new ArgumentNullException(nameof(forward)); + + forward(); + + var handle = new ShrinkEffectHandle(); + if (inverse != null) + handle.AttachInverse(() => + { + inverse(); + return UniTask.CompletedTask; + }); + Attach(handle); + return handle; + } + + /// 异步前向,完成时产出异步逆操作(论文 𝔈Γ)。DisposeAsync 会等待进行中的前向。 + public async UniTask EffectAsync(Func>> effectFn) + { + if (effectFn == null) + throw new ArgumentNullException(nameof(effectFn)); + + var handle = new ShrinkEffectHandle(); + var gate = new UniTaskCompletionSource(); + handle.BeginForward(gate.Task); + Attach(handle); + + Func? inverse = null; + try + { + inverse = await effectFn(); + handle.CompleteForward(inverse); + } + finally + { + // 先登记逆操作再放行 gate,保证等待前向的 DisposeAsync 能读到完整逆链 + gate.TrySetResult(); + } + + return handle; + } + + internal void Attach(ShrinkEffectHandle handle) => _effects.Add(handle); + + /// + /// 前向已在外部执行完毕的效应:仅登记逆操作(供桥接适配器把既有 API 的"注册/注销"对包装为可逆效应)。 + /// + public ShrinkEffectHandle EffectInverse(Func inverse) + { + if (inverse == null) + throw new ArgumentNullException(nameof(inverse)); + + var handle = new ShrinkEffectHandle(); + handle.AttachInverse(inverse); + Attach(handle); + return handle; + } + + /// 按 LIFO 顺序执行本上下文注册的全部逆操作。 + public async UniTask DisposeAsync() + { + for (var i = _effects.Count - 1; i >= 0; i--) + await _effects[i].DisposeAsync(); + _effects.Clear(); + } + + // ---------------- 余效应(论文 3.2 / 算法 2) ---------------- + + /// + /// 提供依赖(set 是可逆效应):立即安装绑定并通知依赖者; + /// 逆操作撤回绑定并再次通知。句柄同时挂在当前上下文上,随上下文卸载自动撤回。 + /// + public ShrinkEffectHandle Set(string key, T value) => Runtime.SetBinding(this, key, value, null); + + /// 提供带访问策略的依赖;策略只在介导 Get 时运行,原始编排查找不会运行策略。 + public ShrinkEffectHandle Set(string key, T value, IShrinkCoeffectAccessPolicy accessPolicy) => + Runtime.SetBinding(this, key, value, accessPolicy ?? throw new ArgumentNullException(nameof(accessPolicy))); + + public ShrinkEffectHandle Set(ShrinkKey key, T value) => + Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value); + + public ShrinkEffectHandle Set(ShrinkKey key, T value, IShrinkCoeffectAccessPolicy accessPolicy) => + Set((key ?? throw new ArgumentNullException(nameof(key))).Id, value, accessPolicy); + + /// + /// 代理介导的依赖访问(论文算法 6):沿纤程链解析已提交视图; + /// 声明了但未激活的键抛 , + /// 全链未声明的键抛 。 + /// + public T Get(string key) + { + var fiber = OwnerFiber; + while (fiber != null) + { + if (fiber.Committed != null && fiber.Committed.TryGetValue(key, out var binding)) + return Runtime.ResolveAccess(this, key, binding); + if (fiber.InjectSet.Contains(key)) + throw new ShrinkInactiveAccessException(key, fiber.Name); + fiber = fiber.Parent; + } + + throw new ShrinkUndeclaredAccessException(key); + } + + public T Get(ShrinkKey key) => Get( + (key ?? throw new ArgumentNullException(nameof(key))).Id); + + /// 相同的介导规则;未激活返回 false 而不抛异常。 + public bool TryGet(string key, out T value) + { + var fiber = OwnerFiber; + while (fiber != null) + { + if (fiber.Committed != null && fiber.Committed.TryGetValue(key, out var binding)) + { + value = Runtime.ResolveAccess(this, key, binding); + return true; + } + if (fiber.InjectSet.Contains(key)) + break; + fiber = fiber.Parent; + } + + value = default!; + return false; + } + + public bool TryGet(ShrinkKey key, out T value) => TryGet( + (key ?? throw new ArgumentNullException(nameof(key))).Id, out value); + + /// + /// 派生隔离子上下文(论文定义 28/29 的派生实现): + /// 覆盖 key 的 realm 解析;父上下文不受影响;丢弃子上下文即隐式恢复。 + /// + public ShrinkCtx Isolate(string key, string realm) + { + if (string.IsNullOrEmpty(key)) + throw new ArgumentException("Key must not be null or empty.", nameof(key)); + if (string.IsNullOrEmpty(realm)) + throw new ArgumentException("Realm must not be null or empty.", nameof(realm)); + + return new ShrinkCtx(Runtime, this, OwnerFiber, new Dictionary { [key] = realm }); + } + + public ShrinkCtx Isolate(ShrinkKey key, string realm) => Isolate( + (key ?? throw new ArgumentNullException(nameof(key))).Id, realm); + + /// + /// 派生带拦截元数据的子上下文。子级同名 metadata 覆盖父级;丢弃子上下文即恢复。 + /// 该操作不改变 realm、provider 或 target,因此不会触发 fiber 重载。 + /// + public ShrinkCtx Intercept(string key, IReadOnlyDictionary metadata) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key must not be null or empty.", nameof(key)); + if (metadata == null) + throw new ArgumentNullException(nameof(metadata)); + + var metadataCopy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in metadata) + metadataCopy[pair.Key] = pair.Value; + var overlay = new Dictionary>(StringComparer.Ordinal) + { + [key] = metadataCopy + }; + return new ShrinkCtx(Runtime, this, OwnerFiber, null, overlay); + } + + public ShrinkCtx Intercept(ShrinkKey key, IReadOnlyDictionary metadata) => + Intercept((key ?? throw new ArgumentNullException(nameof(key))).Id, metadata); + + internal ShrinkCtx CreateChildForFiber(ShrinkFiber fiber) => new(Runtime, this, fiber, null); + + /// 在派生子上下文中叠加隔离覆盖(同键后者优先),供加载器为条目配置隔离域。 + internal ShrinkCtx WithIsolate(IReadOnlyDictionary isolate) + { + var overlay = new Dictionary(); + if (_isolateOverlay != null) + { + foreach (var pair in _isolateOverlay) + overlay[pair.Key] = pair.Value; + } + + foreach (var pair in isolate) + overlay[pair.Key] = pair.Value; + + return new ShrinkCtx(Runtime, this, OwnerFiber, overlay); + } + + /// 在当前纤程上下文上叠加 loader 条目配置的 intercept。 + internal ShrinkCtx WithIntercept( + IReadOnlyDictionary> intercept) => + new(Runtime, this, OwnerFiber, null, interceptOverlay: intercept); + + /// 原地替换本层 intercept,供 loader 在不重载 fiber 的情况下更新访问策略。 + internal void ReplaceIntercept( + IReadOnlyDictionary>? intercept) + { + if (intercept == null || intercept.Count == 0) + { + _interceptOverlay = null; + return; + } + + var copy = new Dictionary>(StringComparer.Ordinal); + foreach (var pair in intercept) + { + if (string.IsNullOrWhiteSpace(pair.Key)) + throw new ArgumentException("Intercept key must not be empty.", nameof(intercept)); + if (pair.Value == null) + throw new ArgumentException($"Intercept metadata for '{pair.Key}' must not be null.", + nameof(intercept)); + var metadataCopy = new Dictionary(StringComparer.Ordinal); + foreach (var metadataPair in pair.Value) + metadataCopy[metadataPair.Key] = metadataPair.Value; + copy[pair.Key] = metadataCopy; + } + + _interceptOverlay = copy; + } + + /// 解析 key 在本上下文中归属的 realm:沿父链找最近的隔离覆盖,默认 realm 为 key 自身。 + internal string ResolveRealm(string key) + { + var current = this; + while (current != null) + { + if (current._isolateOverlay != null && current._isolateOverlay.TryGetValue(key, out var realm)) + return realm; + current = current.Parent; + } + + return key; + } + + /// 按根到叶顺序合并拦截元数据;越接近访问方的上下文优先。 + internal IReadOnlyDictionary ResolveInterceptMetadata(string key) + { + var chain = new Stack(); + for (var current = this; current != null; current = current.Parent) + chain.Push(current); + + var merged = new Dictionary(StringComparer.Ordinal); + while (chain.Count > 0) + { + var current = chain.Pop(); + if (current._interceptOverlay == null || + !current._interceptOverlay.TryGetValue(key, out var metadata)) + continue; + + foreach (var pair in metadata) + merged[pair.Key] = pair.Value; + } + + return merged; + } + + internal IReadOnlyCollection GetLocalInterceptKeys() => + _interceptOverlay?.Keys ?? (IReadOnlyCollection)Array.Empty(); + } +} diff --git a/Runtime/Context/ShrinkCtx.cs.meta b/Runtime/Context/ShrinkCtx.cs.meta new file mode 100644 index 0000000..f34f79f --- /dev/null +++ b/Runtime/Context/ShrinkCtx.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3c4e5dd3f658ca14a929dd41f2466927 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Context/ShrinkEffect.cs b/Runtime/Context/ShrinkEffect.cs new file mode 100644 index 0000000..66e784f --- /dev/null +++ b/Runtime/Context/ShrinkEffect.cs @@ -0,0 +1,61 @@ +#nullable enable +using System; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + /// + /// 单个可逆效应的句柄(论文算法 1 的 effect 封装)。 + /// 前向执行时产出逆操作并累积在句柄内部;DisposeAsync 至多执行一次(armed 语义), + /// 若前向仍在进行中则先等待其完成再运行累积的逆操作(dispose = await task; recover())。 + /// + public sealed class ShrinkEffectHandle + { + private static readonly Func Nop = static () => UniTask.CompletedTask; + + private bool _armed = true; + private UniTask _forwardTask; + private bool _hasForwardTask; + private Func _inverseChain = Nop; + + public bool IsArmed => _armed; + + /// 登记一个进行中的异步前向;DisposeAsync 会先等待它完成。 + internal void BeginForward(UniTask forwardTask) + { + _forwardTask = forwardTask; + _hasForwardTask = true; + } + + /// 前向完成后登记其逆操作;新逆操作在链首执行(LIFO)。 + internal void CompleteForward(Func inverse) + { + if (inverse == null) + return; + + var previous = _inverseChain; + _inverseChain = async () => + { + await inverse(); + await previous(); + }; + } + + /// 直接附加逆操作(等效于前向已同步完成)。 + internal void AttachInverse(Func inverse) => CompleteForward(inverse); + + public async UniTask DisposeAsync() + { + if (!_armed) + return; + + _armed = false; + if (_hasForwardTask && _forwardTask.Status == UniTaskStatus.Pending) + await _forwardTask; + + var chain = _inverseChain; + _inverseChain = Nop; + await chain(); + } + } +} diff --git a/Runtime/Context/ShrinkEffect.cs.meta b/Runtime/Context/ShrinkEffect.cs.meta new file mode 100644 index 0000000..69c8ee1 --- /dev/null +++ b/Runtime/Context/ShrinkEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6baa51f655b3c9a4d9dc0b1dbdce8461 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Fibers.meta b/Runtime/Fibers.meta new file mode 100644 index 0000000..b2181ad --- /dev/null +++ b/Runtime/Fibers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ea9777e12a1bd8e40a2afbce45d23622 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Fibers/IShrinkComponent.cs b/Runtime/Fibers/IShrinkComponent.cs new file mode 100644 index 0000000..8c0dfc6 --- /dev/null +++ b/Runtime/Fibers/IShrinkComponent.cs @@ -0,0 +1,47 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + /// + /// 组件 = (inject 依赖键集合 d, provide 供给键集合 p, apply 效应函数 e)(论文定义 43)。 + /// apply 内部通过 ctx.Set 提供依赖、ctx.Get 消费依赖、ctx.Effect 注册可逆效应; + /// 组件的拆卸由运行时按已追踪的逆操作自动推导,无需手写卸载路径。 + /// + public interface IShrinkComponent + { + string Name { get; } + + /// 声明的依赖键集合(论文 d):全部可用时组件才激活。 + IReadOnlyList Inject { get; } + + /// 供给键集合(论文 p):apply 的效应不应写入 p 之外的键。 + IReadOnlyList Provide { get; } + + /// 效应函数(论文 e):激活时执行;其中注册的效应在停用时按 LIFO 回滚。 + UniTask ApplyAsync(ShrinkCtx ctx, object? config); + } + + /// + /// 分步效应组件(论文 𝔈iterΓ):apply 以步进器逐步产出逆操作。 + /// 目标在步进边界发生变化时中断迭代并回滚已执行步骤(部分回滚,论文 4.3.2 / 定理 64)。 + /// 使用自定义 UniTask 步进协议而非 IAsyncEnumerable: + /// Mono 的 ValueTask 迭代器恢复经同步上下文调度,会破坏同步内联的确定性语义。 + /// + public interface IShrinkIterativeComponent : IShrinkComponent + { + IShrinkStepEffectEnumerator ApplySteps(ShrinkCtx ctx, object? config); + } + + /// 分步效应步进器:MoveNextAsync 执行该步前向并使 Current 指向其逆操作。 + public interface IShrinkStepEffectEnumerator + { + /// 推进到下一步;返回 false 表示迭代结束。前向在该调用内执行完毕。 + UniTask MoveNextAsync(); + + /// 最近一次 MoveNextAsync 成功后该步的逆操作。 + Func Current { get; } + } +} diff --git a/Runtime/Fibers/IShrinkComponent.cs.meta b/Runtime/Fibers/IShrinkComponent.cs.meta new file mode 100644 index 0000000..4c004eb --- /dev/null +++ b/Runtime/Fibers/IShrinkComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc9d2e1bcf849da42a70c6c720e1c770 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Fibers/ShrinkFiber.cs b/Runtime/Fibers/ShrinkFiber.cs new file mode 100644 index 0000000..fbf9f66 --- /dev/null +++ b/Runtime/Fibers/ShrinkFiber.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + public enum ShrinkFiberState + { + Inactive = 0, + Loading = 1, + Active = 2, + Unloading = 3, + } + + /// + /// 纤程(论文定义 44):组件的一次运行时实例化。 + /// 生命周期由 Target(期望提供者集合,null 即 ⊥)与惯性状态机驱动: + /// 一旦进入转换即运行至完成,之后才响应新的目标变化(算法 5)。 + /// + public sealed class ShrinkFiber + { + internal ShrinkFiber(long uid, IShrinkComponent component, ShrinkFiber? parent, object? config) + { + Uid = uid; + Component = component; + Parent = parent; + Config = config; + InjectSet = new HashSet(component.Inject ?? Array.Empty(), StringComparer.Ordinal); + ProvideSet = new HashSet(component.Provide ?? Array.Empty(), StringComparer.Ordinal); + } + + public long Uid { get; } + + public string Name => Component.Name; + + public IShrinkComponent Component { get; } + + /// 实例化该纤程时的父纤程(根级为 null);父纤程卸载会级联到本纤程。 + public ShrinkFiber? Parent { get; } + + /// 本纤程运行所在的派生子上下文。 + public ShrinkCtx Ctx { get; internal set; } = null!; + + public object? Config { get; } + + public ShrinkFiberState State { get; internal set; } = ShrinkFiberState.Inactive; + + /// 是否到达过 Active(用于断言“从未激活”)。 + public bool EverActive { get; internal set; } + + /// 最近一次 apply 抛出的异常;成功激活后清空。 + public Exception? LastError { get; internal set; } + + /// 已提交视图(算法 5 reload 提交 / 算法 6 读取):依赖键 → 绑定快照。 + public IReadOnlyDictionary? Committed => _committed; + + /// 进行中转换的句柄;任务在纤程到达静止态(Active 或 Inactive)时完成。 + public UniTask Inertia { get; internal set; } + + internal Dictionary? _committed; + internal long[]? Target; + internal bool InTransition; + internal bool Retired; + internal HashSet InjectSet { get; } + internal HashSet ProvideSet { get; } + } +} diff --git a/Runtime/Fibers/ShrinkFiber.cs.meta b/Runtime/Fibers/ShrinkFiber.cs.meta new file mode 100644 index 0000000..ed6141d --- /dev/null +++ b/Runtime/Fibers/ShrinkFiber.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 812996226a7accb44b00963f89ff15fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loader.meta b/Runtime/Loader.meta new file mode 100644 index 0000000..db5b0bf --- /dev/null +++ b/Runtime/Loader.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f54de3c6db6ef7f4097d692f8ac11e38 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Loader/ShrinkLoader.cs b/Runtime/Loader/ShrinkLoader.cs new file mode 100644 index 0000000..e9c9f3a --- /dev/null +++ b/Runtime/Loader/ShrinkLoader.cs @@ -0,0 +1,412 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + /// 声明式配置条目(论文定义 74 的原型子集): + public sealed class ShrinkLoaderEntry + { + public ShrinkLoaderEntry(string id, string component, object? config = null, bool disabled = false, + IReadOnlyDictionary? isolate = null, + IReadOnlyDictionary>? intercept = null) + { + if (string.IsNullOrEmpty(id)) + throw new ArgumentException("Entry id must not be null or empty.", nameof(id)); + if (string.IsNullOrEmpty(component)) + throw new ArgumentException("Entry component must not be null or empty.", nameof(component)); + + Id = id; + Component = component; + Config = config; + Disabled = disabled; + Isolate = isolate; + Intercept = intercept; + } + + /// 稳定协调键:条目增删与更新的 diff 依据。 + public string Id { get; } + + /// 组件目录名(论文条目的 url 字段)。 + public string Component { get; } + + /// 绑定给组件 apply 的配置。 + public object? Config { get; } + + /// 管理性关闭:true 时卸载对应纤程。 + public bool Disabled { get; } + + /// 条目级隔离域:键 → realm,叠加到该纤程自身的解析。 + public IReadOnlyDictionary? Isolate { get; } + + /// 条目级访问元数据:依赖键 → metadata。更新时不改变 target,也不重载 fiber。 + public IReadOnlyDictionary>? Intercept { get; } + } + + /// 组件目录:条目的组件名 → 组件工厂。装配期由宿主/CodeGen 注册,加载器不做全域反射扫描。 + public sealed class ShrinkComponentCatalog + { + private readonly Dictionary> _factories = + new(StringComparer.Ordinal); + + public void Register(string name, Func factory) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Component name must not be null or empty.", nameof(name)); + _factories[name] = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public void Register(string name) + where TComponent : IShrinkComponent, new() + { + Register(name, static () => new TComponent()); + } + + public bool TryCreate(string name, out IShrinkComponent component) + { + if (_factories.TryGetValue(name, out var factory)) + { + component = factory(); + return component != null; + } + + component = null!; + return false; + } + + public bool Contains(string name) => _factories.ContainsKey(name); + } + + /// 加载器配置错误(重复条目 id、未知组件名等)。 + public class ShrinkLoaderException : Exception + { + public ShrinkLoaderException(string message) : base(message) + { + } + } + + public enum ShrinkLoaderTransactionPhase + { + Idle = 0, + Validating = 1, + Removing = 2, + Applying = 3, + Restoring = 4, + Completed = 5, + Failed = 6, + } + + /// 最近一次声明式协调的稳定诊断结果。 + public sealed class ShrinkLoaderTransactionDiagnostic + { + internal ShrinkLoaderTransactionDiagnostic(long generation) + { + Generation = generation; + } + + public long Generation { get; } + public ShrinkLoaderTransactionPhase Phase { get; internal set; } + public string? CurrentEntryId { get; internal set; } + public string? ErrorType { get; internal set; } + public string? ErrorMessage { get; internal set; } + public bool RestoreAttempted { get; internal set; } + public bool PreviousCompositionRestored { get; internal set; } + public string? RestoreErrorType { get; internal set; } + public string? RestoreErrorMessage { get; internal set; } + } + + public sealed class ShrinkLoaderRestoreException : ShrinkLoaderException + { + public ShrinkLoaderRestoreException(Exception applyError, Exception restoreError) + : base("Loader apply failed and restoring the previous composition also failed.") + { + ApplyError = applyError; + RestoreError = restoreError; + } + + public Exception ApplyError { get; } + public Exception RestoreError { get; } + } + + /// + /// 声明式组件加载器(论文 5.2 的原型子集): + /// 编排者把期望组合表达为条目列表,加载器将其增量协调为纤程操作—— + /// 条目消失 → 退役;disabled → 卸载;组件/配置变化 → 重建;其余保持不动(幂等)。 + /// + /// 与完整实现的差异:config 变化当前统一走重建而非组件自决 diff; + /// isolate 变化仍走重建而非领域原地重分配。intercept metadata 可原地更新,不触发重载。 + /// + public sealed class ShrinkContextLoader + { + private sealed class ManagedEntry + { + public string Component = string.Empty; + public object? Config; + public bool Disabled; + public IReadOnlyDictionary? Isolate; + public IReadOnlyDictionary>? Intercept; + public ShrinkFiber? Fiber; + } + + private readonly ShrinkContextRuntime _runtime; + private readonly ShrinkComponentCatalog _catalog; + private readonly Dictionary _entries = new(StringComparer.Ordinal); + private long _transactionGeneration; + private bool _applying; + + public ShrinkContextLoader(ShrinkContextRuntime runtime, ShrinkComponentCatalog catalog) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + } + + /// 当前托管条目 id(含 disabled 但仍被管理的条目)。 + public IEnumerable ManagedEntryIds => _entries.Keys; + + public ShrinkLoaderTransactionDiagnostic? LastTransaction { get; private set; } + + /// 查询条目当前纤程(可能处于 Inactive——依赖缺失时被响应式停用但仍被管理)。 + public bool TryGetFiber(string entryId, out ShrinkFiber fiber) + { + if (_entries.TryGetValue(entryId, out var managed) && managed.Fiber != null) + { + fiber = managed.Fiber; + return true; + } + + fiber = null!; + return false; + } + + /// + /// 增量应用期望配置:不整体重建,只对差异部分执行最小破坏性操作。 + /// 协调顺序确定性:先移除消失条目,再按声明顺序处理其余条目。 + /// + public async UniTask ApplyAsync(IReadOnlyList desired) + { + if (desired == null) + throw new ArgumentNullException(nameof(desired)); + if (_applying) + throw new InvalidOperationException("A loader composition transaction is already running."); + + var previous = CaptureEntries(); + var transaction = new ShrinkLoaderTransactionDiagnostic(++_transactionGeneration); + LastTransaction = transaction; + _applying = true; + try + { + await ApplyCoreAsync(desired, transaction, restoring: false); + transaction.Phase = ShrinkLoaderTransactionPhase.Completed; + transaction.CurrentEntryId = null; + } + catch (Exception applyError) + { + transaction.ErrorType = applyError.GetType().FullName; + transaction.ErrorMessage = applyError.Message; + transaction.RestoreAttempted = true; + transaction.Phase = ShrinkLoaderTransactionPhase.Restoring; + transaction.CurrentEntryId = null; + + try + { + await ApplyCoreAsync(previous, transaction, restoring: true); + transaction.PreviousCompositionRestored = true; + } + catch (Exception restoreError) + { + transaction.RestoreErrorType = restoreError.GetType().FullName; + transaction.RestoreErrorMessage = restoreError.Message; + transaction.Phase = ShrinkLoaderTransactionPhase.Failed; + transaction.CurrentEntryId = null; + throw new ShrinkLoaderRestoreException(applyError, restoreError); + } + + transaction.Phase = ShrinkLoaderTransactionPhase.Failed; + transaction.CurrentEntryId = null; + throw; + } + finally + { + _applying = false; + } + } + + private async UniTask ApplyCoreAsync(IReadOnlyList desired, + ShrinkLoaderTransactionDiagnostic transaction, bool restoring) + { + transaction.Phase = restoring + ? ShrinkLoaderTransactionPhase.Restoring + : ShrinkLoaderTransactionPhase.Validating; + + var desiredIds = new HashSet(StringComparer.Ordinal); + foreach (var entry in desired) + { + if (!desiredIds.Add(entry.Id)) + throw new ShrinkLoaderException($"Duplicate loader entry id: '{entry.Id}'."); + if (!entry.Disabled && !_catalog.Contains(entry.Component)) + throw new ShrinkLoaderException( + $"Unknown component '{entry.Component}' requested by entry '{entry.Id}'."); + } + + // 1) 消失的条目退役(其依赖者由响应式通知自动停用,条目本身仍被管理) + if (!restoring) + transaction.Phase = ShrinkLoaderTransactionPhase.Removing; + var removedIds = new List(); + foreach (var id in _entries.Keys) + { + if (!desiredIds.Contains(id)) + removedIds.Add(id); + } + + foreach (var id in removedIds) + { + transaction.CurrentEntryId = id; + var managed = _entries[id]; + if (managed.Fiber != null) + await _runtime.RetireAsync(managed.Fiber); + _entries.Remove(id); + } + + // 2) 按声明顺序逐条分派 + if (!restoring) + transaction.Phase = ShrinkLoaderTransactionPhase.Applying; + foreach (var entry in desired) + { + transaction.CurrentEntryId = entry.Id; + _entries.TryGetValue(entry.Id, out var managed); + + if (entry.Disabled) + { + if (managed == null) + { + _entries[entry.Id] = new ManagedEntry + { + Component = entry.Component, + Config = entry.Config, + Disabled = true, + Isolate = entry.Isolate, + Intercept = entry.Intercept + }; + } + else + { + if (managed.Fiber != null) + await _runtime.RetireAsync(managed.Fiber); + managed.Fiber = null; + managed.Disabled = true; + managed.Component = entry.Component; + managed.Config = entry.Config; + managed.Isolate = entry.Isolate; + managed.Intercept = entry.Intercept; + } + + continue; + } + + if (managed != null && !managed.Disabled && managed.Fiber != null && + managed.Component == entry.Component && + Equals(managed.Config, entry.Config) && + IsolateEquals(managed.Isolate, entry.Isolate)) + { + if (!InterceptEquals(managed.Intercept, entry.Intercept)) + { + managed.Fiber.Ctx.ReplaceIntercept(entry.Intercept); + managed.Intercept = entry.Intercept; + } + continue; // 幂等:无变化不打扰(被响应式停用的纤程保持管理,等待依赖回归) + } + + if (managed?.Fiber != null) + { + await _runtime.RetireAsync(managed.Fiber); + managed.Fiber = null; + } + + if (!_catalog.TryCreate(entry.Component, out var component)) + throw new ShrinkLoaderException($"Component factory '{entry.Component}' returned no component."); + + var fiber = _runtime.Use(component, entry.Config, _runtime.RootContext, entry.Isolate, entry.Intercept); + if (fiber.LastError != null) + { + var failure = fiber.LastError; + await _runtime.RetireAsync(fiber); + throw new ShrinkLoaderException( + $"Component '{entry.Component}' for entry '{entry.Id}' failed during apply: {failure.Message}"); + } + _entries[entry.Id] = new ManagedEntry + { + Component = entry.Component, + Config = entry.Config, + Disabled = false, + Isolate = entry.Isolate, + Intercept = entry.Intercept, + Fiber = fiber + }; + } + } + + private IReadOnlyList CaptureEntries() + { + var entries = new List(_entries.Count); + foreach (var pair in _entries.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var managed = pair.Value; + entries.Add(new ShrinkLoaderEntry( + pair.Key, + managed.Component, + managed.Config, + managed.Disabled, + managed.Isolate, + managed.Intercept)); + } + + return entries; + } + + private static bool IsolateEquals(IReadOnlyDictionary? a, IReadOnlyDictionary? b) + { + if (a == null && b == null) + return true; + if (a == null || b == null) + return false; + if (a.Count != b.Count) + return false; + + foreach (var pair in a) + { + if (!b.TryGetValue(pair.Key, out var value) || value != pair.Value) + return false; + } + + return true; + } + + private static bool InterceptEquals( + IReadOnlyDictionary>? a, + IReadOnlyDictionary>? b) + { + if (a == null && b == null) + return true; + if (a == null || b == null || a.Count != b.Count) + return false; + + foreach (var keyPair in a) + { + if (!b.TryGetValue(keyPair.Key, out var otherMetadata) || + keyPair.Value.Count != otherMetadata.Count) + return false; + + foreach (var metadataPair in keyPair.Value) + { + if (!otherMetadata.TryGetValue(metadataPair.Key, out var otherValue) || + !Equals(metadataPair.Value, otherValue)) + return false; + } + } + + return true; + } + } +} diff --git a/Runtime/Loader/ShrinkLoader.cs.meta b/Runtime/Loader/ShrinkLoader.cs.meta new file mode 100644 index 0000000..9145d06 --- /dev/null +++ b/Runtime/Loader/ShrinkLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c236b8aa6fec940449f9303b855a3989 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkContext.Core.Runtime.asmdef b/Runtime/ShrinkContext.Core.Runtime.asmdef new file mode 100644 index 0000000..e5cc55a --- /dev/null +++ b/Runtime/ShrinkContext.Core.Runtime.asmdef @@ -0,0 +1,12 @@ +{ + "name": "ShrinkContext.Core.Runtime", + "rootNamespace": "ShrinkContext", + "references": [ + "UniTask" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": true +} \ No newline at end of file diff --git a/Runtime/ShrinkContext.Core.Runtime.asmdef.meta b/Runtime/ShrinkContext.Core.Runtime.asmdef.meta new file mode 100644 index 0000000..f371708 --- /dev/null +++ b/Runtime/ShrinkContext.Core.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 768c7f0b5000fcc4084d4c4de00ff868 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkContextDefaults.cs b/Runtime/ShrinkContextDefaults.cs new file mode 100644 index 0000000..4a15611 --- /dev/null +++ b/Runtime/ShrinkContextDefaults.cs @@ -0,0 +1,28 @@ +#nullable enable +using UnityEngine; + +namespace ShrinkContext +{ + /// + /// 默认运行时入口 + Domain Reload 安全重置。 + /// 不自动启动任何组件;上层 composition root 通过 ShrinkContextLoader 提交期望组合。 + /// + public static class ShrinkContextDefaults + { + private static ShrinkContextRuntime? _default; + + public static ShrinkContextRuntime Default => _default ??= new ShrinkContextRuntime(); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStaticState() + { + _default = null; + } + + /// 测试用:丢弃默认运行时,下次访问时重建(模拟 Domain Reload)。 + public static void ResetForTesting() + { + _default = null; + } + } +} diff --git a/Runtime/ShrinkContextDefaults.cs.meta b/Runtime/ShrinkContextDefaults.cs.meta new file mode 100644 index 0000000..02bb26c --- /dev/null +++ b/Runtime/ShrinkContextDefaults.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2734645dd1546e4fa4b496fda9349d0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkContextDiagnostics.cs b/Runtime/ShrinkContextDiagnostics.cs new file mode 100644 index 0000000..a612858 --- /dev/null +++ b/Runtime/ShrinkContextDiagnostics.cs @@ -0,0 +1,112 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace ShrinkContext +{ + public enum ShrinkFiberDiagnosticStatus + { + Inactive = 0, + Waiting = 1, + Loading = 2, + Active = 3, + Unloading = 4, + Failed = 5, + Retired = 6, + } + + /// 单个依赖键在快照时刻的解析、提交与潜在提供者。 + public sealed class ShrinkDependencyDiagnostic + { + public ShrinkDependencyDiagnostic(string key, string realm, long? currentProviderUid, + long? committedProviderUid, IReadOnlyList potentialProviderUids) + { + Key = key; + Realm = realm; + CurrentProviderUid = currentProviderUid; + CommittedProviderUid = committedProviderUid; + PotentialProviderUids = potentialProviderUids; + } + + public string Key { get; } + public string Realm { get; } + public long? CurrentProviderUid { get; } + public long? CommittedProviderUid { get; } + public IReadOnlyList PotentialProviderUids { get; } + public bool IsSatisfied => CurrentProviderUid.HasValue; + } + + /// 一个 fiber 的稳定只读诊断投影,不暴露可变运行时对象。 + public sealed class ShrinkFiberDiagnostic + { + public ShrinkFiberDiagnostic(long uid, string name, ShrinkFiberDiagnosticStatus status, + ShrinkFiberState lifecycleState, bool retired, bool inTransition, + IReadOnlyList inject, IReadOnlyList provide, + IReadOnlyList targetProviderUids, + IReadOnlyList dependencies, + IReadOnlyList interceptKeys, + string? errorType, string? errorMessage) + { + Uid = uid; + Name = name; + Status = status; + LifecycleState = lifecycleState; + Retired = retired; + InTransition = inTransition; + Inject = inject; + Provide = provide; + TargetProviderUids = targetProviderUids; + Dependencies = dependencies; + InterceptKeys = interceptKeys; + ErrorType = errorType; + ErrorMessage = errorMessage; + } + + public long Uid { get; } + public string Name { get; } + public ShrinkFiberDiagnosticStatus Status { get; } + public ShrinkFiberState LifecycleState { get; } + public bool Retired { get; } + public bool InTransition { get; } + public IReadOnlyList Inject { get; } + public IReadOnlyList Provide { get; } + public IReadOnlyList TargetProviderUids { get; } + public IReadOnlyList Dependencies { get; } + public IReadOnlyList InterceptKeys { get; } + public string? ErrorType { get; } + public string? ErrorMessage { get; } + } + + public sealed class ShrinkNotificationDiagnostic + { + public ShrinkNotificationDiagnostic(long dispatchCount, long candidateVisitCount, + int lastCandidateCount, int indexedKeyCount) + { + DispatchCount = dispatchCount; + CandidateVisitCount = candidateVisitCount; + LastCandidateCount = lastCandidateCount; + IndexedKeyCount = indexedKeyCount; + } + + public long DispatchCount { get; } + public long CandidateVisitCount { get; } + public int LastCandidateCount { get; } + public int IndexedKeyCount { get; } + } + + /// 运行时整体快照:fiber、绑定与 notify 索引状态。 + public sealed class ShrinkContextRuntimeDiagnostic + { + public ShrinkContextRuntimeDiagnostic(IReadOnlyList fibers, + int bindingCount, ShrinkNotificationDiagnostic notifications) + { + Fibers = fibers; + BindingCount = bindingCount; + Notifications = notifications; + } + + public IReadOnlyList Fibers { get; } + public int BindingCount { get; } + public ShrinkNotificationDiagnostic Notifications { get; } + } +} diff --git a/Runtime/ShrinkContextDiagnostics.cs.meta b/Runtime/ShrinkContextDiagnostics.cs.meta new file mode 100644 index 0000000..303d4ab --- /dev/null +++ b/Runtime/ShrinkContextDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ee8044af3885c84a9b86b101af8ae28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/ShrinkContextRuntime.cs b/Runtime/ShrinkContextRuntime.cs new file mode 100644 index 0000000..da8ad68 --- /dev/null +++ b/Runtime/ShrinkContextRuntime.cs @@ -0,0 +1,528 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; + +namespace ShrinkContext +{ + /// + /// Cordis 核心原型运行时:纤程注册表 + 共享余效应存储 + 惯性生命周期状态机 + /// (论文算法 2-5 的 C#/UniTask 移植)。 + /// + /// 约定: + /// - 仅在主线程使用; + /// - 异步转换在同步可完成路径上内联跑完(无需 PlayerLoop 泵即可确定性测试); + /// - 目标(Target)按 inject 声明顺序记录 provider uid 视图,uid 永不复用, + /// 因此“同值不同提供者”的替换及多键 provider 映射变化都能触发重载。 + /// + public sealed class ShrinkContextRuntime + { + private readonly ShrinkCtx _rootCtx; + private readonly List _fibers = new(); + private readonly Dictionary _store = new(); + private readonly Dictionary> _injectIndex = + new(StringComparer.Ordinal); + private long _uidCounter; + private long _notificationDispatchCount; + private long _notificationCandidateVisitCount; + private int _lastNotificationCandidateCount; + + public ShrinkContextRuntime() + { + _rootCtx = new ShrinkCtx(this, null, null, null); + } + + public ShrinkCtx RootContext => _rootCtx; + + public IReadOnlyList Fibers => _fibers; + + // ---------------- 实例化(论文算法 4) ---------------- + + /// + /// 实例化组件为纤程:在父上下文(默认根上下文)上注册一个可逆效应, + /// 前向 = 启动子纤程生命周期,逆 = 强制子纤程目标为 ⊥ 并卸载——父卸载自动级联子卸载。 + /// isolate 为该纤程自身的键解析叠加隔离域(不影响父上下文)。 + /// + public ShrinkFiber Use(IShrinkComponent component, object? config = null, ShrinkCtx? parentCtx = null, + IReadOnlyDictionary? isolate = null, + IReadOnlyDictionary>? intercept = null) + { + if (component == null) + throw new ArgumentNullException(nameof(component)); + + var parent = parentCtx ?? _rootCtx; + var fiber = new ShrinkFiber(++_uidCounter, component, parent.OwnerFiber, config); + var fiberCtx = parent.CreateChildForFiber(fiber); + if (isolate != null && isolate.Count > 0) + fiberCtx = fiberCtx.WithIsolate(isolate); + if (intercept != null && intercept.Count > 0) + fiberCtx = fiberCtx.WithIntercept(intercept); + fiber.Ctx = fiberCtx; + _fibers.Add(fiber); + IndexFiber(fiber); + + var instantiationHandle = new ShrinkEffectHandle(); + instantiationHandle.AttachInverse(() => RetireCoreAsync(fiber)); + parent.Attach(instantiationHandle); + + Refresh(fiber); + return fiber; + } + + /// + /// 退役并卸载纤程(论文 O-Retire):目标强制 ⊥,等待纤程到达静止态。 + /// 退役是永久的:之后不再参与依赖通知。 + /// + public async UniTask RetireAsync(ShrinkFiber fiber) + { + if (fiber.Retired) + return; + UnindexFiber(fiber); + if (fiber.State == ShrinkFiberState.Inactive && !fiber.InTransition) + { + fiber.Retired = true; + return; + } + + fiber.Retired = true; + fiber.Target = null; + if (!fiber.InTransition) + { + fiber.InTransition = true; + fiber.Inertia = RunTransitionAsync(fiber); + } + + if (fiber.InTransition) + await fiber.Inertia; + } + + /// 卸载全部纤程并回滚根上下文效应。 + public async UniTask ShutdownAsync() + { + foreach (var fiber in _fibers.ToArray()) + await RetireAsync(fiber); + await _rootCtx.DisposeAsync(); + } + + // ---------------- 生命周期(论文算法 5) ---------------- + + /// + /// 重算目标:任一依赖键不可解析 → ⊥;否则为按 inject 键顺序记录的 provider uid 视图。 + /// 目标变化即启动(或在惯性边界链式触发)reload / unload 转换。 + /// + internal bool Refresh(ShrinkFiber fiber) + { + if (fiber.Retired) + return false; + + var target = ComputeTarget(fiber); + if (TargetEquals(fiber.Target, target)) + return false; + + fiber.Target = target; + if (!fiber.InTransition) + { + fiber.InTransition = true; + fiber.Inertia = RunTransitionAsync(fiber); + } + + return true; + } + + private async UniTask RunTransitionAsync(ShrinkFiber fiber) + { + try + { + while (true) + { + if (fiber.Target is null) + { + // L-Leave:先停止供给(状态置 Unloading),再调度任何逆操作 + fiber.State = ShrinkFiberState.Unloading; + await UnloadCoreAsync(fiber); + if (fiber.Target is null) + { + fiber.State = ShrinkFiberState.Inactive; + return; + } + + // 卸载中途目标恢复 → 链式重载(惯性) + continue; + } + + fiber.State = ShrinkFiberState.Loading; + var stable = await ReloadCoreAsync(fiber); + if (stable) + { + fiber.State = ShrinkFiberState.Active; + fiber.EverActive = true; + fiber.LastError = null; + Notify(fiber.Ctx, fiber.Component.Provide); + return; + } + + // 装载中途目标变化(⊥ 或换提供者)→ 链式卸载(惯性) + } + } + finally + { + fiber.InTransition = false; + } + } + + private async UniTask ReloadCoreAsync(ShrinkFiber fiber) + { + var target0 = fiber.Target!; + // 提交视图:装载期间(含自身卸载过程)读取同一份绑定(定理 63) + fiber._committed = ResolveView(fiber); + + try + { + if (fiber.Component is IShrinkIterativeComponent iterative) + { + var stepper = iterative.ApplySteps(fiber.Ctx, fiber.Config); + // 守卫在每步步进边界检查目标稳定性(算法 1 的 guard 循环) + while (TargetEquals(fiber.Target, target0)) + { + if (!await stepper.MoveNextAsync()) + break; + + var handle = new ShrinkEffectHandle(); + handle.AttachInverse(stepper.Current); + fiber.Ctx.Attach(handle); + } + } + else + { + await fiber.Component.ApplyAsync(fiber.Ctx, fiber.Config); + } + } + catch (Exception ex) + { + // L-Raise:记录错误并强制走卸载路径,回收已执行的部分效应 + fiber.LastError = ex; + fiber.Target = null; + return false; + } + + return TargetEquals(fiber.Target, target0); + } + + private async UniTask UnloadCoreAsync(ShrinkFiber fiber) + { + // 先通知依赖者并等待它们到达静止态(drain),再运行自身的逆操作(L-Unload 守卫) + var affected = Notify(fiber.Ctx, fiber.Component.Provide); + foreach (var dependent in affected) + { + if (dependent.InTransition) + await dependent.Inertia; + } + + await fiber.Ctx.DisposeAsync(); + // 卸载完成后才丢弃提交视图,保证自身拆除期间仍可读取依赖 + fiber._committed = null; + } + + // ---------------- 通知(论文算法 3) ---------------- + + /// + /// 将键变更传播给声明了该键、且 realm 解析一致的依赖纤程; + /// 返回本次被触发刷新的纤程(供卸载方等待)。 + /// + internal IReadOnlyList Notify(ShrinkCtx sourceCtx, IReadOnlyCollection keys) + { + var affected = new List(); + if (keys.Count == 0) + return affected; + + var candidates = new HashSet(); + foreach (var key in keys) + { + if (_injectIndex.TryGetValue(key, out var indexed)) + candidates.UnionWith(indexed); + } + + _notificationDispatchCount++; + _lastNotificationCandidateCount = candidates.Count; + _notificationCandidateVisitCount += candidates.Count; + + foreach (var fiber in candidates.OrderBy(candidate => candidate.Uid)) + { + if (fiber.Retired) + continue; + + var realmMatches = false; + foreach (var key in keys) + { + if (!fiber.InjectSet.Contains(key)) + continue; + if (fiber.Ctx.ResolveRealm(key) != sourceCtx.ResolveRealm(key)) + continue; + realmMatches = true; + break; + } + + if (realmMatches && Refresh(fiber)) + affected.Add(fiber); + } + + return affected; + } + + // ---------------- 余效应存储(论文算法 2) ---------------- + + internal ShrinkEffectHandle SetBinding(ShrinkCtx ctx, string key, object? value, + IShrinkCoeffectAccessPolicy? accessPolicy) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key must not be null or empty.", nameof(key)); + + var realm = ctx.ResolveRealm(key); + var setter = ctx.OwnerFiber; + if (setter != null && !setter.ProvideSet.Contains(key)) + throw new ShrinkUndeclaredSupplyException(key, setter.Name); + + InstallBinding(realm, key, value, setter, ctx, accessPolicy); + + var handle = new ShrinkEffectHandle(); + handle.AttachInverse(() => + { + RemoveBinding(realm, key, setter, ctx); + return UniTask.CompletedTask; + }); + ctx.Attach(handle); + return handle; + } + + private void InstallBinding(string realm, string key, object? value, ShrinkFiber? setter, ShrinkCtx ctx, + IShrinkCoeffectAccessPolicy? accessPolicy) + { + if (_store.TryGetValue(realm, out var existing) && !IsTakeoverAllowed(existing, setter)) + throw new ShrinkSupplyConflictException(key, existing.Provider, setter); + + _store[realm] = new ShrinkBinding(value, setter, key, accessPolicy); + Notify(ctx, new[] { key }); + } + + private void RemoveBinding(string realm, string key, ShrinkFiber? setter, ShrinkCtx ctx) + { + // 只撤回仍归属本设置者的绑定,不误删后继提供者 + if (_store.TryGetValue(realm, out var current) && current.Provider == setter) + _store.Remove(realm); + Notify(ctx, new[] { key }); + } + + private static bool IsTakeoverAllowed(ShrinkBinding existing, ShrinkFiber? setter) + { + if (existing.Provider == null || existing.Provider == setter) + return true; + + var state = existing.Provider.State; + return state == ShrinkFiberState.Unloading || state == ShrinkFiberState.Inactive; + } + + /// 解析绑定:realm 不存在、或提供者非 Active(正在装载/卸载/失活)时视为不可用。 + internal ShrinkBinding? ResolveBinding(ShrinkCtx ctx, string key) + { + var realm = ctx.ResolveRealm(key); + if (!_store.TryGetValue(realm, out var binding)) + return null; + + if (binding.Provider != null && binding.Provider.State != ShrinkFiberState.Active) + return null; + + return binding; + } + + /// 绕过介导规则的原始查找(编排层/测试用):active 提供者或无主绑定才命中。 + public bool TryGetRaw(ShrinkCtx ctx, string key, out T value) + { + value = default!; + var binding = ResolveBinding(ctx, key); + if (binding == null) + return false; + + value = (T)binding.Value!; + return true; + } + + public bool TryGetRaw(ShrinkCtx ctx, ShrinkKey key, out T value) => TryGetRaw( + ctx, (key ?? throw new ArgumentNullException(nameof(key))).Id, out value); + + /// 执行 provider 绑定的访问策略;无策略时直接返回原始值。 + internal T ResolveAccess(ShrinkCtx ctx, string key, ShrinkBinding binding) + { + object? value = binding.Value; + if (binding.AccessPolicy != null) + { + var access = new ShrinkCoeffectAccessContext( + key, + typeof(T), + ctx.OwnerFiber, + ctx.ResolveInterceptMetadata(key)); + value = binding.AccessPolicy.Resolve(access, value); + } + + if (value is T typed) + return typed; + if (value == null && (!typeof(T).IsValueType || Nullable.GetUnderlyingType(typeof(T)) != null)) + return default!; + + throw new InvalidCastException( + $"Coeffect '{key}' resolved value of type '{value?.GetType().FullName ?? ""}' " + + $"but consumer requested '{typeof(T).FullName}'."); + } + + // ---------------- 目标计算 ---------------- + + private long[]? ComputeTarget(ShrinkFiber fiber) + { + var uids = new List(); + foreach (var key in fiber.Component.Inject) + { + var binding = ResolveBinding(fiber.Ctx, key); + if (binding == null) + return null; + uids.Add(binding.Provider?.Uid ?? 0); + } + + // target 是 key -> provider uid 的视图,不是 uid 集合。保留 inject 声明顺序与重复 uid, + // 才能观察两个键在相同提供者集合之间互换的变化。 + return uids.ToArray(); + } + + private Dictionary ResolveView(ShrinkFiber fiber) + { + var view = new Dictionary(); + foreach (var key in fiber.Component.Inject) + { + var binding = ResolveBinding(fiber.Ctx, key); + if (binding != null) + view[key] = binding; + } + + return view; + } + + private static bool TargetEquals(long[]? a, long[]? b) + { + if (a == null || b == null) + return a == null && b == null; + if (a.Length != b.Length) + return false; + + for (var i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) + return false; + } + + return true; + } + + private async UniTask RetireCoreAsync(ShrinkFiber fiber) + { + if (fiber.Retired) + return; + await RetireAsync(fiber); + } + + private void IndexFiber(ShrinkFiber fiber) + { + foreach (var key in fiber.InjectSet) + { + if (!_injectIndex.TryGetValue(key, out var fibers)) + { + fibers = new HashSet(); + _injectIndex.Add(key, fibers); + } + + fibers.Add(fiber); + } + } + + private void UnindexFiber(ShrinkFiber fiber) + { + foreach (var key in fiber.InjectSet) + { + if (!_injectIndex.TryGetValue(key, out var fibers)) + continue; + fibers.Remove(fiber); + if (fibers.Count == 0) + _injectIndex.Remove(key); + } + } + + /// 捕获不含可变运行时引用的诊断快照。 + public ShrinkContextRuntimeDiagnostic CaptureDiagnostic() + { + var diagnostics = new List(_fibers.Count); + foreach (var fiber in _fibers.OrderBy(item => item.Uid)) + { + var dependencies = new List(); + foreach (var key in fiber.Component.Inject ?? Array.Empty()) + { + var realm = fiber.Ctx.ResolveRealm(key); + var current = ResolveBinding(fiber.Ctx, key); + ShrinkBinding? committed = null; + fiber.Committed?.TryGetValue(key, out committed); + var potential = _fibers + .Where(candidate => !candidate.Retired && candidate.ProvideSet.Contains(key) && + candidate.Ctx.ResolveRealm(key) == realm) + .Select(candidate => candidate.Uid) + .OrderBy(uid => uid) + .ToArray(); + + dependencies.Add(new ShrinkDependencyDiagnostic( + key, + realm, + current?.Provider?.Uid, + committed?.Provider?.Uid, + potential)); + } + + diagnostics.Add(new ShrinkFiberDiagnostic( + fiber.Uid, + fiber.Name, + GetDiagnosticStatus(fiber), + fiber.State, + fiber.Retired, + fiber.InTransition, + (fiber.Component.Inject ?? Array.Empty()).ToArray(), + (fiber.Component.Provide ?? Array.Empty()).ToArray(), + fiber.Target?.ToArray() ?? Array.Empty(), + dependencies, + fiber.Ctx.GetLocalInterceptKeys().OrderBy(key => key, StringComparer.Ordinal).ToArray(), + fiber.LastError?.GetType().FullName, + fiber.LastError?.Message)); + } + + return new ShrinkContextRuntimeDiagnostic( + diagnostics, + _store.Count, + new ShrinkNotificationDiagnostic( + _notificationDispatchCount, + _notificationCandidateVisitCount, + _lastNotificationCandidateCount, + _injectIndex.Count)); + } + + private static ShrinkFiberDiagnosticStatus GetDiagnosticStatus(ShrinkFiber fiber) + { + if (fiber.Retired) + return ShrinkFiberDiagnosticStatus.Retired; + if (fiber.LastError != null) + return ShrinkFiberDiagnosticStatus.Failed; + + return fiber.State switch + { + ShrinkFiberState.Loading => ShrinkFiberDiagnosticStatus.Loading, + ShrinkFiberState.Active => ShrinkFiberDiagnosticStatus.Active, + ShrinkFiberState.Unloading => ShrinkFiberDiagnosticStatus.Unloading, + ShrinkFiberState.Inactive when fiber.InjectSet.Count > 0 => ShrinkFiberDiagnosticStatus.Waiting, + _ => ShrinkFiberDiagnosticStatus.Inactive, + }; + } + } +} diff --git a/Runtime/ShrinkContextRuntime.cs.meta b/Runtime/ShrinkContextRuntime.cs.meta new file mode 100644 index 0000000..a55c2cb --- /dev/null +++ b/Runtime/ShrinkContextRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9abfe7bff0c65d4b8dae01a0fba1c09 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..4d7ab66 --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1dae93ae310c91b42a482e989015132c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/CoeffectAccessTests.cs b/Tests/CoeffectAccessTests.cs new file mode 100644 index 0000000..8520f1e --- /dev/null +++ b/Tests/CoeffectAccessTests.cs @@ -0,0 +1,156 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkContext; + +namespace ShrinkContext.Tests +{ + /// + /// 余效应操作与介导访问(论文 3.2 / 算法 2、6): + /// set 是可逆效应;代理介导的 get 拒绝未声明/未激活访问;隔离域派生独立绑定。 + /// + public class CoeffectAccessTests + { + private ShrinkContextRuntime _runtime = null!; + private ShrinkCtx _ctx = null!; + + [SetUp] + public void SetUp() + { + _runtime = new ShrinkContextRuntime(); + _ctx = _runtime.RootContext; + } + + [Test] + public void Set_IsReversibleEffect_EarlyWithdrawAndContextDispose() + { + var handle = _ctx.Set("cfg", 42); + Assert.IsTrue(_runtime.TryGetRaw(_ctx, "cfg", out var v1)); + Assert.AreEqual(42, v1); + + // 早期手动撤回 + TestAwait.Run(handle.DisposeAsync()); + Assert.IsFalse(_runtime.TryGetRaw(_ctx, "cfg", out _)); + + // 再次安装后随上下文卸载自动撤回 + _ctx.Set("cfg", 43); + Assert.IsTrue(_runtime.TryGetRaw(_ctx, "cfg", out var v2)); + Assert.AreEqual(43, v2); + + TestAwait.Run(_ctx.DisposeAsync()); + Assert.IsFalse(_runtime.TryGetRaw(_ctx, "cfg", out _), "set 的逆操作必须移除绑定"); + } + + [Test] + public void MediatedGet_RejectsUndeclaredKey_InApply() + { + var provider = new ProviderComponent("provider", "real", "v"); + var fiber = _runtime.Use(provider); + Assert.AreEqual(ShrinkFiberState.Active, fiber.State); + + var greedy = new GreedyConsumerComponent("greedy", "real", "undeclared"); + var greedyFiber = _runtime.Use(greedy); + + Assert.AreEqual(ShrinkFiberState.Inactive, greedyFiber.State); + Assert.IsInstanceOf(greedyFiber.LastError, + "未声明的依赖访问在访问点被拒绝,并使装载失败(算法 6 UNDECLARED_ACCESS)"); + } + + [Test] + public void MediatedGet_ThrowsWhenOwnKeyNotActivated() + { + var consumer = new ConsumerComponent("consumer", "missing"); + var fiber = _runtime.Use(consumer); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State, + "依赖缺失时组件保持非活动,而不是抛错(响应式余效应)"); + + Assert.Throws(() => fiber.Ctx.Get("missing")); + } + + [Test] + public void Isolate_DerivesIndependentRealm_RestoreByDiscard() + { + _ctx.Set("k", 1); + Assert.IsTrue(_runtime.TryGetRaw(_ctx, "k", out var rootValue)); + Assert.AreEqual(1, rootValue); + + var isolated = _ctx.Isolate("k", "test-realm"); + + // 隔离域内初始为空:逻辑键相同但 realm 不同 + Assert.IsFalse(_runtime.TryGetRaw(isolated, "k", out _)); + + isolated.Set("k", 2); + Assert.IsTrue(_runtime.TryGetRaw(isolated, "k", out var isolatedValue)); + Assert.AreEqual(2, isolatedValue); + Assert.IsTrue(_runtime.TryGetRaw(_ctx, "k", out var rootValueAfter)); + Assert.AreEqual(1, rootValueAfter, "隔离域写入不得影响父上下文绑定"); + + // 派生上下文的恢复是隐式的:丢弃即恢复(论文定义 27 派生实现) + TestAwait.Run(isolated.DisposeAsync()); + Assert.IsFalse(_runtime.TryGetRaw(isolated, "k", out _)); + Assert.IsTrue(_runtime.TryGetRaw(_ctx, "k", out var rootValueFinal)); + Assert.AreEqual(1, rootValueFinal); + } + + [Test] + public void TypedKey_PreservesVersionedIdentityAcrossMediatedAccess() + { + var key = new ShrinkKey("test.package", "typed-service", 2); + Assert.AreEqual("test.package/typed-service@v2", key.Id); + + _runtime.Use(new PolicyProviderComponent(key.Id), "typed-value"); + var consumer = _runtime.Use(new PolicyConsumerComponent(key.Id)); + + Assert.AreEqual(ShrinkFiberState.Active, consumer.State); + Assert.AreEqual("typed-value", consumer.Ctx.Get(key)); + } + + [Test] + public void Intercept_MergesRootToLeafAndChildMetadataWins() + { + const string key = "policy.service"; + _runtime.Use(new PolicyProviderComponent(key), "value"); + var consumer = _runtime.Use(new PolicyConsumerComponent(key)); + Assert.AreEqual(ShrinkFiberState.Active, consumer.State); + + var parent = consumer.Ctx.Intercept(key, new Dictionary + { + ["prefix"] = "parent-", + ["suffix"] = "-parent" + }); + var child = parent.Intercept(key, new Dictionary + { + ["suffix"] = "-child" + }); + + Assert.AreEqual("parent-value-child", child.Get(key)); + } + + /// 声明一个键却访问另一个未声明键的组件(验证能力介导纪律)。 + private sealed class GreedyConsumerComponent : IShrinkComponent + { + private readonly string[] _inject; + + public GreedyConsumerComponent(string name, string declaredKey, string undeclaredKey) + { + Name = name; + UndeclaredKey = undeclaredKey; + _inject = new[] { declaredKey }; + } + + public string Name { get; } + public string UndeclaredKey { get; } + public IReadOnlyList Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Get(UndeclaredKey); + return UniTask.CompletedTask; + } + } + } +} diff --git a/Tests/CoeffectAccessTests.cs.meta b/Tests/CoeffectAccessTests.cs.meta new file mode 100644 index 0000000..713feaf --- /dev/null +++ b/Tests/CoeffectAccessTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9521a5ada1510c24d8e17fee319ff808 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/EffectTrackingTests.cs b/Tests/EffectTrackingTests.cs new file mode 100644 index 0000000..f22e06f --- /dev/null +++ b/Tests/EffectTrackingTests.cs @@ -0,0 +1,131 @@ +#nullable enable +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkContext; + +namespace ShrinkContext.Tests +{ + /// + /// 可逆效应语义(论文 3.1 / 算法 1): + /// 定理 7(恢复到初始上下文)、定理 16(LIFO 逐步还原)、armed 单次执行、异步前向等待。 + /// + public class EffectTrackingTests + { + private ShrinkContextRuntime _runtime = null!; + private ShrinkCtx _ctx = null!; + + [SetUp] + public void SetUp() + { + _runtime = new ShrinkContextRuntime(); + _ctx = _runtime.RootContext; + } + + [Test] + public void DisposeAsync_RestoresInitialState_Theorem7() + { + var shared = new Dictionary(); + _ctx.Effect(() => shared["a"] = 1, () => shared.Remove("a")); + _ctx.Effect(() => shared["b"] = 2, () => shared.Remove("b")); + _ctx.Effect(() => shared["c"] = 3, () => shared.Remove("c")); + + Assert.AreEqual(3, shared.Count); + + TestAwait.Run(_ctx.DisposeAsync()); + + Assert.IsEmpty(shared, "累加器回滚后上下文必须恢复到初始状态(定理 7)"); + } + + [Test] + public void ManualDispose_UndoesOnlyItsOwnEffect() + { + var shared = new Dictionary(); + _ctx.Effect(() => shared["a"] = 1, () => shared.Remove("a")); + var effectB = _ctx.Effect(() => shared["b"] = 2, () => shared.Remove("b")); + _ctx.Effect(() => shared["c"] = 3, () => shared.Remove("c")); + + TestAwait.Run(effectB.DisposeAsync()); + + Assert.IsFalse(shared.ContainsKey("b")); + Assert.IsTrue(shared.ContainsKey("a")); + Assert.IsTrue(shared.ContainsKey("c"), "选择性撤销不得影响其它效应(𝔈Γ 提升)"); + + TestAwait.Run(_ctx.DisposeAsync()); + Assert.IsEmpty(shared); + } + + [Test] + public void ManualDispose_IsIdempotent_ArmedSemantics() + { + var undoCount = 0; + var handle = _ctx.Effect(() => { }, () => undoCount++); + + TestAwait.Run(handle.DisposeAsync()); + TestAwait.Run(handle.DisposeAsync()); + TestAwait.Run(_ctx.DisposeAsync()); + + Assert.AreEqual(1, undoCount, "逆操作至多执行一次(armed 语义)"); + } + + [Test] + public void AsyncInverses_RunInLifoOrder() + { + var log = new List(); + + var h1 = TestAwait.Run(_ctx.EffectAsync(async () => + { + log.Add("f1"); + return () => + { + log.Add("i1"); + return UniTask.CompletedTask; + }; + })); + var h2 = TestAwait.Run(_ctx.EffectAsync(async () => + { + log.Add("f2"); + return async () => + { + log.Add("i2"); + await UniTask.CompletedTask; + }; + })); + + TestAwait.Run(h2.DisposeAsync()); + CollectionAssert.AreEqual(new[] { "f1", "f2", "i2" }, log); + + TestAwait.Run(_ctx.DisposeAsync()); + CollectionAssert.AreEqual(new[] { "f1", "f2", "i2", "i1" }, log, "后注册的逆操作先执行(LIFO)"); + } + + [Test] + public void DisposeAsync_WaitsForInflightForwardBeforeRecovering() + { + var log = new List(); + var gate = new UniTaskCompletionSource(); + + var effectTask = _ctx.EffectAsync(async () => + { + await gate.Task; + log.Add("fwd"); + return () => + { + log.Add("inv"); + return UniTask.CompletedTask; + }; + }); + + // 前向仍在进行中时启动卸载:dispose 应先等待前向完成,再执行其产出的逆操作 + var disposeTask = _ctx.DisposeAsync(); + Assert.IsEmpty(log, "前向未完成时不得提前执行恢复"); + + gate.TrySetResult(); + TestAwait.Run(disposeTask); + var handle = TestAwait.Run(effectTask); + + CollectionAssert.AreEqual(new[] { "fwd", "inv" }, log, "dispose = await task; recover()"); + Assert.IsFalse(handle.IsArmed); + } + } +} diff --git a/Tests/EffectTrackingTests.cs.meta b/Tests/EffectTrackingTests.cs.meta new file mode 100644 index 0000000..c1544f6 --- /dev/null +++ b/Tests/EffectTrackingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3ec8f710e1ae95f4dbcb4d1b7eb471c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/FiberLifecycleTests.cs b/Tests/FiberLifecycleTests.cs new file mode 100644 index 0000000..7201dc0 --- /dev/null +++ b/Tests/FiberLifecycleTests.cs @@ -0,0 +1,323 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Cysharp.Threading.Tasks; +using NUnit.Framework; +using ShrinkContext; + +namespace ShrinkContext.Tests +{ + /// + /// 纤程生命周期(论文算法 4、5 与元理论 4.4 的可观察行为): + /// 依赖驱动激活/停用、提供者替换重载、drain-before-inverse 次序、 + /// 父子级联、供给冲突、循环依赖静止、惯性链式转换、分步部分回滚、错误路径回滚。 + /// + public class FiberLifecycleTests + { + private ShrinkContextRuntime _runtime = null!; + + [SetUp] + public void SetUp() + { + _runtime = new ShrinkContextRuntime(); + } + + [Test] + public void ProviderReady_ConsumerActivates() + { + var provider = new ProviderComponent("provider", "svc", "v1"); + var consumer = new ConsumerComponent("consumer", "svc"); + + var providerFiber = _runtime.Use(provider); + var consumerFiber = _runtime.Use(consumer); + + Assert.AreEqual(ShrinkFiberState.Active, providerFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + Assert.AreEqual("v1", consumer.LastSeen); + Assert.AreEqual(1, consumer.LoadCount); + } + + [Test] + public void MissingDependency_StaysInactiveThenActivatesOnLateProvider() + { + var consumer = new ConsumerComponent("consumer", "svc"); + var consumerFiber = _runtime.Use(consumer); + + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State, + "依赖缺失时静默保持非活动,不抛错、不加载"); + Assert.AreEqual(0, consumer.LoadCount); + + var provider = new ProviderComponent("provider", "svc", "late"); + var providerFiber = _runtime.Use(provider); + + Assert.AreEqual(ShrinkFiberState.Active, providerFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State, + "提供者出现后依赖者被响应式激活(activating 转换)"); + Assert.AreEqual("late", consumer.LastSeen); + } + + [Test] + public void RetireProvider_DrainsDependentsBeforeOwnInverses() + { + var log = new List(); + var provider = new OrderProviderComponent("provider", "svc", "v1", log); + var consumer = new OrderConsumerComponent("consumer", "svc", log); + + var providerFiber = _runtime.Use(provider); + var consumerFiber = _runtime.Use(consumer); + + CollectionAssert.AreEqual(new[] { "provider-effect", "consumer-load" }, log); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + + TestAwait.Run(_runtime.RetireAsync(providerFiber)); + + CollectionAssert.AreEqual( + new[] { "provider-effect", "consumer-load", "consumer-undo", "provider-undo" }, + log, + "依赖者必须先于提供者的逆操作完成卸载(L-Unload 守卫:drain dependents)"); + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State); + Assert.AreEqual(ShrinkFiberState.Inactive, providerFiber.State); + } + + [Test] + public void ProviderReplacement_ReloadsDependent() + { + var first = new ProviderComponent("p1", "svc", "v1"); + var consumer = new ConsumerComponent("consumer", "svc"); + var firstFiber = _runtime.Use(first); + var consumerFiber = _runtime.Use(consumer); + Assert.AreEqual("v1", consumer.LastSeen); + + TestAwait.Run(_runtime.RetireAsync(firstFiber)); + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State); + + var second = new ProviderComponent("p2", "svc", "v2"); + var secondFiber = _runtime.Use(second); + + Assert.AreEqual(ShrinkFiberState.Active, secondFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + Assert.AreEqual("v2", consumer.LastSeen); + Assert.AreEqual(2, consumer.LoadCount, "提供者替换必须触发依赖者重载"); + } + + [Test] + public void ProviderReplacement_SameValueStillReloads_UidSemantics() + { + var first = new ProviderComponent("p1", "svc", "same"); + var consumer = new ConsumerComponent("consumer", "svc"); + var firstFiber = _runtime.Use(first); + var consumerFiber = _runtime.Use(consumer); + + TestAwait.Run(_runtime.RetireAsync(firstFiber)); + + var second = new ProviderComponent("p2", "svc", "same"); + _runtime.Use(second); + + Assert.AreEqual(2, consumer.LoadCount, + "目标按提供者 uid 而非值比较:同值不同提供者的替换也要重载(定义 46)"); + Assert.AreEqual("same", consumer.LastSeen); + } + + [Test] + public void ParentRetire_CascadesToChildFiber() + { + var parent = new ProviderComponent("parent", "parent.svc", "pv"); + var parentFiber = _runtime.Use(parent); + + var child = new ConsumerComponent("child", "parent.svc"); + var childFiber = _runtime.Use(child, null, parentFiber.Ctx); + Assert.AreEqual(ShrinkFiberState.Active, childFiber.State); + Assert.AreSame(parentFiber, childFiber.Parent); + + TestAwait.Run(_runtime.RetireAsync(parentFiber)); + + Assert.AreEqual(ShrinkFiberState.Inactive, parentFiber.State); + Assert.AreEqual(ShrinkFiberState.Inactive, childFiber.State, + "父纤程卸载必须级联卸载子纤程(算法 4:实例化是父的可逆效应)"); + } + + [Test] + public void SupplyConflict_SecondProviderFailsAndKeepsFirst() + { + var first = new ProviderComponent("p1", "svc", "v1"); + var firstFiber = _runtime.Use(first); + Assert.AreEqual(ShrinkFiberState.Active, firstFiber.State); + + var second = new ProviderComponent("p2", "svc", "v2"); + var secondFiber = _runtime.Use(second); + + Assert.AreEqual(ShrinkFiberState.Inactive, secondFiber.State); + Assert.IsInstanceOf(secondFiber.LastError, + "两个活跃纤程供给同一 realm 必须在生成阶段报错(供给不相交)"); + + Assert.IsTrue(_runtime.TryGetRaw(_runtime.RootContext, "svc", out var value)); + Assert.AreEqual("v1", value, "冲突失败后第一个提供者的绑定必须保持完好"); + Assert.AreEqual(ShrinkFiberState.Active, firstFiber.State); + } + + [Test] + public void UndeclaredSupply_FailsFiberAndLeavesNoBinding() + { + var fiber = _runtime.Use(new UndeclaredProviderComponent()); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State); + Assert.IsInstanceOf(fiber.LastError); + Assert.IsFalse(_runtime.TryGetRaw(_runtime.RootContext, "not-declared", out _)); + + var snapshot = _runtime.CaptureDiagnostic(); + var diagnostic = snapshot.Fibers.Single(item => item.Uid == fiber.Uid); + Assert.AreEqual(ShrinkFiberDiagnosticStatus.Failed, diagnostic.Status); + StringAssert.Contains("not-declared", diagnostic.ErrorMessage); + } + + [Test] + public void DiagnosticSnapshot_ExplainsWaitingDependencyAndPotentialProvider() + { + var waiting = _runtime.Use(new ConsumerComponent("waiting", "svc")); + var blockedProvider = _runtime.Use(new ProviderComponent("blocked-provider", "svc") + { + Inject = new[] { "missing" } + }); + + var snapshot = _runtime.CaptureDiagnostic(); + var waitingDiagnostic = snapshot.Fibers.Single(item => item.Uid == waiting.Uid); + Assert.AreEqual(ShrinkFiberDiagnosticStatus.Waiting, waitingDiagnostic.Status); + Assert.AreEqual(1, waitingDiagnostic.Dependencies.Count); + Assert.IsFalse(waitingDiagnostic.Dependencies[0].IsSatisfied); + CollectionAssert.Contains(waitingDiagnostic.Dependencies[0].PotentialProviderUids, + blockedProvider.Uid, + "等待链应显示声明了该键、但自身依赖未满足的潜在提供者"); + } + + [Test] + public void NotifyIndex_VisitsOnlyConsumersOfChangedKey() + { + var target = new ConsumerComponent("target", "target-key"); + _runtime.Use(target); + for (var i = 0; i < 40; i++) + _runtime.Use(new ConsumerComponent("unrelated-" + i, "other-" + i)); + + _runtime.Use(new ProviderComponent("provider", "target-key", "value")); + + var notifications = _runtime.CaptureDiagnostic().Notifications; + Assert.AreEqual(1, notifications.LastCandidateCount, + "notify 应从 key 倒排索引取得候选,不扫描 40 个无关 fiber"); + Assert.AreEqual(41, notifications.IndexedKeyCount); + Assert.AreEqual("value", target.LastSeen); + } + + [Test] + public void CircularDependencies_BothStayInactiveWithoutError() + { + var a = new ProviderComponent("a", "ka") { Inject = new[] { "kb" } }; + var b = new ProviderComponent("b", "kb") { Inject = new[] { "ka" } }; + + var fiberA = _runtime.Use(a); + var fiberB = _runtime.Use(b); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiberA.State); + Assert.AreEqual(ShrinkFiberState.Inactive, fiberB.State); + Assert.IsNull(fiberA.LastError); + Assert.IsNull(fiberB.LastError, + "依赖循环表现为可预测的永久非活动,而不是死锁或异常(论文 6.5)"); + } + + [Test] + public void Inertia_MidLoadRetire_NeverActivatesAndChainsToUnload() + { + var gate1 = new UniTaskCompletionSource(); + var gate2 = new UniTaskCompletionSource(); + + var first = new ProviderComponent("p1", "svc", "v1") { OnApply = () => gate1.Task }; + var consumer = new ConsumerComponent("consumer", "svc"); + + var firstFiber = _runtime.Use(first); + Assert.AreEqual(ShrinkFiberState.Loading, firstFiber.State); + + var consumerFiber = _runtime.Use(consumer); + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State, + "提供者仍在装载(未 Active)时,依赖者不可见其绑定"); + + // 装载中途退役:目标翻转 ⊥,但进行中的转换继续跑到惯性边界 + var retireTask = _runtime.RetireAsync(firstFiber); + Assert.AreEqual(ShrinkFiberState.Loading, firstFiber.State, + "惯性:已进入的转换继续运行,之后才响应新目标"); + + var second = new ProviderComponent("p2", "svc", "v2") { OnApply = () => gate2.Task }; + var secondFiber = _runtime.Use(second); + + gate2.TrySetResult(); + Assert.AreEqual(ShrinkFiberState.Active, secondFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + Assert.AreEqual("v2", consumer.LastSeen, "依赖者切换到新提供者"); + + gate1.TrySetResult(); + TestAwait.Run(retireTask); + + Assert.AreEqual(ShrinkFiberState.Inactive, firstFiber.State); + Assert.IsFalse(firstFiber.EverActive, + "装载中途目标变化的组件不得进入 Active,必须链式转入卸载并回滚"); + Assert.IsNotNull(firstFiber.LastError, "旧提供者补装时撞上活跃的新提供者,按供给冲突失败"); + + Assert.IsTrue(_runtime.TryGetRaw(_runtime.RootContext, "svc", out var value)); + Assert.AreEqual("v2", value, "失败路径不得破坏新提供者的绑定"); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + } + + [Test] + public void IterativeApply_TargetChangeInterruptsAndRollsBackExecutedSteps() + { + var component = new IterativeComponent("iterative", "svc", 3); + var fiber = _runtime.Use(component); + Assert.AreEqual(ShrinkFiberState.Loading, fiber.State); + + component.Gates[0].TrySetResult(); + Assert.AreEqual(1, component.ExecutedSteps); + + // 转换仍在步进器等待中时翻转目标(不阻塞:延续由后续闸门驱动) + var retireTask = _runtime.RetireAsync(fiber); + + // 放行第 2 步:执行完该步后,步进边界守卫发现目标已变 → 中断 + 回滚已执行步骤 + component.Gates[1].TrySetResult(); + TestAwait.Run(retireTask); + + Assert.AreEqual(2, component.ExecutedSteps, "守卫在第 3 步之前中断迭代"); + CollectionAssert.AreEqual( + new[] { "step1", "step2", "undo2", "undo1" }, + component.Steps, + "部分回滚:仅已执行步骤的逆操作按 LIFO 运行(定理 64 转换内中断)"); + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State); + Assert.IsFalse(fiber.EverActive); + Assert.IsFalse(_runtime.TryGetRaw(_runtime.RootContext, "svc", out _), + "步骤中的 set 效应必须随回滚撤回"); + } + + [Test] + public void ApplyFailure_RollsBackPartialEffects() + { + var failing = new FailingComponent("failing", "svc"); + var fiber = _runtime.Use(failing); + + Assert.AreEqual(ShrinkFiberState.Inactive, fiber.State); + Assert.IsFalse(fiber.EverActive); + Assert.IsInstanceOf(fiber.LastError); + Assert.IsFalse(_runtime.TryGetRaw(_runtime.RootContext, "svc", out _), + "apply 抛异常前注册的效应必须全部回滚(L-Raise → 卸载路径)"); + } + + [Test] + public void DomainReset_DefaultRuntimeIsRebuilt() + { + ShrinkContextDefaults.ResetForTesting(); + var runtime = ShrinkContextDefaults.Default; + runtime.Use(new ProviderComponent("p", "k", "v")); + + ShrinkContextDefaults.ResetForTesting(); + + var rebuilt = ShrinkContextDefaults.Default; + Assert.AreNotSame(runtime, rebuilt); + Assert.AreEqual(0, rebuilt.Fibers.Count, "Domain Reload 后注册表必须为空"); + } + } +} diff --git a/Tests/FiberLifecycleTests.cs.meta b/Tests/FiberLifecycleTests.cs.meta new file mode 100644 index 0000000..9d3a09d --- /dev/null +++ b/Tests/FiberLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ed0e47a6dd74e242809ce87c39833aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/LoaderTests.cs b/Tests/LoaderTests.cs new file mode 100644 index 0000000..8501f15 --- /dev/null +++ b/Tests/LoaderTests.cs @@ -0,0 +1,312 @@ +#nullable enable +using System; +using System.Collections.Generic; +using NUnit.Framework; +using ShrinkContext; + +namespace ShrinkContext.Tests +{ + /// + /// 声明式加载器(论文 5.2 的原型子集): + /// 增量协调、幂等、退役、disabled 开关、组件/配置变化重建、依赖缺失等待、隔离域条目。 + /// + public class LoaderTests + { + private ShrinkContextRuntime _runtime = null!; + private ShrinkComponentCatalog _catalog = null!; + private ShrinkContextLoader _loader = null!; + + [SetUp] + public void SetUp() + { + _runtime = new ShrinkContextRuntime(); + _catalog = new ShrinkComponentCatalog(); + _catalog.Register("provider", () => new ConfigProviderComponent("provider", "svc")); + _catalog.Register("provider.alt", () => new ConfigProviderComponent("provider.alt", "svc")); + _catalog.Register("consumer", () => new ConsumerComponent("consumer", "svc")); + _catalog.Register("failing", () => new FailingComponent("failing", "svc")); + _catalog.Register("policy.provider", () => new PolicyProviderComponent("policy.service")); + _catalog.Register("policy.consumer", () => new PolicyConsumerComponent("policy.service")); + _loader = new ShrinkContextLoader(_runtime, _catalog); + } + + [Test] + public void Apply_CreatesEntriesAndActivates() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + })); + + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var providerFiber)); + Assert.IsTrue(_loader.TryGetFiber("consumer-entry", out var consumerFiber)); + Assert.AreEqual(ShrinkFiberState.Active, providerFiber.State); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + } + + [Test] + public void Apply_IsIdempotent_SecondApplyDoesNotReload() + { + var config = new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + }; + + TestAwait.Run(_loader.ApplyAsync(config)); + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var firstFiber)); + + TestAwait.Run(_loader.ApplyAsync(config)); + + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var secondFiber)); + Assert.AreSame(firstFiber, secondFiber, "无变化的条目不得重建纤程"); + var providerComponent = (ConfigProviderComponent)secondFiber.Component; + Assert.AreEqual(1, providerComponent.ApplyCount, "幂等协调不触发重载"); + } + + [Test] + public void RemovedEntry_RetiresFiber_DependentDeactivatesButStaysManaged() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + })); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("consumer-entry", "consumer"), + })); + + Assert.IsFalse(_loader.TryGetFiber("provider-entry", out _), "消失的条目不再被管理"); + Assert.IsTrue(_loader.TryGetFiber("consumer-entry", out var consumerFiber)); + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State, + "提供者退役后依赖者被响应式停用,但条目仍被管理、等待依赖回归"); + + // 依赖回归后无需重建条目即可重新激活 + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + })); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + } + + [Test] + public void DisabledEntry_UnloadsAndReloadsOnEnable() + { + var enabled = new List { new("provider-entry", "provider", "v1") }; + TestAwait.Run(_loader.ApplyAsync(enabled)); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1", disabled: true), + })); + Assert.IsFalse(_loader.TryGetFiber("provider-entry", out _), + "disabled 条目不持有纤程,但条目本身仍被管理"); + CollectionAssert.Contains(_loader.ManagedEntryIds, "provider-entry"); + + TestAwait.Run(_loader.ApplyAsync(enabled)); + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var reloadedFiber)); + Assert.AreEqual(ShrinkFiberState.Active, reloadedFiber.State, "取消 disabled 后重新加载"); + } + + [Test] + public void ComponentChange_RebuildsEntry() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + })); + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var firstFiber)); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider.alt", "v2"), + new("consumer-entry", "consumer"), + })); + + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var rebuiltFiber)); + Assert.AreNotSame(firstFiber, rebuiltFiber, "组件名变化必须重建条目(论文 id/url → 重建)"); + Assert.AreEqual(ShrinkFiberState.Active, rebuiltFiber.State); + Assert.AreEqual("provider.alt", rebuiltFiber.Component.Name); + + Assert.IsTrue(_loader.TryGetFiber("consumer-entry", out var consumerFiber)); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + var consumer = (ConsumerComponent)consumerFiber.Component; + Assert.AreEqual("v2", consumer.LastSeen, "依赖者切换到重建后的新提供者"); + } + + [Test] + public void ConfigChange_ReloadsEntryWithNewConfig() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v1"), + new("consumer-entry", "consumer"), + })); + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var firstFiber)); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "v2"), + new("consumer-entry", "consumer"), + })); + + // 原型语义:配置变化走重建(组件自决 diff 属后续阶段) + Assert.AreEqual(ShrinkFiberState.Inactive, firstFiber.State); + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var rebuiltFiber)); + Assert.AreNotSame(firstFiber, rebuiltFiber); + Assert.AreEqual("v2", rebuiltFiber.Config, "重建后的纤程携带新配置"); + + Assert.IsTrue(_loader.TryGetFiber("consumer-entry", out var consumerFiber)); + var consumer = (ConsumerComponent)consumerFiber.Component; + Assert.AreEqual("v2", consumer.LastSeen, "依赖者看到新配置的值"); + } + + [Test] + public void MissingDependency_WaitsWithoutError_ThenActivatesWhenProvided() + { + // 首轮只有消费者:不抛“依赖缺失”异常,条目保持被管理、纤程 Inactive + TestAwait.Run(_loader.ApplyAsync(new List + { + new("consumer-entry", "consumer"), + })); + Assert.IsTrue(_loader.TryGetFiber("consumer-entry", out var consumerFiber)); + Assert.AreEqual(ShrinkFiberState.Inactive, consumerFiber.State); + + // 次轮补上提供者:依赖者被响应式激活,无需重发消费者条目 + TestAwait.Run(_loader.ApplyAsync(new List + { + new("consumer-entry", "consumer"), + new("provider-entry", "provider", "late"), + })); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + Assert.AreEqual("late", ((ConsumerComponent)consumerFiber.Component).LastSeen); + } + + [Test] + public void DuplicateEntryIds_Throw() + { + Assert.Throws(() => TestAwait.Run(_loader.ApplyAsync(new List + { + new("dup", "provider", "v1"), + new("dup", "provider", "v2"), + }))); + } + + [Test] + public void UnknownComponent_ThrowsWithEntryContext() + { + var ex = Assert.Throws(() => TestAwait.Run(_loader.ApplyAsync( + new List { new("entry", "no.such.component") }))); + StringAssert.Contains("no.such.component", ex!.Message); + StringAssert.Contains("entry", ex.Message); + } + + [Test] + public void IsolatedEntries_SameKeyCoexistInDifferentRealms() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("p1", "provider", "from-r1", isolate: new Dictionary { ["svc"] = "r1" }), + new("p2", "provider", "from-r2", isolate: new Dictionary { ["svc"] = "r2" }), + new("c2", "consumer", isolate: new Dictionary { ["svc"] = "r2" }), + })); + + Assert.IsTrue(_loader.TryGetFiber("p1", out var f1)); + Assert.IsTrue(_loader.TryGetFiber("p2", out var f2)); + Assert.AreEqual(ShrinkFiberState.Active, f1.State, "不同隔离域的同键供给互不冲突"); + Assert.AreEqual(ShrinkFiberState.Active, f2.State); + + Assert.IsTrue(_loader.TryGetFiber("c2", out var consumerFiber)); + Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State); + Assert.AreEqual("from-r2", ((ConsumerComponent)consumerFiber.Component).LastSeen, + "消费者按自身隔离域解析到 r2 的绑定"); + } + + [Test] + public void IsolatedProviderReplacement_DoesNotReloadOtherRealmConsumer() + { + var isolateR1 = new Dictionary { ["svc"] = "r1" }; + var isolateR2 = new Dictionary { ["svc"] = "r2" }; + TestAwait.Run(_loader.ApplyAsync(new List + { + new("p1", "provider", "r1-v1", isolate: isolateR1), + new("p2", "provider", "r2-v1", isolate: isolateR2), + new("c2", "consumer", isolate: isolateR2), + })); + Assert.IsTrue(_loader.TryGetFiber("c2", out var consumerFiber)); + var consumer = (ConsumerComponent)consumerFiber.Component; + Assert.AreEqual(1, consumer.LoadCount); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("p1", "provider", "r1-v2", isolate: isolateR1), + new("p2", "provider", "r2-v1", isolate: isolateR2), + new("c2", "consumer", isolate: isolateR2), + })); + + Assert.AreEqual(1, consumer.LoadCount, + "r1 提供者替换不得触发只解析 r2 的消费者重载"); + Assert.AreEqual("r2-v1", consumer.LastSeen); + } + + [Test] + public void InterceptUpdate_ChangesAccessWithoutReloadingFiber() + { + var suffixA = new Dictionary> + { + ["policy.service"] = new Dictionary { ["suffix"] = "-a" } + }; + var suffixB = new Dictionary> + { + ["policy.service"] = new Dictionary { ["suffix"] = "-b" } + }; + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("policy-provider", "policy.provider", "base"), + new("policy-consumer", "policy.consumer", intercept: suffixA), + })); + Assert.IsTrue(_loader.TryGetFiber("policy-consumer", out var firstFiber)); + var consumer = (PolicyConsumerComponent)firstFiber.Component; + Assert.AreEqual("base-a", consumer.Read()); + Assert.AreEqual(1, consumer.ApplyCount); + + TestAwait.Run(_loader.ApplyAsync(new List + { + new("policy-provider", "policy.provider", "base"), + new("policy-consumer", "policy.consumer", intercept: suffixB), + })); + + Assert.IsTrue(_loader.TryGetFiber("policy-consumer", out var sameFiber)); + Assert.AreSame(firstFiber, sameFiber); + Assert.AreEqual(1, consumer.ApplyCount, "intercept 元数据变化不应触发 reload"); + Assert.AreEqual("base-b", consumer.Read(), "访问时应读取更新后的 metadata"); + } + + [Test] + public void FailedReplacement_RestoresPreviousLoaderCompositionAndReportsTransaction() + { + TestAwait.Run(_loader.ApplyAsync(new List + { + new("provider-entry", "provider", "stable") + })); + + Assert.Throws(() => TestAwait.Run(_loader.ApplyAsync( + new List { new("provider-entry", "failing") }))); + + Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var restored)); + Assert.AreEqual(ShrinkFiberState.Active, restored.State); + Assert.AreEqual("stable", restored.Config); + Assert.IsNotNull(_loader.LastTransaction); + Assert.AreEqual(ShrinkLoaderTransactionPhase.Failed, _loader.LastTransaction!.Phase); + Assert.IsTrue(_loader.LastTransaction.RestoreAttempted); + Assert.IsTrue(_loader.LastTransaction.PreviousCompositionRestored); + StringAssert.Contains("failed during apply", _loader.LastTransaction.ErrorMessage); + } + } +} diff --git a/Tests/LoaderTests.cs.meta b/Tests/LoaderTests.cs.meta new file mode 100644 index 0000000..8005cce --- /dev/null +++ b/Tests/LoaderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1d3d814cbbc1964fbbdbbd819944624 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/ShrinkContext.Core.Tests.asmdef b/Tests/ShrinkContext.Core.Tests.asmdef new file mode 100644 index 0000000..51f2bd0 --- /dev/null +++ b/Tests/ShrinkContext.Core.Tests.asmdef @@ -0,0 +1,22 @@ +{ + "name": "ShrinkContext.Core.Tests", + "rootNamespace": "ShrinkContext.Tests", + "references": [ + "ShrinkContext.Core.Runtime", + "UniTask", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Tests/ShrinkContext.Core.Tests.asmdef.meta b/Tests/ShrinkContext.Core.Tests.asmdef.meta new file mode 100644 index 0000000..b7eb7b9 --- /dev/null +++ b/Tests/ShrinkContext.Core.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 61a3fcbd28ec0454cb1afe26d1efb9ee +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/TestComponents.cs b/Tests/TestComponents.cs new file mode 100644 index 0000000..98d61f9 --- /dev/null +++ b/Tests/TestComponents.cs @@ -0,0 +1,341 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +using ShrinkContext; + +namespace ShrinkContext.Tests +{ + /// 提供者测试组件:把 Value 设置到供给键上;OnApply 作为装载闸门。 + public sealed class ProviderComponent : IShrinkComponent + { + private readonly string[] _provide; + + public ProviderComponent(string name, string key, object? value = null) + { + Name = name; + Value = value; + _provide = new[] { key }; + } + + public string Name { get; } + public object? Value { get; set; } + public IReadOnlyList Inject { get; set; } = Array.Empty(); + public IReadOnlyList Provide => _provide; + public Func? OnApply { get; set; } + public int ApplyCount { get; private set; } + + public async UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ApplyCount++; + if (OnApply != null) + await OnApply(); + foreach (var key in _provide) + ctx.Set(key, Value); + } + } + + /// 消费者测试组件:装载时读取依赖键的当前值;OnApply 作为装载闸门。 + public sealed class ConsumerComponent : IShrinkComponent + { + private readonly string[] _inject; + + public ConsumerComponent(string name, string key) + { + Name = name; + Key = key; + _inject = new[] { key }; + } + + public string Name { get; } + public string Key { get; } + public IReadOnlyList Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + public Func? OnApply { get; set; } + public int LoadCount { get; private set; } + public object? LastSeen { get; private set; } + + public async UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + LoadCount++; + if (OnApply != null) + await OnApply(); + LastSeen = ctx.Get(Key); + } + } + + /// 记录效应顺序的提供者/消费者组合组件(验证 drain-before-inverse 次序)。 + public sealed class OrderProviderComponent : IShrinkComponent + { + private readonly string[] _provide; + + public OrderProviderComponent(string name, string key, object? value, List log) + { + Name = name; + Key = key; + Value = value; + Log = log; + _provide = new[] { key }; + } + + public string Name { get; } + public string Key { get; } + public object? Value { get; } + public List Log { get; } + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set(Key, Value); + ctx.Effect( + () => Log.Add("provider-effect"), + () => Log.Add("provider-undo")); + return UniTask.CompletedTask; + } + } + + /// 记录效应顺序的消费者组件。 + public sealed class OrderConsumerComponent : IShrinkComponent + { + private readonly string[] _inject; + + public OrderConsumerComponent(string name, string key, List log) + { + Name = name; + Key = key; + Log = log; + _inject = new[] { key }; + } + + public string Name { get; } + public string Key { get; } + public List Log { get; } + public IReadOnlyList Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + public object? LastSeen { get; private set; } + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + LastSeen = ctx.Get(Key); + ctx.Effect( + () => Log.Add("consumer-load"), + () => Log.Add("consumer-undo")); + return UniTask.CompletedTask; + } + } + + /// apply 中抛异常的组件:验证错误路径的部分回滚。 + public sealed class FailingComponent : IShrinkComponent + { + private readonly string[] _provide; + + public FailingComponent(string name, string key, Func? beforeThrow = null) + { + Name = name; + Key = key; + BeforeThrow = beforeThrow; + _provide = new[] { key }; + } + + public string Name { get; } + public string Key { get; } + public Func? BeforeThrow { get; set; } + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public async UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + if (BeforeThrow != null) + await BeforeThrow(); + ctx.Set(Key, "poison"); + throw new InvalidOperationException("apply failed: " + Name); + } + } + + /// + /// 分步效应组件(论文 𝔈iterΓ):每步等待对应闸门放行后执行并产出逆操作; + /// 用于验证目标变化在步进边界中断迭代并部分回滚。 + /// + public sealed class IterativeComponent : IShrinkComponent, IShrinkIterativeComponent + { + private readonly string[] _provide; + + public IterativeComponent(string name, string key, int stepCount) + { + Name = name; + Key = key; + _provide = new[] { key }; + Steps = new List(); + Gates = new List(); + for (var i = 0; i < stepCount; i++) + Gates.Add(new UniTaskCompletionSource()); + } + + public string Name { get; } + public string Key { get; } + public List Steps { get; } + public List Gates { get; } + public int ExecutedSteps { get; private set; } + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + throw new NotSupportedException("Iterative component must be driven via ApplySteps."); + } + + public IShrinkStepEffectEnumerator ApplySteps(ShrinkCtx ctx, object? config) => new StepEnumerator(this, ctx); + + private sealed class StepEnumerator : IShrinkStepEffectEnumerator + { + private readonly IterativeComponent _owner; + private readonly ShrinkCtx _ctx; + private int _index = -1; + + public StepEnumerator(IterativeComponent owner, ShrinkCtx ctx) + { + _owner = owner; + _ctx = ctx; + } + + public Func Current { get; private set; } = null!; + + public async UniTask MoveNextAsync() + { + _index++; + if (_index >= _owner.Gates.Count) + return false; + + await _owner.Gates[_index].Task; + + var step = _index + 1; + _ctx.Set(_owner.Key, "step-" + step); + _owner.Steps.Add("step" + step); + _owner.ExecutedSteps++; + Current = () => + { + _owner.Steps.Add("undo" + step); + return UniTask.CompletedTask; + }; + return true; + } + } + } + + /// 配置驱动值的提供者组件:apply 时把纤程 config 写入供给键(加载器测试用)。 + public sealed class ConfigProviderComponent : IShrinkComponent + { + private readonly string[] _provide; + + public ConfigProviderComponent(string name, string key) + { + Name = name; + Key = key; + _provide = new[] { key }; + } + + public string Name { get; } + public string Key { get; } + public int ApplyCount { get; private set; } + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ApplyCount++; + ctx.Set(Key, config); + return UniTask.CompletedTask; + } + } + + /// 故意写入未声明键,验证 Provide 运行时契约。 + public sealed class UndeclaredProviderComponent : IShrinkComponent + { + public string Name => "undeclared-provider"; + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => Array.Empty(); + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set("not-declared", 1); + return UniTask.CompletedTask; + } + } + + public sealed class MetadataSuffixPolicy : IShrinkCoeffectAccessPolicy + { + public object? Resolve(ShrinkCoeffectAccessContext context, object? value) + { + var text = value?.ToString() ?? string.Empty; + if (context.TryGetMetadata("prefix", out var prefix)) + text = prefix + text; + return context.TryGetMetadata("suffix", out var suffix) ? text + suffix : text; + } + } + + public sealed class PolicyProviderComponent : IShrinkComponent + { + private readonly string[] _provide; + + public PolicyProviderComponent(string key) + { + Key = key; + _provide = new[] { key }; + } + + public string Key { get; } + public string Name => "policy-provider"; + public IReadOnlyList Inject => Array.Empty(); + public IReadOnlyList Provide => _provide; + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + ctx.Set(Key, config?.ToString() ?? "value", new MetadataSuffixPolicy()); + return UniTask.CompletedTask; + } + } + + public sealed class PolicyConsumerComponent : IShrinkComponent + { + private readonly string[] _inject; + private ShrinkCtx? _ctx; + + public PolicyConsumerComponent(string key) + { + Key = key; + _inject = new[] { key }; + } + + public string Key { get; } + public string Name => "policy-consumer"; + public int ApplyCount { get; private set; } + public IReadOnlyList Inject => _inject; + public IReadOnlyList Provide => Array.Empty(); + + public UniTask ApplyAsync(ShrinkCtx ctx, object? config) + { + _ctx = ctx; + ApplyCount++; + return UniTask.CompletedTask; + } + + public string Read() => (_ctx ?? throw new InvalidOperationException("Consumer is not active.")) + .Get(Key); + } + + /// 测试辅助:同步等待 UniTask(测试保证全部延续同步完成,无死锁风险)。 + public static class TestAwait + { + public static void Run(UniTask task) + { + task.GetAwaiter().GetResult(); + } + + public static T Run(UniTask task) + { + return task.GetAwaiter().GetResult(); + } + } +} diff --git a/Tests/TestComponents.cs.meta b/Tests/TestComponents.cs.meta new file mode 100644 index 0000000..72078f0 --- /dev/null +++ b/Tests/TestComponents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b69edb9cb0df36d42bdce226b11ebb71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json new file mode 100644 index 0000000..4aaaac3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "com.cneicy.shrink-context-core", + "version": "0.1.0", + "displayName": "ShrinkContext Core", + "description": "Cordis 时空可组合性范式的 Unity/C# 原型核心:可逆效应追踪、响应式余效应与组件纤程生命周期。", + "unity": "2022.3", + "dependencies": { + "com.cysharp.unitask": "2.5.10" + }, + "keywords": [ + "context", + "composability", + "effect", + "coeffect", + "fiber", + "cordis" + ], + "author": { + "name": "cneicy", + "url": "https://git.crash.work/ShrinkSDK" + }, + "documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkContext.Core" +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..c56b0d5 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 578cf78705b2fd149b54c94f558be58e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: