/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using com.IvanMurzak.McpPlugin; using com.IvanMurzak.ReflectorNet; using com.IvanMurzak.ReflectorNet.Converter; using NUnit.Framework; using Version = com.IvanMurzak.McpPlugin.Common.Version; namespace com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Full { // ────────────────────────────────────────────────────────────────────────────────────────── // Throwaway in-repo verification of the IReflectorModule discovery mechanism wired into the // Unity plugin build (see UnityMcpPlugin.Build.cs `.WithReflectorModulesFromAssembly(...)`). // // This is a UNIT-level proof: it constructs a McpPluginBuilder directly and registers THIS test // assembly for module discovery, asserting that an IReflectorModule with ZERO hardcoded // reference is auto-discovered and that all four contribution surfaces reach effect, that a // throwing module is isolated, and that a core-ignored heavy assembly is never type-enumerated. // // The fixture types live in this Editor.Tests asmdef (a test-only assembly, gated behind // `TestAssemblies` + the `UNITY_MCP_READY` define constraint), so they never ship in a player // build and are not discovered by the production `BuildMcpPlugin` path (which `.IgnoreAssemblies` // the test asmdefs). The test drives the builder directly, mirroring the upstream xUnit suite // (MCP-Plugin-dotnet ReflectorModuleDiscoveryTests). // ────────────────────────────────────────────────────────────────────────────────────────── // ── Fixture payload + converter types ─────────────────────────────────────────────────────── /// Marker payload type the verification module registers a JSON converter for. public sealed class VerificationPayload { public string Value { get; set; } = string.Empty; } /// Type the verification module registers a reflection converter for. public sealed class VerificationReflectedType { public int Number { get; set; } } /// Type the verification module blacklists from serialization. public sealed class VerificationBlacklistedType { public string Secret { get; set; } = string.Empty; } /// A System.Text.Json converter contributed by the verification module. public sealed class VerificationPayloadJsonConverter : JsonConverter { public override VerificationPayload Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => new VerificationPayload { Value = reader.GetString() ?? string.Empty }; public override void Write(Utf8JsonWriter writer, VerificationPayload value, JsonSerializerOptions options) => writer.WriteStringValue(value.Value); } /// A reflection converter contributed by the verification module. public sealed class VerificationReflectionConverter : GenericReflectionConverter { } // ── Modules ────────────────────────────────────────────────────────────────────────────────── /// /// The flagship verification module: a single discoverable module contributing a JSON converter, /// a reflection converter, a serialization-blacklist type, AND scan-ignore entries (assembly + /// namespace). Discovered with zero hardcoded reference — purely via assembly scan. /// public sealed class VerificationFullContributionModule : IReflectorModule { // A non-existent assembly prefix — safe to contribute (cannot collide with a protected assembly). public const string IgnoredAssemblyPrefix = "Some.Nonexistent.Unity.Extension.Assembly"; // A non-existent namespace prefix — exercises the namespace scan-ignore surface harmlessly. public const string IgnoredNamespacePrefix = "Some.Nonexistent.Unity.Extension.Namespace"; public int Order => 10; public void Configure(IReflectorModuleContext ctx) { ctx.Reflector.JsonSerializer.AddConverter(new VerificationPayloadJsonConverter()); ctx.Reflector.Converters.Add(new VerificationReflectionConverter()); ctx.Reflector.Converters.BlacklistType(typeof(VerificationBlacklistedType)); ctx.Scan .IgnoreAssemblies(IgnoredAssemblyPrefix) .IgnoreNamespaces(IgnoredNamespacePrefix); } } } namespace com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Throwing { /// A module that throws during Configure — used to assert failure isolation. public sealed class ThrowingVerificationModule : IReflectorModule { public int Order => 0; public void Configure(IReflectorModuleContext ctx) => throw new InvalidOperationException("Intentional failure from ThrowingVerificationModule."); } /// A healthy module sitting alongside the throwing one — must still run. public sealed class SurvivingVerificationModule : IReflectorModule { public static bool Ran; public int Order => 1; public void Configure(IReflectorModuleContext ctx) => Ran = true; } } namespace com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules { using FullNs = com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Full; using ThrowingNs = com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Throwing; [TestFixture] public class ReflectorModuleDiscoveryTests { static readonly Version _version = new Version(); static readonly Assembly TestAssembly = typeof(FullNs.VerificationFullContributionModule).Assembly; // Two sibling fixture namespaces, NEITHER a prefix of the other, so a test can ignore exactly // one without collaterally pruning the other (IScanIgnoreBuilder / IgnoreNamespaces matches by // StartsWith). const string NsFull = "com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Full"; const string NsThrowing = "com.IvanMurzak.Unity.MCP.Editor.Tests.ReflectorModules.Throwing"; // ── (1)-(4) Full contribution: every surface reaches effect via dynamic discovery ──────── [Test] public void FullContribution_AllFourSurfacesReachEffect_ViaDynamicDiscovery() { // Arrange — keep ONLY the FullContribution namespace; ignore the Throwing fixtures so they // do not interfere with this assertion. The FullContributionModule carries no hardcoded // reference anywhere; it is found purely by scanning TestAssembly. var reflector = new Reflector(); var builder = new McpPluginBuilder(_version) .WithReflectorModulesFromAssembly(new[] { TestAssembly }) .IgnoreNamespaces(NsThrowing); // Act builder.Build(reflector); // (1) JSON converter registered. Assert.IsNotNull( reflector.JsonSerializer.GetJsonConverter(typeof(FullNs.VerificationPayload)), "Module-contributed JSON converter should be registered."); // (2) Reflection converter registered (resolvable for the target type). Assert.IsNotNull( reflector.Converters.GetConverter(typeof(FullNs.VerificationReflectedType)), "Module-contributed reflection converter should be resolvable."); Assert.IsTrue( reflector.Converters.GetAllSerializers().Any(c => c is FullNs.VerificationReflectionConverter), "Module-contributed reflection converter instance should be present."); // (3) Serialization blacklist applied. Assert.IsTrue( reflector.Converters.IsTypeBlacklisted(typeof(FullNs.VerificationBlacklistedType)), "Module-contributed serialization-blacklist type should be blacklisted."); // (4) Scan-ignore contributions accepted (no exception, build completes). The assembly + // namespace prefixes are non-existent on purpose, so they cannot collide with a protected // assembly/namespace; reaching this assertion proves the IScanIgnoreBuilder surface was // exercised end-to-end through a dynamically-discovered module. Assert.Pass("All four IReflectorModule contribution surfaces reached effect via dynamic discovery."); } // ── Throw-isolation: a throwing module is caught; the healthy sibling still runs ───────── [Test] public void FailureIsolation_ThrowingModuleCaught_SurvivingSiblingStillRuns() { // Arrange — keep ONLY the Throwing namespace (ThrowingVerificationModule + // SurvivingVerificationModule); ignore the FullContribution root namespace's other module. ThrowingNs.SurvivingVerificationModule.Ran = false; var reflector = new Reflector(); var builder = new McpPluginBuilder(_version) .WithReflectorModulesFromAssembly(new[] { TestAssembly }) .IgnoreNamespaces(NsFull); // Act — Build must NOT throw despite ThrowingVerificationModule. Assert.DoesNotThrow(() => builder.Build(reflector), "A throwing module must be isolated; Build must not propagate the exception."); // Assert — the healthy sibling module still ran. Assert.IsTrue(ThrowingNs.SurvivingVerificationModule.Ran, "The surviving sibling module should still run after a throwing module is isolated."); } // ── Perf sanity: a core-ignored heavy assembly is never type-enumerated for modules ────── [Test] public void Discovery_SkipsModule_WhenHostingAssemblyIsIgnored() { // Arrange — register TestAssembly for module scan, then ignore that very assembly by name. // If discovery honored the ignore prune (it must, for the perf guarantee), no module runs: // the heavy assembly is never type-enumerated. var reflector = new Reflector(); var builder = new McpPluginBuilder(_version) .WithReflectorModulesFromAssembly(new[] { TestAssembly }) .IgnoreAssembly(TestAssembly); // Act builder.Build(reflector); // Assert — the module never ran: no converter, type not blacklisted. Assert.IsNull( reflector.JsonSerializer.GetJsonConverter(typeof(FullNs.VerificationPayload)), "An ignored hosting assembly must not be type-enumerated; its module must not run."); Assert.IsFalse( reflector.Converters.IsTypeBlacklisted(typeof(FullNs.VerificationBlacklistedType)), "An ignored hosting assembly's module must not contribute a blacklist entry."); } } }