477 lines
21 KiB
C#
477 lines
21 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using Cysharp.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using ShrinkContext;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
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);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ExternalDllRevision_FailedReplacementRestoresPreviousAndBrokenBytesDoNotReplaceIt()
|
|
{
|
|
ResetExternalRuntime();
|
|
var settings = CreateExternalSettings();
|
|
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
|
|
var target = Path.Combine(directory, "ExternalFixture.Mod.dll");
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target);
|
|
|
|
ShrinkModLoader.LoadAll(settings);
|
|
AssertExternalFixture("1.0.0", "v1");
|
|
var restoredGeneration = ShrinkModLoader.Mods["external.fixture"].Generation;
|
|
|
|
CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target);
|
|
var transaction = Assert.Throws<ShrinkModTransactionException>(() =>
|
|
ShrinkModLoader.LoadNewExternalMods(settings));
|
|
|
|
Assert.IsTrue(transaction!.PreviousCompositionRestored);
|
|
AssertExternalFixture("1.0.0", "v1");
|
|
Assert.AreNotEqual(0, restoredGeneration);
|
|
Assert.Greater(ShrinkModLoader.Mods["external.fixture"].Generation, restoredGeneration);
|
|
var failedReplacementGeneration = ShrinkModLoader.Mods["external.fixture"].Generation;
|
|
|
|
File.WriteAllBytes(target, new byte[] { 0, 1, 2, 3, 4, 5 });
|
|
LogAssert.Expect(LogType.Error,
|
|
new Regex(@"\[ShrinkModFramework\] 加载外部 DLL 失败:.*ExternalFixture\.Mod\.dll"));
|
|
ShrinkModLoader.LoadNewExternalMods(settings);
|
|
|
|
AssertExternalFixture("1.0.0", "v1");
|
|
Assert.AreEqual(failedReplacementGeneration,
|
|
ShrinkModLoader.Mods["external.fixture"].Generation,
|
|
"坏 DLL 不应让失败 revision 重新成为当前 source");
|
|
|
|
CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target);
|
|
ShrinkModLoader.LoadNewExternalMods(settings);
|
|
AssertExternalFixture("3.0.0", "v3");
|
|
|
|
settings.externalAssemblyRevisionSoftLimit = 2;
|
|
var diagnostics = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
|
|
Assert.AreEqual(1, diagnostics.CurrentRevisionCount,
|
|
"同一路径只有最终有效 revision 是 current");
|
|
Assert.GreaterOrEqual(diagnostics.ResidentRevisionCount, 3,
|
|
"v1、失败但已加载的 v2、v3 Assembly 在 Mono 下都会常驻");
|
|
Assert.Greater(diagnostics.EstimatedResidentBytes, 0L);
|
|
Assert.IsTrue(diagnostics.IsSoftLimitExceeded);
|
|
Assert.AreEqual(1, diagnostics.ResidentRevisions.Count(item => item.IsCurrent));
|
|
}
|
|
finally
|
|
{
|
|
CleanupExternalRuntime(settings, directory);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ExternalDllWatcher_DebouncesBurstAndHandlesDeletion()
|
|
{
|
|
ResetExternalRuntime();
|
|
var settings = CreateExternalSettings();
|
|
settings.externalModsReloadDelaySeconds = 0.05f;
|
|
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
|
|
var target = Path.Combine(directory, "ExternalFixture.Mod.dll");
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
ShrinkModRuntimeDriver.EnsureCreated(settings);
|
|
Assert.IsTrue(ShrinkModRuntimeDriver.InstanceForTesting!.HasWatcherForTesting);
|
|
|
|
CopyFixture("ExternalFixture.Mod.V1.dll.bytes", target);
|
|
ShrinkModLoader.LoadAll(settings);
|
|
AssertExternalFixture("1.0.0", "v1");
|
|
|
|
// Two writes arrive as one debounce window; only the final valid revision should commit.
|
|
CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target);
|
|
CopyFixture("ExternalFixture.Mod.V3.dll.bytes", target);
|
|
Assert.IsTrue(PumpDriverUntil(() =>
|
|
ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle) &&
|
|
handle.Info.Version == "3.0.0"),
|
|
"watcher 应在 burst 后提交最后一个有效 revision");
|
|
AssertExternalFixture("3.0.0", "v3");
|
|
|
|
File.Delete(target);
|
|
Assert.IsTrue(PumpDriverUntil(() => ShrinkModLoader.Mods.Count == 0),
|
|
"删除 DLL 应触发当前 source 卸载");
|
|
}
|
|
finally
|
|
{
|
|
CleanupExternalRuntime(settings, directory);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ExternalDllDependencyRemoval_RestoresPreviousComposition()
|
|
{
|
|
ResetExternalRuntime();
|
|
var settings = CreateExternalSettings();
|
|
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings);
|
|
var providerPath = Path.Combine(directory, "ExternalFixture.Provider.dll");
|
|
var consumerPath = Path.Combine(directory, "ExternalFixture.Consumer.dll");
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath);
|
|
CopyFixture("ExternalFixture.Consumer.dll.bytes", consumerPath);
|
|
|
|
ShrinkModLoader.LoadAll(settings);
|
|
Assert.AreEqual(2, ShrinkModLoader.Mods.Count);
|
|
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State);
|
|
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State);
|
|
|
|
File.Delete(providerPath);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
ShrinkModLoader.LoadNewExternalMods(settings));
|
|
|
|
Assert.AreEqual(2, ShrinkModLoader.Mods.Count,
|
|
"依赖导入失败发生在协调前,旧组合必须保持 active");
|
|
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.provider"].State);
|
|
Assert.AreEqual(ShrinkModState.Ready, ShrinkModLoader.Mods["external.consumer"].State);
|
|
|
|
CopyFixture("ExternalFixture.Provider.dll.bytes", providerPath);
|
|
ShrinkModLoader.LoadNewExternalMods(settings);
|
|
Assert.AreEqual(2, ShrinkModLoader.Mods.Count);
|
|
}
|
|
finally
|
|
{
|
|
CleanupExternalRuntime(settings, directory);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void ExternalReloadDebouncer_CoalescesBurstAndUsesMinimumDelay()
|
|
{
|
|
var debouncer = new ShrinkModReloadDebouncer();
|
|
|
|
debouncer.Schedule(10f, 0f);
|
|
Assert.IsTrue(debouncer.IsPending);
|
|
Assert.IsFalse(debouncer.TryConsume(10.049f));
|
|
|
|
debouncer.Schedule(10.04f, 0.5f);
|
|
Assert.IsFalse(debouncer.TryConsume(10.539f));
|
|
Assert.IsTrue(debouncer.TryConsume(10.54f));
|
|
Assert.IsFalse(debouncer.IsPending);
|
|
Assert.IsFalse(debouncer.TryConsume(11f));
|
|
}
|
|
|
|
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 static void ResetExternalRuntime()
|
|
{
|
|
if (ShrinkModRuntimeDriver.InstanceForTesting != null)
|
|
Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject);
|
|
ShrinkModLoader.ResetForTesting();
|
|
}
|
|
|
|
private static ShrinkModFrameworkSettings CreateExternalSettings()
|
|
{
|
|
var settings = ScriptableObject.CreateInstance<ShrinkModFrameworkSettings>();
|
|
settings.useContextHost = true;
|
|
settings.enableExternalDllMods = true;
|
|
settings.autoCreateExternalModsDirectory = false;
|
|
settings.externalModsFolderName = "ShrinkModFrameworkTests_" + Guid.NewGuid().ToString("N");
|
|
settings.assemblyNamePrefixes = new[] { "ExternalFixture." };
|
|
settings.enableHarmonyPatching = false;
|
|
settings.enableNetworkSync = false;
|
|
settings.verboseLogging = false;
|
|
return settings;
|
|
}
|
|
|
|
private static void CleanupExternalRuntime(ShrinkModFrameworkSettings settings, string directory)
|
|
{
|
|
try
|
|
{
|
|
if (ShrinkModRuntimeDriver.InstanceForTesting != null)
|
|
Object.DestroyImmediate(ShrinkModRuntimeDriver.InstanceForTesting.gameObject);
|
|
ShrinkModLoader.ResetForTesting();
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(directory))
|
|
Directory.Delete(directory, true);
|
|
Object.DestroyImmediate(settings);
|
|
}
|
|
}
|
|
|
|
private static void CopyFixture(string fixtureName, string targetPath)
|
|
{
|
|
var fixturePath = Path.Combine(Application.dataPath, "Modules", "ShrinkModFramework",
|
|
"Tests", "Fixtures", fixtureName);
|
|
File.Copy(fixturePath, targetPath, true);
|
|
}
|
|
|
|
private static void AssertExternalFixture(string version, string registryValue)
|
|
{
|
|
Assert.IsTrue(ShrinkModLoader.Mods.TryGetValue("external.fixture", out var handle));
|
|
Assert.AreEqual(version, handle!.Info.Version);
|
|
Assert.AreEqual(ShrinkModState.Ready, handle.State);
|
|
|
|
var registry = ShrinkModLoader.GetOrCreateRegistry<string>("external.fixture");
|
|
Assert.IsTrue(registry.TryGet("external.fixture:version", out var value));
|
|
Assert.AreEqual(registryValue, value);
|
|
}
|
|
|
|
private static bool PumpDriverUntil(Func<bool> predicate)
|
|
{
|
|
for (var i = 0; i < 120; i++)
|
|
{
|
|
ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting();
|
|
if (predicate())
|
|
return true;
|
|
Thread.Sleep(50);
|
|
}
|
|
|
|
ShrinkModRuntimeDriver.InstanceForTesting?.PumpForTesting();
|
|
return predicate();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|