This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using ShrinkContext;
|
||||
|
||||
namespace ShrinkContext.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 余效应操作与介导访问(论文 3.2 / 算法 2、6):
|
||||
/// set 是可逆效应;代理介导的 get 拒绝未声明/未激活访问;隔离域派生独立绑定。
|
||||
/// </summary>
|
||||
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<int>(_ctx, "cfg", out var v1));
|
||||
Assert.AreEqual(42, v1);
|
||||
|
||||
// 早期手动撤回
|
||||
TestAwait.Run(handle.DisposeAsync());
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(_ctx, "cfg", out _));
|
||||
|
||||
// 再次安装后随上下文卸载自动撤回
|
||||
_ctx.Set("cfg", 43);
|
||||
Assert.IsTrue(_runtime.TryGetRaw<int>(_ctx, "cfg", out var v2));
|
||||
Assert.AreEqual(43, v2);
|
||||
|
||||
TestAwait.Run(_ctx.DisposeAsync());
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(_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<ShrinkUndeclaredAccessException>(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<ShrinkInactiveAccessException>(() => fiber.Ctx.Get<object>("missing"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Isolate_DerivesIndependentRealm_RestoreByDiscard()
|
||||
{
|
||||
_ctx.Set("k", 1);
|
||||
Assert.IsTrue(_runtime.TryGetRaw<int>(_ctx, "k", out var rootValue));
|
||||
Assert.AreEqual(1, rootValue);
|
||||
|
||||
var isolated = _ctx.Isolate("k", "test-realm");
|
||||
|
||||
// 隔离域内初始为空:逻辑键相同但 realm 不同
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(isolated, "k", out _));
|
||||
|
||||
isolated.Set("k", 2);
|
||||
Assert.IsTrue(_runtime.TryGetRaw<int>(isolated, "k", out var isolatedValue));
|
||||
Assert.AreEqual(2, isolatedValue);
|
||||
Assert.IsTrue(_runtime.TryGetRaw<int>(_ctx, "k", out var rootValueAfter));
|
||||
Assert.AreEqual(1, rootValueAfter, "隔离域写入不得影响父上下文绑定");
|
||||
|
||||
// 派生上下文的恢复是隐式的:丢弃即恢复(论文定义 27 派生实现)
|
||||
TestAwait.Run(isolated.DisposeAsync());
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(isolated, "k", out _));
|
||||
Assert.IsTrue(_runtime.TryGetRaw<int>(_ctx, "k", out var rootValueFinal));
|
||||
Assert.AreEqual(1, rootValueFinal);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypedKey_PreservesVersionedIdentityAcrossMediatedAccess()
|
||||
{
|
||||
var key = new ShrinkKey<string>("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<string, object?>
|
||||
{
|
||||
["prefix"] = "parent-",
|
||||
["suffix"] = "-parent"
|
||||
});
|
||||
var child = parent.Intercept(key, new Dictionary<string, object?>
|
||||
{
|
||||
["suffix"] = "-child"
|
||||
});
|
||||
|
||||
Assert.AreEqual("parent-value-child", child.Get<string>(key));
|
||||
}
|
||||
|
||||
/// <summary>声明一个键却访问另一个未声明键的组件(验证能力介导纪律)。</summary>
|
||||
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<string> Inject => _inject;
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
ctx.Get<object>(UndeclaredKey);
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9521a5ada1510c24d8e17fee319ff808
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,131 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using ShrinkContext;
|
||||
|
||||
namespace ShrinkContext.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 可逆效应语义(论文 3.1 / 算法 1):
|
||||
/// 定理 7(恢复到初始上下文)、定理 16(LIFO 逐步还原)、armed 单次执行、异步前向等待。
|
||||
/// </summary>
|
||||
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<string, int>();
|
||||
_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<string, int>();
|
||||
_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<string>();
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ec8f710e1ae95f4dbcb4d1b7eb471c2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 纤程生命周期(论文算法 4、5 与元理论 4.4 的可观察行为):
|
||||
/// 依赖驱动激活/停用、提供者替换重载、drain-before-inverse 次序、
|
||||
/// 父子级联、供给冲突、循环依赖静止、惯性链式转换、分步部分回滚、错误路径回滚。
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<ShrinkSupplyConflictException>(secondFiber.LastError,
|
||||
"两个活跃纤程供给同一 realm 必须在生成阶段报错(供给不相交)");
|
||||
|
||||
Assert.IsTrue(_runtime.TryGetRaw<string>(_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<ShrinkUndeclaredSupplyException>(fiber.LastError);
|
||||
Assert.IsFalse(_runtime.TryGetRaw<int>(_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<string>(_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<string>(_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<InvalidOperationException>(fiber.LastError);
|
||||
Assert.IsFalse(_runtime.TryGetRaw<string>(_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 后注册表必须为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ed0e47a6dd74e242809ce87c39833aa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,312 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using ShrinkContext;
|
||||
|
||||
namespace ShrinkContext.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 声明式加载器(论文 5.2 的原型子集):
|
||||
/// 增量协调、幂等、退役、disabled 开关、组件/配置变化重建、依赖缺失等待、隔离域条目。
|
||||
/// </summary>
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "v1"),
|
||||
new("consumer-entry", "consumer"),
|
||||
}));
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "v1"),
|
||||
new("consumer-entry", "consumer"),
|
||||
}));
|
||||
Assert.AreEqual(ShrinkFiberState.Active, consumerFiber.State);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DisabledEntry_UnloadsAndReloadsOnEnable()
|
||||
{
|
||||
var enabled = new List<ShrinkLoaderEntry> { new("provider-entry", "provider", "v1") };
|
||||
TestAwait.Run(_loader.ApplyAsync(enabled));
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "v1"),
|
||||
new("consumer-entry", "consumer"),
|
||||
}));
|
||||
Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var firstFiber));
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "v1"),
|
||||
new("consumer-entry", "consumer"),
|
||||
}));
|
||||
Assert.IsTrue(_loader.TryGetFiber("provider-entry", out var firstFiber));
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderException>(() => TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
new("dup", "provider", "v1"),
|
||||
new("dup", "provider", "v2"),
|
||||
})));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnknownComponent_ThrowsWithEntryContext()
|
||||
{
|
||||
var ex = Assert.Throws<ShrinkLoaderException>(() => TestAwait.Run(_loader.ApplyAsync(
|
||||
new List<ShrinkLoaderEntry> { 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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("p1", "provider", "from-r1", isolate: new Dictionary<string, string> { ["svc"] = "r1" }),
|
||||
new("p2", "provider", "from-r2", isolate: new Dictionary<string, string> { ["svc"] = "r2" }),
|
||||
new("c2", "consumer", isolate: new Dictionary<string, string> { ["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<string, string> { ["svc"] = "r1" };
|
||||
var isolateR2 = new Dictionary<string, string> { ["svc"] = "r2" };
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<string, IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
["policy.service"] = new Dictionary<string, object?> { ["suffix"] = "-a" }
|
||||
};
|
||||
var suffixB = new Dictionary<string, IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
["policy.service"] = new Dictionary<string, object?> { ["suffix"] = "-b" }
|
||||
};
|
||||
|
||||
TestAwait.Run(_loader.ApplyAsync(new List<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
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<ShrinkLoaderEntry>
|
||||
{
|
||||
new("provider-entry", "provider", "stable")
|
||||
}));
|
||||
|
||||
Assert.Throws<ShrinkLoaderException>(() => TestAwait.Run(_loader.ApplyAsync(
|
||||
new List<ShrinkLoaderEntry> { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1d3d814cbbc1964fbbdbbd819944624
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61a3fcbd28ec0454cb1afe26d1efb9ee
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>提供者测试组件:把 Value 设置到供给键上;OnApply 作为装载闸门。</summary>
|
||||
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<string> Inject { get; set; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> Provide => _provide;
|
||||
public Func<UniTask>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>消费者测试组件:装载时读取依赖键的当前值;OnApply 作为装载闸门。</summary>
|
||||
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<string> Inject => _inject;
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
public Func<UniTask>? 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<object>(Key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>记录效应顺序的提供者/消费者组合组件(验证 drain-before-inverse 次序)。</summary>
|
||||
public sealed class OrderProviderComponent : IShrinkComponent
|
||||
{
|
||||
private readonly string[] _provide;
|
||||
|
||||
public OrderProviderComponent(string name, string key, object? value, List<string> 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<string> Log { get; }
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>记录效应顺序的消费者组件。</summary>
|
||||
public sealed class OrderConsumerComponent : IShrinkComponent
|
||||
{
|
||||
private readonly string[] _inject;
|
||||
|
||||
public OrderConsumerComponent(string name, string key, List<string> log)
|
||||
{
|
||||
Name = name;
|
||||
Key = key;
|
||||
Log = log;
|
||||
_inject = new[] { key };
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Key { get; }
|
||||
public List<string> Log { get; }
|
||||
public IReadOnlyList<string> Inject => _inject;
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
public object? LastSeen { get; private set; }
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
LastSeen = ctx.Get<object>(Key);
|
||||
ctx.Effect(
|
||||
() => Log.Add("consumer-load"),
|
||||
() => Log.Add("consumer-undo"));
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>apply 中抛异常的组件:验证错误路径的部分回滚。</summary>
|
||||
public sealed class FailingComponent : IShrinkComponent
|
||||
{
|
||||
private readonly string[] _provide;
|
||||
|
||||
public FailingComponent(string name, string key, Func<UniTask>? beforeThrow = null)
|
||||
{
|
||||
Name = name;
|
||||
Key = key;
|
||||
BeforeThrow = beforeThrow;
|
||||
_provide = new[] { key };
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Key { get; }
|
||||
public Func<UniTask>? BeforeThrow { get; set; }
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分步效应组件(论文 𝔈iterΓ):每步等待对应闸门放行后执行并产出逆操作;
|
||||
/// 用于验证目标变化在步进边界中断迭代并部分回滚。
|
||||
/// </summary>
|
||||
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<string>();
|
||||
Gates = new List<UniTaskCompletionSource>();
|
||||
for (var i = 0; i < stepCount; i++)
|
||||
Gates.Add(new UniTaskCompletionSource());
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Key { get; }
|
||||
public List<string> Steps { get; }
|
||||
public List<UniTaskCompletionSource> Gates { get; }
|
||||
public int ExecutedSteps { get; private set; }
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> 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<UniTask> Current { get; private set; } = null!;
|
||||
|
||||
public async UniTask<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>配置驱动值的提供者组件:apply 时把纤程 config 写入供给键(加载器测试用)。</summary>
|
||||
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<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> Provide => _provide;
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
ApplyCount++;
|
||||
ctx.Set(Key, config);
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>故意写入未声明键,验证 Provide 运行时契约。</summary>
|
||||
public sealed class UndeclaredProviderComponent : IShrinkComponent
|
||||
{
|
||||
public string Name => "undeclared-provider";
|
||||
public IReadOnlyList<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
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<string>("prefix", out var prefix))
|
||||
text = prefix + text;
|
||||
return context.TryGetMetadata<string>("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<string> Inject => Array.Empty<string>();
|
||||
public IReadOnlyList<string> 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<string> Inject => _inject;
|
||||
public IReadOnlyList<string> Provide => Array.Empty<string>();
|
||||
|
||||
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<string>(Key);
|
||||
}
|
||||
|
||||
/// <summary>测试辅助:同步等待 UniTask(测试保证全部延续同步完成,无死锁风险)。</summary>
|
||||
public static class TestAwait
|
||||
{
|
||||
public static void Run(UniTask task)
|
||||
{
|
||||
task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public static T Run<T>(UniTask<T> task)
|
||||
{
|
||||
return task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b69edb9cb0df36d42bdce226b11ebb71
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user