#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 RegistryNamespacedRegistration_BuildsOwnedKeyAndRejectsAmbiguousLocalKeys() { var registry = new ShrinkModRegistryManager().GetOrCreateMutableRegistry("items"); var key = registry.RegisterNamespaced("example.mod", "iron_hammer", "Iron Hammer"); Assert.AreEqual("example.mod:iron_hammer", key); Assert.IsTrue(registry.TryGet(key, out var value)); Assert.AreEqual("Iron Hammer", value); Assert.Throws(() => registry.RegisterNamespaced("example.mod", "other:key", "invalid")); Assert.Throws(() => registry.RegisterNamespaced("invalid:owner", "key", "invalid")); } [Test] public void RegistryOverrides_HighestPriorityWinsAndOwnerRemovalRestoresPreviousValue() { var manager = new ShrinkModRegistryManager(); var registry = manager.GetOrCreateMutableRegistry("items"); registry.RegisterNamespaced("core", "iron_hammer", "Core"); registry.RegisterOverride("mod.low", "core:iron_hammer", 10, "Low"); registry.RegisterOverride("mod.high", "core:iron_hammer", 20, "High"); Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var high)); Assert.AreEqual("High", high.Value); Assert.AreEqual("mod.high", high.OwnerModId); Assert.IsTrue(high.IsOverride); Assert.AreEqual(20, high.Priority); Assert.AreEqual("mod.high", registry.Entries.Single().OwnerModId); manager.RemoveOwnedEntries("mod.high"); Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var low)); Assert.AreEqual("Low", low.Value); Assert.AreEqual("mod.low", low.OwnerModId); manager.RemoveOwnedEntries("mod.low"); Assert.IsTrue(registry.TryGetEntry("core:iron_hammer", out var original)); Assert.AreEqual("Core", original.Value); Assert.AreEqual("core", original.OwnerModId); Assert.IsFalse(original.IsOverride); manager.RemoveOwnedEntries("core"); Assert.IsFalse(registry.Contains("core:iron_hammer")); Assert.IsFalse(registry.TryGet("core:iron_hammer", out _)); } [Test] public void RegistryOverrides_RejectMissingTargetsAndSamePriorityConflicts() { var registry = new ShrinkModRegistryManager().GetOrCreateMutableRegistry("items"); Assert.Throws(() => registry.RegisterOverride("mod.one", "core:missing", 10, "missing")); registry.RegisterNamespaced("core", "iron_hammer", "Core"); registry.RegisterOverride("mod.one", "core:iron_hammer", 10, "One"); Assert.Throws(() => registry.RegisterOverride("mod.two", "core:iron_hammer", 10, "Two")); Assert.That(registry.GetOverrideCandidates("core:iron_hammer").Select(entry => entry.OwnerModId), Is.EqualTo(new[] { "mod.one" })); } [Test] public void ModContext_ExposesReadOnlyRegistryAndOwnedWriteCommands() { var getRegistry = typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.GetRegistry)); Assert.IsNotNull(getRegistry); Assert.IsTrue(getRegistry!.ReturnType.IsGenericType); Assert.AreEqual(typeof(IReadOnlyShrinkModRegistry<>), getRegistry.ReturnType.GetGenericTypeDefinition()); var readMethods = typeof(IReadOnlyShrinkModRegistry) .GetMethods() .Select(method => method.Name) .ToArray(); Assert.That(readMethods, Does.Not.Contain("Register")); Assert.That(readMethods, Does.Not.Contain("RegisterNamespaced")); Assert.That(readMethods, Does.Not.Contain("RegisterOverride")); Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.RegisterContent))); Assert.IsNotNull(typeof(ShrinkModContext).GetMethod(nameof(ShrinkModContext.OverrideContent))); } [Test] public void FailedReplacement_RestoresPreviousModEffectsAndRegistryContent() { var host = new ShrinkModContextHost(enableHarmonyPatching: false); var activeEffects = new HashSet(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.RegisterContent("items", "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.RegisterContent("items", "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(() => 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("items").Contains("test.transaction: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(() => 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(); 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 Settings_Defaults_DoNotAutoLoadOrWatchExternalDlls() { var settings = ScriptableObject.CreateInstance(); try { Assert.IsFalse(settings.autoLoadOnStartup); Assert.IsFalse(settings.enableExternalDllMods); Assert.IsFalse(settings.watchExternalModsDirectory); } finally { Object.DestroyImmediate(settings); } } [Test] public void ExternalDll_IsIgnoredUntilItsExactPathIsAuthorized() { 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); Assert.IsEmpty(ShrinkModLoader.Mods); Assert.AreEqual(0, ShrinkModDiagnostics.CaptureExternalAssemblies(settings).CurrentRevisionCount); ShrinkModLoader.LoadAuthorized(settings, new[] { target }); AssertExternalFixture("1.0.0", "v1"); } finally { CleanupExternalRuntime(settings, directory); } } [Test] public void LegacyLoader_OnlyLoadsExplicitlyAuthorizedDllPaths() { ResetExternalRuntime(); var settings = CreateExternalSettings(); settings.useContextHost = false; var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(settings); var authorized = Path.Combine(directory, "Authorized.Mod.dll"); var unauthorized = Path.Combine(directory, "Unauthorized.Mod.dll"); try { Directory.CreateDirectory(directory); CopyFixture("ExternalFixture.Mod.V1.dll.bytes", authorized); CopyFixture("ExternalFixture.Mod.V3.dll.bytes", unauthorized); ShrinkModLoader.LoadAuthorized(settings, new[] { authorized }); AssertExternalFixture("1.0.0", "v1"); Assert.AreEqual(1, ShrinkModDiagnostics.CaptureExternalAssemblies(settings).CurrentRevisionCount); } finally { CleanupExternalRuntime(settings, directory); } } [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.LoadAuthorized(settings, new[] { target }); AssertExternalFixture("1.0.0", "v1"); var restoredGeneration = ShrinkModLoader.Mods["external.fixture"].Generation; CopyFixture("ExternalFixture.Mod.V2.Failing.dll.bytes", target); var transaction = Assert.Throws(() => 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.LoadAuthorized(settings, new[] { target }); 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.LoadAuthorized(settings, new[] { providerPath, consumerPath }); 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(() => 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 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("items"); Assert.IsTrue(registry.TryGet("test.transaction: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(); settings.useContextHost = true; settings.enableExternalDllMods = true; settings.autoCreateExternalModsDirectory = false; settings.watchExternalModsDirectory = true; 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 package = UnityEditor.PackageManager.PackageInfo.FindForAssembly( typeof(ShrinkModContextHostTests).Assembly); var testsRoot = package != null && !string.IsNullOrWhiteSpace(package.resolvedPath) ? Path.Combine(package.resolvedPath, "Tests") : UnityEditor.AssetDatabase.FindAssets("ShrinkModFramework.Tests t:asmdef") .Select(UnityEditor.AssetDatabase.GUIDToAssetPath) .Where(path => string.Equals(Path.GetFileName(path), "ShrinkModFramework.Tests.asmdef", StringComparison.Ordinal)) .Select(Path.GetDirectoryName) .FirstOrDefault(path => !string.IsNullOrWhiteSpace(path)); if (string.IsNullOrWhiteSpace(testsRoot)) throw new InvalidOperationException("Could not resolve the ShrinkModFramework tests path."); var fixturePath = Path.Combine(testsRoot, "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("external.fixture"); Assert.IsTrue(registry.TryGet("external.fixture:version", out var value)); Assert.AreEqual(registryValue, value); } private static bool PumpDriverUntil(Func 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? _onConstruct; private readonly Action? _onRegisterContent; private readonly Action? _onInitialize; private readonly Action? _onReady; public DelegateMod( Action? onConstruct = null, Action? onRegisterContent = null, Action? onInitialize = null, Action? 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); } } }