feat: 公开宿主组合加载 API、Forge 风格依赖版本区间与加载顺序、程序集解析策略
- ShrinkModComponentDiscovery.Discover 公开:只发现、不执行生命周期 - ShrinkModLoader.LoadComposed / ShrinkModCordisRuntime.ApplyComposedAsync:宿主提供元数据组合加载,支持纯数据包与 IShrinkModWrapper 公开真实实例 - 公开 TryAttachEventBusInstance,随生命周期逆操作释放 - 依赖新增 VersionRange(Maven/Forge 区间语法)与 Ordering(Before/After) - AssemblyResolvePolicy 可限制跨模组程序集解析
This commit is contained in:
1 parent
5398036d3b
commit
d15bf00512
29 files changed
+917
-132
No files matched your search
@@ -0,0 +1,312 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44e4b1f576a745fe9c36b173015b98a3
|
||||
@@ -3,6 +3,7 @@
|
||||
"rootNamespace": "ShrinkModFramework.Tests",
|
||||
"references": [
|
||||
"ShrinkModFramework.Runtime",
|
||||
"ShrinkEventBus.Runtime",
|
||||
"ShrinkContext.Core.Runtime",
|
||||
"UniTask",
|
||||
"UnityEngine.TestRunner",
|
||||
|
||||
Reference in new issue
Block a user