using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using NUnit.Framework; using ShrinkEventBus; using UnityEngine; namespace ShrinkModFramework.Tests { public sealed class ShrinkModCompositionTests { private ShrinkModFrameworkSettings _settings; [SetUp] public void SetUp() { ShrinkModLoader.ResetForTesting(); _settings = ScriptableObject.CreateInstance(); _settings.enableHarmonyPatching = false; _settings.verboseLogging = false; _settings.assemblyNamePrefixes = new[] { "ExternalFixture." }; } [TearDown] public void TearDown() { ShrinkModLoader.ResetForTesting(); UnityEngine.Object.DestroyImmediate(_settings); } private static ShrinkModInfo Info(string id, params ShrinkModDependency[] dependencies) => new ShrinkModInfo(id, id, "1.2.0", 0, null, false, dependencies); private static ShrinkModComponentSource Source(string id, Action construct = null, params ShrinkModDependency[] dependencies) => new ShrinkModComponentSource(Info(id, dependencies), "r1", () => new Mod { Construct = construct }); private static ShrinkModDependency Dep(string id, ShrinkModDependencyOrdering ordering, bool optional = true, string range = null) => new ShrinkModDependency(id, null, optional, ordering, range); [Test] public void ComposedLoad_UsesManifestOrderAndAllFourPhases_AndPublishesReadyOnce() { var phases = new List(); var ready = new List(); var all = 0; ShrinkModLoader.OnModReady += handle => { Assert.IsTrue(ShrinkModLoader.IsLoaded); ready.Add(handle.Info.ModId); }; ShrinkModLoader.OnAllModsReady += mods => { Assert.AreEqual(2, mods.Count); all++; }; ShrinkModComponentSource Make(string id, params ShrinkModDependency[] dependencies) => new ShrinkModComponentSource(Info(id, dependencies), "r1", () => new Mod { Construct = _ => phases.Add(id + ":construct"), Register = _ => phases.Add(id + ":register"), Initialize = _ => phases.Add(id + ":initialize"), Ready = _ => phases.Add(id + ":ready") }); var sources = new[] { Make("a.addon", new ShrinkModDependency("z.core", "1.0", false)), Make("z.core") }; ShrinkModLoader.LoadComposed(_settings, sources); Assert.That(phases, Is.EqualTo(new[] { "z.core:construct", "z.core:register", "z.core:initialize", "z.core:ready", "a.addon:construct", "a.addon:register", "a.addon:initialize", "a.addon:ready" })); Assert.That(ready, Is.EquivalentTo(new[] { "z.core", "a.addon" })); ShrinkModLoader.LoadComposed(_settings, sources); Assert.AreEqual(2, ready.Count); Assert.AreEqual(2, all); Assert.AreEqual(8, phases.Count); Assert.AreEqual(2, ShrinkModLoader.LoadNewExternalMods(_settings).Count); } [Test] public void DataOnly_AllowsNullEntryAndReversibleContent() { var source = ShrinkModComponentSource.CreateDataOnly(Info("data"), "r1", () => new Mod { Register = ctx => ctx.RegisterContent("items", "one", "value") }); ShrinkModLoader.LoadComposed(_settings, new[] { source, ShrinkModComponentSource.CreateDataOnly(Info("empty"), "r1") }); Assert.IsNull(ShrinkModLoader.Mods["empty"].Instance); Assert.IsNull(ShrinkModLoader.Mods["data"].Info.EntryType); var registry = ShrinkModLoader.GetOrCreateRegistry("items"); Assert.IsTrue(registry.Contains("data:one")); ShrinkModLoader.LoadComposed(_settings, Array.Empty()); Assert.IsFalse(registry.Contains("data:one")); Assert.IsEmpty(ShrinkModLoader.Mods); } [Test] public void Wrapper_ExposesRealInstance_AndAutomaticallyAttachesAndDetachesGeneratedSubscribers() { var real = new Subscriber(); var busKey = ShrinkBusKey.Mod("wrapped"); var bus = EventBus.GetOrCreateBus(busKey, ShrinkBusOptions.Inline()); var wrapperCalls = 0; var source = new ShrinkModComponentSource(Info("wrapped"), "r1", () => new Wrapper(real, ctx => { wrapperCalls++; Assert.IsTrue(ctx.TryGetLoadedMod("wrapped", out var handle)); Assert.AreSame(real, handle.Instance); })); ShrinkModLoader.LoadComposed(_settings, new[] { source }); Assert.AreSame(real, ShrinkModLoader.Mods["wrapped"].Instance); bus.Post(new Ping()); Assert.AreEqual(1, real.Events); Assert.AreEqual(1, wrapperCalls); ShrinkModLoader.LoadComposed(_settings, Array.Empty()); Assert.AreEqual(1, real.Detaches); } [Test] public void PublicEventAttachment_CanBeDisposedExplicitly() { var real = new Subscriber(); var key = ShrinkBusKey.Mod("manual"); var bus = EventBus.GetOrCreateBus(key, ShrinkBusOptions.Inline()); try { var binding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(real.GetType(), real, "manual", false); Assert.IsNotNull(binding); bus.Post(new Ping()); Assert.AreEqual(1, real.Events); binding.Dispose(); bus.Post(new Ping()); Assert.AreEqual(1, real.Events); } finally { EventBus.RemoveBus(key); } } [TestCase("[1.0,2.0)", "1.0.0", true)] [TestCase("[1.0,2.0)", "2.0", false)] [TestCase("[1.2,)", "7.0", true)] [TestCase("1.0", "0.9", false)] [TestCase("1.0", "1.0.0", true)] [TestCase("[1.2]", "1.2.0", true)] [TestCase("(,1.0],[1.2,)", "1.1", false)] public void Ranges_ParseAndCheckBounds(string range, string version, bool expected) => Assert.AreEqual(expected, ShrinkModVersionRange.Parse(range).Contains(version)); [TestCase("[2,1]")] [TestCase("[1,2")] [TestCase("[1,)", false)] public void Ranges_RejectInvalidSyntax(string range, bool invalid = true) { if (invalid) Assert.Throws(() => ShrinkModVersionRange.Parse(range)); else Assert.DoesNotThrow(() => ShrinkModVersionRange.Parse(range)); } [Test] public void RangeAndMissingMandatoryErrors_PreserveCurrentComposition() { ShrinkModLoader.LoadComposed(_settings, new[] { Source("core") }); var generation = ShrinkModLoader.Mods["core"].Generation; var violation = Source("addon", null, Dep("core", ShrinkModDependencyOrdering.After, false, "[2,3)")); var error = Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { Source("core"), violation })); Assert.That(error.Message, Does.Contain("[2,3)")); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { violation })); Assert.AreEqual(generation, ShrinkModLoader.Mods["core"].Generation); } [TestCase(ShrinkModDependencyOrdering.Before, "a", "z")] [TestCase(ShrinkModDependencyOrdering.After, "z", "a")] [TestCase(ShrinkModDependencyOrdering.None, "a", "z")] public void OptionalOrdering_OrdersPresentTargets_AndAllowsAbsentTargets(ShrinkModDependencyOrdering order, string first, string second) { var calls = new List(); var a = Source("a", _ => calls.Add("a"), Dep("z", order)); ShrinkModLoader.LoadComposed(_settings, new[] { a, Source("z", _ => calls.Add("z")) }); Assert.That(calls, Is.EqualTo(new[] { first, second })); ShrinkModLoader.LoadComposed(_settings, new[] { a }); Assert.That(ShrinkModLoader.Mods.Keys, Is.EquivalentTo(new[] { "a" })); } [Test] public void BeforeCycles_AreRejectedBeforeLifecycle() { var a = Source("a", _ => Assert.Fail("No lifecycle before validation"), Dep("b", ShrinkModDependencyOrdering.Before)); var b = Source("b", null, Dep("a", ShrinkModDependencyOrdering.Before)); var error = Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { a, b })); Assert.That(error.Message, Does.Contain("Circular")); Assert.IsFalse(ShrinkModLoader.IsLoaded); } [Test] public void ChangedIncomingOrdering_RebuildsExistingTarget() { var calls = new List(); var z = Source("z", _ => calls.Add("z")); ShrinkModLoader.LoadComposed(_settings, new[] { z }); calls.Clear(); ShrinkModLoader.LoadComposed(_settings, new[] { z, Source("a", _ => calls.Add("a"), Dep("z", ShrinkModDependencyOrdering.Before)) }); Assert.That(calls, Is.EqualTo(new[] { "a", "z" })); } [Test] public void FailedReplacement_RestoresPublicInstanceAndEventBinding() { var instances = new List(); var source = new ShrinkModComponentSource(Info("wrapped"), "r1", () => { var real = new Subscriber(); instances.Add(real); return new Wrapper(real, null); }); ShrinkModLoader.LoadComposed(_settings, new[] { source }); var broken = source.WithMetadata(source.Info, "r2", () => new Mod { Initialize = _ => throw new InvalidOperationException("broken") }); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { broken })); Assert.IsTrue(ShrinkModLoader.IsLoaded); Assert.AreEqual(2, instances.Count); Assert.AreEqual(1, instances[0].Detaches); Assert.AreSame(instances[1], ShrinkModLoader.Mods["wrapped"].Instance); } [Test] public void Discovery_IsPublicAuthorizedAndDeferred_WithShaRevisionAndRollback() { _settings.enableExternalDllMods = true; var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".dll"); try { File.Copy(Fixture("ExternalFixture.Mod.V1.dll.bytes"), path); Assert.IsEmpty(ShrinkModComponentDiscovery.Discover(_settings, Array.Empty())); var source = ShrinkModComponentDiscovery.Discover(_settings, new[] { path }).Single(); Assert.IsFalse(ShrinkModLoader.IsLoaded); Assert.IsEmpty(ShrinkModLoader.Mods); Assert.AreEqual(0, ShrinkModDiagnostics.CaptureExternalAssemblies(_settings).CurrentRevisionCount); using (var sha = System.Security.Cryptography.SHA256.Create()) Assert.That(source.Revision, Does.StartWith(BitConverter.ToString(sha.ComputeHash(File.ReadAllBytes(path))).Replace("-", "") + ":")); ShrinkModLoader.LoadComposed(_settings, new[] { source.WithMetadata(source.Info, source.Revision) }); Assert.AreEqual(1, ShrinkModDiagnostics.CaptureExternalAssemblies(_settings).CurrentRevisionCount); var oldRevision = source.Revision; File.Copy(Fixture("ExternalFixture.Mod.V2.Failing.dll.bytes"), path, true); var broken = ShrinkModComponentDiscovery.Discover(_settings, new[] { path }).Single(); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { broken })); Assert.AreEqual(oldRevision, ShrinkModCordisRuntime.Host.CurrentSources.Single().Revision); Assert.AreEqual("1.0.0", ShrinkModLoader.Mods["external.fixture"].Info.Version); } finally { File.Delete(path); } } [Test] public void ResolvePolicy_ReceivesRequesterNameAndAuthorizedPath_AndCanDenyCachedCandidate() { _settings.enableExternalDllMods = true; var path = Path.Combine(Path.GetTempPath(), "ExternalFixture.Mod." + Guid.NewGuid() + ".dll"); try { File.Copy(Fixture("ExternalFixture.Mod.V1.dll.bytes"), path); ShrinkModLoader.LoadAuthorized(_settings, new[] { path }); var name = Path.GetFileNameWithoutExtension(path); var args = new ResolveEventArgs(name, typeof(ShrinkModCompositionTests).Assembly); Assert.IsNotNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, args)); var calls = 0; ShrinkModLoader.AssemblyResolvePolicy = (requester, requested, candidate) => { calls++; Assert.AreSame(args.RequestingAssembly, requester); Assert.AreEqual(name, requested.Name); Assert.AreEqual(path, candidate); return false; }; Assert.IsNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, args)); Assert.AreEqual(1, calls); Assert.IsNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, new ResolveEventArgs("not.authorized"))); Assert.AreEqual(1, calls); ShrinkModLoader.AssemblyResolvePolicy = (_, __, ___) => true; Assert.IsNotNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, args)); var state = ShrinkExternalModAssemblyLoader.CaptureState(); state.Current.Clear(); ShrinkExternalModAssemblyLoader.RestoreState(state); ShrinkModLoader.AssemblyResolvePolicy = (_, __, ___) => false; Assert.IsNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, args)); Assert.AreEqual(0, ShrinkModDiagnostics.CaptureExternalAssemblies(_settings).CurrentRevisionCount); ShrinkModLoader.AssemblyResolvePolicy = (_, __, ___) => true; Assert.IsNotNull(ShrinkExternalModAssemblyLoader.OnAssemblyResolve(null, args)); } finally { File.Delete(path); } } [Test] public void ManifestMetadataChange_WithSameRevision_RevalidatesAndRebuilds() { var first = Source("mod"); ShrinkModLoader.LoadComposed(_settings, new[] { first }); var generation = ShrinkModLoader.Mods["mod"].Generation; var info = new ShrinkModInfo("mod", "new manifest name", "1.3", 0, null, false, new[] { Dep("missing.optional", ShrinkModDependencyOrdering.None) }); ShrinkModLoader.LoadComposed(_settings, new[] { first.WithMetadata(info, first.Revision) }); Assert.Greater(ShrinkModLoader.Mods["mod"].Generation, generation); Assert.AreEqual("1.3", ShrinkModLoader.Mods["mod"].Info.Version); } [Test] public void MixedBeforeAfterCycle_AndOptionalRangeViolation_AreRejected() { var a = Source("a", null, Dep("b", ShrinkModDependencyOrdering.Before)); var b = Source("b", null, Dep("c", ShrinkModDependencyOrdering.Before)); var c = Source("c", null, Dep("a", ShrinkModDependencyOrdering.Before)); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { a, b, c })); c = Source("c", null, Dep("b", ShrinkModDependencyOrdering.After), Dep("a", ShrinkModDependencyOrdering.Before)); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { a, b, c })); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { Source("a", null, Dep("b", ShrinkModDependencyOrdering.None, true, "[2,3)")), Source("b") })); } [Test] public void InitialLifecycleFailure_KeepsLoaderUnloadedAndSuppressesReadyEvents() { var notifications = 0; ShrinkModLoader.OnModReady += _ => notifications++; ShrinkModLoader.OnAllModsReady += _ => notifications++; var source = new ShrinkModComponentSource(Info("broken"), "r1", () => new Mod { Ready = _ => throw new InvalidOperationException("failure") }); Assert.Throws(() => ShrinkModLoader.LoadComposed(_settings, new[] { source })); Assert.IsFalse(ShrinkModLoader.IsLoaded); Assert.IsEmpty(ShrinkModLoader.Mods); Assert.AreEqual(0, notifications); } [Test] public void AttributeRangeAndOrdering_ArePreservedByDiscovery() { _settings.assemblyNamePrefixes = new[] { typeof(AttributeMod).Assembly.GetName().Name }; var source = ShrinkModComponentDiscovery.Discover(_settings, Array.Empty()) .Single(item => item.Info.ModId == "composition.attribute"); var dependency = source.Info.Dependencies.Single(); Assert.AreEqual("[1,2)", dependency.VersionRange); Assert.AreEqual(ShrinkModDependencyOrdering.Before, dependency.Ordering); Assert.IsTrue(dependency.Optional); Assert.IsFalse(ShrinkModLoader.IsLoaded); } [ShrinkMod("composition.attribute", "Attribute fixture", "1.0")] [ShrinkModDependency("composition.target", optional: true, VersionRange = "[1,2)", Ordering = ShrinkModDependencyOrdering.Before)] public sealed class AttributeMod : ShrinkModBase { } private static string Fixture(string name) { var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ShrinkModCompositionTests).Assembly); var root = package != null ? package.resolvedPath : UnityEditor.AssetDatabase.FindAssets("ShrinkModFramework.Tests t:asmdef") .Select(UnityEditor.AssetDatabase.GUIDToAssetPath).Select(Path.GetDirectoryName).Select(Path.GetDirectoryName).First(); return Path.Combine(root, "Tests", "Fixtures", name); } private class Mod : ShrinkModBase { public Action Construct, Register, Initialize, Ready; public override void OnConstruct(ShrinkModContext context) => Construct?.Invoke(context); public override void OnRegisterContent(ShrinkModContext context) => Register?.Invoke(context); public override void OnInitialize(ShrinkModContext context) => Initialize?.Invoke(context); public override void OnReady(ShrinkModContext context) => Ready?.Invoke(context); } private sealed class Wrapper : Mod, IShrinkModWrapper { public Wrapper(IShrinkMod real, Action construct) { PublicInstance = real; Construct = construct; } public IShrinkMod PublicInstance { get; } } private readonly struct Ping : IShrinkEvent { } private sealed class Subscriber : ShrinkModBase, IShrinkGeneratedSubscriber { public int Events, Detaches; public IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null) { var subscription = ShrinkGeneratedBinding.Subscribe(resolver, defaultBus, null, this, _ => Events++, ShrinkEventPriority.Normal, 0, false); return new Lease(() => { subscription.Dispose(); Detaches++; }); } } private sealed class Lease : IDisposable { private Action _dispose; public Lease(Action dispose) { _dispose = dispose; } public void Dispose() { var dispose = _dispose; _dispose = null; dispose?.Invoke(); } } } }