/*
┌──────────────────────────────────────────────────────────────────┐
│ 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 com.IvanMurzak.Unity.MCP.Editor.API;
using AIGD;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace com.IvanMurzak.Unity.MCP.Editor.Tests
{
///
/// EditMode coverage for the jsonPatch merge-patch surface resolving
/// UnityEngine.Object references supplied as {"instanceID": ...}
/// nodes — see issue #791.
///
/// Before the fix, Reflector.TryPatch descended structurally into an
/// object-ref node (e.g. {"sharedMaterial":{"instanceID":"..."}}) and
/// failed with "Segment 'instanceID' not found on type 'Material'".
/// The fix overrides TreatJsonObjectAsAtomicValue(Type) => true on the
/// Unity object/GameObject reflection converters so ReflectorNet 5.3.0 routes
/// those atomic nodes through SetValue, resolving them to the live object.
///
/// Each behaviour is covered in BOTH the object form (jsonPatch supplied as a
/// JSON object literal) and the string form (the same JSON passed as a string),
/// matching the dual-shape contract the [JsonStringOrObject] attribute
/// exposes on the tool parameters. At the C# tool boundary jsonPatch is
/// always a string, so both forms drive the identical Reflector.TryPatch
/// apply path that the bug lived on; the string form additionally exercises a
/// patch whose object-ref is embedded verbatim as the agent would send it.
///
/// This file is version-agnostic: the only Unity-6.5-vs-pre-6.5 differences
/// (EntityId/GetEntityId vs int/GetInstanceID, and the instanceID JSON wire form
/// — string on 6.5, number on pre-6.5) are isolated in the version-split helper
/// partial TestToolGameObject.JsonPatchObjectOrString.Helpers(.pre-Unity.6.5).cs,
/// so the 8 tests compile and run on Unity 2022.3 through 6.5+.
///
public partial class TestToolGameObject : BaseTest
{
// Builds a SolarSystem fixture identical in shape to PathBasedToolTests so the
// multi-field regression below exercises the same surface the AI agent hits.
(GameObject go, SolarSystem solar, GameObject sun, GameObject earth) BuildSolarFixtureForPatch()
{
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);
}
// ─── (a) regression: multi-field component jsonPatch still descends per-field ───
[UnityTest]
public IEnumerator JsonPatch_ObjectForm_MultiField_ScalarComponentPatch_StillDescends()
{
var (go, solar, _, _) = BuildSolarFixtureForPatch();
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
jsonPatch: "{\"globalOrbitSpeedMultiplier\": 7.5, \"globalSizeMultiplier\": 3.25}");
Assert.IsTrue(response.Success, $"Multi-field scalar jsonPatch should still succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(7.5f, solar.globalOrbitSpeedMultiplier);
Assert.AreEqual(3.25f, solar.globalSizeMultiplier);
yield return null;
}
[UnityTest]
public IEnumerator JsonPatch_StringForm_MultiField_ScalarComponentPatch_StillDescends()
{
var (go, solar, _, _) = BuildSolarFixtureForPatch();
// String form: identical JSON, asserting the dual-shape param does not regress
// the ordinary per-field descent for non-object-ref scalar fields.
var json = "{\"globalOrbitSpeedMultiplier\": 11.0, \"globalSizeMultiplier\": 4.0}";
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
jsonPatch: json);
Assert.IsTrue(response.Success, $"String-form multi-field jsonPatch should succeed. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.AreEqual(11.0f, solar.globalOrbitSpeedMultiplier);
Assert.AreEqual(4.0f, solar.globalSizeMultiplier);
yield return null;
}
// ─── (b) asset-ref: set Renderer.sharedMaterial by {instanceID} ────────────────
[UnityTest]
public IEnumerator JsonPatch_ObjectForm_AssetRef_SetsSharedMaterialByInstanceId()
{
var folder = "Assets/JsonPatchObjectRefTests";
var assetPath = $"{folder}/PatchMat.mat";
if (!AssetDatabase.IsValidFolder(folder))
AssetDatabase.CreateFolder("Assets", "JsonPatchObjectRefTests");
var material = new Material(Shader.Find("Standard"));
AssetDatabase.CreateAsset(material, assetPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
var go = new GameObject("Renderer host");
var renderer = go.AddComponent();
try
{
Assert.IsNull(renderer.sharedMaterial, "Precondition: renderer has no shared material yet.");
var json = InstanceIdPatch("sharedMaterial", material);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: CompRef(renderer),
jsonPatch: json);
Assert.IsTrue(response.Success, $"Asset-ref jsonPatch should resolve and assign the Material. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsNotNull(renderer.sharedMaterial, "sharedMaterial should have been assigned.");
Assert.IsTrue(SameRef(material, renderer.sharedMaterial!),
"The assigned Material must be the one referenced by {instanceID}.");
}
finally
{
AssetDatabase.DeleteAsset(assetPath);
AssetDatabase.DeleteAsset(folder);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
yield return null;
}
[UnityTest]
public IEnumerator JsonPatch_StringForm_AssetRef_SetsSharedMaterialByInstanceId()
{
var folder = "Assets/JsonPatchObjectRefTests";
var assetPath = $"{folder}/PatchMatString.mat";
if (!AssetDatabase.IsValidFolder(folder))
AssetDatabase.CreateFolder("Assets", "JsonPatchObjectRefTests");
var material = new Material(Shader.Find("Standard"));
AssetDatabase.CreateAsset(material, assetPath);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
var go = new GameObject("Renderer host str");
var renderer = go.AddComponent();
try
{
// String form: the agent passes the patch as a JSON string; same apply path.
var json = InstanceIdPatch("sharedMaterial", material);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: CompRef(renderer),
jsonPatch: json);
Assert.IsTrue(response.Success, $"String-form asset-ref jsonPatch should resolve the Material. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsNotNull(renderer.sharedMaterial, "sharedMaterial should have been assigned (string form).");
Assert.IsTrue(SameRef(material, renderer.sharedMaterial!),
"The assigned Material must be the one referenced by {instanceID} (string form).");
}
finally
{
AssetDatabase.DeleteAsset(assetPath);
AssetDatabase.DeleteAsset(folder);
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
}
yield return null;
}
// ─── (c) component-ref: set HingeJoint.connectedBody (Rigidbody) by {instanceID} ─
[UnityTest]
public IEnumerator JsonPatch_ObjectForm_ComponentRef_SetsConnectedBodyByInstanceId()
{
var bodyGo = new GameObject("ConnectedBody");
var rigidbody = bodyGo.AddComponent();
var jointGo = new GameObject("Joint host");
var hinge = jointGo.AddComponent();
Assert.IsNull(hinge.connectedBody, "Precondition: hinge joint has no connected body yet.");
var json = InstanceIdPatch("connectedBody", rigidbody);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(jointGo),
componentRef: CompRef(hinge),
jsonPatch: json);
Assert.IsTrue(response.Success, $"Component-ref jsonPatch should resolve and assign the Rigidbody. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsNotNull(hinge.connectedBody, "connectedBody should have been assigned.");
Assert.IsTrue(SameRef(rigidbody, hinge.connectedBody!),
"The assigned Rigidbody must be the one referenced by {instanceID}.");
yield return null;
}
[UnityTest]
public IEnumerator JsonPatch_StringForm_ComponentRef_SetsConnectedBodyByInstanceId()
{
var bodyGo = new GameObject("ConnectedBody str");
var rigidbody = bodyGo.AddComponent();
var jointGo = new GameObject("Joint host str");
var hinge = jointGo.AddComponent();
var json = InstanceIdPatch("connectedBody", rigidbody);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(jointGo),
componentRef: CompRef(hinge),
jsonPatch: json);
Assert.IsTrue(response.Success, $"String-form component-ref jsonPatch should resolve the Rigidbody. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsNotNull(hinge.connectedBody, "connectedBody should have been assigned (string form).");
Assert.IsTrue(SameRef(rigidbody, hinge.connectedBody!),
"The assigned Rigidbody must be the one referenced by {instanceID} (string form).");
yield return null;
}
// ─── (d) gameobject-ref: set a GameObject-typed field by {instanceID} ──────────
[UnityTest]
public IEnumerator JsonPatch_ObjectForm_GameObjectRef_SetsGameObjectFieldByInstanceId()
{
var (go, solar, _, _) = BuildSolarFixtureForPatch();
var newSun = new GameObject("ReplacementSun");
Assert.IsFalse(SameRef(newSun, solar.sun),
"Precondition: solar.sun starts as the original Sun, not the replacement.");
var json = InstanceIdPatch("sun", newSun);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
jsonPatch: json);
Assert.IsTrue(response.Success, $"GameObject-ref jsonPatch should resolve and assign the GameObject. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsNotNull(solar.sun, "solar.sun should still be assigned after the patch.");
Assert.IsTrue(SameRef(newSun, solar.sun),
"solar.sun must now be the GameObject referenced by {instanceID}.");
yield return null;
}
[UnityTest]
public IEnumerator JsonPatch_StringForm_GameObjectRef_SetsGameObjectFieldByInstanceId()
{
var (go, solar, _, _) = BuildSolarFixtureForPatch();
var newSun = new GameObject("ReplacementSun str");
var json = InstanceIdPatch("sun", newSun);
var response = new Tool_GameObject().ModifyComponent(
gameObjectRef: GoRef(go),
componentRef: new ComponentRef { TypeName = typeof(SolarSystem).FullName! },
jsonPatch: json);
Assert.IsTrue(response.Success, $"String-form GameObject-ref jsonPatch should resolve the GameObject. Logs: {string.Join(", ", response.Logs ?? Array.Empty())}");
Assert.IsTrue(SameRef(newSun, solar.sun),
"solar.sun must now be the GameObject referenced by {instanceID} (string form).");
yield return null;
}
}
}