/*
┌──────────────────────────────────────────────────────────────────┐
│ 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
#if UNITY_6000_5_OR_NEWER
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using com.IvanMurzak.ReflectorNet;
using com.IvanMurzak.ReflectorNet.Model;
using com.IvanMurzak.Unity.MCP.Editor.API;
using com.IvanMurzak.Unity.MCP.Editor.Extensions;
using AIGD;
using com.IvanMurzak.Unity.MCP.Runtime.Extensions;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace com.IvanMurzak.Unity.MCP.Editor.Tests
{
///
/// EditMode coverage for the ReflectorNet 5.1.0 path-based read/modify API
/// adoption across the MCP tools — see issue #691 / PR adopting TryReadAt,
/// TryModifyAt, TryPatch, and View.
///
/// Each test exercises one scenario from the acceptance criteria:
/// * single-path read,
/// * multi-path read,
/// * view-query with name regex,
/// * single-path modify,
/// * multi-field JSON Patch,
/// * partial array-element modify,
/// * invalid path → structured error.
///
/// The fixture uses the production-shaped MonoBehaviour
/// (defined under TestFiles/Scripts) so tests reflect the exact reflection
/// surface the AI agent will hit at runtime.
///
public class PathBasedToolTests : BaseTest
{
[UnitySetUp]
public override IEnumerator SetUp() => base.SetUp();
[UnityTearDown]
public override IEnumerator TearDown() => base.TearDown();
Reflector Reflector =>
UnityMcpPluginEditor.Instance.Reflector ?? throw new Exception("Reflector is not available.");
///
/// Builds a SolarSystem fixture with one configured planet so path-based
/// reads/modifies have something concrete to walk into.
///
(GameObject go, SolarSystem solar, GameObject sun, GameObject earth) BuildSolarFixture()
{
var sun = new GameObject("Sun");
var earth = new GameObject("Earth");
var go = new GameObject("Solar");
var solar = go.AddComponent();
solar.sun = sun;
solar.globalOrbitSpeedMultiplier = 1f;
solar.globalSizeMultiplier = 1f;
solar.planets = new[]
{
new SolarSystem.PlanetData
{
planet = earth,
orbitRadius = 10f,
orbitSpeed = 1f,
rotationSpeed = 1f,
orbitTilt = Vector3.zero
}
};
return (go, solar, sun, earth);
}
// ─── 1. Single-path read via gameobject-component-get ──────────────────
[UnityTest]
public IEnumerator ComponentGet_SinglePath_ReturnsOnlyTheRequestedField()
{
var (go, _, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
paths: new List { "globalOrbitSpeedMultiplier" });
Assert.IsNotNull(response.View, "View should be populated for a path-scoped read.");
Assert.AreEqual(PathReadHelper.PathReadAggregateTypeName, response.View!.typeName);
Assert.IsNull(response.Fields, "Legacy Fields list should be skipped on path-scoped path.");
Assert.IsNull(response.Properties, "Legacy Properties list should be skipped on path-scoped path.");
var only = response.View.fields?.SingleOrDefault(f => f.name == "globalOrbitSpeedMultiplier");
Assert.IsNotNull(only, "The aggregate must contain exactly one field named 'globalOrbitSpeedMultiplier'.");
Assert.AreEqual(1f, only!.GetValue(Reflector));
yield return null;
}
// ─── 2. Multi-path read via gameobject-component-get ───────────────────
[UnityTest]
public IEnumerator ComponentGet_MultiPath_ReturnsAllRequestedFields()
{
var (go, _, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
paths: new List
{
"globalOrbitSpeedMultiplier",
"globalSizeMultiplier",
"planets/[0]/orbitRadius"
});
Assert.IsNotNull(response.View);
Assert.AreEqual(3, response.View!.fields?.Count, "Expected 3 entries — one per requested path.");
var byName = response.View!.fields!.ToDictionary(f => f.name!, f => f);
Assert.AreEqual(1f, byName["globalOrbitSpeedMultiplier"].GetValue(Reflector));
Assert.AreEqual(1f, byName["globalSizeMultiplier"].GetValue(Reflector));
Assert.AreEqual(10f, byName["planets/[0]/orbitRadius"].GetValue(Reflector));
yield return null;
}
// ─── 3. View-query with name regex via gameobject-component-get ────────
[UnityTest]
public IEnumerator ComponentGet_ViewQuery_NameRegex_KeepsOnlyMatchingBranches()
{
var (go, _, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
viewQuery: new ViewQuery { NamePattern = "orbit.*" });
Assert.IsNotNull(response.View, "View should be populated for a view-query read.");
// SolarSystem has globalOrbitSpeedMultiplier at root, plus orbitRadius / orbitSpeed
// / orbitTilt on PlanetData. Any of these contain 'orbit' (case-insensitive).
var rx = new Regex("orbit", RegexOptions.IgnoreCase);
Assert.IsTrue(ContainsNameMatching(response.View!, rx),
$"View result should retain at least one field/property whose name contains 'orbit'.");
yield return null;
}
static bool ContainsNameMatching(SerializedMember m, Regex rx)
{
if (m.name != null && rx.IsMatch(m.name)) return true;
if (m.fields != null)
foreach (var f in m.fields) if (ContainsNameMatching(f, rx)) return true;
if (m.props != null)
foreach (var p in m.props) if (ContainsNameMatching(p, rx)) return true;
return false;
}
// ─── 4. Single-path modify via gameobject-component-modify ─────────────
[UnityTest]
public IEnumerator ComponentModify_SinglePath_AtomicallyUpdatesScalar()
{
var (go, solar, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
pathPatches: new List
{
new PathPatch
{
Path = "globalOrbitSpeedMultiplier",
Value = SerializedMember.FromValue(Reflector, 5f)
}
});
Assert.IsTrue(response.Success, $"Modify should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(5f, solar.globalOrbitSpeedMultiplier);
// Untouched field should still be the original value.
Assert.AreEqual(1f, solar.globalSizeMultiplier);
yield return null;
}
// ─── 5. Multi-field JSON Patch via gameobject-component-modify ─────────
[UnityTest]
public IEnumerator ComponentModify_JsonPatch_UpdatesMultipleFieldsAtOnce()
{
var (go, solar, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
jsonPatch: "{\"globalOrbitSpeedMultiplier\": 7.5, \"globalSizeMultiplier\": 3.25}");
Assert.IsTrue(response.Success, $"JSON-patch modify should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(7.5f, solar.globalOrbitSpeedMultiplier);
Assert.AreEqual(3.25f, solar.globalSizeMultiplier);
yield return null;
}
// ─── 6. Partial array-element modify via gameobject-component-modify ───
[UnityTest]
public IEnumerator ComponentModify_PartialArrayElement_UpdatesOneFieldOnTargetElement()
{
var (go, solar, _, _) = BuildSolarFixture();
// Sanity — planets[0] starts at orbitRadius=10
Assert.AreEqual(10f, solar.planets[0].orbitRadius);
var earthRef = solar.planets[0].planet; // capture so we can assert it survives
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
pathPatches: new List
{
new PathPatch
{
Path = "planets/[0]/orbitRadius",
Value = SerializedMember.FromValue(Reflector, 42f)
}
});
Assert.IsTrue(response.Success, $"Partial array-element modify should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(42f, solar.planets[0].orbitRadius,
"orbitRadius on the targeted element should be updated.");
Assert.AreEqual(1f, solar.planets[0].orbitSpeed,
"Sibling field on the same element should be untouched (no whole-element replacement).");
Assert.IsTrue(solar.planets[0].planet == earthRef,
"GameObject reference on the same element should be untouched.");
yield return null;
}
// ─── 7. Invalid path → structured error (no exception) ─────────────────
[UnityTest]
public IEnumerator ComponentModify_InvalidPath_ReportsStructuredError()
{
var (go, solar, _, _) = BuildSolarFixture();
var before = solar.globalOrbitSpeedMultiplier;
// Reflector surfaces the unknown-segment failure as a Unity LogError via the bound
// logger. Tell Unity's test framework we expect that error so it does not fail the test.
UnityEngine.TestTools.LogAssert.Expect(UnityEngine.LogType.Error,
new Regex("thisFieldDoesNotExist", RegexOptions.IgnoreCase));
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
pathPatches: new List
{
new PathPatch
{
Path = "thisFieldDoesNotExist",
Value = SerializedMember.FromValue(Reflector, 999f)
}
});
Assert.IsFalse(response.Success,
"Modify should report failure when every path patch fails.");
Assert.IsTrue(response.Logs != null && response.Logs.Length > 0,
"Failure must surface diagnostic logs.");
var combined = string.Join("\n", response.Logs!);
StringAssert.Contains("thisFieldDoesNotExist", combined,
"Diagnostic logs should name the failing path so the AI agent can correct itself.");
Assert.AreEqual(before, solar.globalOrbitSpeedMultiplier,
"No untouched field should mutate when the only patch fails.");
yield return null;
}
// ─── 8. object-get-data path-scoped read ──────────────────────────────
[UnityTest]
public IEnumerator ObjectGetData_SinglePath_ReturnsFilteredAggregate()
{
var go = new GameObject("Probe") { tag = "Untagged" };
var result = new Tool_Object().GetData(
new ObjectRef(go),
paths: new List { "name" });
Assert.IsNotNull(result);
Assert.AreEqual(PathReadHelper.PathReadAggregateTypeName, result!.typeName);
var nameField = result.fields?.SingleOrDefault(f => f.name == "name");
Assert.IsNotNull(nameField);
Assert.AreEqual("Probe", nameField!.GetValue(Reflector));
yield return null;
}
// ─── 9. object-modify path-patch ──────────────────────────────────────
[UnityTest]
public IEnumerator ObjectModify_PathPatch_UpdatesGameObjectName()
{
var go = new GameObject("OldName");
var response = new Tool_Object().Modify(
new ObjectRef(go),
pathPatches: new List
{
new PathPatch
{
Path = "name",
Value = SerializedMember.FromValue(Reflector, "NewName")
}
});
Assert.IsTrue(response.Success, $"Path-patch modify on UnityEngine.Object should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual("NewName", go.name);
yield return null;
}
// ─── 10. gameobject-modify per-GameObject path patches ────────────────
[UnityTest]
public IEnumerator GameObjectModify_PerGameObjectPathPatches_ParallelArrays()
{
var go1 = new GameObject("First");
var go2 = new GameObject("Second");
var refs = new GameObjectRefList { new GameObjectRef(go1.GetEntityId()), new GameObjectRef(go2.GetEntityId()) };
var perGo = new List?>
{
new List
{
new PathPatch { Path = "name", Value = SerializedMember.FromValue(Reflector, "FirstRenamed") }
},
new List
{
new PathPatch { Path = "name", Value = SerializedMember.FromValue(Reflector, "SecondRenamed") }
}
};
var logs = new Tool_GameObject().Modify(
gameObjectRefs: refs,
pathPatchesPerGameObject: perGo);
Assert.IsNotNull(logs);
Assert.AreEqual("FirstRenamed", go1.name);
Assert.AreEqual("SecondRenamed", go2.name);
yield return null;
}
// ─── 11. assets-get-data with viewQuery — scoped to an actual asset ───
[UnityTest]
public IEnumerator AssetsGetData_ViewQuery_FiltersByMaxDepth()
{
var folder = "Assets/PathBasedToolTests";
var assetPath = $"{folder}/Mat.mat";
if (!AssetDatabase.IsValidFolder(folder))
AssetDatabase.CreateFolder("Assets", "PathBasedToolTests");
var material = new Material(Shader.Find("Standard"));
AssetDatabase.CreateAsset(material, assetPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
try
{
var result = new Tool_Assets().GetData(
new AssetObjectRef(material),
viewQuery: new ViewQuery { MaxDepth = 0 });
Assert.IsNotNull(result, "View result should not be null.");
Assert.IsTrue(result.fields == null || result.fields.Count == 0,
"MaxDepth=0 should strip all nested fields from the view.");
Assert.IsTrue(result.props == null || result.props.Count == 0,
"MaxDepth=0 should strip all nested properties from the view.");
}
finally
{
AssetDatabase.DeleteAsset(assetPath);
AssetDatabase.DeleteAsset(folder);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
yield return null;
}
// ─── 12. legacy code path still works (regression guard) ──────────────
[UnityTest]
public IEnumerator ComponentGet_NoPathParams_StillReturnsLegacyFieldsList()
{
var (go, _, _, _) = BuildSolarFixture();
// Pass deepSerialization: true so the legacy Serialize call walks into the SolarSystem
// user-defined fields. With deepSerialization: false (default) primitive-only top-level
// may produce a null 'fields' list — that is correct legacy serializer behaviour and
// not what we are asserting here; this regression test is about the *path-based change*
// not accidentally suppressing the legacy fields surface.
var response = new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
deepSerialization: true);
Assert.IsNull(response.View, "Legacy code path must NOT populate 'View'.");
var fieldCount = response.Fields?.Count ?? 0;
var propCount = response.Properties?.Count ?? 0;
Assert.IsTrue(fieldCount + propCount > 0,
$"Expected the legacy code path to surface at least one serialised member. " +
$"Fields={fieldCount} Properties={propCount}");
yield return null;
}
// ─── 13. paths AND viewQuery simultaneously → ArgumentException ────────
[UnityTest]
public IEnumerator ComponentGet_PathsAndViewQueryTogether_ThrowsArgumentException()
{
var (go, _, _, _) = BuildSolarFixture();
var ex = Assert.Throws(() =>
new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
paths: new List { "globalOrbitSpeedMultiplier" },
viewQuery: new ViewQuery { NamePattern = "orbit.*" }));
Assert.IsNotNull(ex);
StringAssert.Contains("mutually exclusive", ex!.Message);
yield return null;
}
// ─── 14. pathPatches with a null element → no NRE, structured log ─────
[UnityTest]
public IEnumerator ComponentModify_PathPatches_WithNullElement_DoesNotThrow_AndLogsSkip()
{
var (go, solar, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
pathPatches: new List
{
null!,
new PathPatch
{
Path = "globalOrbitSpeedMultiplier",
Value = SerializedMember.FromValue(Reflector, 11f)
}
});
Assert.IsTrue(response.Success,
$"At least one of the patches succeeded; overall result should be Success. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(11f, solar.globalOrbitSpeedMultiplier);
var combined = string.Join("\n", response.Logs ?? Array.Empty());
StringAssert.Contains("PathPatch[0]", combined,
"The skip log must reference the failing patch index, not just say 'a patch was skipped'.");
yield return null;
}
// ─── 15. paths with empty/null entry → no full-object dump ────────────
[UnityTest]
public IEnumerator ComponentGet_PathsWithEmptyEntry_DoesNotSerializeWholeObject()
{
var (go, _, _, _) = BuildSolarFixture();
var response = new Tool_GameObject().GetComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
paths: new List { "", "globalOrbitSpeedMultiplier" });
Assert.IsNotNull(response.View);
Assert.AreEqual(2, response.View!.fields?.Count, "Empty path is reflected as its own sentinel field.");
var emptyEntry = response.View!.fields![0];
Assert.AreEqual(PathReadHelper.EmptyPathTypeName, emptyEntry.typeName,
"Empty path entry must surface the sentinel rather than serializing the whole object.");
// Sanity — the second path still resolved
var resolved = response.View!.fields![1];
Assert.AreEqual("globalOrbitSpeedMultiplier", resolved.name);
yield return null;
}
// ─── 16. routing order jsonPatch → pathPatches → diff ─────────────────
[UnityTest]
public IEnumerator ComponentModify_AllThreeSurfaces_ApplyInDocumentedOrder()
{
var (go, solar, _, _) = BuildSolarFixture();
// Three distinct fields, one per surface, so we can assert all three landed
// and that none of them stomped on the others.
// Build the legacy diff via the same factory used by every other ModifyComponent
// test in the repo (TestToolGameObject.ModifyComponent.cs#36–53). The component
// already exists; we wrap a ComponentRef as the root value and AddField the change.
var diff = SerializedMember.FromValue(
reflector: Reflector,
name: null,
type: typeof(SolarSystem),
value: new ComponentRef { TypeName = typeof(SolarSystem).FullName! })
.AddField(SerializedMember.FromValue(
reflector: Reflector,
name: "globalSizeMultiplier",
type: typeof(float),
value: 99f));
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: new GameObjectRef(go.GetEntityId()),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
componentDiff: diff,
pathPatches: new List
{
new PathPatch
{
Path = "planets/[0]/orbitRadius",
Value = SerializedMember.FromValue(Reflector, 77f)
}
},
jsonPatch: "{\"globalOrbitSpeedMultiplier\": 33.0}");
Assert.IsTrue(response.Success, $"Combined modify should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(33f, solar.globalOrbitSpeedMultiplier, "JSON patch should have set the orbit-speed multiplier.");
Assert.AreEqual(77f, solar.planets[0].orbitRadius, "Path patch should have set the planet's orbit radius.");
Assert.AreEqual(99f, solar.globalSizeMultiplier, "Legacy diff should have set the size multiplier.");
yield return null;
}
// ─── 17. parallel-array length mismatch on GameObject.Modify ──────────
[UnityTest]
public IEnumerator GameObjectModify_PathPatchesPerGameObject_LengthMismatch_ThrowsArgumentException()
{
var go1 = new GameObject("First");
var go2 = new GameObject("Second");
var refs = new GameObjectRefList { new GameObjectRef(go1.GetEntityId()), new GameObjectRef(go2.GetEntityId()) };
// Only 1 entry for 2 GameObjectRefs — must throw.
var perGo = new List?>
{
new List
{
new PathPatch { Path = "name", Value = SerializedMember.FromValue(Reflector, "Solo") }
}
};
var ex = Assert.Throws(() =>
new Tool_GameObject().Modify(gameObjectRefs: refs, pathPatchesPerGameObject: perGo));
Assert.IsNotNull(ex);
Assert.AreEqual("pathPatchesPerGameObject", ex!.ParamName);
// Same for jsonPatchesPerGameObject — supply 1 patch for 2 refs.
var jsonPatches = new List { "{\"name\":\"Solo\"}" };
var ex2 = Assert.Throws(() =>
new Tool_GameObject().Modify(gameObjectRefs: refs, jsonPatchesPerGameObject: jsonPatches));
Assert.IsNotNull(ex2);
Assert.AreEqual("jsonPatchesPerGameObject", ex2!.ParamName);
yield return null;
}
}
}
#endif