Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions MCPForUnity/Editor/Helpers/VectorParsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,49 @@ public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue =
return null;
}

/// <summary>
/// Parses a JToken (array or object) into a Vector4.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector4 or null if parsing fails</returns>
public static Vector4? ParseVector4(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;

try
{
// Array format: [x, y, z, w]
if (token is JArray array && array.Count >= 4)
{
return new Vector4(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}

// Object format: {x: 1, y: 2, z: 3, w: 4}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") &&
obj.ContainsKey("z") && obj.ContainsKey("w"))
{
return new Vector4(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>(),
obj["w"].ToObject<float>()
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Vector4 from '{token}': {ex.Message}");
}

return null;
}

/// <summary>
/// Parses a JToken (array or object) into a Quaternion.
/// Supports both euler angles [x, y, z] and quaternion components [x, y, z, w].
Expand Down
903 changes: 727 additions & 176 deletions MCPForUnity/Editor/Tools/ManageScriptableObject.cs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Server/src/services/tools/manage_scriptable_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ async def manage_scriptable_object(
target: Annotated[dict[str, Any] | str | None, "Target asset reference {guid|path} (for modify)."] = None,
# --- shared ---
patches: Annotated[list[dict[str, Any]] | str | None, "Patch list (or JSON string) to apply."] = None,
# --- validation ---
dry_run: Annotated[bool | str | None, "If true, validate patches without applying (modify only)."] = None,
) -> dict[str, Any]:
unity_instance = get_unity_instance_from_context(ctx)

Expand All @@ -62,6 +64,7 @@ async def manage_scriptable_object(
"overwrite": coerce_bool(overwrite, default=None),
"target": parsed_target,
"patches": parsed_patches,
"dryRun": coerce_bool(dry_run, default=None),
}

# Remove None values to keep Unity handler simpler
Expand Down
58 changes: 58 additions & 0 deletions Server/tests/integration/test_manage_scriptable_object_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,62 @@ async def fake_async_send(cmd, params, **kwargs):
assert captured["params"]["patches"][0]["op"] == "array_resize"


def test_manage_scriptable_object_forwards_dry_run_param(monkeypatch):
captured = {}

async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"dryRun": True, "validationResults": []}}

monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)

ctx = DummyContext()
ctx.set_state("unity_instance", "UnityMCPTests@dummy")

result = asyncio.run(
mod.manage_scriptable_object(
ctx=ctx,
action="modify",
target='{"guid":"abc123"}',
patches=[{"propertyPath": "intValue", "op": "set", "value": 42}],
dry_run=True,
)
)

assert result["success"] is True
assert captured["cmd"] == "manage_scriptable_object"
assert captured["params"]["action"] == "modify"
assert captured["params"]["dryRun"] is True
assert captured["params"]["target"] == {"guid": "abc123"}


def test_manage_scriptable_object_dry_run_string_coercion(monkeypatch):
"""Test that dry_run accepts string 'true' and coerces to boolean."""
captured = {}

async def fake_async_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"dryRun": True}}

monkeypatch.setattr(mod, "async_send_command_with_retry", fake_async_send)

ctx = DummyContext()
ctx.set_state("unity_instance", "UnityMCPTests@dummy")

result = asyncio.run(
mod.manage_scriptable_object(
ctx=ctx,
action="modify",
target={"guid": "xyz"},
patches=[],
dry_run="true", # String instead of bool
)
)

assert result["success"] is True
assert captured["params"]["dryRun"] is True



140 changes: 140 additions & 0 deletions TestProjects/UnityMCPTests/Assets/Tests/EditMode/TestUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System;
using System.Collections;
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;

namespace MCPForUnityTests.Editor
{
/// <summary>
/// Shared test utilities for EditMode tests across the MCP for Unity test suite.
/// Consolidates common patterns to avoid duplication across test files.
/// </summary>
public static class TestUtilities
{
/// <summary>
/// Safely converts a command result to JObject, handling both JSON objects and other types.
/// Returns an empty JObject if result is null.
/// </summary>
public static JObject ToJObject(object result)
{
if (result == null) return new JObject();
Comment on lines +21 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Returning an empty JObject on null results may hide bugs that earlier per-test helpers caught; consider providing an assertion-based variant or using this helper carefully in tests that expect non-null results.

Previously, local ToJObject helpers in tests (e.g., ReadConsoleTests) would Assert.Fail on null results. The shared TestUtilities.ToJObject now returns an empty JObject instead, which can mask cases where a non-null response is required.

For tests where a null result should fail, consider either:

  • Adding a ToNonNullJObject helper that asserts on null and using it there, or
  • Explicitly asserting result is not null before calling ToJObject.

This preserves the convenience of the shared helper while keeping tests from treating null results as valid empty payloads.

Suggested implementation:

        /// <summary>
        /// Safely converts a command result to JObject, handling both JSON objects and other types.
        /// Returns an empty JObject if result is null.
        /// </summary>
        public static JObject ToJObject(object result)
        {
            if (result == null) return new JObject();
            return result as JObject ?? JObject.FromObject(result);
        }

        /// <summary>
        /// Converts a command result to JObject, asserting that the result is non-null.
        /// Use this in tests where a null result should cause the test to fail.
        /// </summary>
        /// <param name="result">The command result to convert. Must not be null.</param>
        /// <param name="message">Optional assertion failure message.</param>
        public static JObject ToNonNullJObject(object result, string message = null)
        {
            Assert.IsNotNull(result, message ?? "Expected non-null result when converting to JObject.");
            return ToJObject(result);
        }

        /// <summary>
        /// Creates all parent directories for the given asset path if they don't exist.
        /// Handles normalization and validates against dangerous patterns.
        /// </summary>
  1. Ensure that TestUtilities.cs has using NUnit.Framework; (or the appropriate assertion library) at the top of the file so that Assert.IsNotNull is available.
  2. Update tests that previously relied on null causing failures (e.g., those that had local ToJObject helpers with Assert.Fail) to call TestUtilities.ToNonNullJObject(...) instead of ToJObject(...) where a non-null result is required.

return result as JObject ?? JObject.FromObject(result);
}

/// <summary>
/// Creates all parent directories for the given asset path if they don't exist.
/// Handles normalization and validates against dangerous patterns.
/// </summary>
/// <param name="folderPath">An Assets-relative folder path (e.g., "Assets/Temp/MyFolder")</param>
public static void EnsureFolder(string folderPath)
{
if (AssetDatabase.IsValidFolder(folderPath))
return;

var sanitized = MCPForUnity.Editor.Helpers.AssetPathUtility.SanitizeAssetPath(folderPath);
if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase))
return;

var parts = sanitized.Split('/');
string current = "Assets";
for (int i = 1; i < parts.Length; i++)
{
var next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, parts[i]);
}
current = next;
}
}

/// <summary>
/// Waits for Unity to finish compiling and updating, with a configurable timeout.
/// Some EditMode tests trigger script compilation/domain reload.
/// Tools intentionally return "compiling_or_reloading" during these windows.
/// </summary>
/// <param name="timeoutSeconds">Maximum time to wait before failing the test.</param>
public static IEnumerator WaitForUnityReady(double timeoutSeconds = 30.0)
{
double start = EditorApplication.timeSinceStartup;
while (EditorApplication.isCompiling || EditorApplication.isUpdating)
{
if (EditorApplication.timeSinceStartup - start > timeoutSeconds)
{
Assert.Fail($"Timed out waiting for Unity to finish compiling/updating (>{timeoutSeconds:0.0}s).");
}
yield return null;
}
}

/// <summary>
/// Finds a fallback shader for creating materials in tests.
/// Tries modern pipelines first, then falls back to Standard/Unlit.
/// </summary>
/// <returns>A shader suitable for test materials, or null if none found.</returns>
public static Shader FindFallbackShader()
{
return Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("HDRP/Lit")
?? Shader.Find("Standard")
?? Shader.Find("Unlit/Color");
}

/// <summary>
/// Safely deletes an asset if it exists.
/// </summary>
/// <param name="path">The asset path to delete.</param>
public static void SafeDeleteAsset(string path)
{
if (!string.IsNullOrEmpty(path) && AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path) != null)
{
AssetDatabase.DeleteAsset(path);
}
}

/// <summary>
/// Cleans up empty parent folders recursively up to but not including "Assets".
/// Useful in TearDown to avoid leaving folder debris.
/// </summary>
/// <param name="folderPath">The starting folder path to check.</param>
public static void CleanupEmptyParentFolders(string folderPath)
{
if (string.IsNullOrEmpty(folderPath))
return;

var parent = Path.GetDirectoryName(folderPath)?.Replace('\\', '/');
while (!string.IsNullOrEmpty(parent) && parent != "Assets")
{
if (AssetDatabase.IsValidFolder(parent))
{
try
{
var dirs = Directory.GetDirectories(parent);
var files = Directory.GetFiles(parent);
if (dirs.Length == 0 && files.Length == 0)
{
AssetDatabase.DeleteAsset(parent);
parent = Path.GetDirectoryName(parent)?.Replace('\\', '/');
}
else
{
break;
}
}
catch
{
break;
}
Comment on lines +113 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty catch block silently swallows exceptions.

Consider logging or at minimum breaking out with a comment explaining why the exception is intentionally ignored. Silent exception swallowing can mask real issues during test cleanup.

🔎 Proposed fix
                     catch
                     {
+                        // Cleanup is best-effort; ignore errors (e.g., file locks, permissions)
                         break;
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try
{
var dirs = Directory.GetDirectories(parent);
var files = Directory.GetFiles(parent);
if (dirs.Length == 0 && files.Length == 0)
{
AssetDatabase.DeleteAsset(parent);
parent = Path.GetDirectoryName(parent)?.Replace('\\', '/');
}
else
{
break;
}
}
catch
{
break;
}
try
{
var dirs = Directory.GetDirectories(parent);
var files = Directory.GetFiles(parent);
if (dirs.Length == 0 && files.Length == 0)
{
AssetDatabase.DeleteAsset(parent);
parent = Path.GetDirectoryName(parent)?.Replace('\\', '/');
}
else
{
break;
}
}
catch
{
// Cleanup is best-effort; ignore errors (e.g., file locks, permissions)
break;
}
🤖 Prompt for AI Agents
In @TestProjects/UnityMCPTests/Assets/Tests/EditMode/TestUtilities.cs around
lines 113 - 130, The catch block around
Directory.GetDirectories/Directory.GetFiles/AssetDatabase.DeleteAsset silently
swallows exceptions; update the catch in the cleanup loop to at least log the
caught exception (e.g., using UnityEngine.Debug.LogException or Debug.LogWarning
with context like the current parent path) and then break/return as appropriate
so failures aren't hidden—locate the try/catch that references parent,
Directory.GetDirectories, Directory.GetFiles and AssetDatabase.DeleteAsset and
replace the empty catch with logging of the exception and a clear break.

}
else
{
break;
}
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using UnityEngine;

[CreateAssetMenu(fileName = "ArrayStressSO", menuName = "StressTests/ArrayStressSO")]
public class ArrayStressSO : ScriptableObject
{
public float[] floatArray = new float[3];
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using UnityEngine;
using System.Collections.Generic;

[System.Serializable]
public struct NestedData
{
public string id;
public float value;
public Vector3 position;
}

[System.Serializable]
public class ComplexSubClass
{
public string name;
public int level;
public List<float> scores;
}

public enum TestEnum
{
Alpha,
Beta,
Gamma
}

[CreateAssetMenu(fileName = "ComplexStressSO", menuName = "StressTests/ComplexStressSO")]
public class ComplexStressSO : ScriptableObject
{
[Header("Basic Types")]
public int intValue;
public float floatValue;
public string stringValue;
public bool boolValue;
public Vector3 vectorValue;
public Color colorValue;
public TestEnum enumValue;

[Header("Arrays & Lists")]
public int[] intArray;
public List<string> stringList;
public Vector3[] vectorArray;

[Header("Complex Types")]
public NestedData nestedStruct;
public ComplexSubClass nestedClass;
public List<NestedData> nestedDataList;

[Header("Extended Types (Phase 6)")]
public AnimationCurve animCurve;
public Quaternion rotation;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading