unity-mcp/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs

483 lines
16 KiB
C#
Raw Normal View History

🎮 GameObject Toolset Redesign and Streamlining (#518) * feat: Redesign GameObject API for better LLM ergonomics ## New Tools - find_gameobjects: Search GameObjects, returns paginated instance IDs only - manage_components: Component lifecycle (add, remove, set_property) ## New Resources - unity://scene/gameobject/{id}: Single GameObject data (no component serialization) - unity://scene/gameobject/{id}/components: All components (paginated) - unity://scene/gameobject/{id}/component/{name}: Single component by type ## Updated - manage_scene get_hierarchy: Now includes componentTypes array - manage_gameobject: Slimmed to lifecycle only (create, modify, delete) - Legacy actions (find, get_components, etc.) log deprecation warnings ## Extracted Utilities - ParamCoercion: Centralized int/bool/float/string coercion - VectorParsing: Vector3/Vector2/Quaternion/Color parsing - GameObjectLookup: Centralized GameObject search logic ## Test Coverage - 76 new Unity EditMode tests for ManageGameObject actions - 21 new pytest tests for Python tools/resources - New NL/T CI suite for GameObject API (GO-0 to GO-5) Addresses LLM confusion with parameter overload by splitting into focused tools and read-only resources. * feat: Add static gameobject_api helper resource for UI discoverability Adds unity://scene/gameobject-api resource that: - Shows in Cursor's resource list UI (no parameters needed) - Documents the parameterized gameobject resources - Explains the workflow: find_gameobjects → read resource - Lists examples and related tools * feat: Add GO tests to main NL/T CI workflow - Adds GO pass (GO-0 to GO-5) after T pass in claude-nl-suite.yml - Includes retry logic for incomplete GO tests - Updates all regex patterns to recognize GO-* test IDs - Updates DESIRED lists to include all 21 tests (NL-0..4, T-A..J, GO-0..5) - Updates default_titles for GO tests in markdown summary - Keeps separate claude-gameobject-suite.yml for standalone runs * feat: Add GameObject API stress tests and NL/T suite updates Stress Tests (12 new tests): - BulkCreate small/medium batches - FindGameObjects pagination with by_component search - AddComponents to single object - GetComponents with full serialization - SetComponentProperties (complex Rigidbody) - Deep hierarchy creation and path lookup - GetHierarchy with large scenes - Resource read performance tests - RapidFire create-modify-delete cycles NL/T Suite Updates: - Added GO-0..GO-10 tests in nl-gameobject-suite.md - Fixed tool naming: mcp__unity__ → mcp__UnityMCP__ Other: - Fixed LongUnityScriptClaudeTest.cs compilation errors - Added reports/, .claude/local/, scripts/local-test/ to .gitignore All 254 EditMode tests pass (250 run, 4 explicit skips) * fix: Address code review feedback - ParamCoercion: Use CultureInfo.InvariantCulture for float parsing - ManageComponents: Move Transform removal check before GetComponent - ManageGameObjectFindTests: Use try-finally for LogAssert.ignoreFailingMessages - VectorParsing: Document that quaternions are not auto-normalized - gameobject.py: Prefix unused ctx parameter with underscore * fix: Address additional code review feedback - ManageComponents: Reuse GameObjectLookup.FindComponentType instead of duplicate - ManageComponents: Log warnings when SetPropertiesOnComponent fails - GameObjectLookup: Make FindComponentType public for reuse - gameobject.py: Extract _normalize_response helper to reduce duplication - gameobject.py: Add TODO comment for unused typed response classes * fix: Address more code review feedback NL/T Prompt Fixes: - nl-gameobject-suite.md: Remove non-existent list_resources/read_resource from AllowedTools - nl-gameobject-suite.md: Fix parameter names (component_type, properties) - nl-unity-suite-nl.md: Remove unused manage_editor from AllowedTools Test Fixes: - GameObjectAPIStressTests: Add null check to ToJObject helper - GameObjectAPIStressTests: Clarify AudioSource usage comment - ManageGameObjectFindTests: Use built-in 'UI' layer instead of 'Water' - LongUnityScriptClaudeTest: Clean up NL/T test artifacts (Counte42 typo, HasTarget) * docs: Add documentation for API limitations and behaviors - GameObjectLookup.SearchByPath: Document and warn that includeInactive has no effect (Unity API limitation) - ManageComponents.TrySetProperty: Document case-insensitive lookup behavior * More test fixes and tighten parameters on python tools * fix: Align test expectation with implementation error message case * docs: update README tools and resources lists - Add missing tools: manage_components, batch_execute, find_gameobjects, refresh_unity - Add missing resources: gameobject_api, editor_state_v2 - Make descriptions more concise across all tools and resources - Ensure documentation matches current MCP server functionality * fix: Address code review feedback - ParamCoercion: Use InvariantCulture for int/double parsing consistency - ManageComponents: Remove redundant Undo.RecordObject (AddComponent handles undo) - ManageScene: Replace deprecated FindObjectsOfType with FindObjectsByType - GameObjectLookup: Add explanatory comment to empty catch block - gameobject.py: Extract _validate_instance_id helper to reduce duplication - Tests: Fix assertion for instanceID (Unity IDs can be negative) * chore: Remove accidentally committed test artifacts - Remove Materials folder (40 .mat files from interactive testing) - Remove Shaders folder (5 noise shaders from testing) - Remove test scripts (Bounce*, CylinderBounce* from testing) - Remove Temp.meta and commit.sh * test: Improve delete tests to verify actual deletion - Delete_ByTag_DeletesMatchingObjects: Verify objects are actually destroyed - Delete_ByLayer_DeletesMatchingObjects: Assert deletion using Unity null check - Delete_MultipleObjectsSameName_DeletesCorrectly: Document first-match behavior - Delete_Success_ReturnsDeletedCount: Verify count value if present All tests now verify deletion occurred rather than just checking for a result. * refactor: remove deprecated manage_gameobject actions - Remove deprecated switch cases: find, get_components, get_component, add_component, remove_component, set_component_property - Remove deprecated wrapper methods (423 lines deleted from ManageGameObject.cs) - Delete ManageGameObjectFindTests.cs (tests deprecated 'find' action) - Remove deprecated test methods from ManageGameObjectTests.cs - Add GameObject resource URIs to README documentation - Add batch_execute performance tips to README, tool description, and gameobject_api resource - Enhance batch_execute description to emphasize 10-100x performance gains Total: ~1200 lines removed. New API (find_gameobjects, manage_components, resources) is the recommended path forward. * fix: Remove starlette stubs from conftest.py Starlette is now a proper dependency via the mcp package, so we don't need to stub it anymore. The real package handles all HTTP transport needs.
2026-01-07 02:13:45 +08:00
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Comprehensive baseline tests for ManageGameObject "create" action.
/// These tests capture existing behavior before API redesign.
/// </summary>
public class ManageGameObjectCreateTests
{
private List<GameObject> createdObjects = new List<GameObject>();
[TearDown]
public void TearDown()
{
foreach (var go in createdObjects)
{
if (go != null)
{
Object.DestroyImmediate(go);
}
}
createdObjects.Clear();
}
private GameObject FindAndTrack(string name)
{
var go = GameObject.Find(name);
if (go != null && !createdObjects.Contains(go))
{
createdObjects.Add(go);
}
return go;
}
#region Basic Create Tests
[Test]
public void Create_WithNameOnly_CreatesEmptyGameObject()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestEmptyObject"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestEmptyObject");
Assert.IsNotNull(created, "GameObject should be created");
Assert.AreEqual("TestEmptyObject", created.name);
}
[Test]
public void Create_WithoutName_ReturnsError()
{
var p = new JObject
{
["action"] = "create"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without name");
}
[Test]
public void Create_WithEmptyName_ReturnsError()
{
var p = new JObject
{
["action"] = "create",
["name"] = ""
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail with empty name");
}
#endregion
#region Primitive Type Tests
[Test]
public void Create_PrimitiveCube_CreatesCubeWithComponents()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCube",
["primitiveType"] = "Cube"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCube");
Assert.IsNotNull(created, "Cube should be created");
Assert.IsNotNull(created.GetComponent<MeshFilter>(), "Cube should have MeshFilter");
Assert.IsNotNull(created.GetComponent<MeshRenderer>(), "Cube should have MeshRenderer");
Assert.IsNotNull(created.GetComponent<BoxCollider>(), "Cube should have BoxCollider");
}
[Test]
public void Create_PrimitiveSphere_CreatesSphereWithComponents()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestSphere",
["primitiveType"] = "Sphere"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestSphere");
Assert.IsNotNull(created, "Sphere should be created");
Assert.IsNotNull(created.GetComponent<SphereCollider>(), "Sphere should have SphereCollider");
}
[Test]
public void Create_PrimitiveCapsule_CreatesCapsule()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCapsule",
["primitiveType"] = "Capsule"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCapsule");
Assert.IsNotNull(created, "Capsule should be created");
Assert.IsNotNull(created.GetComponent<CapsuleCollider>(), "Capsule should have CapsuleCollider");
}
[Test]
public void Create_PrimitivePlane_CreatesPlane()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestPlane",
["primitiveType"] = "Plane"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestPlane");
Assert.IsNotNull(created, "Plane should be created");
}
[Test]
public void Create_PrimitiveCylinder_CreatesCylinder()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCylinder",
["primitiveType"] = "Cylinder"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCylinder");
Assert.IsNotNull(created, "Cylinder should be created");
}
[Test]
public void Create_PrimitiveQuad_CreatesQuad()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestQuad",
["primitiveType"] = "Quad"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestQuad");
Assert.IsNotNull(created, "Quad should be created");
}
[Test]
public void Create_InvalidPrimitiveType_HandlesGracefully()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestInvalidPrimitive",
["primitiveType"] = "InvalidType"
};
var result = ManageGameObject.HandleCommand(p);
// Should either fail or create empty object - capture current behavior
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Transform Tests
[Test]
public void Create_WithPosition_SetsPosition()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestPositioned",
["position"] = new JArray { 1.0f, 2.0f, 3.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestPositioned");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(1f, 2f, 3f), created.transform.position);
}
[Test]
public void Create_WithRotation_SetsRotation()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestRotated",
["rotation"] = new JArray { 0.0f, 90.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestRotated");
Assert.IsNotNull(created);
// Check Y rotation is approximately 90 degrees
Assert.AreEqual(90f, created.transform.eulerAngles.y, 0.1f);
}
[Test]
public void Create_WithScale_SetsScale()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestScaled",
["scale"] = new JArray { 2.0f, 3.0f, 4.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestScaled");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(2f, 3f, 4f), created.transform.localScale);
}
[Test]
public void Create_WithAllTransformProperties_SetsAll()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestFullTransform",
["position"] = new JArray { 5.0f, 6.0f, 7.0f },
["rotation"] = new JArray { 45.0f, 90.0f, 0.0f },
["scale"] = new JArray { 1.5f, 1.5f, 1.5f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestFullTransform");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(5f, 6f, 7f), created.transform.position);
Assert.AreEqual(new Vector3(1.5f, 1.5f, 1.5f), created.transform.localScale);
}
#endregion
#region Parenting Tests
[Test]
public void Create_WithParentByName_SetsParent()
{
// Create parent first
var parent = new GameObject("TestParent");
createdObjects.Add(parent);
var p = new JObject
{
["action"] = "create",
["name"] = "TestChild",
["parent"] = "TestParent"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var child = FindAndTrack("TestChild");
Assert.IsNotNull(child);
Assert.AreEqual(parent.transform, child.transform.parent);
}
[Test]
public void Create_WithNonExistentParent_HandlesGracefully()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestOrphan",
["parent"] = "NonExistentParent"
};
var result = ManageGameObject.HandleCommand(p);
// Should either fail or create without parent - capture current behavior
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Tag and Layer Tests
[Test]
public void Create_WithTag_SetsTag()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestTagged",
["tag"] = "MainCamera" // Use built-in tag
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestTagged");
Assert.IsNotNull(created);
Assert.AreEqual("MainCamera", created.tag);
}
[Test]
public void Create_WithLayer_SetsLayer()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestLayered",
["layer"] = "UI" // Use built-in layer
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestLayered");
Assert.IsNotNull(created);
Assert.AreEqual(LayerMask.NameToLayer("UI"), created.layer);
}
[Test]
public void Create_WithInvalidTag_HandlesGracefully()
{
// Expect the error log from Unity about invalid tag
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
var p = new JObject
{
["action"] = "create",
["name"] = "TestInvalidTag",
["tag"] = "NonExistentTag12345"
};
var result = ManageGameObject.HandleCommand(p);
// Current behavior: logs error but may create object anyway
Assert.IsNotNull(result, "Should return a result");
// Clean up if object was created
FindAndTrack("TestInvalidTag");
}
#endregion
#region Response Structure Tests
[Test]
public void Create_Success_ReturnsInstanceID()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestInstanceID"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Check that instanceID is returned (case-insensitive check)
var instanceID = data["instanceID"]?.Value<int>() ?? data["InstanceID"]?.Value<int>();
Assert.IsTrue(instanceID.HasValue && instanceID.Value != 0,
$"Response should include a non-zero instanceID. Data: {data}");
FindAndTrack("TestInstanceID");
}
[Test]
public void Create_Success_ReturnsName()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestReturnedName"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Check name is in response
var nameValue = data["name"]?.ToString() ?? data["Name"]?.ToString();
Assert.IsTrue(!string.IsNullOrEmpty(nameValue) || data.ToString().Contains("TestReturnedName"),
"Response should include name");
FindAndTrack("TestReturnedName");
}
#endregion
}
}