Files
Workspace/Assets/Modules/ShrinkModFramework/Tests/ShrinkModContextHostTests.cs
T

253 lines
11 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Reflection;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using ShrinkContext;
using UnityEngine;
using Object = UnityEngine.Object;
namespace ShrinkModFramework.Tests
{
public sealed class ShrinkModContextHostTests
{
[Test]
public void FailedReplacement_RestoresPreviousModEffectsAndRegistryContent()
{
var host = new ShrinkModContextHost(enableHarmonyPatching: false);
var activeEffects = new HashSet<string>(StringComparer.Ordinal);
var oldActivationCount = 0;
var oldSource = Source("test.transaction", "1.0.0", "old", () =>
new DelegateMod(
onConstruct: context =>
{
oldActivationCount++;
context.Effect(
() => activeEffects.Add("old"),
() => activeEffects.Remove("old"));
},
onRegisterContent: context =>
context.GetRegistry<string>("items")
.Register(context.ModInfo.ModId, "test:item", "old")));
var failingSource = Source("test.transaction", "2.0.0", "broken", () =>
new DelegateMod(
onConstruct: context => context.Effect(
() => activeEffects.Add("new-partial"),
() => activeEffects.Remove("new-partial")),
onRegisterContent: context =>
context.GetRegistry<string>("items")
.Register(context.ModInfo.ModId, "test:item", "new"),
onInitialize: _ => throw new InvalidOperationException("new mod failed")));
try
{
Run(host.ApplyAsync(new[] { oldSource }));
Assert.That(activeEffects, Is.EquivalentTo(new[] { "old" }));
AssertRegistryValue(host, "old");
var error = Assert.Throws<ShrinkModTransactionException>(() =>
Run(host.ApplyAsync(new[] { failingSource })));
Assert.IsTrue(error!.PreviousCompositionRestored);
Assert.AreEqual(2, oldActivationCount, "旧模组应以新 fiber 重新激活,而不是复用已退役实例");
Assert.That(activeEffects, Is.EquivalentTo(new[] { "old" }),
"失败新模组的部分效应必须回滚");
Assert.AreEqual("1.0.0", host.Mods["test.transaction"].Info.Version);
AssertRegistryValue(host, "old");
Assert.IsTrue(host.Loader.TryGetFiber("test.transaction", out var restoredFiber));
Assert.AreEqual(ShrinkFiberState.Active, restoredFiber.State);
Assert.IsNull(restoredFiber.LastError);
}
finally
{
Run(host.ShutdownAsync());
}
Assert.IsEmpty(activeEffects);
Assert.IsEmpty(host.Mods);
Assert.IsFalse(host.GetOrCreateRegistry<string>("items").Contains("test:item"));
}
[Test]
public void ProviderRevisionChange_ReloadsUnchangedDependentByProviderUid()
{
var host = new ShrinkModContextHost(enableHarmonyPatching: false);
var consumerApplyCount = 0;
var activeConsumers = 0;
var providerV1 = Source("test.provider", "1.0.0", "v1", () => new DelegateMod());
var providerV2 = Source("test.provider", "2.0.0", "v2", () => new DelegateMod());
var dependency = new ShrinkModDependency("test.provider", "1.0.0", optional: false);
var consumer = Source("test.consumer", "1.0.0", "stable", () =>
new DelegateMod(onConstruct: context =>
{
consumerApplyCount++;
context.Effect(() => activeConsumers++, () => activeConsumers--);
}), dependency);
try
{
Run(host.ApplyAsync(new[] { providerV1, consumer }));
var firstProviderGeneration = host.Mods["test.provider"].Generation;
var firstConsumerGeneration = host.Mods["test.consumer"].Generation;
Assert.AreEqual(1, consumerApplyCount);
Assert.AreEqual(1, activeConsumers);
Run(host.ApplyAsync(new[] { providerV2, consumer }));
Assert.AreEqual(2, consumerApplyCount,
"依赖组件源未变,但提供者 uid 变化必须触发消费者重载");
Assert.AreEqual(1, activeConsumers);
Assert.Greater(host.Mods["test.provider"].Generation, firstProviderGeneration);
Assert.Greater(host.Mods["test.consumer"].Generation, firstConsumerGeneration);
Assert.AreEqual("2.0.0", host.Mods["test.provider"].Info.Version);
}
finally
{
Run(host.ShutdownAsync());
}
Assert.AreEqual(0, activeConsumers);
}
[Test]
public void InvalidDesiredGraph_IsRejectedBeforeCurrentCompositionChanges()
{
var host = new ShrinkModContextHost(enableHarmonyPatching: false);
var activeProviderEffects = 0;
var provider = Source("test.provider", "1.0.0", "v1", () =>
new DelegateMod(onConstruct: context =>
context.Effect(() => activeProviderEffects++, () => activeProviderEffects--)));
var missingDependency = new ShrinkModDependency("test.missing", null, optional: false);
var invalid = Source("test.invalid", "1.0.0", "v1", () => new DelegateMod(), missingDependency);
try
{
Run(host.ApplyAsync(new[] { provider }));
var generation = host.Mods["test.provider"].Generation;
Assert.Throws<InvalidOperationException>(() => Run(host.ApplyAsync(new[] { invalid })));
Assert.AreEqual(1, activeProviderEffects);
Assert.AreEqual(generation, host.Mods["test.provider"].Generation,
"验证失败发生在协调前,现有 fiber 不应重建");
Assert.AreEqual(1, host.Mods.Count);
}
finally
{
Run(host.ShutdownAsync());
}
}
[Test]
public void SameRevisionFromFreshScan_IsIdempotent()
{
var host = new ShrinkModContextHost(enableHarmonyPatching: false);
var activationCount = 0;
var sourceA = Source("test.stable", "1.0.0", "same-hash", () =>
new DelegateMod(onConstruct: _ => activationCount++));
var sourceB = Source("test.stable", "1.0.0", "same-hash", () =>
new DelegateMod(onConstruct: _ => activationCount++));
try
{
Run(host.ApplyAsync(new[] { sourceA }));
var generation = host.Mods["test.stable"].Generation;
Run(host.ApplyAsync(new[] { sourceB }));
Assert.AreEqual(1, activationCount);
Assert.AreEqual(generation, host.Mods["test.stable"].Generation);
Assert.AreSame(sourceA, host.CurrentSources[0],
"相同内容指纹应沿用已提交 source,不以扫描对象引用制造变更");
}
finally
{
Run(host.ShutdownAsync());
}
}
[Test]
public void PublicLoader_DefaultRoute_UsesContextHostAndRemainsIdempotent()
{
ResetStaticLoader();
var settings = ScriptableObject.CreateInstance<ShrinkModFrameworkSettings>();
settings.useContextHost = true;
settings.enableExternalDllMods = false;
settings.enableHarmonyPatching = false;
settings.verboseLogging = false;
settings.assemblyNamePrefixes = new[] { "__no_matching_mod_assembly__" };
try
{
var first = ShrinkModLoader.LoadAll(settings);
var second = ShrinkModLoader.LoadAll(settings);
Assert.IsTrue(ShrinkModLoader.IsLoaded);
Assert.IsTrue(ShrinkModCordisRuntime.IsInitialized);
Assert.AreSame(first, second);
Assert.IsEmpty(first);
}
finally
{
ResetStaticLoader();
Object.DestroyImmediate(settings);
}
}
private static ShrinkModComponentSource Source(string id, string version, string revision,
Func<IShrinkMod> factory, params ShrinkModDependency[] dependencies)
{
return new ShrinkModComponentSource(
new ShrinkModInfo(id, id, version, 0, typeof(DelegateMod),
autoApplyHarmonyPatches: false, dependencies),
revision,
factory);
}
private static void AssertRegistryValue(ShrinkModContextHost host, string expected)
{
var registry = host.GetOrCreateRegistry<string>("items");
Assert.IsTrue(registry.TryGet("test:item", out var value));
Assert.AreEqual(expected, value);
}
private static void Run(UniTask task) => task.GetAwaiter().GetResult();
private static void ResetStaticLoader()
{
var method = typeof(ShrinkModLoader).GetMethod("ResetForTesting",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(method);
method!.Invoke(null, null);
}
private sealed class DelegateMod : IShrinkMod
{
private readonly Action<ShrinkModContext>? _onConstruct;
private readonly Action<ShrinkModContext>? _onRegisterContent;
private readonly Action<ShrinkModContext>? _onInitialize;
private readonly Action<ShrinkModContext>? _onReady;
public DelegateMod(
Action<ShrinkModContext>? onConstruct = null,
Action<ShrinkModContext>? onRegisterContent = null,
Action<ShrinkModContext>? onInitialize = null,
Action<ShrinkModContext>? onReady = null)
{
_onConstruct = onConstruct;
_onRegisterContent = onRegisterContent;
_onInitialize = onInitialize;
_onReady = onReady;
}
public void OnConstruct(ShrinkModContext context) => _onConstruct?.Invoke(context);
public void OnRegisterContent(ShrinkModContext context) => _onRegisterContent?.Invoke(context);
public void OnInitialize(ShrinkModContext context) => _onInitialize?.Invoke(context);
public void OnReady(ShrinkModContext context) => _onReady?.Invoke(context);
}
}
}