/* ┌──────────────────────────────────────────────────────────────────┐ │ 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.Collections; using System.Linq; using com.IvanMurzak.ReflectorNet; using com.IvanMurzak.ReflectorNet.Model; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; namespace com.IvanMurzak.Unity.MCP.Editor.Tests.AtomicApi { /// /// EditMode coverage for ReflectorNet.Reflector.View and Reflector.Grep — the /// filtered-serialization and live-graph "grep" APIs introduced by the /// atomic-api PR. Includes a circular-reference graph to confirm the walkers /// terminate, and verifies that maxDepth pruning, NameRegex filtering and /// TypeFilter compose as documented. /// public class ViewAndGrepTests : BaseTest { [UnitySetUp] public override IEnumerator SetUp() => base.SetUp(); [UnityTearDown] public override IEnumerator TearDown() => base.TearDown(); // ─── View — NameRegex + TypeFilter on a graph with a circular ref ───── [UnityTest] public IEnumerator View_NameRegexAndTypeFilter_OnCyclicGraph_TerminatesAndFilters() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); // Build a 2-node cycle: a → b → a. var a = new CyclicNode { label = "alpha", weight = 1f }; var b = new CyclicNode { label = "beta", weight = 2f }; a.next = b; b.next = a; // cycle // NameRegex matches "weight" and TypeFilter narrows to float. var query = new ViewQuery { NamePattern = "weight", TypeFilter = typeof(float), MaxDepth = 4 }; var result = reflector.View(a, query); Assert.IsNotNull(result, "View should not throw or return null on a cyclic graph."); StringAssert.Contains("CyclicNode", result!.typeName); // Top-level "weight" survives both filters var topWeight = result.fields?.FirstOrDefault(f => f.name == "weight"); Assert.IsNotNull(topWeight, "Top-level weight should survive NameRegex+TypeFilter."); Assert.IsTrue(topWeight!.typeName != null && topWeight.typeName.Contains("Single"), "Surviving fields must resolve to float / Single after TypeFilter."); // "label" is a string and must be pruned by TypeFilter=float var topLabel = result.fields?.FirstOrDefault(f => f.name == "label"); Assert.IsNull(topLabel, "label is string — TypeFilter=float must prune it."); yield return null; } // ─── View — MaxDepth=0 strips all nested fields ─────────────────────── [UnityTest] public IEnumerator View_MaxDepthZero_RootEnvelopeOnly() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var system = new StarSystemPoco { globalOrbitSpeedMultiplier = 1f }; object? obj = system; var result = reflector.View(obj, new ViewQuery { MaxDepth = 0 }); Assert.IsNotNull(result); StringAssert.Contains("StarSystemPoco", result!.typeName); Assert.IsTrue(result.fields == null || result.fields.Count == 0, "MaxDepth=0 should strip all nested fields."); yield return null; } // ─── View — MaxDepth=1 keeps top-level fields, strips their children ── [UnityTest] public IEnumerator View_MaxDepthOne_TopLevelVisible_NestedStripped() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var system = new StarSystemPoco { celestialBodies = new[] { new CelestialBody { orbitRadius = 5f } } }; object? obj = system; var result = reflector.View(obj, new ViewQuery { MaxDepth = 1 }); Assert.IsNotNull(result); // Top-level field celestialBodies is present var bodies = result!.fields?.FirstOrDefault(f => f.name == "celestialBodies"); Assert.IsNotNull(bodies, "MaxDepth=1 must keep top-level field celestialBodies."); yield return null; } // ─── Grep — name-regex returns flat list of ViewMatch entries ───────── [UnityTest] public IEnumerator Grep_NameRegex_ReturnsFlatListOfViewMatches() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var system = new StarSystemPoco { celestialBodies = new[] { new CelestialBody { orbitRadius = 10f, orbitSpeed = 1f, name = "alpha" }, new CelestialBody { orbitRadius = 20f, orbitSpeed = 2f, name = "beta" }, new CelestialBody { orbitRadius = 30f, orbitSpeed = 3f, name = "gamma" }, } }; object? obj = system; var matches = reflector.Grep(obj, "^orbitRadius$"); Debug.Log($"[Grep_NameRegex] found {matches.Count} matches:"); foreach (var m in matches) Debug.Log($" {m.Path} = {m.Value.GetValue(reflector)}"); Assert.IsNotNull(matches); Assert.AreEqual(3, matches.Count, "Expected exactly one orbitRadius match per array element."); // Each entry must be a ViewMatch (flat list, not a tree) foreach (var m in matches) { Assert.IsInstanceOf(m); Assert.IsNotNull(m.Path); Assert.IsNotNull(m.Value); } // Paths must include the array-index bracket notation Assert.IsTrue(matches.Any(m => m.Path.Contains("[0]") && m.Value.GetValue(reflector) == 10f)); Assert.IsTrue(matches.Any(m => m.Path.Contains("[1]") && m.Value.GetValue(reflector) == 20f)); Assert.IsTrue(matches.Any(m => m.Path.Contains("[2]") && m.Value.GetValue(reflector) == 30f)); yield return null; } // ─── Grep — type-filter via TypeFilter on View only; here we use ────── // a pattern that effectively narrows to float-typed members // via the field-name regex and verify the result type. [UnityTest] public IEnumerator Grep_NameRegexNarrowsToFloatFields() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var system = new StarSystemPoco { celestialBodies = new[] { new CelestialBody { orbitRadius = 7f, orbitSpeed = 1f, rotationSpeed = 9f, name = "x" } } }; object? obj = system; // ".*Speed" matches orbitSpeed and rotationSpeed (both float) but not "name" (string). var matches = reflector.Grep(obj, ".*Speed$"); Debug.Log($"[Grep_Speed] found {matches.Count} matches:"); foreach (var m in matches) Debug.Log($" {m.Path}"); Assert.IsTrue(matches.Count >= 2, "Expected at least orbitSpeed and rotationSpeed."); Assert.IsTrue(matches.All(m => m.Path.EndsWith("orbitSpeed") || m.Path.EndsWith("rotationSpeed")), "All Grep matches must end in a 'Speed' segment by regex contract."); yield return null; } // ─── Grep — null input returns empty list ───────────────────────────── [UnityTest] public IEnumerator Grep_NullInput_ReturnsEmpty() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var matches = reflector.Grep(null, ".*"); Assert.IsNotNull(matches); Assert.AreEqual(0, matches.Count); yield return null; } // ─── Grep — invalid regex logs error + returns empty list ───────────── [UnityTest] public IEnumerator Grep_InvalidRegex_ReturnsEmpty_WithLogs() { var reflector = UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available."); var system = new StarSystemPoco { globalOrbitSpeedMultiplier = 1f }; object? obj = system; var logs = new Logs(); var matches = reflector.Grep(obj, "(invalid[", logs: logs); Assert.IsNotNull(matches); Assert.AreEqual(0, matches.Count); StringAssert.Contains("Invalid regex pattern", logs.ToString()); yield return null; } } }