Files
ShrinkModFramework/Tests/ShrinkModCompositionTests.cs
T
cneicy d15bf00512
Publish UPM package / publish (push) Successful in 3s
Publish NuGet packages / publish (push) Successful in 2m18s
feat: 公开宿主组合加载 API、Forge 风格依赖版本区间与加载顺序、程序集解析策略
- ShrinkModComponentDiscovery.Discover 公开:只发现、不执行生命周期
- ShrinkModLoader.LoadComposed / ShrinkModCordisRuntime.ApplyComposedAsync:宿主提供元数据组合加载,支持纯数据包与 IShrinkModWrapper 公开真实实例
- 公开 TryAttachEventBusInstance,随生命周期逆操作释放
- 依赖新增 VersionRange(Maven/Forge 区间语法)与 Ordering(Before/After)
- AssemblyResolvePolicy 可限制跨模组程序集解析
2026-11-10 07:39:22 +08:00

313 lines
19 KiB
C#

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<ShrinkModFrameworkSettings>();
_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<ShrinkModContext> 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<string>(); var ready = new List<string>(); 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<string>("items");
Assert.IsTrue(registry.Contains("data:one"));
ShrinkModLoader.LoadComposed(_settings, Array.Empty<ShrinkModComponentSource>());
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<ShrinkModComponentSource>());
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<FormatException>(() => 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<InvalidOperationException>(() => ShrinkModLoader.LoadComposed(_settings, new[] { Source("core"), violation }));
Assert.That(error.Message, Does.Contain("[2,3)"));
Assert.Throws<InvalidOperationException>(() => 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<string>();
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<InvalidOperationException>(() => 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<string>(); 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<Subscriber>();
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<ShrinkModTransactionException>(() => 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<string>()));
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<ShrinkModTransactionException>(() => 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<InvalidOperationException>(() => ShrinkModLoader.LoadComposed(_settings, new[] { a, b, c }));
c = Source("c", null, Dep("b", ShrinkModDependencyOrdering.After), Dep("a", ShrinkModDependencyOrdering.Before));
Assert.Throws<InvalidOperationException>(() => ShrinkModLoader.LoadComposed(_settings, new[] { a, b, c }));
Assert.Throws<InvalidOperationException>(() => 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<ShrinkModTransactionException>(() => 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<string>())
.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<ShrinkModContext> 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<ShrinkModContext> 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<Ping>(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(); }
}
}
}