🎮 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.
main
parent
9d5a817540
commit
dbdaa546b2
|
|
@ -0,0 +1,150 @@
|
|||
# Unity GameObject API Test Suite — Tool/Resource Separation
|
||||
|
||||
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
|
||||
|
||||
**Print this once, verbatim, early in the run:**
|
||||
AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobject,mcp__UnityMCP__find_gameobjects,mcp__UnityMCP__manage_components,mcp__UnityMCP__manage_scene,mcp__UnityMCP__read_console
|
||||
|
||||
---
|
||||
|
||||
## Mission
|
||||
1) Test the new Tool/Resource separation for GameObject management
|
||||
2) Execute GO tests GO-0..GO-10 in order
|
||||
3) Verify deprecation warnings appear for legacy actions
|
||||
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`
|
||||
|
||||
**CRITICAL XML FORMAT REQUIREMENTS:**
|
||||
- Each file must contain EXACTLY one `<testcase>` root element
|
||||
- NO prologue, epilogue, code fences, or extra characters
|
||||
- Use this exact shape:
|
||||
|
||||
<testcase name="GO-0 — Hierarchy with ComponentTypes" classname="UnityMCP.GO-T">
|
||||
<system-out><![CDATA[
|
||||
(evidence of what was accomplished)
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
|
||||
- If test fails, include: `<failure message="reason"/>`
|
||||
- TESTID must be one of: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10
|
||||
|
||||
---
|
||||
|
||||
## Test Specs
|
||||
|
||||
### GO-0. Hierarchy with ComponentTypes
|
||||
**Goal**: Verify get_hierarchy now includes componentTypes list
|
||||
**Actions**:
|
||||
- Call `mcp__UnityMCP__manage_scene(action="get_hierarchy", page_size=10)`
|
||||
- Verify response includes `componentTypes` array for each item in `data.items`
|
||||
- Check that Main Camera (or similar) has component types like `["Transform", "Camera", "AudioListener"]`
|
||||
- **Pass criteria**: componentTypes present and non-empty for at least one item
|
||||
|
||||
### GO-1. Find GameObjects Tool
|
||||
**Goal**: Test the new find_gameobjects tool
|
||||
**Actions**:
|
||||
- Call `mcp__UnityMCP__find_gameobjects(search_term="Camera", search_method="by_component")`
|
||||
- Verify response contains `instanceIDs` array in `data`
|
||||
- Verify response contains pagination info (`pageSize`, `cursor`, `totalCount`)
|
||||
- **Pass criteria**: Returns at least one instance ID
|
||||
|
||||
### GO-2. GameObject Resource Read
|
||||
**Goal**: Test reading a single GameObject via resource
|
||||
**Actions**:
|
||||
- Use the instance ID from GO-1
|
||||
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID
|
||||
- Verify response includes: instanceID, name, tag, layer, transform, path
|
||||
- **Pass criteria**: All expected fields present
|
||||
|
||||
### GO-3. Components Resource Read
|
||||
**Goal**: Test reading components via resource
|
||||
**Actions**:
|
||||
- Use the instance ID from GO-1
|
||||
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID
|
||||
- Verify response includes paginated component list in `data.items`
|
||||
- Verify at least one component has typeName and instanceID
|
||||
- **Pass criteria**: Components list returned with proper pagination
|
||||
|
||||
### GO-4. Manage Components Tool - Add and Set Property
|
||||
**Goal**: Test the new manage_components tool (add component, set property)
|
||||
**Actions**:
|
||||
- Create a test GameObject: `mcp__UnityMCP__manage_gameobject(action="create", name="GO_Test_Object")`
|
||||
- Add a component: `mcp__UnityMCP__manage_components(action="add", target="GO_Test_Object", component_type="Rigidbody")`
|
||||
- Set a property: `mcp__UnityMCP__manage_components(action="set_property", target="GO_Test_Object", component_type="Rigidbody", properties={"mass": 5.0})`
|
||||
- Verify the component was added and property was set
|
||||
- **Pass criteria**: Component added, property set successfully
|
||||
- **Note**: Keep GO_Test_Object for GO-5 through GO-8
|
||||
|
||||
### GO-5. Find GameObjects by Name
|
||||
**Goal**: Test find_gameobjects with by_name search method
|
||||
**Actions**:
|
||||
- Call `mcp__UnityMCP__find_gameobjects(search_term="GO_Test_Object", search_method="by_name")`
|
||||
- Verify response contains the GameObject created in GO-4
|
||||
- Verify pagination info is present
|
||||
- **Pass criteria**: Returns at least one instance ID matching GO_Test_Object
|
||||
|
||||
### GO-6. Find GameObjects by Tag
|
||||
**Goal**: Test find_gameobjects with by_tag search method
|
||||
**Actions**:
|
||||
- Set a tag on GO_Test_Object: `mcp__UnityMCP__manage_gameobject(action="modify", target="GO_Test_Object", tag="TestTag")`
|
||||
- Call `mcp__UnityMCP__find_gameobjects(search_term="TestTag", search_method="by_tag")`
|
||||
- Verify response contains the tagged GameObject
|
||||
- **Pass criteria**: Returns at least one instance ID
|
||||
|
||||
### GO-7. Single Component Resource Read
|
||||
**Goal**: Test reading a single component via resource
|
||||
**Actions**:
|
||||
- Get instance ID of GO_Test_Object from GO-5
|
||||
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID}
|
||||
- Verify response includes component data with typeName="Rigidbody"
|
||||
- Verify mass property is 5.0 (set in GO-4)
|
||||
- **Pass criteria**: Component data returned with correct properties
|
||||
|
||||
### GO-8. Remove Component
|
||||
**Goal**: Test manage_components remove action
|
||||
**Actions**:
|
||||
- Remove the Rigidbody from GO_Test_Object: `mcp__UnityMCP__manage_components(action="remove", target="GO_Test_Object", component_type="Rigidbody")`
|
||||
- Verify the component was removed by attempting to read it again
|
||||
- **Pass criteria**: Component successfully removed
|
||||
|
||||
### GO-9. Find with Pagination
|
||||
**Goal**: Test find_gameobjects pagination
|
||||
**Actions**:
|
||||
- Call `mcp__UnityMCP__find_gameobjects(search_term="", search_method="by_name", page_size=2)`
|
||||
- Verify response includes cursor for next page
|
||||
- If cursor is present, call again with the cursor to get next page
|
||||
- Clean up: `mcp__UnityMCP__manage_gameobject(action="delete", target="GO_Test_Object")`
|
||||
- **Pass criteria**: Pagination works (cursor present when more results available)
|
||||
|
||||
### GO-10. Deprecation Warnings
|
||||
**Goal**: Verify legacy actions log deprecation warnings
|
||||
**Actions**:
|
||||
- Call legacy action: `mcp__UnityMCP__manage_gameobject(action="find", search_term="Camera", search_method="by_component")`
|
||||
- Read console using `mcp__UnityMCP__read_console` for deprecation warning
|
||||
- Verify warning mentions "find_gameobjects" as replacement
|
||||
- **Pass criteria**: Deprecation warning logged
|
||||
|
||||
---
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### New Tools
|
||||
- `find_gameobjects(search_term, search_method, page_size?, cursor?, search_inactive?)` - Returns instance IDs only
|
||||
- `manage_components(action, target, component_type?, properties?)` - Add/remove/set_property/get_all/get_single
|
||||
|
||||
### New Resources
|
||||
- `unity://scene/gameobject/{instanceID}` - Single GameObject data
|
||||
- `unity://scene/gameobject/{instanceID}/components` - All components (paginated)
|
||||
- `unity://scene/gameobject/{instanceID}/component/{componentName}` - Single component
|
||||
|
||||
### Updated Resources
|
||||
- `manage_scene(action="get_hierarchy")` - Now includes `componentTypes` array in each item
|
||||
|
||||
---
|
||||
|
||||
## Transcript Minimization Rules
|
||||
- Do not restate tool JSON; summarize in ≤ 2 short lines
|
||||
- Per-test `system-out` ≤ 400 chars
|
||||
- Console evidence: include ≤ 3 lines in the fragment
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
|
||||
|
||||
**Print this once, verbatim, early in the run:**
|
||||
AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha
|
||||
AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_edits,mcp__UnityMCP__validate_script,mcp__UnityMCP__find_in_file,mcp__UnityMCP__read_console,mcp__UnityMCP__get_sha
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__un
|
|||
1) Pick target file (prefer):
|
||||
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
|
||||
2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other.
|
||||
3) Validate each edit with `mcp__unity__validate_script(level:"standard")`.
|
||||
3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`.
|
||||
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
|
||||
|
||||
**CRITICAL XML FORMAT REQUIREMENTS:**
|
||||
|
|
@ -50,7 +50,7 @@ CI provides:
|
|||
## Transcript Minimization Rules
|
||||
- Do not restate tool JSON; summarize in ≤ 2 short lines.
|
||||
- Never paste full file contents. For matches, include only the matched line and ±1 line.
|
||||
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
|
||||
- Prefer `mcp__UnityMCP__find_in_file` for targeting to minimize transcript size.
|
||||
- Per‑test `system-out` ≤ 400 chars: brief status only (no SHA).
|
||||
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
|
||||
- Avoid quoting multi‑line diffs; reference markers instead.
|
||||
|
|
@ -59,17 +59,17 @@ CI provides:
|
|||
---
|
||||
|
||||
## Tool Mapping
|
||||
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
|
||||
- **Anchors/regex/structured**: `mcp__UnityMCP__script_apply_edits`
|
||||
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
|
||||
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
|
||||
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges)
|
||||
- **Precise ranges / atomic batch**: `mcp__UnityMCP__apply_text_edits` (non‑overlapping ranges)
|
||||
STRICT OP GUARDRAILS
|
||||
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__UnityMCP__find_in_file` and use `mcp__UnityMCP__apply_text_edits`.
|
||||
|
||||
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__unity__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
|
||||
- **Hash-only**: `mcp__UnityMCP__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__UnityMCP__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__UnityMCP__find_in_file` to locate current positions of methods/markers
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ STRICT OP GUARDRAILS
|
|||
5. **Composability**: Tests demonstrate how operations work together in real workflows
|
||||
|
||||
**State Tracking:**
|
||||
- Track file SHA after each test (`mcp__unity__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments.
|
||||
- Track file SHA after each test (`mcp__UnityMCP__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments.
|
||||
- Use content signatures (method names, comment markers) to verify expected state
|
||||
- Validate structural integrity after each major change
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
|
||||
|
||||
**Print this once, verbatim, early in the run:**
|
||||
AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha
|
||||
AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,mcp__UnityMCP__read_resource,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_edits,mcp__UnityMCP__validate_script,mcp__UnityMCP__find_in_file,mcp__UnityMCP__read_console,mcp__UnityMCP__get_sha
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__un
|
|||
1) Pick target file (prefer):
|
||||
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
|
||||
2) Execute T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state.
|
||||
3) Validate each edit with `mcp__unity__validate_script(level:"standard")`.
|
||||
3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`.
|
||||
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
|
||||
|
||||
**CRITICAL XML FORMAT REQUIREMENTS:**
|
||||
|
|
@ -49,7 +49,7 @@ CI provides:
|
|||
## Transcript Minimization Rules
|
||||
- Do not restate tool JSON; summarize in ≤ 2 short lines.
|
||||
- Never paste full file contents. For matches, include only the matched line and ±1 line.
|
||||
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
|
||||
- Prefer `mcp__UnityMCP__find_in_file` for targeting; avoid `mcp__UnityMCP__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
|
||||
- Per‑test `system-out` ≤ 400 chars: brief status only (no SHA).
|
||||
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
|
||||
- Avoid quoting multi‑line diffs; reference markers instead.
|
||||
|
|
@ -59,17 +59,17 @@ CI provides:
|
|||
---
|
||||
|
||||
## Tool Mapping
|
||||
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
|
||||
- **Anchors/regex/structured**: `mcp__UnityMCP__script_apply_edits`
|
||||
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
|
||||
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
|
||||
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges)
|
||||
- **Precise ranges / atomic batch**: `mcp__UnityMCP__apply_text_edits` (non‑overlapping ranges)
|
||||
STRICT OP GUARDRAILS
|
||||
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__UnityMCP__find_in_file` and use `mcp__UnityMCP__apply_text_edits`.
|
||||
|
||||
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__unity__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
|
||||
- **Hash-only**: `mcp__UnityMCP__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__UnityMCP__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__UnityMCP__find_in_file` to locate current positions of methods/markers
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ STRICT OP GUARDRAILS
|
|||
5. **Composability**: Tests demonstrate how operations work together in real workflows
|
||||
|
||||
**State Tracking:**
|
||||
- Track file SHA after each test (`mcp__unity__get_sha`) and use it as a precondition
|
||||
- Track file SHA after each test (`mcp__UnityMCP__get_sha`) and use it as a precondition
|
||||
for `apply_text_edits` in T‑F/T‑G/T‑I to exercise `stale_file` semantics. Do not include SHA values in report fragments.
|
||||
- Use content signatures (method names, comment markers) to verify expected state
|
||||
- Validate structural integrity after each major change
|
||||
|
|
@ -100,14 +100,14 @@ STRICT OP GUARDRAILS
|
|||
- **Expected final state**: Return to State C (helper removed, other changes intact)
|
||||
|
||||
### Late-Test Editing Rule
|
||||
- When modifying a method body, use `mcp__unity__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__unity__validate_script` and rollback on error. Use `//` comments in inserted code.
|
||||
- When modifying a method body, use `mcp__UnityMCP__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__UnityMCP__validate_script` and rollback on error. Use `//` comments in inserted code.
|
||||
|
||||
### T-B. Method Body Interior Edit (Additive State D)
|
||||
**Goal**: Edit method interior without affecting structure, on modified file
|
||||
**Actions**:
|
||||
- Use `find_in_file` to locate current `HasTarget()` method (modified in NL-1)
|
||||
- Edit method body interior: change return statement to `return true; /* test modification */`
|
||||
- Validate with `mcp__unity__validate_script(level:"standard")` for consistency
|
||||
- Validate with `mcp__UnityMCP__validate_script(level:"standard")` for consistency
|
||||
- Verify edit succeeded and file remains balanced
|
||||
- **Expected final state**: State C + modified HasTarget() body
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ STRICT OP GUARDRAILS
|
|||
**Actions**:
|
||||
- Use smart anchor matching to find current class-ending brace (after NL-3 tail comments)
|
||||
- Insert permanent helper before class brace: `private void TestHelper() { /* placeholder */ }`
|
||||
- Validate with `mcp__unity__validate_script(level:"standard")`
|
||||
- Validate with `mcp__UnityMCP__validate_script(level:"standard")`
|
||||
- **IMMEDIATELY** write clean XML fragment to `reports/T-D_results.xml` (no extra text). The `<testcase name>` must start with `T-D`. Include brief evidence in `system-out`.
|
||||
- **Expected final state**: State E + TestHelper() method before class end
|
||||
|
||||
|
|
@ -178,12 +178,12 @@ STRICT OP GUARDRAILS
|
|||
### T-J. Idempotency on Modified File (Additive State I)
|
||||
**Goal**: Verify operations behave predictably when repeated
|
||||
**Actions**:
|
||||
- **Insert (structured)**: `mcp__unity__script_apply_edits` with:
|
||||
- **Insert (structured)**: `mcp__UnityMCP__script_apply_edits` with:
|
||||
`{"op":"anchor_insert","anchor":"// Tail test C","position":"after","text":"\n // idempotency test marker"}`
|
||||
- **Insert again** (same op) → expect `no_op: true`.
|
||||
- **Remove (structured)**: `{"op":"regex_replace","pattern":"(?m)^\\s*// idempotency test marker\\r?\\n?","text":""}`
|
||||
- **Remove again** (same `regex_replace`) → expect `no_op: true`.
|
||||
- `mcp__unity__validate_script(level:"standard")`
|
||||
- `mcp__UnityMCP__validate_script(level:"standard")`
|
||||
- Perform a final console scan for errors/exceptions (errors only, up to 3); include "no errors" if none
|
||||
- **IMMEDIATELY** write clean XML fragment to `reports/T-J_results.xml` with evidence of both `no_op: true` outcomes and the console result. The `<testcase name>` must start with `T-J`.
|
||||
- **Expected final state**: State H + verified idempotent behavior
|
||||
|
|
|
|||
|
|
@ -0,0 +1,637 @@
|
|||
name: Claude GameObject API Tests (Unity live)
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3
|
||||
|
||||
jobs:
|
||||
go-suite:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
JUNIT_OUT: reports/junit-go-suite.xml
|
||||
MD_OUT: reports/junit-go-suite.md
|
||||
|
||||
steps:
|
||||
# ---------- Secrets check ----------
|
||||
- name: Detect secrets (outputs)
|
||||
id: detect
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi
|
||||
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then
|
||||
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ---------- Python env for MCP server (uv) ----------
|
||||
- uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install MCP server
|
||||
run: |
|
||||
set -eux
|
||||
uv venv
|
||||
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
|
||||
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
|
||||
if [ -f Server/pyproject.toml ]; then
|
||||
uv pip install -e Server
|
||||
elif [ -f Server/requirements.txt ]; then
|
||||
uv pip install -r Server/requirements.txt
|
||||
else
|
||||
echo "No MCP Python deps found (skipping)"
|
||||
fi
|
||||
|
||||
# --- Licensing ---
|
||||
- name: Decide license sources
|
||||
id: lic
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
run: |
|
||||
set -eu
|
||||
use_ulf=false; use_ebl=false
|
||||
[[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
|
||||
[[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true
|
||||
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
|
||||
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
|
||||
echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage Unity .ulf license (from secret)
|
||||
if: steps.lic.outputs.use_ulf == 'true'
|
||||
id: ulf
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
|
||||
f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
|
||||
if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
|
||||
printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
|
||||
else
|
||||
printf "%s" "$UNITY_LICENSE" > "$f"
|
||||
fi
|
||||
chmod 600 "$f" || true
|
||||
if head -c 100 "$f" | grep -qi '<\?xml'; then
|
||||
mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses"
|
||||
mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml"
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
elif grep -qi '<Signature>' "$f"; then
|
||||
cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Activate Unity (EBL via container - host-mount)
|
||||
if: steps.lic.outputs.use_ebl == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
|
||||
docker run --rm --network host \
|
||||
-e HOME=/root \
|
||||
-e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
|
||||
"$UNITY_IMAGE" bash -lc '
|
||||
set -euxo pipefail
|
||||
if [[ -n "${UNITY_SERIAL:-}" ]]; then
|
||||
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
|
||||
else
|
||||
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true
|
||||
fi
|
||||
ls -la /root/.config/unity3d/Unity/licenses || true
|
||||
'
|
||||
|
||||
if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then
|
||||
if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then
|
||||
echo "EBL entitlement not found; proceeding with ULF-only (ok=true)."
|
||||
else
|
||||
echo "No entitlement produced and no valid ULF; cannot continue." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- Warm up project ----------
|
||||
- name: Warm up project (import Library once)
|
||||
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
ULF_OK: ${{ steps.ulf.outputs.ok }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
manual_args=()
|
||||
if [[ "${ULF_OK:-false}" == "true" ]]; then
|
||||
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
|
||||
fi
|
||||
docker run --rm --network host \
|
||||
-e HOME=/root \
|
||||
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
|
||||
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
|
||||
"${manual_args[@]}" \
|
||||
-quit
|
||||
|
||||
# ---------- Clean old MCP status ----------
|
||||
- name: Clean old MCP status
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
|
||||
rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true
|
||||
|
||||
# ---------- Start headless Unity ----------
|
||||
- name: Start Unity (persistent bridge)
|
||||
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
ULF_OK: ${{ steps.ulf.outputs.ok }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
manual_args=()
|
||||
if [[ "${ULF_OK:-false}" == "true" ]]; then
|
||||
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
|
||||
fi
|
||||
|
||||
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
|
||||
docker rm -f unity-mcp >/dev/null 2>&1 || true
|
||||
docker run -d --name unity-mcp --network host \
|
||||
-e HOME=/root \
|
||||
-e UNITY_MCP_ALLOW_BATCH=1 \
|
||||
-e UNITY_MCP_STATUS_DIR="${{ github.workspace }}/.unity-mcp" \
|
||||
-e UNITY_MCP_BIND_HOST=127.0.0.1 \
|
||||
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
|
||||
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \
|
||||
-stackTraceLogType Full \
|
||||
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
|
||||
"${manual_args[@]}" \
|
||||
-executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi
|
||||
|
||||
# ---------- Wait for Unity bridge ----------
|
||||
- name: Wait for Unity bridge (robust)
|
||||
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deadline=$((SECONDS+600))
|
||||
fatal_after=$((SECONDS+120))
|
||||
ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)'
|
||||
license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)'
|
||||
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
logs="$(docker logs unity-mcp 2>&1 || true)"
|
||||
port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)"
|
||||
if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then
|
||||
echo "Bridge ready on port $port"
|
||||
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
|
||||
exit 0
|
||||
fi
|
||||
if echo "$logs" | grep -qiE "$ok_pat"; then
|
||||
echo "Bridge ready (log markers)"
|
||||
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
|
||||
exit 0
|
||||
fi
|
||||
if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then
|
||||
echo "::error::Fatal licensing signal detected after warm-up"
|
||||
echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
fi
|
||||
st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)"
|
||||
if [[ "$st" != "running" ]]; then
|
||||
echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::Bridge not ready before deadline"
|
||||
docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
|
||||
- name: Pin Claude tool permissions
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p .claude
|
||||
cat > .claude/settings.json <<'JSON'
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__unity",
|
||||
"Edit(reports/**)",
|
||||
"MultiEdit(reports/**)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"NotebookEdit",
|
||||
"NotebookRead"
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
- name: Prepare reports
|
||||
run: |
|
||||
set -eux
|
||||
rm -f reports/*.xml reports/*.md || true
|
||||
mkdir -p reports
|
||||
|
||||
- name: Create report skeletons
|
||||
run: |
|
||||
set -eu
|
||||
cat > "$JUNIT_OUT" <<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites><testsuite name="UnityMCP.GO-T" tests="1" failures="1" errors="0" skipped="0" time="0">
|
||||
<testcase name="GO-Suite.Bootstrap" classname="UnityMCP.GO-T">
|
||||
<failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure>
|
||||
</testcase>
|
||||
</testsuite></testsuites>
|
||||
XML
|
||||
printf '# Unity GameObject API Test Results\n\n' > "$MD_OUT"
|
||||
|
||||
- name: Verify Unity bridge status
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
shopt -s nullglob
|
||||
status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json)
|
||||
if ((${#status_files[@]})); then
|
||||
first_status="${status_files[0]}"
|
||||
fname="$(basename "$first_status")"
|
||||
hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}"
|
||||
proj="$(jq -r '.project_name // empty' "$first_status" || true)"
|
||||
if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then
|
||||
echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV"
|
||||
echo "Default instance set to ${proj}@${hash_part}"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Write MCP config
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p .claude
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
workspace = os.environ["GITHUB_WORKSPACE"]
|
||||
default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
|
||||
|
||||
cfg = {
|
||||
"mcpServers": {
|
||||
"unity": {
|
||||
"args": [
|
||||
"run",
|
||||
"--active",
|
||||
"--directory",
|
||||
"Server",
|
||||
"mcp-for-unity",
|
||||
"--transport",
|
||||
"stdio",
|
||||
],
|
||||
"transport": {"type": "stdio"},
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MCP_LOG_LEVEL": "debug",
|
||||
"UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests",
|
||||
"UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp",
|
||||
"UNITY_MCP_HOST": "127.0.0.1",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unity = cfg["mcpServers"]["unity"]
|
||||
if default_inst:
|
||||
unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst
|
||||
if "--default-instance" not in unity["args"]:
|
||||
unity["args"] += ["--default-instance", default_inst]
|
||||
|
||||
runner_script = Path(".claude/run-unity-mcp.sh")
|
||||
workspace_path = Path(workspace)
|
||||
uv_candidate = workspace_path / ".venv" / "bin" / "uv"
|
||||
uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv"
|
||||
script = textwrap.dedent(f"""\
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
echo "" >> "$LOG"
|
||||
echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG"
|
||||
exec {uv_cmd} "$@" 2>> "$LOG"
|
||||
""")
|
||||
runner_script.write_text(script)
|
||||
runner_script.chmod(0o755)
|
||||
|
||||
unity["command"] = runner_script.resolve().as_posix()
|
||||
|
||||
path = Path(".claude/mcp.json")
|
||||
path.write_text(json.dumps(cfg, indent=2) + "\n")
|
||||
print(f"Wrote {path} and {runner_script}")
|
||||
PY
|
||||
|
||||
# ---------- Run Claude GO pass ----------
|
||||
- name: Run Claude GO pass
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }}
|
||||
with:
|
||||
use_node_cache: false
|
||||
prompt_file: .claude/prompts/nl-gameobject-suite.md
|
||||
mcp_config: .claude/mcp.json
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-haiku-4-5-20251001
|
||||
fallback_model: claude-sonnet-4-5-20250929
|
||||
append_system_prompt: |
|
||||
You are running the GameObject API tests.
|
||||
- Emit exactly GO-0, GO-1, GO-2, GO-3, GO-4, GO-5.
|
||||
- Write each to reports/${ID}_results.xml.
|
||||
- Stop after GO-5_results.xml is written.
|
||||
timeout_minutes: "25"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# ---------- Backfill missing tests ----------
|
||||
- name: Backfill missing GO tests
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
DESIRED = ["GO-0","GO-1","GO-2","GO-3","GO-4","GO-5"]
|
||||
seen = set()
|
||||
|
||||
def id_from_filename(p: Path):
|
||||
n = p.name
|
||||
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"GO-{int(m.group(1))}"
|
||||
return None
|
||||
|
||||
for p in Path("reports").glob("*_results.xml"):
|
||||
fid = id_from_filename(p)
|
||||
if fid in DESIRED:
|
||||
seen.add(fid)
|
||||
|
||||
Path("reports").mkdir(parents=True, exist_ok=True)
|
||||
for d in DESIRED:
|
||||
if d in seen:
|
||||
continue
|
||||
frag = Path(f"reports/{d}_results.xml")
|
||||
tc = ET.Element("testcase", {"classname":"UnityMCP.GO-T", "name": d})
|
||||
fail = ET.SubElement(tc, "failure", {"message":"not produced"})
|
||||
fail.text = "The agent did not emit a fragment for this test."
|
||||
ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False)
|
||||
print(f"backfill: {d}")
|
||||
PY
|
||||
|
||||
# ---------- Merge fragments into JUnit ----------
|
||||
- name: Assemble JUnit
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
import re, os
|
||||
|
||||
def localname(tag: str) -> str:
|
||||
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
||||
|
||||
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
|
||||
if not src.exists():
|
||||
raise SystemExit(0)
|
||||
|
||||
tree = ET.parse(src)
|
||||
root = tree.getroot()
|
||||
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
|
||||
if suite is None:
|
||||
raise SystemExit(0)
|
||||
|
||||
def id_from_filename(p: Path):
|
||||
n = p.name
|
||||
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"GO-{int(m.group(1))}"
|
||||
return None
|
||||
|
||||
fragments = sorted(Path('reports').glob('GO-*_results.xml'))
|
||||
added = 0
|
||||
|
||||
for frag in fragments:
|
||||
try:
|
||||
froot = ET.parse(frag).getroot()
|
||||
if localname(froot.tag) == 'testcase':
|
||||
suite.append(froot)
|
||||
added += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if added:
|
||||
for tc in list(suite.findall('.//testcase')):
|
||||
if (tc.get('name') or '') == 'GO-Suite.Bootstrap':
|
||||
suite.remove(tc)
|
||||
testcases = suite.findall('.//testcase')
|
||||
failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None))
|
||||
suite.set('tests', str(len(testcases)))
|
||||
suite.set('failures', str(failures_cnt))
|
||||
suite.set('errors', '0')
|
||||
suite.set('skipped', '0')
|
||||
tree.write(src, encoding='utf-8', xml_declaration=True)
|
||||
print(f"Appended {added} testcase(s).")
|
||||
PY
|
||||
|
||||
# ---------- Build markdown summary ----------
|
||||
- name: Build markdown summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
import os, html, re
|
||||
|
||||
def localname(tag: str) -> str:
|
||||
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
||||
|
||||
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
|
||||
md_out = Path(os.environ.get('MD_OUT', 'reports/junit-go-suite.md'))
|
||||
md_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not src.exists():
|
||||
md_out.write_text("# Unity GameObject API Test Results\n\n(No JUnit found)\n", encoding='utf-8')
|
||||
raise SystemExit(0)
|
||||
|
||||
tree = ET.parse(src)
|
||||
root = tree.getroot()
|
||||
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
|
||||
cases = [] if suite is None else list(suite.findall('.//testcase'))
|
||||
|
||||
desired = ['GO-0','GO-1','GO-2','GO-3','GO-4','GO-5']
|
||||
default_titles = {
|
||||
'GO-0': 'Hierarchy with ComponentTypes',
|
||||
'GO-1': 'Find GameObjects Tool',
|
||||
'GO-2': 'GameObject Resource Read',
|
||||
'GO-3': 'Components Resource Read',
|
||||
'GO-4': 'Manage Components Tool',
|
||||
'GO-5': 'Deprecation Warnings',
|
||||
}
|
||||
|
||||
def id_from_case(tc):
|
||||
n = (tc.get('name') or '')
|
||||
m = re.match(r'\s*(GO-\d+)\b', n)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
id_status = {}
|
||||
for tc in cases:
|
||||
tid = id_from_case(tc)
|
||||
if not tid or tid not in desired or tid in id_status:
|
||||
continue
|
||||
ok = (tc.find('failure') is None and tc.find('error') is None)
|
||||
id_status[tid] = ok
|
||||
|
||||
total = len(cases)
|
||||
failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None))
|
||||
passed = total - failures
|
||||
|
||||
lines = [
|
||||
'# Unity GameObject API Test Results',
|
||||
'',
|
||||
f'Totals: {passed} passed, {failures} failed, {total} total',
|
||||
'',
|
||||
'## Test Checklist'
|
||||
]
|
||||
for p in desired:
|
||||
st = id_status.get(p, None)
|
||||
label = f"{p} — {default_titles.get(p, '')}"
|
||||
lines.append(f"- [x] {label}" if st is True else (f"- [ ] {label} (fail)" if st is False else f"- [ ] {label} (not run)"))
|
||||
lines.append('')
|
||||
|
||||
lines.append('## Test Details')
|
||||
for tc in cases:
|
||||
tid = id_from_case(tc)
|
||||
if not tid:
|
||||
continue
|
||||
title = tc.get('name') or tid
|
||||
ok = (tc.find('failure') is None and tc.find('error') is None)
|
||||
badge = "PASS" if ok else "FAIL"
|
||||
lines.append(f"### {title} — {badge}")
|
||||
so = tc.find('system-out')
|
||||
text = '' if so is None or so.text is None else html.unescape(so.text.strip())
|
||||
if text:
|
||||
lines += ['```', text[:2000], '```']
|
||||
else:
|
||||
lines.append('(no system-out)')
|
||||
node = tc.find('failure') or tc.find('error')
|
||||
if node is not None:
|
||||
msg = (node.get('message') or '').strip()
|
||||
if msg:
|
||||
lines.append(f"- Message: {msg}")
|
||||
lines.append('')
|
||||
|
||||
md_out.write_text('\n'.join(lines), encoding='utf-8')
|
||||
PY
|
||||
|
||||
- name: GO details -> Job Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Unity GameObject API Tests — Summary" >> $GITHUB_STEP_SUMMARY
|
||||
python3 - <<'PY' >> $GITHUB_STEP_SUMMARY
|
||||
from pathlib import Path
|
||||
p = Path('reports/junit-go-suite.md')
|
||||
if p.exists():
|
||||
text = p.read_bytes().decode('utf-8', 'replace')
|
||||
print(text[:65000])
|
||||
else:
|
||||
print("_No markdown report found._")
|
||||
PY
|
||||
|
||||
- name: Publish JUnit report
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v5
|
||||
with:
|
||||
report_paths: "${{ env.JUNIT_OUT }}"
|
||||
include_passed: true
|
||||
detailed_summary: true
|
||||
annotate_notice: true
|
||||
require_tests: false
|
||||
fail_on_parse_error: true
|
||||
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-go-suite-artifacts
|
||||
path: |
|
||||
${{ env.JUNIT_OUT }}
|
||||
${{ env.MD_OUT }}
|
||||
reports/*_results.xml
|
||||
retention-days: 7
|
||||
|
||||
# ---------- Cleanup ----------
|
||||
- name: Stop Unity
|
||||
if: always()
|
||||
run: |
|
||||
docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
|
||||
docker rm -f unity-mcp || true
|
||||
|
||||
- name: Return Pro license (if used)
|
||||
if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true'
|
||||
uses: game-ci/unity-return-license@v2
|
||||
continue-on-error: true
|
||||
env:
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
|
||||
|
|
@ -751,6 +751,75 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- Run GO pass (GameObject API tests) ----------
|
||||
- name: Run Claude GO pass
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }}
|
||||
with:
|
||||
use_node_cache: false
|
||||
prompt_file: .claude/prompts/nl-gameobject-suite.md
|
||||
mcp_config: .claude/mcp.json
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-haiku-4-5-20251001
|
||||
fallback_model: claude-sonnet-4-5-20250929
|
||||
append_system_prompt: |
|
||||
You are running the GO pass (GameObject API tests) only.
|
||||
Output requirements:
|
||||
- Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10.
|
||||
- Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml).
|
||||
- Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch.
|
||||
- Do not emit any NL-* or T-* fragments.
|
||||
Stop condition:
|
||||
- After GO-10_results.xml is written, stop.
|
||||
timeout_minutes: "20"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Check GO coverage incomplete (pre-retry)
|
||||
id: go_cov
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
for id in GO-0 GO-1 GO-2 GO-3 GO-4 GO-5 GO-6 GO-7 GO-8 GO-9 GO-10; do
|
||||
if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then
|
||||
missing+=("$id")
|
||||
fi
|
||||
done
|
||||
echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT"
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "list=${missing[*]}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Retry GO pass (Sonnet) if incomplete
|
||||
if: steps.go_cov.outputs.missing != '0'
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
use_node_cache: false
|
||||
prompt_file: .claude/prompts/nl-gameobject-suite.md
|
||||
mcp_config: .claude/mcp.json
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-sonnet-4-5-20250929
|
||||
fallback_model: claude-haiku-4-5-20251001
|
||||
append_system_prompt: |
|
||||
You are running the GO pass only.
|
||||
Output requirements:
|
||||
- Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10.
|
||||
- Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml).
|
||||
- Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch.
|
||||
- Do not emit any NL-* or T-* fragments.
|
||||
Stop condition:
|
||||
- After GO-10_results.xml is written, stop.
|
||||
timeout_minutes: "20"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# (kept) Finalize staged report fragments (promote to reports/)
|
||||
|
||||
# (removed duplicate) Finalize staged report fragments
|
||||
|
|
@ -796,6 +865,17 @@ jobs:
|
|||
("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"),
|
||||
("T-I", r"\b(T-?I|Failure\s*Surface)\b"),
|
||||
("T-J", r"\b(T-?J|Idempotenc(y|e))\b"),
|
||||
("GO-0", r"\b(GO-?0|Hierarchy.*ComponentTypes)\b"),
|
||||
("GO-1", r"\b(GO-?1|Find\s*GameObjects\s*Tool)\b"),
|
||||
("GO-2", r"\b(GO-?2|GameObject\s*Resource)\b"),
|
||||
("GO-3", r"\b(GO-?3|Components\s*Resource)\b"),
|
||||
("GO-4", r"\b(GO-?4|Manage\s*Components)\b"),
|
||||
("GO-5", r"\b(GO-?5|Find.*by.*Name)\b"),
|
||||
("GO-6", r"\b(GO-?6|Find.*by.*Tag)\b"),
|
||||
("GO-7", r"\b(GO-?7|Single\s*Component)\b"),
|
||||
("GO-8", r"\b(GO-?8|Remove\s*Component)\b"),
|
||||
("GO-9", r"\b(GO-?9|Pagination)\b"),
|
||||
("GO-10", r"\b(GO-?10|Deprecation)\b"),
|
||||
]
|
||||
|
||||
def canon_name(name: str) -> str:
|
||||
|
|
@ -822,6 +902,9 @@ jobs:
|
|||
m = re.match(r'T-?([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"GO-{int(m.group(1))}"
|
||||
return None
|
||||
|
||||
frags = list(sorted(Path("reports").glob("*_results.xml")))
|
||||
|
|
@ -837,7 +920,7 @@ jobs:
|
|||
# Prefer filename-derived ID; if name doesn't start with it, override
|
||||
if file_id:
|
||||
# Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns)
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip()
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', old).strip()
|
||||
new = f"{file_id} — {title}" if title else file_id
|
||||
else:
|
||||
new = canon_name(old)
|
||||
|
|
@ -860,7 +943,7 @@ jobs:
|
|||
import re
|
||||
import shutil
|
||||
|
||||
DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"]
|
||||
DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J","GO-0","GO-1","GO-2","GO-3","GO-4","GO-5","GO-6","GO-7","GO-8","GO-9","GO-10"]
|
||||
seen = set()
|
||||
bad = set()
|
||||
def id_from_filename(p: Path):
|
||||
|
|
@ -871,6 +954,9 @@ jobs:
|
|||
m = re.match(r'T-?([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"GO-{int(m.group(1))}"
|
||||
return None
|
||||
|
||||
for p in Path("reports").glob("*_results.xml"):
|
||||
|
|
@ -963,12 +1049,15 @@ jobs:
|
|||
m = re.match(r'T([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
m = re.match(r'GO(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"GO-{int(m.group(1))}"
|
||||
return None
|
||||
|
||||
def id_from_system_out(tc):
|
||||
so = tc.find('system-out')
|
||||
if so is not None and so.text:
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
|
@ -1005,13 +1094,13 @@ jobs:
|
|||
current_name = tc.get('name') or ''
|
||||
tid = test_id or id_from_system_out(tc)
|
||||
# Enforce filename-derived ID as prefix; repair names if needed
|
||||
if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name):
|
||||
if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', current_name):
|
||||
title = current_name.strip()
|
||||
new_name = f'{tid} — {title}' if title else tid
|
||||
tc.set('name', new_name)
|
||||
elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name):
|
||||
# Replace any wrong leading ID with the correct one
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip()
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', current_name).strip()
|
||||
new_name = f'{tid} — {title}' if title else tid
|
||||
tc.set('name', new_name)
|
||||
renamed += 1
|
||||
|
|
@ -1061,12 +1150,12 @@ jobs:
|
|||
|
||||
def id_from_case(tc):
|
||||
n = (tc.get('name') or '')
|
||||
m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n)
|
||||
m = re.match(r'\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', n)
|
||||
if m:
|
||||
return m.group(1)
|
||||
so = tc.find('system-out')
|
||||
if so is not None and so.text:
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
|
@ -1080,7 +1169,7 @@ jobs:
|
|||
id_status[tid] = ok
|
||||
name_map[tid] = (tc.get('name') or tid)
|
||||
|
||||
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
|
||||
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10']
|
||||
default_titles = {
|
||||
'NL-0': 'Baseline State Capture',
|
||||
'NL-1': 'Core Method Operations',
|
||||
|
|
@ -1097,6 +1186,17 @@ jobs:
|
|||
'T-H': 'Validation on Modified',
|
||||
'T-I': 'Failure Surface',
|
||||
'T-J': 'Idempotency',
|
||||
'GO-0': 'Hierarchy with ComponentTypes',
|
||||
'GO-1': 'Find GameObjects Tool',
|
||||
'GO-2': 'GameObject Resource Read',
|
||||
'GO-3': 'Components Resource Read',
|
||||
'GO-4': 'Manage Components Tool',
|
||||
'GO-5': 'Find GameObjects by Name',
|
||||
'GO-6': 'Find GameObjects by Tag',
|
||||
'GO-7': 'Single Component Resource Read',
|
||||
'GO-8': 'Remove Component',
|
||||
'GO-9': 'Find with Pagination',
|
||||
'GO-10': 'Deprecation Warnings',
|
||||
}
|
||||
|
||||
def display_name(test_id: str) -> str:
|
||||
|
|
@ -1189,7 +1289,7 @@ jobs:
|
|||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
|
||||
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10']
|
||||
|
||||
junit_path = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml'))
|
||||
if not junit_path.exists():
|
||||
|
|
@ -1206,12 +1306,12 @@ jobs:
|
|||
|
||||
def id_from_case(tc):
|
||||
name = (tc.get('name') or '').strip()
|
||||
m = re.match(r'(NL-\d+|T-[A-Z])\b', name)
|
||||
m = re.match(r'(NL-\d+|T-[A-Z]|GO-\d+)\b', name)
|
||||
if m:
|
||||
return m.group(1)
|
||||
so = tc.find('system-out')
|
||||
if so is not None and so.text:
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -42,3 +42,12 @@ TestProjects/UnityMCPTests/Assets/Temp/
|
|||
*.backup.meta
|
||||
|
||||
.wt-origin-main/
|
||||
|
||||
# CI test reports (generated during test runs)
|
||||
reports/
|
||||
|
||||
# Local Claude configs (not for repo)
|
||||
.claude/local/
|
||||
|
||||
# Local testing harness
|
||||
scripts/local-test/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,343 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for finding and looking up GameObjects in the scene.
|
||||
/// Provides search functionality by name, tag, layer, component, path, and instance ID.
|
||||
/// </summary>
|
||||
public static class GameObjectLookup
|
||||
{
|
||||
/// <summary>
|
||||
/// Supported search methods for finding GameObjects.
|
||||
/// </summary>
|
||||
public enum SearchMethod
|
||||
{
|
||||
ByName,
|
||||
ByTag,
|
||||
ByLayer,
|
||||
ByComponent,
|
||||
ByPath,
|
||||
ById
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a search method string into the enum value.
|
||||
/// </summary>
|
||||
public static SearchMethod ParseSearchMethod(string method)
|
||||
{
|
||||
if (string.IsNullOrEmpty(method))
|
||||
return SearchMethod.ByName;
|
||||
|
||||
return method.ToLowerInvariant() switch
|
||||
{
|
||||
"by_name" => SearchMethod.ByName,
|
||||
"by_tag" => SearchMethod.ByTag,
|
||||
"by_layer" => SearchMethod.ByLayer,
|
||||
"by_component" => SearchMethod.ByComponent,
|
||||
"by_path" => SearchMethod.ByPath,
|
||||
"by_id" => SearchMethod.ById,
|
||||
_ => SearchMethod.ByName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a single GameObject based on the target and search method.
|
||||
/// </summary>
|
||||
/// <param name="target">The target identifier (name, ID, path, etc.)</param>
|
||||
/// <param name="searchMethod">The search method to use</param>
|
||||
/// <param name="includeInactive">Whether to include inactive objects</param>
|
||||
/// <returns>The found GameObject or null</returns>
|
||||
public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false)
|
||||
{
|
||||
if (target == null)
|
||||
return null;
|
||||
|
||||
var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1);
|
||||
return results.Count > 0 ? FindById(results[0]) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a GameObject by its instance ID.
|
||||
/// </summary>
|
||||
public static GameObject FindById(int instanceId)
|
||||
{
|
||||
return EditorUtility.InstanceIDToObject(instanceId) as GameObject;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for GameObjects and returns their instance IDs.
|
||||
/// </summary>
|
||||
/// <param name="searchMethod">The search method string (by_name, by_tag, etc.)</param>
|
||||
/// <param name="searchTerm">The term to search for</param>
|
||||
/// <param name="includeInactive">Whether to include inactive objects</param>
|
||||
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
|
||||
/// <returns>List of instance IDs</returns>
|
||||
public static List<int> SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0)
|
||||
{
|
||||
var method = ParseSearchMethod(searchMethod);
|
||||
return SearchGameObjects(method, searchTerm, includeInactive, maxResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for GameObjects and returns their instance IDs.
|
||||
/// </summary>
|
||||
/// <param name="method">The search method</param>
|
||||
/// <param name="searchTerm">The term to search for</param>
|
||||
/// <param name="includeInactive">Whether to include inactive objects</param>
|
||||
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
|
||||
/// <returns>List of instance IDs</returns>
|
||||
public static List<int> SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0)
|
||||
{
|
||||
var results = new List<int>();
|
||||
|
||||
switch (method)
|
||||
{
|
||||
case SearchMethod.ById:
|
||||
if (int.TryParse(searchTerm, out int instanceId))
|
||||
{
|
||||
var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
|
||||
if (obj != null && (includeInactive || obj.activeInHierarchy))
|
||||
{
|
||||
results.Add(instanceId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case SearchMethod.ByName:
|
||||
results.AddRange(SearchByName(searchTerm, includeInactive, maxResults));
|
||||
break;
|
||||
|
||||
case SearchMethod.ByPath:
|
||||
results.AddRange(SearchByPath(searchTerm, includeInactive));
|
||||
break;
|
||||
|
||||
case SearchMethod.ByTag:
|
||||
results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults));
|
||||
break;
|
||||
|
||||
case SearchMethod.ByLayer:
|
||||
results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults));
|
||||
break;
|
||||
|
||||
case SearchMethod.ByComponent:
|
||||
results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults));
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IEnumerable<int> SearchByName(string name, bool includeInactive, int maxResults)
|
||||
{
|
||||
var allObjects = GetAllSceneObjects(includeInactive);
|
||||
var matching = allObjects.Where(go => go.name == name);
|
||||
|
||||
if (maxResults > 0)
|
||||
matching = matching.Take(maxResults);
|
||||
|
||||
return matching.Select(go => go.GetInstanceID());
|
||||
}
|
||||
|
||||
private static IEnumerable<int> SearchByPath(string path, bool includeInactive)
|
||||
{
|
||||
// NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects.
|
||||
// The includeInactive parameter has no effect here due to Unity API limitations.
|
||||
// Consider using by_name search with includeInactive if you need to find inactive objects.
|
||||
if (includeInactive)
|
||||
{
|
||||
Debug.LogWarning("[GameObjectLookup] SearchByPath with includeInactive=true: " +
|
||||
"GameObject.Find() cannot find inactive objects. Use by_name search instead.");
|
||||
}
|
||||
|
||||
var found = GameObject.Find(path);
|
||||
if (found != null)
|
||||
{
|
||||
yield return found.GetInstanceID();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> SearchByTag(string tag, bool includeInactive, int maxResults)
|
||||
{
|
||||
GameObject[] taggedObjects;
|
||||
try
|
||||
{
|
||||
if (includeInactive)
|
||||
{
|
||||
// FindGameObjectsWithTag doesn't find inactive, so we need to iterate all
|
||||
var allObjects = GetAllSceneObjects(true);
|
||||
taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
taggedObjects = GameObject.FindGameObjectsWithTag(tag);
|
||||
}
|
||||
}
|
||||
catch (UnityException)
|
||||
{
|
||||
// Tag doesn't exist
|
||||
yield break;
|
||||
}
|
||||
|
||||
var results = taggedObjects.AsEnumerable();
|
||||
if (maxResults > 0)
|
||||
results = results.Take(maxResults);
|
||||
|
||||
foreach (var go in results)
|
||||
{
|
||||
yield return go.GetInstanceID();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> SearchByLayer(string layerName, bool includeInactive, int maxResults)
|
||||
{
|
||||
int layer = LayerMask.NameToLayer(layerName);
|
||||
if (layer == -1)
|
||||
{
|
||||
// Try parsing as layer number
|
||||
if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
var allObjects = GetAllSceneObjects(includeInactive);
|
||||
var matching = allObjects.Where(go => go.layer == layer);
|
||||
|
||||
if (maxResults > 0)
|
||||
matching = matching.Take(maxResults);
|
||||
|
||||
foreach (var go in matching)
|
||||
{
|
||||
yield return go.GetInstanceID();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> SearchByComponent(string componentTypeName, bool includeInactive, int maxResults)
|
||||
{
|
||||
Type componentType = FindComponentType(componentTypeName);
|
||||
if (componentType == null)
|
||||
{
|
||||
Debug.LogWarning($"[GameObjectLookup] Component type '{componentTypeName}' not found.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var allObjects = GetAllSceneObjects(includeInactive);
|
||||
var count = 0;
|
||||
|
||||
foreach (var go in allObjects)
|
||||
{
|
||||
if (go.GetComponent(componentType) != null)
|
||||
{
|
||||
yield return go.GetInstanceID();
|
||||
count++;
|
||||
|
||||
if (maxResults > 0 && count >= maxResults)
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all GameObjects in the current scene.
|
||||
/// </summary>
|
||||
public static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
|
||||
{
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
if (!scene.IsValid())
|
||||
yield break;
|
||||
|
||||
var rootObjects = scene.GetRootGameObjects();
|
||||
foreach (var root in rootObjects)
|
||||
{
|
||||
foreach (var go in GetObjectAndDescendants(root, includeInactive))
|
||||
{
|
||||
yield return go;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<GameObject> GetObjectAndDescendants(GameObject obj, bool includeInactive)
|
||||
{
|
||||
if (!includeInactive && !obj.activeInHierarchy)
|
||||
yield break;
|
||||
|
||||
yield return obj;
|
||||
|
||||
foreach (Transform child in obj.transform)
|
||||
{
|
||||
foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a component type by name, searching loaded assemblies.
|
||||
/// </summary>
|
||||
public static Type FindComponentType(string typeName)
|
||||
{
|
||||
// Try direct type lookup first
|
||||
var type = Type.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Search in UnityEngine
|
||||
type = typeof(Component).Assembly.GetType($"UnityEngine.{typeName}");
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Search all loaded assemblies
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try exact match
|
||||
type = assembly.GetType(typeName);
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
|
||||
// Try with UnityEngine prefix
|
||||
type = assembly.GetType($"UnityEngine.{typeName}");
|
||||
if (type != null && typeof(Component).IsAssignableFrom(type))
|
||||
return type;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip assemblies that can't be searched (e.g., dynamic, reflection-only)
|
||||
// This is expected for some assemblies in Unity's domain
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hierarchical path of a GameObject.
|
||||
/// </summary>
|
||||
public static string GetGameObjectPath(GameObject obj)
|
||||
{
|
||||
if (obj == null)
|
||||
return string.Empty;
|
||||
|
||||
var path = obj.name;
|
||||
var parent = obj.transform.parent;
|
||||
|
||||
while (parent != null)
|
||||
{
|
||||
path = parent.name + "/" + path;
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4964205faa8dd4f8a960e58fd8c0d4f7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for coercing JSON parameter values to strongly-typed values.
|
||||
/// Handles various input formats (strings, numbers, booleans) gracefully.
|
||||
/// </summary>
|
||||
public static class ParamCoercion
|
||||
{
|
||||
/// <summary>
|
||||
/// Coerces a JToken to an integer value, handling strings and floats.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to coerce</param>
|
||||
/// <param name="defaultValue">Default value if coercion fails</param>
|
||||
/// <returns>The coerced integer value or default</returns>
|
||||
public static int CoerceInt(JToken token, int defaultValue)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
if (token.Type == JTokenType.Integer)
|
||||
return token.Value<int>();
|
||||
|
||||
var s = token.ToString().Trim();
|
||||
if (s.Length == 0)
|
||||
return defaultValue;
|
||||
|
||||
if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
|
||||
return i;
|
||||
|
||||
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
|
||||
return (int)d;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow and return default
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a JToken to a boolean value, handling strings like "true", "1", etc.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to coerce</param>
|
||||
/// <param name="defaultValue">Default value if coercion fails</param>
|
||||
/// <returns>The coerced boolean value or default</returns>
|
||||
public static bool CoerceBool(JToken token, bool defaultValue)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
if (token.Type == JTokenType.Boolean)
|
||||
return token.Value<bool>();
|
||||
|
||||
var s = token.ToString().Trim().ToLowerInvariant();
|
||||
if (s.Length == 0)
|
||||
return defaultValue;
|
||||
|
||||
if (bool.TryParse(s, out var b))
|
||||
return b;
|
||||
|
||||
if (s == "1" || s == "yes" || s == "on")
|
||||
return true;
|
||||
|
||||
if (s == "0" || s == "no" || s == "off")
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow and return default
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a JToken to a float value, handling strings and integers.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to coerce</param>
|
||||
/// <param name="defaultValue">Default value if coercion fails</param>
|
||||
/// <returns>The coerced float value or default</returns>
|
||||
public static float CoerceFloat(JToken token, float defaultValue)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
|
||||
return token.Value<float>();
|
||||
|
||||
var s = token.ToString().Trim();
|
||||
if (s.Length == 0)
|
||||
return defaultValue;
|
||||
|
||||
if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f))
|
||||
return f;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow and return default
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a JToken to a string value, with null handling.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to coerce</param>
|
||||
/// <param name="defaultValue">Default value if null or empty</param>
|
||||
/// <returns>The string value or default</returns>
|
||||
public static string CoerceString(JToken token, string defaultValue = null)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return defaultValue;
|
||||
|
||||
var s = token.ToString();
|
||||
return string.IsNullOrEmpty(s) ? defaultValue : s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coerces a JToken to an enum value, handling strings.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type</typeparam>
|
||||
/// <param name="token">The JSON token to coerce</param>
|
||||
/// <param name="defaultValue">Default value if coercion fails</param>
|
||||
/// <returns>The coerced enum value or default</returns>
|
||||
public static T CoerceEnum<T>(JToken token, T defaultValue) where T : struct, Enum
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return defaultValue;
|
||||
|
||||
try
|
||||
{
|
||||
var s = token.ToString().Trim();
|
||||
if (s.Length == 0)
|
||||
return defaultValue;
|
||||
|
||||
if (Enum.TryParse<T>(s, ignoreCase: true, out var result))
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow and return default
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: db54fbbe3ac7f429fbf808f72831374a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Utility class for parsing JSON tokens into Unity vector and math types.
|
||||
/// Supports both array format [x, y, z] and object format {x: 1, y: 2, z: 3}.
|
||||
/// </summary>
|
||||
public static class VectorParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a JToken (array or object) into a Vector3.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to parse</param>
|
||||
/// <returns>The parsed Vector3 or null if parsing fails</returns>
|
||||
public static Vector3? ParseVector3(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Array format: [x, y, z]
|
||||
if (token is JArray array && array.Count >= 3)
|
||||
{
|
||||
return new Vector3(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
// Object format: {x: 1, y: 2, z: 3}
|
||||
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z"))
|
||||
{
|
||||
return new Vector3(
|
||||
obj["x"].ToObject<float>(),
|
||||
obj["y"].ToObject<float>(),
|
||||
obj["z"].ToObject<float>()
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JToken into a Vector3, returning a default value if parsing fails.
|
||||
/// </summary>
|
||||
public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default)
|
||||
{
|
||||
return ParseVector3(token) ?? defaultValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JToken (array or object) into a Vector2.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to parse</param>
|
||||
/// <returns>The parsed Vector2 or null if parsing fails</returns>
|
||||
public static Vector2? ParseVector2(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Array format: [x, y]
|
||||
if (token is JArray array && array.Count >= 2)
|
||||
{
|
||||
return new Vector2(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
// Object format: {x: 1, y: 2}
|
||||
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y"))
|
||||
{
|
||||
return new Vector2(
|
||||
obj["x"].ToObject<float>(),
|
||||
obj["y"].ToObject<float>()
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Vector2 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].
|
||||
/// Note: Raw quaternion components are NOT normalized. Callers should normalize if needed
|
||||
/// for operations like interpolation where non-unit quaternions cause issues.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to parse</param>
|
||||
/// <param name="asEulerAngles">If true, treats 3-element arrays as euler angles</param>
|
||||
/// <returns>The parsed Quaternion or null if parsing fails</returns>
|
||||
public static Quaternion? ParseQuaternion(JToken token, bool asEulerAngles = true)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (token is JArray array)
|
||||
{
|
||||
// Quaternion components: [x, y, z, w]
|
||||
if (array.Count >= 4)
|
||||
{
|
||||
return new Quaternion(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>(),
|
||||
array[3].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
// Euler angles: [x, y, z]
|
||||
if (array.Count >= 3 && asEulerAngles)
|
||||
{
|
||||
return Quaternion.Euler(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Object format: {x: 0, y: 0, z: 0, w: 1}
|
||||
if (token is JObject obj)
|
||||
{
|
||||
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w"))
|
||||
{
|
||||
return new Quaternion(
|
||||
obj["x"].ToObject<float>(),
|
||||
obj["y"].ToObject<float>(),
|
||||
obj["z"].ToObject<float>(),
|
||||
obj["w"].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
// Euler format in object: {x: 45, y: 90, z: 0} (as euler angles)
|
||||
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && asEulerAngles)
|
||||
{
|
||||
return Quaternion.Euler(
|
||||
obj["x"].ToObject<float>(),
|
||||
obj["y"].ToObject<float>(),
|
||||
obj["z"].ToObject<float>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JToken (array or object) into a Color.
|
||||
/// Supports both [r, g, b, a] and {r: 1, g: 1, b: 1, a: 1} formats.
|
||||
/// </summary>
|
||||
/// <param name="token">The JSON token to parse</param>
|
||||
/// <returns>The parsed Color or null if parsing fails</returns>
|
||||
public static Color? ParseColor(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Array format: [r, g, b, a] or [r, g, b]
|
||||
if (token is JArray array)
|
||||
{
|
||||
if (array.Count >= 4)
|
||||
{
|
||||
return new Color(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>(),
|
||||
array[3].ToObject<float>()
|
||||
);
|
||||
}
|
||||
if (array.Count >= 3)
|
||||
{
|
||||
return new Color(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>(),
|
||||
1f // Default alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Object format: {r: 1, g: 1, b: 1, a: 1}
|
||||
if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b"))
|
||||
{
|
||||
float a = obj.ContainsKey("a") ? obj["a"].ToObject<float>() : 1f;
|
||||
return new Color(
|
||||
obj["r"].ToObject<float>(),
|
||||
obj["g"].ToObject<float>(),
|
||||
obj["b"].ToObject<float>(),
|
||||
a
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JToken into a Rect.
|
||||
/// Supports {x, y, width, height} format.
|
||||
/// </summary>
|
||||
public static Rect? ParseRect(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (token is JObject obj &&
|
||||
obj.ContainsKey("x") && obj.ContainsKey("y") &&
|
||||
obj.ContainsKey("width") && obj.ContainsKey("height"))
|
||||
{
|
||||
return new Rect(
|
||||
obj["x"].ToObject<float>(),
|
||||
obj["y"].ToObject<float>(),
|
||||
obj["width"].ToObject<float>(),
|
||||
obj["height"].ToObject<float>()
|
||||
);
|
||||
}
|
||||
|
||||
// Array format: [x, y, width, height]
|
||||
if (token is JArray array && array.Count >= 4)
|
||||
{
|
||||
return new Rect(
|
||||
array[0].ToObject<float>(),
|
||||
array[1].ToObject<float>(),
|
||||
array[2].ToObject<float>(),
|
||||
array[3].ToObject<float>()
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a JToken into a Bounds.
|
||||
/// Supports {center: {x,y,z}, size: {x,y,z}} format.
|
||||
/// </summary>
|
||||
public static Bounds? ParseBounds(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size"))
|
||||
{
|
||||
var center = ParseVector3(obj["center"]) ?? Vector3.zero;
|
||||
var size = ParseVector3(obj["size"]) ?? Vector3.zero;
|
||||
return new Bounds(center, size);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[VectorParsing] Failed to parse Bounds from '{token}': {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ca2205caede3744aebda9f6da2fa2c22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 563f6050485b445449a1db200bfba51c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Resources.Scene
|
||||
{
|
||||
/// <summary>
|
||||
/// Resource handler for reading GameObject data.
|
||||
/// Provides read-only access to GameObject information without component serialization.
|
||||
///
|
||||
/// URI: unity://scene/gameobject/{instanceID}
|
||||
/// </summary>
|
||||
[McpForUnityResource("get_gameobject")]
|
||||
public static class GameObjectResource
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
// Get instance ID from params
|
||||
int? instanceID = null;
|
||||
|
||||
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
|
||||
if (idToken != null)
|
||||
{
|
||||
instanceID = ParamCoercion.CoerceInt(idToken, -1);
|
||||
if (instanceID == -1)
|
||||
{
|
||||
instanceID = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!instanceID.HasValue)
|
||||
{
|
||||
return new ErrorResponse("'instanceID' parameter is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var go = EditorUtility.InstanceIDToObject(instanceID.Value) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = SerializeGameObject(go)
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[GameObjectResource] Error getting GameObject: {e}");
|
||||
return new ErrorResponse($"Error getting GameObject: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a GameObject without component details.
|
||||
/// For component data, use GetComponents or GetComponent resources.
|
||||
/// </summary>
|
||||
public static object SerializeGameObject(GameObject go)
|
||||
{
|
||||
if (go == null)
|
||||
return null;
|
||||
|
||||
var transform = go.transform;
|
||||
|
||||
// Get component type names (not full serialization)
|
||||
var componentTypes = go.GetComponents<Component>()
|
||||
.Where(c => c != null)
|
||||
.Select(c => c.GetType().Name)
|
||||
.ToList();
|
||||
|
||||
// Get children instance IDs (not full serialization)
|
||||
var childrenIds = new List<int>();
|
||||
foreach (Transform child in transform)
|
||||
{
|
||||
childrenIds.Add(child.gameObject.GetInstanceID());
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
instanceID = go.GetInstanceID(),
|
||||
name = go.name,
|
||||
tag = go.tag,
|
||||
layer = go.layer,
|
||||
layerName = LayerMask.LayerToName(go.layer),
|
||||
active = go.activeSelf,
|
||||
activeInHierarchy = go.activeInHierarchy,
|
||||
isStatic = go.isStatic,
|
||||
transform = new
|
||||
{
|
||||
position = SerializeVector3(transform.position),
|
||||
localPosition = SerializeVector3(transform.localPosition),
|
||||
rotation = SerializeVector3(transform.eulerAngles),
|
||||
localRotation = SerializeVector3(transform.localEulerAngles),
|
||||
scale = SerializeVector3(transform.localScale),
|
||||
lossyScale = SerializeVector3(transform.lossyScale)
|
||||
},
|
||||
parent = transform.parent != null ? transform.parent.gameObject.GetInstanceID() : (int?)null,
|
||||
children = childrenIds,
|
||||
componentTypes = componentTypes,
|
||||
path = GameObjectLookup.GetGameObjectPath(go)
|
||||
};
|
||||
}
|
||||
|
||||
private static object SerializeVector3(Vector3 v)
|
||||
{
|
||||
return new { x = v.x, y = v.y, z = v.z };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource handler for reading all components on a GameObject.
|
||||
///
|
||||
/// URI: unity://scene/gameobject/{instanceID}/components
|
||||
/// </summary>
|
||||
[McpForUnityResource("get_gameobject_components")]
|
||||
public static class GameObjectComponentsResource
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
|
||||
int instanceID = ParamCoercion.CoerceInt(idToken, -1);
|
||||
if (instanceID == -1)
|
||||
{
|
||||
return new ErrorResponse("'instanceID' parameter is required.");
|
||||
}
|
||||
|
||||
// Pagination parameters
|
||||
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 25);
|
||||
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
|
||||
bool includeProperties = ParamCoercion.CoerceBool(@params["includeProperties"] ?? @params["include_properties"], true);
|
||||
|
||||
pageSize = Mathf.Clamp(pageSize, 1, 100);
|
||||
|
||||
try
|
||||
{
|
||||
var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
|
||||
}
|
||||
|
||||
var allComponents = go.GetComponents<Component>().Where(c => c != null).ToList();
|
||||
int total = allComponents.Count;
|
||||
|
||||
var pagedComponents = allComponents.Skip(cursor).Take(pageSize).ToList();
|
||||
|
||||
var componentData = new List<object>();
|
||||
foreach (var component in pagedComponents)
|
||||
{
|
||||
if (includeProperties)
|
||||
{
|
||||
componentData.Add(GameObjectSerializer.GetComponentData(component));
|
||||
}
|
||||
else
|
||||
{
|
||||
componentData.Add(new
|
||||
{
|
||||
typeName = component.GetType().FullName,
|
||||
instanceID = component.GetInstanceID()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int? nextCursor = cursor + pagedComponents.Count < total ? cursor + pagedComponents.Count : (int?)null;
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
gameObjectID = instanceID,
|
||||
gameObjectName = go.name,
|
||||
components = componentData,
|
||||
cursor = cursor,
|
||||
pageSize = pageSize,
|
||||
nextCursor = nextCursor,
|
||||
totalCount = total,
|
||||
hasMore = nextCursor.HasValue,
|
||||
includeProperties = includeProperties
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[GameObjectComponentsResource] Error getting components: {e}");
|
||||
return new ErrorResponse($"Error getting components: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource handler for reading a single component on a GameObject.
|
||||
///
|
||||
/// URI: unity://scene/gameobject/{instanceID}/component/{componentName}
|
||||
/// </summary>
|
||||
[McpForUnityResource("get_gameobject_component")]
|
||||
public static class GameObjectComponentResource
|
||||
{
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
|
||||
int instanceID = ParamCoercion.CoerceInt(idToken, -1);
|
||||
if (instanceID == -1)
|
||||
{
|
||||
return new ErrorResponse("'instanceID' parameter is required.");
|
||||
}
|
||||
|
||||
string componentName = ParamCoercion.CoerceString(@params["componentName"] ?? @params["component_name"] ?? @params["component"], null);
|
||||
if (string.IsNullOrEmpty(componentName))
|
||||
{
|
||||
return new ErrorResponse("'componentName' parameter is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
|
||||
if (go == null)
|
||||
{
|
||||
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
|
||||
}
|
||||
|
||||
// Find the component by type name
|
||||
Component targetComponent = null;
|
||||
foreach (var component in go.GetComponents<Component>())
|
||||
{
|
||||
if (component == null) continue;
|
||||
|
||||
var typeName = component.GetType().Name;
|
||||
var fullTypeName = component.GetType().FullName;
|
||||
|
||||
if (string.Equals(typeName, componentName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(fullTypeName, componentName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetComponent = component;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetComponent == null)
|
||||
{
|
||||
return new ErrorResponse($"Component '{componentName}' not found on GameObject '{go.name}'.");
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
gameObjectID = instanceID,
|
||||
gameObjectName = go.name,
|
||||
component = GameObjectSerializer.GetComponentData(targetComponent)
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[GameObjectComponentResource] Error getting component: {e}");
|
||||
return new ErrorResponse($"Error getting component: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5ee79050d9f6d42798a0757cc7672517
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool for searching GameObjects in the scene.
|
||||
/// Returns only instance IDs with pagination support.
|
||||
///
|
||||
/// This is a focused search tool that returns lightweight results (IDs only).
|
||||
/// For detailed GameObject data, use the unity://scene/gameobject/{id} resource.
|
||||
/// </summary>
|
||||
[McpForUnityTool("find_gameobjects")]
|
||||
public static class FindGameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the find_gameobjects command.
|
||||
/// </summary>
|
||||
/// <param name="params">Command parameters</param>
|
||||
/// <returns>Paginated list of instance IDs</returns>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
// Parse search parameters
|
||||
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], "by_name");
|
||||
string searchTerm = ParamCoercion.CoerceString(@params["searchTerm"] ?? @params["search_term"] ?? @params["target"], null);
|
||||
|
||||
if (string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
|
||||
}
|
||||
|
||||
// Pagination parameters
|
||||
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 50);
|
||||
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
|
||||
|
||||
// Search options
|
||||
bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false);
|
||||
|
||||
// Validate pageSize bounds
|
||||
pageSize = Mathf.Clamp(pageSize, 1, 500);
|
||||
|
||||
try
|
||||
{
|
||||
// Get all matching instance IDs
|
||||
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
|
||||
int totalCount = allIds.Count;
|
||||
|
||||
// Apply pagination
|
||||
var pagedIds = allIds.Skip(cursor).Take(pageSize).ToList();
|
||||
|
||||
// Calculate next cursor
|
||||
int? nextCursor = cursor + pagedIds.Count < totalCount ? cursor + pagedIds.Count : (int?)null;
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
instanceIDs = pagedIds,
|
||||
pageSize = pageSize,
|
||||
cursor = cursor,
|
||||
nextCursor = nextCursor,
|
||||
totalCount = totalCount,
|
||||
hasMore = nextCursor.HasValue
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError($"[FindGameObjects] Error searching GameObjects: {ex.Message}");
|
||||
return new ErrorResponse($"Error searching GameObjects: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4511082b395b14922b34e90f7a23027e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool for managing components on GameObjects.
|
||||
/// Actions: add, remove, set_property
|
||||
///
|
||||
/// This is a focused tool for component lifecycle operations.
|
||||
/// For reading component data, use the unity://scene/gameobject/{id}/components resource.
|
||||
/// </summary>
|
||||
[McpForUnityTool("manage_components")]
|
||||
public static class ManageComponents
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the manage_components command.
|
||||
/// </summary>
|
||||
/// <param name="params">Command parameters</param>
|
||||
/// <returns>Result of the component operation</returns>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
if (@params == null)
|
||||
{
|
||||
return new ErrorResponse("Parameters cannot be null.");
|
||||
}
|
||||
|
||||
string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
return new ErrorResponse("'action' parameter is required (add, remove, set_property).");
|
||||
}
|
||||
|
||||
// Target resolution
|
||||
JToken targetToken = @params["target"];
|
||||
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], null);
|
||||
|
||||
if (targetToken == null)
|
||||
{
|
||||
return new ErrorResponse("'target' parameter is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"add" => AddComponent(@params, targetToken, searchMethod),
|
||||
"remove" => RemoveComponent(@params, targetToken, searchMethod),
|
||||
"set_property" => SetProperty(@params, targetToken, searchMethod),
|
||||
_ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property")
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[ManageComponents] Action '{action}' failed: {e}");
|
||||
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Action Implementations
|
||||
|
||||
private static object AddComponent(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject targetGo = FindTarget(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||
if (string.IsNullOrEmpty(componentType))
|
||||
{
|
||||
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found. Use a fully-qualified name if needed.");
|
||||
}
|
||||
|
||||
// Optional properties to set on the new component
|
||||
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
|
||||
|
||||
try
|
||||
{
|
||||
// Undo.AddComponent creates its own undo record, no need for RecordObject
|
||||
Component newComponent = Undo.AddComponent(targetGo, type);
|
||||
|
||||
if (newComponent == null)
|
||||
{
|
||||
return new ErrorResponse($"Failed to add component '{componentType}' to '{targetGo.name}'.");
|
||||
}
|
||||
|
||||
// Set properties if provided
|
||||
if (properties != null && properties.HasValues)
|
||||
{
|
||||
SetPropertiesOnComponent(newComponent, properties);
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Component '{componentType}' added to '{targetGo.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = targetGo.GetInstanceID(),
|
||||
componentType = type.FullName,
|
||||
componentInstanceID = newComponent.GetInstanceID()
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error adding component '{componentType}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject targetGo = FindTarget(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||
if (string.IsNullOrEmpty(componentType))
|
||||
{
|
||||
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found.");
|
||||
}
|
||||
|
||||
// Prevent removal of Transform (check early before GetComponent)
|
||||
if (type == typeof(Transform))
|
||||
{
|
||||
return new ErrorResponse("Cannot remove the Transform component.");
|
||||
}
|
||||
|
||||
Component component = targetGo.GetComponent(type);
|
||||
if (component == null)
|
||||
{
|
||||
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Undo.DestroyObjectImmediate(component);
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Component '{componentType}' removed from '{targetGo.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = targetGo.GetInstanceID()
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error removing component '{componentType}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object SetProperty(JObject @params, JToken targetToken, string searchMethod)
|
||||
{
|
||||
GameObject targetGo = FindTarget(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
|
||||
}
|
||||
|
||||
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
|
||||
if (string.IsNullOrEmpty(componentType))
|
||||
{
|
||||
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
|
||||
}
|
||||
|
||||
// Resolve component type
|
||||
Type type = FindComponentType(componentType);
|
||||
if (type == null)
|
||||
{
|
||||
return new ErrorResponse($"Component type '{componentType}' not found.");
|
||||
}
|
||||
|
||||
Component component = targetGo.GetComponent(type);
|
||||
if (component == null)
|
||||
{
|
||||
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
|
||||
}
|
||||
|
||||
// Get property and value
|
||||
string propertyName = ParamCoercion.CoerceString(@params["property"], null);
|
||||
JToken valueToken = @params["value"];
|
||||
|
||||
// Support both single property or properties object
|
||||
JObject properties = @params["properties"] as JObject;
|
||||
|
||||
if (string.IsNullOrEmpty(propertyName) && (properties == null || !properties.HasValues))
|
||||
{
|
||||
return new ErrorResponse("Either 'property'+'value' or 'properties' object is required for 'set_property' action.");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
Undo.RecordObject(component, $"Set property on {componentType}");
|
||||
|
||||
if (!string.IsNullOrEmpty(propertyName) && valueToken != null)
|
||||
{
|
||||
// Single property mode
|
||||
var error = TrySetProperty(component, propertyName, valueToken);
|
||||
if (error != null)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (properties != null && properties.HasValues)
|
||||
{
|
||||
// Multiple properties mode
|
||||
foreach (var prop in properties.Properties())
|
||||
{
|
||||
var error = TrySetProperty(component, prop.Name, prop.Value);
|
||||
if (error != null)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(component);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = $"Some properties failed to set on '{componentType}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = targetGo.GetInstanceID(),
|
||||
errors = errors
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
message = $"Properties set on component '{componentType}' on '{targetGo.name}'.",
|
||||
data = new
|
||||
{
|
||||
instanceID = targetGo.GetInstanceID()
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse($"Error setting properties on component '{componentType}': {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static GameObject FindTarget(JToken targetToken, string searchMethod)
|
||||
{
|
||||
if (targetToken == null)
|
||||
return null;
|
||||
|
||||
// Try instance ID first
|
||||
if (targetToken.Type == JTokenType.Integer)
|
||||
{
|
||||
int instanceId = targetToken.Value<int>();
|
||||
return GameObjectLookup.FindById(instanceId);
|
||||
}
|
||||
|
||||
string targetStr = targetToken.ToString();
|
||||
|
||||
// Try parsing as instance ID
|
||||
if (int.TryParse(targetStr, out int parsedId))
|
||||
{
|
||||
var byId = GameObjectLookup.FindById(parsedId);
|
||||
if (byId != null)
|
||||
return byId;
|
||||
}
|
||||
|
||||
// Use GameObjectLookup for search
|
||||
return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a component type by name. Delegates to GameObjectLookup.FindComponentType.
|
||||
/// </summary>
|
||||
private static Type FindComponentType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
return null;
|
||||
return GameObjectLookup.FindComponentType(typeName);
|
||||
}
|
||||
|
||||
private static void SetPropertiesOnComponent(Component component, JObject properties)
|
||||
{
|
||||
if (component == null || properties == null)
|
||||
return;
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var prop in properties.Properties())
|
||||
{
|
||||
var error = TrySetProperty(component, prop.Name, prop.Value);
|
||||
if (error != null)
|
||||
errors.Add(error);
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
Debug.LogWarning($"[ManageComponents] Some properties failed to set on {component.GetType().Name}: {string.Join(", ", errors)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to set a property or field on a component.
|
||||
/// Note: Property/field lookup is case-insensitive for better usability with external callers.
|
||||
/// </summary>
|
||||
private static string TrySetProperty(Component component, string propertyName, JToken value)
|
||||
{
|
||||
if (component == null || string.IsNullOrEmpty(propertyName))
|
||||
return $"Invalid component or property name";
|
||||
|
||||
var type = component.GetType();
|
||||
|
||||
// Try property first
|
||||
var propInfo = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (propInfo != null && propInfo.CanWrite)
|
||||
{
|
||||
try
|
||||
{
|
||||
var convertedValue = ConvertValue(value, propInfo.PropertyType);
|
||||
propInfo.SetValue(component, convertedValue);
|
||||
return null; // Success
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[ManageComponents] Failed to set property '{propertyName}': {e.Message}");
|
||||
return $"Failed to set property '{propertyName}': {e.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// Try field
|
||||
var fieldInfo = type.GetField(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (fieldInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var convertedValue = ConvertValue(value, fieldInfo.FieldType);
|
||||
fieldInfo.SetValue(component, convertedValue);
|
||||
return null; // Success
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[ManageComponents] Failed to set field '{propertyName}': {e.Message}");
|
||||
return $"Failed to set field '{propertyName}': {e.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
Debug.LogWarning($"[ManageComponents] Property or field '{propertyName}' not found on {type.Name}");
|
||||
return $"Property '{propertyName}' not found on {type.Name}";
|
||||
}
|
||||
|
||||
private static object ConvertValue(JToken token, Type targetType)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
// Handle Unity types
|
||||
if (targetType == typeof(Vector3))
|
||||
{
|
||||
return VectorParsing.ParseVector3OrDefault(token);
|
||||
}
|
||||
if (targetType == typeof(Vector2))
|
||||
{
|
||||
return VectorParsing.ParseVector2(token) ?? Vector2.zero;
|
||||
}
|
||||
if (targetType == typeof(Quaternion))
|
||||
{
|
||||
return VectorParsing.ParseQuaternion(token) ?? Quaternion.identity;
|
||||
}
|
||||
if (targetType == typeof(Color))
|
||||
{
|
||||
return VectorParsing.ParseColor(token) ?? Color.white;
|
||||
}
|
||||
|
||||
// Use Newtonsoft for other types
|
||||
return token.ToObject(targetType);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c6f476359563842c79eda2c180566c98
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -73,10 +73,6 @@ namespace MCPForUnity.Editor.Tools
|
|||
string layer = @params["layer"]?.ToString();
|
||||
JToken parentToken = @params["parent"];
|
||||
|
||||
// --- Add parameter for controlling non-public field inclusion ---
|
||||
bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject<bool>() ?? true; // Default to true
|
||||
// --- End add parameter ---
|
||||
|
||||
// Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string
|
||||
var componentPropsToken = @params["componentProperties"];
|
||||
if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String)
|
||||
|
|
@ -166,76 +162,13 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
switch (action)
|
||||
{
|
||||
// --- Primary lifecycle actions (kept in manage_gameobject) ---
|
||||
case "create":
|
||||
return CreateGameObject(@params);
|
||||
case "modify":
|
||||
return ModifyGameObject(@params, targetToken, searchMethod);
|
||||
case "delete":
|
||||
return DeleteGameObject(targetToken, searchMethod);
|
||||
case "find":
|
||||
return FindGameObjects(@params, targetToken, searchMethod);
|
||||
case "get_components":
|
||||
string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string
|
||||
if (getCompTarget == null)
|
||||
return new ErrorResponse(
|
||||
"'target' parameter required for get_components."
|
||||
);
|
||||
// Paging + safety: return metadata by default; deep fields are opt-in.
|
||||
int CoerceInt(JToken t, int @default)
|
||||
{
|
||||
if (t == null || t.Type == JTokenType.Null) return @default;
|
||||
try
|
||||
{
|
||||
if (t.Type == JTokenType.Integer) return t.Value<int>();
|
||||
var s = t.ToString().Trim();
|
||||
if (s.Length == 0) return @default;
|
||||
if (int.TryParse(s, out var i)) return i;
|
||||
if (double.TryParse(s, out var d)) return (int)d;
|
||||
}
|
||||
catch { }
|
||||
return @default;
|
||||
}
|
||||
bool CoerceBool(JToken t, bool @default)
|
||||
{
|
||||
if (t == null || t.Type == JTokenType.Null) return @default;
|
||||
try
|
||||
{
|
||||
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
|
||||
var s = t.ToString().Trim();
|
||||
if (s.Length == 0) return @default;
|
||||
if (bool.TryParse(s, out var b)) return b;
|
||||
if (s == "1") return true;
|
||||
if (s == "0") return false;
|
||||
}
|
||||
catch { }
|
||||
return @default;
|
||||
}
|
||||
|
||||
int pageSize = CoerceInt(@params["pageSize"] ?? @params["page_size"], 25);
|
||||
int cursor = CoerceInt(@params["cursor"], 0);
|
||||
int maxComponents = CoerceInt(@params["maxComponents"] ?? @params["max_components"], 50);
|
||||
bool includeProperties = CoerceBool(@params["includeProperties"] ?? @params["include_properties"], false);
|
||||
|
||||
// Pass the includeNonPublicSerialized flag through, but only used if includeProperties is true.
|
||||
return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized, pageSize, cursor, maxComponents, includeProperties);
|
||||
case "get_component":
|
||||
string getSingleCompTarget = targetToken?.ToString();
|
||||
if (getSingleCompTarget == null)
|
||||
return new ErrorResponse(
|
||||
"'target' parameter required for get_component."
|
||||
);
|
||||
string componentName = @params["componentName"]?.ToString();
|
||||
if (string.IsNullOrEmpty(componentName))
|
||||
return new ErrorResponse(
|
||||
"'componentName' parameter required for get_component."
|
||||
);
|
||||
return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized);
|
||||
case "add_component":
|
||||
return AddComponentToTarget(@params, targetToken, searchMethod);
|
||||
case "remove_component":
|
||||
return RemoveComponentFromTarget(@params, targetToken, searchMethod);
|
||||
case "set_component_property":
|
||||
return SetComponentPropertyOnTarget(@params, targetToken, searchMethod);
|
||||
case "duplicate":
|
||||
return DuplicateGameObject(@params, targetToken, searchMethod);
|
||||
case "move_relative":
|
||||
|
|
@ -1202,381 +1135,6 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
}
|
||||
|
||||
private static object FindGameObjects(
|
||||
JObject @params,
|
||||
JToken targetToken,
|
||||
string searchMethod
|
||||
)
|
||||
{
|
||||
bool findAll = @params["findAll"]?.ToObject<bool>() ?? false;
|
||||
List<GameObject> foundObjects = FindObjectsInternal(
|
||||
targetToken,
|
||||
searchMethod,
|
||||
findAll,
|
||||
@params
|
||||
);
|
||||
|
||||
if (foundObjects.Count == 0)
|
||||
{
|
||||
return new SuccessResponse("No matching GameObjects found.", new List<object>());
|
||||
}
|
||||
|
||||
// Use the new serializer helper
|
||||
//var results = foundObjects.Select(go => GetGameObjectData(go)).ToList();
|
||||
var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList();
|
||||
return new SuccessResponse($"Found {results.Count} GameObject(s).", results);
|
||||
}
|
||||
|
||||
private static object GetComponentsFromTarget(
|
||||
string target,
|
||||
string searchMethod,
|
||||
bool includeNonPublicSerialized = true,
|
||||
int pageSize = 25,
|
||||
int cursor = 0,
|
||||
int maxComponents = 50,
|
||||
bool includeProperties = false
|
||||
)
|
||||
{
|
||||
GameObject targetGo = FindObjectInternal(target, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'."
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int resolvedPageSize = Mathf.Clamp(pageSize, 1, 200);
|
||||
int resolvedCursor = Mathf.Max(0, cursor);
|
||||
int resolvedMaxComponents = Mathf.Clamp(maxComponents, 1, 500);
|
||||
int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxComponents);
|
||||
|
||||
// Build a stable list once; pagination is applied to this list.
|
||||
var all = targetGo.GetComponents<Component>();
|
||||
var components = new List<Component>(all?.Length ?? 0);
|
||||
if (all != null)
|
||||
{
|
||||
for (int i = 0; i < all.Length; i++)
|
||||
{
|
||||
if (all[i] != null) components.Add(all[i]);
|
||||
}
|
||||
}
|
||||
|
||||
int total = components.Count;
|
||||
if (resolvedCursor > total) resolvedCursor = total;
|
||||
int end = Mathf.Min(total, resolvedCursor + effectiveTake);
|
||||
|
||||
var items = new List<object>(Mathf.Max(0, end - resolvedCursor));
|
||||
|
||||
// If caller explicitly asked for properties, we still enforce a conservative payload budget.
|
||||
const int maxPayloadChars = 250_000; // ~250KB assuming 1 char ~= 1 byte ASCII-ish
|
||||
int payloadChars = 0;
|
||||
|
||||
for (int i = resolvedCursor; i < end; i++)
|
||||
{
|
||||
var c = components[i];
|
||||
if (c == null) continue;
|
||||
|
||||
if (!includeProperties)
|
||||
{
|
||||
items.Add(BuildComponentMetadata(c));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized);
|
||||
if (data == null) continue;
|
||||
|
||||
// Rough cap to keep responses from exploding even when includeProperties is true.
|
||||
var token = JToken.FromObject(data);
|
||||
int addChars = token.ToString(Newtonsoft.Json.Formatting.None).Length;
|
||||
if (payloadChars + addChars > maxPayloadChars && items.Count > 0)
|
||||
{
|
||||
// Stop early; next_cursor will allow fetching more (or caller can use get_component).
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
payloadChars += addChars;
|
||||
items.Add(token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Avoid throwing; mark the component as failed.
|
||||
items.Add(
|
||||
new JObject(
|
||||
new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"),
|
||||
new JProperty("instanceID", c.GetInstanceID()),
|
||||
new JProperty("error", ex.Message)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool truncated = end < total;
|
||||
string nextCursor = truncated ? end.ToString() : null;
|
||||
|
||||
var payload = new
|
||||
{
|
||||
cursor = resolvedCursor,
|
||||
pageSize = effectiveTake,
|
||||
next_cursor = nextCursor,
|
||||
truncated = truncated,
|
||||
total = total,
|
||||
includeProperties = includeProperties,
|
||||
items = items,
|
||||
};
|
||||
|
||||
return new SuccessResponse(
|
||||
$"Retrieved components page from '{targetGo.name}'.",
|
||||
payload
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Error getting components from '{targetGo.name}': {e.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static object BuildComponentMetadata(Component c)
|
||||
{
|
||||
if (c == null) return null;
|
||||
var d = new Dictionary<string, object>
|
||||
{
|
||||
{ "typeName", c.GetType().FullName },
|
||||
{ "instanceID", c.GetInstanceID() },
|
||||
};
|
||||
if (c is Behaviour b)
|
||||
{
|
||||
d["enabled"] = b.enabled;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true)
|
||||
{
|
||||
GameObject targetGo = FindObjectInternal(target, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'."
|
||||
);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to find the component by name
|
||||
Component targetComponent = targetGo.GetComponent(componentName);
|
||||
|
||||
// If not found directly, try to find by type name (handle namespaces)
|
||||
if (targetComponent == null)
|
||||
{
|
||||
Component[] allComponents = targetGo.GetComponents<Component>();
|
||||
foreach (Component comp in allComponents)
|
||||
{
|
||||
if (comp != null)
|
||||
{
|
||||
string typeName = comp.GetType().Name;
|
||||
string fullTypeName = comp.GetType().FullName;
|
||||
|
||||
if (typeName == componentName || fullTypeName == componentName)
|
||||
{
|
||||
targetComponent = comp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetComponent == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Component '{componentName}' not found on GameObject '{targetGo.name}'."
|
||||
);
|
||||
}
|
||||
|
||||
var componentData = Helpers.GameObjectSerializer.GetComponentData(targetComponent, includeNonPublicSerialized);
|
||||
|
||||
if (componentData == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'."
|
||||
);
|
||||
}
|
||||
|
||||
return new SuccessResponse(
|
||||
$"Retrieved component '{componentName}' from '{targetGo.name}'.",
|
||||
componentData
|
||||
);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static object AddComponentToTarget(
|
||||
JObject @params,
|
||||
JToken targetToken,
|
||||
string searchMethod
|
||||
)
|
||||
{
|
||||
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
|
||||
);
|
||||
}
|
||||
|
||||
string typeName = null;
|
||||
JObject properties = null;
|
||||
|
||||
// Allow adding component specified directly or via componentsToAdd array (take first)
|
||||
if (@params["componentName"] != null)
|
||||
{
|
||||
typeName = @params["componentName"]?.ToString();
|
||||
properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name
|
||||
}
|
||||
else if (
|
||||
@params["componentsToAdd"] is JArray componentsToAddArray
|
||||
&& componentsToAddArray.Count > 0
|
||||
)
|
||||
{
|
||||
var compToken = componentsToAddArray.First;
|
||||
if (compToken.Type == JTokenType.String)
|
||||
{
|
||||
typeName = compToken.ToString();
|
||||
// Check for properties in top-level componentProperties parameter
|
||||
properties = @params["componentProperties"]?[typeName] as JObject;
|
||||
}
|
||||
else if (compToken is JObject compObj)
|
||||
{
|
||||
typeName = compObj["typeName"]?.ToString();
|
||||
properties = compObj["properties"] as JObject;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
return new ErrorResponse(
|
||||
"Component type name ('componentName' or first element in 'componentsToAdd') is required."
|
||||
);
|
||||
}
|
||||
|
||||
var addResult = AddComponentInternal(targetGo, typeName, properties);
|
||||
if (addResult != null)
|
||||
return addResult; // Return error
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
// Use the new serializer helper
|
||||
return new SuccessResponse(
|
||||
$"Component '{typeName}' added to '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
); // Return updated GO data
|
||||
}
|
||||
|
||||
private static object RemoveComponentFromTarget(
|
||||
JObject @params,
|
||||
JToken targetToken,
|
||||
string searchMethod
|
||||
)
|
||||
{
|
||||
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
|
||||
);
|
||||
}
|
||||
|
||||
string typeName = null;
|
||||
// Allow removing component specified directly or via componentsToRemove array (take first)
|
||||
if (@params["componentName"] != null)
|
||||
{
|
||||
typeName = @params["componentName"]?.ToString();
|
||||
}
|
||||
else if (
|
||||
@params["componentsToRemove"] is JArray componentsToRemoveArray
|
||||
&& componentsToRemoveArray.Count > 0
|
||||
)
|
||||
{
|
||||
typeName = componentsToRemoveArray.First?.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(typeName))
|
||||
{
|
||||
return new ErrorResponse(
|
||||
"Component type name ('componentName' or first element in 'componentsToRemove') is required."
|
||||
);
|
||||
}
|
||||
|
||||
var removeResult = RemoveComponentInternal(targetGo, typeName);
|
||||
if (removeResult != null)
|
||||
return removeResult; // Return error
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
// Use the new serializer helper
|
||||
return new SuccessResponse(
|
||||
$"Component '{typeName}' removed from '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
);
|
||||
}
|
||||
|
||||
private static object SetComponentPropertyOnTarget(
|
||||
JObject @params,
|
||||
JToken targetToken,
|
||||
string searchMethod
|
||||
)
|
||||
{
|
||||
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
|
||||
if (targetGo == null)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
|
||||
);
|
||||
}
|
||||
|
||||
string compName = @params["componentName"]?.ToString();
|
||||
JObject propertiesToSet = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(compName))
|
||||
{
|
||||
// Properties might be directly under componentProperties or nested under the component name
|
||||
if (@params["componentProperties"] is JObject compProps)
|
||||
{
|
||||
propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ErrorResponse("'componentName' parameter is required.");
|
||||
}
|
||||
|
||||
if (propertiesToSet == null || !propertiesToSet.HasValues)
|
||||
{
|
||||
return new ErrorResponse(
|
||||
"'componentProperties' dictionary for the specified component is required and cannot be empty."
|
||||
);
|
||||
}
|
||||
|
||||
var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet);
|
||||
if (setResult != null)
|
||||
return setResult; // Return error
|
||||
|
||||
EditorUtility.SetDirty(targetGo);
|
||||
// Use the new serializer helper
|
||||
return new SuccessResponse(
|
||||
$"Properties set for component '{compName}' on '{targetGo.name}'.",
|
||||
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Internal Helpers ---
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
Camera cam = Camera.main;
|
||||
if (cam == null)
|
||||
{
|
||||
var cams = UnityEngine.Object.FindObjectsOfType<Camera>();
|
||||
var cams = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
|
||||
cam = cams.FirstOrDefault();
|
||||
}
|
||||
|
||||
|
|
@ -629,6 +629,24 @@ namespace MCPForUnity.Editor.Tools
|
|||
try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { }
|
||||
bool childrenTruncated = childCount > 0; // We do not inline children in summary mode.
|
||||
|
||||
// Get component type names (lightweight - no full serialization)
|
||||
var componentTypes = new List<string>();
|
||||
try
|
||||
{
|
||||
var components = go.GetComponents<Component>();
|
||||
if (components != null)
|
||||
{
|
||||
foreach (var c in components)
|
||||
{
|
||||
if (c != null)
|
||||
{
|
||||
componentTypes.Add(c.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
var d = new Dictionary<string, object>
|
||||
{
|
||||
{ "name", go.name },
|
||||
|
|
@ -643,6 +661,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
{ "childrenTruncated", childrenTruncated },
|
||||
{ "childrenCursor", childCount > 0 ? "0" : null },
|
||||
{ "childrenPageSizeDefault", maxChildrenPerNode },
|
||||
{ "componentTypes", componentTypes }, // NEW: Lightweight component type list
|
||||
};
|
||||
|
||||
if (includeTransform && go.transform != null)
|
||||
|
|
|
|||
73
README.md
73
README.md
|
|
@ -41,28 +41,32 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
|
|||
|
||||
Your LLM can use functions like:
|
||||
|
||||
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
|
||||
* `manage_editor`: Controls and queries the editor's state and settings.
|
||||
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
|
||||
* `manage_material`: Manages materials: create, set properties, colors, assign to renderers, and query material info.
|
||||
* `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.).
|
||||
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
|
||||
* `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits.
|
||||
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths.
|
||||
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
||||
* `read_console`: Gets messages from or clears the console.
|
||||
* `run_tests_async`: Starts tests asynchronously and returns a job_id for polling (preferred).
|
||||
* `manage_asset`: Performs asset operations (import, create, modify, delete, search, etc.).
|
||||
* `manage_editor`: Controls editor state (play mode, active tool, tags, layers).
|
||||
* `manage_gameobject`: Manages GameObjects (create, modify, delete, find, duplicate, move).
|
||||
* `manage_components`: Manages components on GameObjects (add, remove, set properties).
|
||||
* `manage_material`: Manages materials (create, set properties, colors, assign to renderers).
|
||||
* `manage_prefabs`: Performs prefab operations (open/close stage, save, create from GameObject).
|
||||
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, screenshot).
|
||||
* `manage_script`: Legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits`.
|
||||
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets.
|
||||
* `manage_shader`: Shader CRUD operations (create, read, modify, delete).
|
||||
* `batch_execute`: ⚡ **RECOMMENDED** - Executes multiple commands in one batch for 10-100x better performance. Use this for any repetitive operations.
|
||||
* `find_gameobjects`: Search for GameObjects by name, tag, layer, component, path, or ID (paginated).
|
||||
* `read_console`: Gets messages from or clears the Unity console.
|
||||
* `refresh_unity`: Request asset database refresh and optional compilation.
|
||||
* `run_tests_async`: Starts tests asynchronously, returns job_id for polling (preferred).
|
||||
* `get_test_job`: Polls an async test job for progress and results.
|
||||
* `run_tests`: Runs tests synchronously (blocks until complete; prefer `run_tests_async` for long suites).
|
||||
* `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity.
|
||||
* `run_tests`: Runs tests synchronously (blocks until complete).
|
||||
* `execute_custom_tool`: Execute project-scoped custom tools registered by Unity.
|
||||
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
|
||||
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`.
|
||||
* `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches.
|
||||
* `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`.
|
||||
* `apply_text_edits`: Precise text edits with line/column ranges and precondition hashes.
|
||||
* `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries.
|
||||
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes.
|
||||
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues.
|
||||
* `create_script`: Create a new C# script at the given project path.
|
||||
* `delete_script`: Delete a C# script by URI or Assets-relative path.
|
||||
* `get_sha`: Get SHA256 and basic metadata for a Unity C# script without returning file contents.
|
||||
* `get_sha`: Get SHA256 and metadata for a Unity C# script without returning contents.
|
||||
</details>
|
||||
|
||||
|
||||
|
|
@ -72,17 +76,22 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
|
|||
Your LLM can retrieve the following resources:
|
||||
|
||||
* `custom_tools`: Lists custom tools available for the active Unity project.
|
||||
* `unity_instances`: Lists all running Unity Editor instances with their details (name, path, port, status).
|
||||
* `menu_items`: Retrieves all available menu items in the Unity Editor.
|
||||
* `tests`: Retrieves all available tests in the Unity Editor. Can select tests of a specific type (e.g., "EditMode", "PlayMode").
|
||||
* `unity_instances`: Lists all running Unity Editor instances with details (name, path, hash, status, session).
|
||||
* `menu_items`: All available menu items in the Unity Editor.
|
||||
* `tests`: All available tests (EditMode, PlayMode) in the Unity Editor.
|
||||
* `gameobject_api`: Documentation for GameObject resources and how to use `find_gameobjects` tool.
|
||||
* `unity://scene/gameobject/{instanceID}`: Read-only access to GameObject data (name, tag, transform, components, children).
|
||||
* `unity://scene/gameobject/{instanceID}/components`: Read-only access to all components on a GameObject with full property serialization.
|
||||
* `unity://scene/gameobject/{instanceID}/component/{componentName}`: Read-only access to a specific component's properties.
|
||||
* `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.
|
||||
* `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode.
|
||||
* `editor_selection`: Detailed information about currently selected objects in the editor.
|
||||
* `editor_state`: Current editor runtime state including play mode, compilation status, active scene, and selection summary.
|
||||
* `editor_windows`: All currently open editor windows with their titles, types, positions, and focus state.
|
||||
* `project_info`: Static project information including root path, Unity version, and platform.
|
||||
* `project_layers`: All layers defined in the project's TagManager with their indices (0-31).
|
||||
* `project_tags`: All tags defined in the project's TagManager.
|
||||
* `editor_state`: Current editor runtime state (play mode, compilation, active scene, selection).
|
||||
* `editor_state_v2`: Canonical editor readiness snapshot with advice and staleness info.
|
||||
* `editor_windows`: All currently open editor windows with titles, types, positions, and focus state.
|
||||
* `project_info`: Static project information (root path, Unity version, platform).
|
||||
* `project_layers`: All layers defined in TagManager with their indices (0-31).
|
||||
* `project_tags`: All tags defined in TagManager.
|
||||
</details>
|
||||
---
|
||||
|
||||
|
|
@ -341,6 +350,20 @@ Replace `YOUR_USERNAME` and `AppSupport` path segments as needed for your platfo
|
|||
|
||||
Example Prompt: `Create a 3D player controller`, `Create a tic-tac-toe game in 3D`, `Create a cool shader and apply to a cube`.
|
||||
|
||||
### 💡 Performance Tip: Use `batch_execute`
|
||||
|
||||
When performing multiple operations, use the `batch_execute` tool instead of calling tools one-by-one. This dramatically reduces latency and token costs:
|
||||
|
||||
```
|
||||
❌ Slow: Create 5 cubes → 5 separate manage_gameobject calls
|
||||
✅ Fast: Create 5 cubes → 1 batch_execute call with 5 commands
|
||||
|
||||
❌ Slow: Find objects, then add components to each → N+M separate calls
|
||||
✅ Fast: Find objects, then add components → 1 find + 1 batch with M component adds
|
||||
```
|
||||
|
||||
**Example prompt:** "Create 10 colored cubes in a grid using batch_execute"
|
||||
|
||||
### Working with Multiple Unity Instances
|
||||
|
||||
MCP for Unity supports multiple Unity Editor instances simultaneously. Each instance is isolated per MCP client session.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
"""
|
||||
MCP Resources for reading GameObject data from Unity scenes.
|
||||
|
||||
These resources provide read-only access to:
|
||||
- Single GameObject data (unity://scene/gameobject/{id})
|
||||
- All components on a GameObject (unity://scene/gameobject/{id}/components)
|
||||
- Single component on a GameObject (unity://scene/gameobject/{id}/component/{name})
|
||||
"""
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
from fastmcp import Context
|
||||
|
||||
from models import MCPResponse
|
||||
from services.registry import mcp_for_unity_resource
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
|
||||
|
||||
def _normalize_response(response: dict | Any) -> MCPResponse:
|
||||
"""Normalize Unity transport response to MCPResponse."""
|
||||
if isinstance(response, dict):
|
||||
return MCPResponse(**response)
|
||||
return response
|
||||
|
||||
|
||||
def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | None]:
|
||||
"""
|
||||
Validate and convert instance_id string to int.
|
||||
Returns (id_int, None) on success or (None, error_response) on failure.
|
||||
"""
|
||||
try:
|
||||
return int(instance_id), None
|
||||
except ValueError:
|
||||
return None, MCPResponse(success=False, error=f"Invalid instance ID: {instance_id}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Static Helper Resource (shows in UI)
|
||||
# =============================================================================
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="unity://scene/gameobject-api",
|
||||
name="gameobject_api",
|
||||
description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below."
|
||||
)
|
||||
async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse:
|
||||
"""
|
||||
Returns documentation for the GameObject resource API.
|
||||
|
||||
This is a helper resource that explains how to use the parameterized
|
||||
GameObject resources which require an instance ID.
|
||||
"""
|
||||
docs = {
|
||||
"overview": "GameObject resources provide read-only access to Unity scene objects.",
|
||||
"workflow": [
|
||||
"1. Use find_gameobjects tool to search for GameObjects and get instance IDs",
|
||||
"2. Use the instance ID to access detailed data via resources below"
|
||||
],
|
||||
"best_practices": [
|
||||
"⚡ Use batch_execute for multiple operations: Combine create/modify/component calls into one batch_execute call for 10-100x better performance",
|
||||
"Example: Creating 5 cubes → 1 batch_execute with 5 manage_gameobject commands instead of 5 separate calls",
|
||||
"Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands"
|
||||
],
|
||||
"resources": {
|
||||
"unity://scene/gameobject/{instance_id}": {
|
||||
"description": "Get basic GameObject data (name, tag, layer, transform, component type list)",
|
||||
"example": "unity://scene/gameobject/-81840",
|
||||
"returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"]
|
||||
},
|
||||
"unity://scene/gameobject/{instance_id}/components": {
|
||||
"description": "Get all components with full property serialization (paginated)",
|
||||
"example": "unity://scene/gameobject/-81840/components",
|
||||
"parameters": {
|
||||
"page_size": "Number of components per page (default: 25)",
|
||||
"cursor": "Pagination offset (default: 0)",
|
||||
"include_properties": "Include full property data (default: true)"
|
||||
}
|
||||
},
|
||||
"unity://scene/gameobject/{instance_id}/component/{component_name}": {
|
||||
"description": "Get a single component by type name with full properties",
|
||||
"example": "unity://scene/gameobject/-81840/component/Camera",
|
||||
"note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')"
|
||||
}
|
||||
},
|
||||
"related_tools": {
|
||||
"find_gameobjects": "Search for GameObjects by name, tag, layer, component, or path",
|
||||
"manage_components": "Add, remove, or modify components on GameObjects",
|
||||
"manage_gameobject": "Create, modify, or delete GameObjects"
|
||||
}
|
||||
}
|
||||
return MCPResponse(success=True, data=docs)
|
||||
|
||||
|
||||
class TransformData(BaseModel):
|
||||
"""Transform component data."""
|
||||
position: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
localPosition: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
rotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
localRotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
|
||||
scale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
|
||||
lossyScale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
|
||||
|
||||
|
||||
class GameObjectData(BaseModel):
|
||||
"""Data for a single GameObject (without full component serialization)."""
|
||||
instanceID: int
|
||||
name: str
|
||||
tag: str = "Untagged"
|
||||
layer: int = 0
|
||||
layerName: str = "Default"
|
||||
active: bool = True
|
||||
activeInHierarchy: bool = True
|
||||
isStatic: bool = False
|
||||
transform: TransformData = TransformData()
|
||||
parent: int | None = None
|
||||
children: list[int] = []
|
||||
componentTypes: list[str] = []
|
||||
path: str = ""
|
||||
|
||||
|
||||
# TODO: Use these typed response classes for better type safety once
|
||||
# we update the endpoints to validate response structure more strictly.
|
||||
class GameObjectResponse(MCPResponse):
|
||||
"""Response containing GameObject data."""
|
||||
data: GameObjectData | None = None
|
||||
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="unity://scene/gameobject/{instance_id}",
|
||||
name="gameobject",
|
||||
description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties)."
|
||||
)
|
||||
async def get_gameobject(ctx: Context, instance_id: str) -> MCPResponse:
|
||||
"""Get GameObject data by instance ID."""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
id_int, error = _validate_instance_id(instance_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"get_gameobject",
|
||||
{"instanceID": id_int}
|
||||
)
|
||||
|
||||
return _normalize_response(response)
|
||||
|
||||
|
||||
class ComponentsData(BaseModel):
|
||||
"""Data for components on a GameObject."""
|
||||
gameObjectID: int
|
||||
gameObjectName: str
|
||||
components: list[Any] = []
|
||||
cursor: int = 0
|
||||
pageSize: int = 25
|
||||
nextCursor: int | None = None
|
||||
totalCount: int = 0
|
||||
hasMore: bool = False
|
||||
includeProperties: bool = True
|
||||
|
||||
|
||||
class ComponentsResponse(MCPResponse):
|
||||
"""Response containing components data."""
|
||||
data: ComponentsData | None = None
|
||||
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="unity://scene/gameobject/{instance_id}/components",
|
||||
name="gameobject_components",
|
||||
description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters."
|
||||
)
|
||||
async def get_gameobject_components(
|
||||
ctx: Context,
|
||||
instance_id: str,
|
||||
page_size: int = 25,
|
||||
cursor: int = 0,
|
||||
include_properties: bool = True
|
||||
) -> MCPResponse:
|
||||
"""Get all components on a GameObject."""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
id_int, error = _validate_instance_id(instance_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"get_gameobject_components",
|
||||
{
|
||||
"instanceID": id_int,
|
||||
"pageSize": page_size,
|
||||
"cursor": cursor,
|
||||
"includeProperties": include_properties
|
||||
}
|
||||
)
|
||||
|
||||
return _normalize_response(response)
|
||||
|
||||
|
||||
class SingleComponentData(BaseModel):
|
||||
"""Data for a single component."""
|
||||
gameObjectID: int
|
||||
gameObjectName: str
|
||||
component: Any = None
|
||||
|
||||
|
||||
class SingleComponentResponse(MCPResponse):
|
||||
"""Response containing single component data."""
|
||||
data: SingleComponentData | None = None
|
||||
|
||||
|
||||
@mcp_for_unity_resource(
|
||||
uri="unity://scene/gameobject/{instance_id}/component/{component_name}",
|
||||
name="gameobject_component",
|
||||
description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties."
|
||||
)
|
||||
async def get_gameobject_component(
|
||||
ctx: Context,
|
||||
instance_id: str,
|
||||
component_name: str
|
||||
) -> MCPResponse:
|
||||
"""Get a specific component on a GameObject."""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
id_int, error = _validate_instance_id(instance_id)
|
||||
if error:
|
||||
return error
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"get_gameobject_component",
|
||||
{
|
||||
"instanceID": id_int,
|
||||
"componentName": component_name
|
||||
}
|
||||
)
|
||||
|
||||
return _normalize_response(response)
|
||||
|
||||
|
|
@ -17,11 +17,10 @@ MAX_COMMANDS_PER_BATCH = 25
|
|||
@mcp_for_unity_tool(
|
||||
name="batch_execute",
|
||||
description=(
|
||||
"Runs a list of MCP tool calls as one batch. Use it to send a full sequence of commands, "
|
||||
"inspect the results, then submit the next batch for the following step. "
|
||||
"Note: Safety characteristics depend on the tools contained in the batch—batches with only "
|
||||
"read-only tools (e.g., find, get_info) are safe, while batches containing create/modify/delete "
|
||||
"operations may be destructive."
|
||||
"Executes multiple MCP commands in a single batch for dramatically better performance. "
|
||||
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
|
||||
"or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
|
||||
"sequential tool calls. Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
title="Batch Execute",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
Tool for searching GameObjects in Unity scenes.
|
||||
Returns only instance IDs with pagination support for efficient searches.
|
||||
"""
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastmcp import Context
|
||||
from services.registry import mcp_for_unity_tool
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.utils import coerce_bool, coerce_int
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Search for GameObjects in the scene. Returns instance IDs only (paginated) for efficient lookups. Use unity://scene/gameobject/{id} resource to get full GameObject data."
|
||||
)
|
||||
async def find_gameobjects(
|
||||
ctx: Context,
|
||||
search_term: Annotated[str, "The value to search for (name, tag, layer name, component type, or path)"],
|
||||
search_method: Annotated[
|
||||
Literal["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"],
|
||||
"How to search for GameObjects"
|
||||
] = "by_name",
|
||||
include_inactive: Annotated[bool | str, "Include inactive GameObjects in search"] | None = None,
|
||||
page_size: Annotated[int | str, "Number of results per page (default: 50, max: 500)"] | None = None,
|
||||
cursor: Annotated[int | str, "Pagination cursor (offset for next page)"] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search for GameObjects and return their instance IDs.
|
||||
|
||||
This is a focused search tool optimized for finding GameObjects efficiently.
|
||||
It returns only instance IDs to minimize payload size.
|
||||
|
||||
For detailed GameObject information, use the returned IDs with:
|
||||
- unity://scene/gameobject/{id} - Get full GameObject data
|
||||
- unity://scene/gameobject/{id}/components - Get all components
|
||||
- unity://scene/gameobject/{id}/component/{name} - Get specific component
|
||||
"""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||
if gate is not None:
|
||||
return gate.model_dump()
|
||||
|
||||
if not search_term:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'search_term'. Specify what to search for."
|
||||
}
|
||||
|
||||
# Coerce parameters
|
||||
include_inactive = coerce_bool(include_inactive, default=False)
|
||||
page_size = coerce_int(page_size, default=50)
|
||||
cursor = coerce_int(cursor, default=0)
|
||||
|
||||
try:
|
||||
params = {
|
||||
"searchMethod": search_method,
|
||||
"searchTerm": search_term,
|
||||
"includeInactive": include_inactive,
|
||||
"pageSize": page_size,
|
||||
"cursor": cursor,
|
||||
}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"find_gameobjects",
|
||||
params,
|
||||
)
|
||||
|
||||
if isinstance(response, dict) and response.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": response.get("message", "Search completed."),
|
||||
"data": response.get("data")
|
||||
}
|
||||
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error searching GameObjects: {e!s}"}
|
||||
|
||||
|
|
@ -17,6 +17,41 @@ from transport.legacy.unity_connection import async_send_command_with_retry
|
|||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize properties parameter to a dict.
|
||||
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return {}, None
|
||||
|
||||
# Already a dict - return as-is
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values from serialization bugs
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
|
||||
|
||||
# Try JSON parsing first
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
# Fallback to ast.literal_eval for Python dict literals
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
return None, f"properties must evaluate to a dict, got {type(parsed).__name__}"
|
||||
except (ValueError, SyntaxError) as e:
|
||||
return None, f"Failed to parse properties: {e}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description=(
|
||||
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
|
||||
|
|
@ -34,8 +69,8 @@ async def manage_asset(
|
|||
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
|
||||
asset_type: Annotated[str,
|
||||
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
|
||||
properties: Annotated[dict[str, Any] | str,
|
||||
"Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
|
||||
properties: Annotated[dict[str, Any],
|
||||
"Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
|
||||
destination: Annotated[str,
|
||||
"Target path for 'duplicate'/'move'."] | None = None,
|
||||
generate_preview: Annotated[bool,
|
||||
|
|
@ -60,46 +95,10 @@ async def manage_asset(
|
|||
if gate is not None:
|
||||
return gate.model_dump()
|
||||
|
||||
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
|
||||
return parsed, "JSON"
|
||||
except json.JSONDecodeError as json_err:
|
||||
try:
|
||||
parsed = ast.literal_eval(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
|
||||
return parsed, "Python literal"
|
||||
except (ValueError, SyntaxError) as literal_err:
|
||||
return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
|
||||
|
||||
async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
|
||||
if raw is None:
|
||||
return {}, None
|
||||
if isinstance(raw, dict):
|
||||
await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
|
||||
return raw, None
|
||||
if isinstance(raw, str):
|
||||
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
|
||||
# Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
|
||||
parsed = parse_json_payload(raw)
|
||||
if isinstance(parsed, dict):
|
||||
await ctx.info("manage_asset: coerced properties using centralized parser")
|
||||
return parsed, None
|
||||
|
||||
# Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
|
||||
parsed, source = _parse_properties_string(raw)
|
||||
if parsed is None:
|
||||
return None, source
|
||||
await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
|
||||
return parsed, None
|
||||
return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
|
||||
|
||||
properties, parse_error = await _normalize_properties(properties)
|
||||
# --- Normalize properties using robust module-level helper ---
|
||||
properties, parse_error = _normalize_properties(properties)
|
||||
if parse_error:
|
||||
await ctx.error(parse_error)
|
||||
await ctx.error(f"manage_asset: {parse_error}")
|
||||
return {"success": False, "message": parse_error}
|
||||
|
||||
page_size = coerce_int(page_size)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
Tool for managing components on GameObjects in Unity.
|
||||
Supports add, remove, and set_property operations.
|
||||
"""
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastmcp import Context
|
||||
from services.registry import mcp_for_unity_tool
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
from services.tools.utils import parse_json_payload
|
||||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize properties parameter to a dict.
|
||||
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
# Already a dict - return as-is
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values from serialization bugs
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"propertyName\": value}}"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the unity://scene/gameobject/{id}/components resource."
|
||||
)
|
||||
async def manage_components(
|
||||
ctx: Context,
|
||||
action: Annotated[
|
||||
Literal["add", "remove", "set_property"],
|
||||
"Action to perform: add (add component), remove (remove component), set_property (set component property)"
|
||||
],
|
||||
target: Annotated[
|
||||
str | int,
|
||||
"Target GameObject - instance ID (preferred) or name/path"
|
||||
],
|
||||
component_type: Annotated[
|
||||
str,
|
||||
"Component type name (e.g., 'Rigidbody', 'BoxCollider', 'MyScript')"
|
||||
],
|
||||
search_method: Annotated[
|
||||
Literal["by_id", "by_name", "by_path"],
|
||||
"How to find the target GameObject"
|
||||
] | None = None,
|
||||
# For set_property action - single property
|
||||
property: Annotated[str, "Property name to set (for set_property action)"] | None = None,
|
||||
value: Annotated[Any, "Value to set (for set_property action)"] | None = None,
|
||||
# For add/set_property - multiple properties
|
||||
properties: Annotated[
|
||||
dict[str, Any],
|
||||
"Dictionary of property names to values. Example: {\"mass\": 5.0, \"useGravity\": false}"
|
||||
] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Manage components on GameObjects.
|
||||
|
||||
Actions:
|
||||
- add: Add a new component to a GameObject
|
||||
- remove: Remove a component from a GameObject
|
||||
- set_property: Set one or more properties on a component
|
||||
|
||||
Examples:
|
||||
- Add Rigidbody: action="add", target="Player", component_type="Rigidbody"
|
||||
- Remove BoxCollider: action="remove", target=-12345, component_type="BoxCollider"
|
||||
- Set single property: action="set_property", target="Enemy", component_type="Rigidbody", property="mass", value=5.0
|
||||
- Set multiple properties: action="set_property", target="Enemy", component_type="Rigidbody", properties={"mass": 5.0, "useGravity": false}
|
||||
"""
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
|
||||
if gate is not None:
|
||||
return gate.model_dump()
|
||||
|
||||
if not action:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'action'. Valid actions: add, remove, set_property"
|
||||
}
|
||||
|
||||
if not target:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'target'. Specify GameObject instance ID or name."
|
||||
}
|
||||
|
||||
if not component_type:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameter 'component_type'. Specify the component type name."
|
||||
}
|
||||
|
||||
# --- Normalize properties with detailed error handling ---
|
||||
properties, props_error = _normalize_properties(properties)
|
||||
if props_error:
|
||||
return {"success": False, "message": props_error}
|
||||
|
||||
# --- Validate value parameter for serialization issues ---
|
||||
if value is not None and isinstance(value, str) and value in ("[object Object]", "undefined"):
|
||||
return {"success": False, "message": f"value received invalid input: '{value}'. Expected an actual value."}
|
||||
|
||||
try:
|
||||
params = {
|
||||
"action": action,
|
||||
"target": target,
|
||||
"componentType": component_type,
|
||||
}
|
||||
|
||||
if search_method:
|
||||
params["searchMethod"] = search_method
|
||||
|
||||
if action == "set_property":
|
||||
if property and value is not None:
|
||||
params["property"] = property
|
||||
params["value"] = value
|
||||
if properties:
|
||||
params["properties"] = properties
|
||||
|
||||
if action == "add" and properties:
|
||||
params["properties"] = properties
|
||||
|
||||
response = await send_with_unity_instance(
|
||||
async_send_command_with_retry,
|
||||
unity_instance,
|
||||
"manage_components",
|
||||
params,
|
||||
)
|
||||
|
||||
if isinstance(response, dict) and response.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": response.get("message", f"Component {action} successful."),
|
||||
"data": response.get("data")
|
||||
}
|
||||
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error managing component: {e!s}"}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
import math
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastmcp import Context
|
||||
from mcp.types import ToolAnnotations
|
||||
|
|
@ -13,6 +13,75 @@ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
|
|||
from services.tools.preflight import preflight
|
||||
|
||||
|
||||
def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
|
||||
"""
|
||||
Robustly normalize a vector parameter to [x, y, z] format.
|
||||
Handles: list, tuple, JSON string, comma-separated string.
|
||||
Returns None if parsing fails.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
# If already a list/tuple with 3 elements, convert to floats
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
try:
|
||||
vec = [float(value[0]), float(value[1]), float(value[2])]
|
||||
return vec if all(math.isfinite(n) for n in vec) else default
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
# Try parsing as JSON string
|
||||
if isinstance(value, str):
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, list) and len(parsed) == 3:
|
||||
try:
|
||||
vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
|
||||
return vec if all(math.isfinite(n) for n in vec) else default
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Handle legacy comma-separated strings "1,2,3" or "[1,2,3]"
|
||||
s = value.strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
s = s[1:-1]
|
||||
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
return vec if all(math.isfinite(n) for n in vec) else default
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
|
||||
"""
|
||||
Robustly normalize component_properties to a dict.
|
||||
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
# Already a dict - validate structure
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
# Try parsing as JSON string
|
||||
if isinstance(value, str):
|
||||
# Check for obviously invalid values
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
|
||||
return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
|
||||
|
||||
return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Performs CRUD operations on GameObjects and components. Read-only actions: find, get_components, get_component. Modifying actions: create, modify, delete, add_component, remove_component, set_component_property, duplicate, move_relative.",
|
||||
annotations=ToolAnnotations(
|
||||
|
|
@ -33,12 +102,12 @@ async def manage_gameobject(
|
|||
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
|
||||
parent: Annotated[str,
|
||||
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
|
||||
position: Annotated[Union[list[float], str],
|
||||
"Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||
rotation: Annotated[Union[list[float], str],
|
||||
"Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||
scale: Annotated[Union[list[float], str],
|
||||
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||
position: Annotated[list[float],
|
||||
"Position as [x, y, z] array"] | None = None,
|
||||
rotation: Annotated[list[float],
|
||||
"Rotation as [x, y, z] euler angles array"] | None = None,
|
||||
scale: Annotated[list[float],
|
||||
"Scale as [x, y, z] array"] | None = None,
|
||||
components_to_add: Annotated[list[str],
|
||||
"List of component names to add"] | None = None,
|
||||
primitive_type: Annotated[str,
|
||||
|
|
@ -54,7 +123,7 @@ async def manage_gameobject(
|
|||
layer: Annotated[str, "Layer name"] | None = None,
|
||||
components_to_remove: Annotated[list[str],
|
||||
"List of component names to remove"] | None = None,
|
||||
component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
|
||||
component_properties: Annotated[dict[str, dict[str, Any]],
|
||||
"""Dictionary of component names to their properties to set. For example:
|
||||
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
|
||||
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
|
||||
|
|
@ -83,8 +152,8 @@ async def manage_gameobject(
|
|||
# --- Parameters for 'duplicate' ---
|
||||
new_name: Annotated[str,
|
||||
"New name for the duplicated object (default: SourceName_Copy)"] | None = None,
|
||||
offset: Annotated[Union[list[float], str],
|
||||
"Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
|
||||
offset: Annotated[list[float],
|
||||
"Offset from original/reference position as [x, y, z] array"] | None = None,
|
||||
# --- Parameters for 'move_relative' ---
|
||||
reference_object: Annotated[str,
|
||||
"Reference object for relative movement (required for move_relative)"] | None = None,
|
||||
|
|
@ -109,41 +178,13 @@ async def manage_gameobject(
|
|||
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative"
|
||||
}
|
||||
|
||||
# Coercers to tolerate stringified booleans and vectors
|
||||
def _coerce_vec(value, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
# --- Normalize vector parameters using robust helper ---
|
||||
position = _normalize_vector(position)
|
||||
rotation = _normalize_vector(rotation)
|
||||
scale = _normalize_vector(scale)
|
||||
offset = _normalize_vector(offset)
|
||||
|
||||
# First try to parse if it's a string
|
||||
val = parse_json_payload(value)
|
||||
|
||||
def _to_vec3(parts):
|
||||
try:
|
||||
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
return vec if all(math.isfinite(n) for n in vec) else default
|
||||
|
||||
if isinstance(val, list) and len(val) == 3:
|
||||
return _to_vec3(val)
|
||||
|
||||
# Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
|
||||
if isinstance(val, str):
|
||||
s = val.strip()
|
||||
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
s = s[1:-1]
|
||||
# support "x,y,z" and "x y z"
|
||||
parts = [p.strip()
|
||||
for p in (s.split(",") if "," in s else s.split())]
|
||||
if len(parts) == 3:
|
||||
return _to_vec3(parts)
|
||||
return default
|
||||
|
||||
position = _coerce_vec(position, default=position)
|
||||
rotation = _coerce_vec(rotation, default=rotation)
|
||||
scale = _coerce_vec(scale, default=scale)
|
||||
offset = _coerce_vec(offset, default=offset)
|
||||
# --- Normalize boolean parameters ---
|
||||
save_as_prefab = coerce_bool(save_as_prefab)
|
||||
set_active = coerce_bool(set_active)
|
||||
find_all = coerce_bool(find_all)
|
||||
|
|
@ -152,17 +193,16 @@ async def manage_gameobject(
|
|||
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
|
||||
include_properties = coerce_bool(include_properties)
|
||||
world_space = coerce_bool(world_space, default=True)
|
||||
# If coercion fails, omit these fields (None) rather than preserving invalid input.
|
||||
|
||||
# --- Normalize integer parameters ---
|
||||
page_size = coerce_int(page_size, default=None)
|
||||
cursor = coerce_int(cursor, default=None)
|
||||
max_components = coerce_int(max_components, default=None)
|
||||
|
||||
# Coerce 'component_properties' from JSON string to dict for client compatibility
|
||||
component_properties = parse_json_payload(component_properties)
|
||||
|
||||
# Ensure final type is a dict (object) if provided
|
||||
if component_properties is not None and not isinstance(component_properties, dict):
|
||||
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
|
||||
# --- Normalize component_properties with detailed error handling ---
|
||||
component_properties, comp_props_error = _normalize_component_properties(component_properties)
|
||||
if comp_props_error:
|
||||
return {"success": False, "message": comp_props_error}
|
||||
|
||||
try:
|
||||
# Map tag to search_term when search_method is by_tag for backward compatibility
|
||||
|
|
|
|||
|
|
@ -2,18 +2,73 @@
|
|||
Defines the manage_material tool for interacting with Unity materials.
|
||||
"""
|
||||
import json
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastmcp import Context
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from services.registry import mcp_for_unity_tool
|
||||
from services.tools import get_unity_instance_from_context
|
||||
from services.tools.utils import parse_json_payload
|
||||
from services.tools.utils import parse_json_payload, coerce_int
|
||||
from transport.unity_transport import send_with_unity_instance
|
||||
from transport.legacy.unity_connection import async_send_command_with_retry
|
||||
|
||||
|
||||
def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
|
||||
"""
|
||||
Normalize color parameter to [r, g, b] or [r, g, b, a] format.
|
||||
Returns (parsed_color, error_message).
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
# Already a list - validate
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) in (3, 4):
|
||||
try:
|
||||
return [float(c) for c in value], None
|
||||
except (ValueError, TypeError):
|
||||
return None, f"color values must be numbers, got {value}"
|
||||
return None, f"color must have 3 or 4 components, got {len(value)}"
|
||||
|
||||
# Try parsing as string
|
||||
if isinstance(value, str):
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
|
||||
try:
|
||||
return [float(c) for c in parsed], None
|
||||
except (ValueError, TypeError):
|
||||
return None, f"color values must be numbers, got {parsed}"
|
||||
return None, f"Failed to parse color string: {value}"
|
||||
|
||||
return None, f"color must be a list or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Normalize properties parameter to a dict.
|
||||
"""
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value, None
|
||||
|
||||
if isinstance(value, str):
|
||||
if value in ("[object Object]", "undefined", "null", ""):
|
||||
return None, f"properties received invalid value: '{value}'. Expected a JSON object"
|
||||
|
||||
parsed = parse_json_payload(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed, None
|
||||
return None, f"properties must parse to a dict, got {type(parsed).__name__}"
|
||||
|
||||
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
|
||||
|
||||
|
||||
@mcp_for_unity_tool(
|
||||
description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
|
||||
annotations=ToolAnnotations(
|
||||
|
|
@ -39,38 +94,40 @@ async def manage_material(
|
|||
|
||||
# create
|
||||
shader: Annotated[str, "Shader name (default: Standard)"] | None = None,
|
||||
properties: Annotated[Union[dict[str, Any], str], "Initial properties to set {name: value}."] | None = None,
|
||||
properties: Annotated[dict[str, Any], "Initial properties to set as {name: value} dict."] | None = None,
|
||||
|
||||
# set_material_shader_property
|
||||
value: Annotated[Union[list, float, int, str, bool, None], "Value to set (color array, float, texture path/instruction)"] | None = None,
|
||||
value: Annotated[list | float | int | str | bool | None, "Value to set (color array, float, texture path/instruction)"] | None = None,
|
||||
|
||||
# set_material_color / set_renderer_color
|
||||
color: Annotated[Union[list[float], list[int], str], "Color as [r,g,b] or [r,g,b,a]."] | None = None,
|
||||
color: Annotated[list[float], "Color as [r, g, b] or [r, g, b, a] array."] | None = None,
|
||||
|
||||
# assign_material_to_renderer / set_renderer_color
|
||||
target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None,
|
||||
search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None,
|
||||
slot: Annotated[int | str, "Material slot index"] | None = None,
|
||||
slot: Annotated[int, "Material slot index (0-based)"] | None = None,
|
||||
mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
unity_instance = get_unity_instance_from_context(ctx)
|
||||
|
||||
# Parse inputs that might be stringified JSON
|
||||
color = parse_json_payload(color)
|
||||
properties = parse_json_payload(properties)
|
||||
value = parse_json_payload(value)
|
||||
# --- Normalize color with validation ---
|
||||
color, color_error = _normalize_color(color)
|
||||
if color_error:
|
||||
return {"success": False, "message": color_error}
|
||||
|
||||
# Coerce slot to int if it's a string
|
||||
if slot is not None:
|
||||
if isinstance(slot, str):
|
||||
try:
|
||||
slot = int(slot)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Invalid slot value: '{slot}' must be a valid integer"
|
||||
}
|
||||
# --- Normalize properties with validation ---
|
||||
properties, props_error = _normalize_properties(properties)
|
||||
if props_error:
|
||||
return {"success": False, "message": props_error}
|
||||
|
||||
# --- Normalize value (parse JSON if string) ---
|
||||
value = parse_json_payload(value)
|
||||
if isinstance(value, str) and value in ("[object Object]", "undefined"):
|
||||
return {"success": False, "message": f"value received invalid input: '{value}'"}
|
||||
|
||||
# --- Normalize slot to int ---
|
||||
slot = coerce_int(slot)
|
||||
|
||||
# Prepare parameters for the C# handler
|
||||
params_dict = {
|
||||
|
|
|
|||
|
|
@ -90,38 +90,5 @@ fastmcp_server.middleware = fastmcp_server_middleware
|
|||
sys.modules.setdefault("fastmcp.server", fastmcp_server)
|
||||
sys.modules.setdefault("fastmcp.server.middleware", fastmcp_server_middleware)
|
||||
|
||||
# Stub minimal starlette modules to avoid optional dependency imports.
|
||||
starlette = types.ModuleType("starlette")
|
||||
starlette_endpoints = types.ModuleType("starlette.endpoints")
|
||||
starlette_websockets = types.ModuleType("starlette.websockets")
|
||||
starlette_requests = types.ModuleType("starlette.requests")
|
||||
starlette_responses = types.ModuleType("starlette.responses")
|
||||
|
||||
|
||||
class _DummyWebSocketEndpoint:
|
||||
pass
|
||||
|
||||
|
||||
class _DummyWebSocket:
|
||||
pass
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
pass
|
||||
|
||||
|
||||
class _DummyJSONResponse:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
starlette_endpoints.WebSocketEndpoint = _DummyWebSocketEndpoint
|
||||
starlette_websockets.WebSocket = _DummyWebSocket
|
||||
starlette_requests.Request = _DummyRequest
|
||||
starlette_responses.JSONResponse = _DummyJSONResponse
|
||||
|
||||
sys.modules.setdefault("starlette", starlette)
|
||||
sys.modules.setdefault("starlette.endpoints", starlette_endpoints)
|
||||
sys.modules.setdefault("starlette.websockets", starlette_websockets)
|
||||
sys.modules.setdefault("starlette.requests", starlette_requests)
|
||||
sys.modules.setdefault("starlette.responses", starlette_responses)
|
||||
# Note: starlette is now a proper dependency (via mcp package), so we don't stub it anymore.
|
||||
# The real starlette package will be imported when needed.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
"""
|
||||
Tests for the find_gameobjects tool.
|
||||
|
||||
This tool provides paginated GameObject search, returning instance IDs only.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.find_gameobjects as find_go_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_basic_search(monkeypatch):
|
||||
"""Test basic search returns instance IDs."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [12345, 67890],
|
||||
"pageSize": 25,
|
||||
"cursor": 0,
|
||||
"totalCount": 2,
|
||||
"hasMore": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Player",
|
||||
search_method="by_name",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["cmd"] == "find_gameobjects"
|
||||
assert captured["params"]["searchTerm"] == "Player"
|
||||
assert captured["params"]["searchMethod"] == "by_name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_component(monkeypatch):
|
||||
"""Test search by component type."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [111, 222, 333],
|
||||
"pageSize": 25,
|
||||
"cursor": 0,
|
||||
"totalCount": 3,
|
||||
"hasMore": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Camera",
|
||||
search_method="by_component",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchTerm"] == "Camera"
|
||||
assert captured["params"]["searchMethod"] == "by_component"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_pagination_params(monkeypatch):
|
||||
"""Test pagination parameters are passed correctly."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceIDs": [444, 555],
|
||||
"pageSize": 10,
|
||||
"cursor": 20,
|
||||
"totalCount": 50,
|
||||
"hasMore": True,
|
||||
"nextCursor": "30",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Enemy",
|
||||
search_method="by_tag",
|
||||
page_size="10",
|
||||
cursor="20",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["pageSize"] == 10
|
||||
assert p["cursor"] == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_boolean_coercion(monkeypatch):
|
||||
"""Test boolean string coercion for include_inactive."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": []}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="HiddenObject",
|
||||
search_method="by_name",
|
||||
include_inactive="true",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
p = captured["params"]
|
||||
assert p["includeInactive"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_layer(monkeypatch):
|
||||
"""Test search by layer."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": [999]}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="UI",
|
||||
search_method="by_layer",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_layer"
|
||||
assert captured["params"]["searchTerm"] == "UI"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_gameobjects_by_path(monkeypatch):
|
||||
"""Test search by hierarchy path."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceIDs": [777]}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
find_go_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await find_go_mod.find_gameobjects(
|
||||
ctx=DummyContext(),
|
||||
search_term="Canvas/Panel/Button",
|
||||
search_method="by_path",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_path"
|
||||
assert captured["params"]["searchTerm"] == "Canvas/Panel/Button"
|
||||
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Tests for the GameObject resources.
|
||||
|
||||
Resources:
|
||||
- unity://scene/gameobject/{instance_id}
|
||||
- unity://scene/gameobject/{instance_id}/components
|
||||
- unity://scene/gameobject/{instance_id}/component/{component_name}
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.resources.gameobject as gameobject_res_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_data(monkeypatch):
|
||||
"""Test reading a single GameObject resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"instanceID": 12345,
|
||||
"name": "Player",
|
||||
"tag": "Player",
|
||||
"layer": 0,
|
||||
"activeSelf": True,
|
||||
"activeInHierarchy": True,
|
||||
"isStatic": False,
|
||||
"path": "/Player",
|
||||
"componentTypes": ["Transform", "PlayerController", "Rigidbody"],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["instanceID"] == 12345
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components(monkeypatch):
|
||||
"""Test reading all components for a GameObject."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"cursor": 0,
|
||||
"pageSize": 25,
|
||||
"next_cursor": None,
|
||||
"truncated": False,
|
||||
"total": 3,
|
||||
"items": [
|
||||
{"typeName": "UnityEngine.Transform", "instanceID": 1, "enabled": True},
|
||||
{"typeName": "UnityEngine.MeshRenderer", "instanceID": 2, "enabled": True},
|
||||
{"typeName": "UnityEngine.BoxCollider", "instanceID": 3, "enabled": True},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["instanceID"] == 12345
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components_pagination(monkeypatch):
|
||||
"""Test pagination parameters for components resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"cursor": 10,
|
||||
"pageSize": 5,
|
||||
"next_cursor": "15",
|
||||
"truncated": True,
|
||||
"total": 20,
|
||||
"items": [],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
page_size=5,
|
||||
cursor=10,
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
p = captured["params"]
|
||||
assert p["pageSize"] == 5
|
||||
assert p["cursor"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_components_include_properties(monkeypatch):
|
||||
"""Test include_properties flag for components resource."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"typeName": "UnityEngine.Rigidbody",
|
||||
"instanceID": 123,
|
||||
"mass": 1.0,
|
||||
"drag": 0.0,
|
||||
"useGravity": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_components(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
include_properties=True,
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert captured["params"]["includeProperties"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_component_single(monkeypatch):
|
||||
"""Test reading a single component by name."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"typeName": "UnityEngine.Rigidbody",
|
||||
"instanceID": 67890,
|
||||
"mass": 5.0,
|
||||
"drag": 0.1,
|
||||
"angularDrag": 0.05,
|
||||
"useGravity": True,
|
||||
"isKinematic": False,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_component(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
component_name="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
p = captured["params"]
|
||||
assert p["instanceID"] == 12345
|
||||
assert p["componentName"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_component_not_found(monkeypatch):
|
||||
"""Test error when component is not found."""
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "GameObject '12345' does not have a 'NonExistent' component.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject_component(
|
||||
ctx=DummyContext(),
|
||||
instance_id="12345",
|
||||
component_name="NonExistent",
|
||||
)
|
||||
|
||||
assert resp.success is False
|
||||
assert "NonExistent" in (resp.message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_gameobject_not_found(monkeypatch):
|
||||
"""Test error when GameObject is not found."""
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "GameObject with instanceID '99999' not found.",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
gameobject_res_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await gameobject_res_mod.get_gameobject(
|
||||
ctx=DummyContext(),
|
||||
instance_id="99999",
|
||||
)
|
||||
|
||||
assert resp.success is False
|
||||
assert "99999" in (resp.message or "")
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ class TestManageAssetJsonParsing:
|
|||
|
||||
# Verify behavior: parsing fails with a clear error
|
||||
assert result.get("success") is False
|
||||
assert "failed to parse properties" in result.get("message", "")
|
||||
assert "Failed to parse properties" in result.get("message", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_properties_dict_unchanged(self, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
"""
|
||||
Tests for the manage_components tool.
|
||||
|
||||
This tool handles component lifecycle operations (add, remove, set_property).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from .test_helpers import DummyContext
|
||||
import services.tools.manage_components as manage_comp_mod
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_add_single(monkeypatch):
|
||||
"""Test adding a single component."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"addedComponents": [{"typeName": "UnityEngine.Rigidbody", "instanceID": 12345}]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["cmd"] == "manage_components"
|
||||
assert captured["params"]["action"] == "add"
|
||||
assert captured["params"]["target"] == "Player"
|
||||
assert captured["params"]["componentType"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_remove(monkeypatch):
|
||||
"""Test removing a component."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345, "name": "Player"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="remove",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "remove"
|
||||
assert captured["params"]["componentType"] == "Rigidbody"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_single(monkeypatch):
|
||||
"""Test setting a single component property."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
property="mass",
|
||||
value=5.0,
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "set_property"
|
||||
assert captured["params"]["property"] == "mass"
|
||||
assert captured["params"]["value"] == 5.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_multiple(monkeypatch):
|
||||
"""Test setting multiple component properties via properties dict."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties={"mass": 5.0, "drag": 0.5},
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["action"] == "set_property"
|
||||
assert captured["params"]["properties"] == {"mass": 5.0, "drag": 0.5}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_set_property_json_string(monkeypatch):
|
||||
"""Test setting component properties with JSON string input."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {"instanceID": 12345}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="set_property",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties='{"mass": 10.0}', # JSON string
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["properties"] == {"mass": 10.0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_add_with_properties(monkeypatch):
|
||||
"""Test adding a component with initial properties."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"addedComponents": [{"typeName": "Rigidbody", "instanceID": 123}]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Player",
|
||||
component_type="Rigidbody",
|
||||
properties={"mass": 2.0, "useGravity": False},
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["properties"] == {"mass": 2.0, "useGravity": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_search_method_passthrough(monkeypatch):
|
||||
"""Test that search_method is correctly passed through."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target="Canvas/Panel",
|
||||
component_type="Image",
|
||||
search_method="by_path",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["searchMethod"] == "by_path"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_components_target_by_id(monkeypatch):
|
||||
"""Test targeting by instance ID."""
|
||||
captured = {}
|
||||
|
||||
async def fake_send(cmd, params, **kwargs):
|
||||
captured["params"] = params
|
||||
return {"success": True, "data": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
manage_comp_mod,
|
||||
"async_send_command_with_retry",
|
||||
fake_send,
|
||||
)
|
||||
|
||||
resp = await manage_comp_mod.manage_components(
|
||||
ctx=DummyContext(),
|
||||
action="add",
|
||||
target=12345, # Integer instance ID
|
||||
component_type="BoxCollider",
|
||||
search_method="by_id",
|
||||
)
|
||||
|
||||
assert resp.get("success") is True
|
||||
assert captured["params"]["target"] == 12345
|
||||
assert captured["params"]["searchMethod"] == "by_id"
|
||||
|
||||
|
|
@ -21,6 +21,10 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
private int padAccumulator = 0;
|
||||
private Vector3 padVector = Vector3.zero;
|
||||
|
||||
// Animation blend hashes
|
||||
private static readonly int BlendXHash = Animator.StringToHash("BlendX");
|
||||
private static readonly int BlendYHash = Animator.StringToHash("BlendY");
|
||||
|
||||
|
||||
[Header("Tuning")]
|
||||
public float maxReachDistance = 2f;
|
||||
|
|
@ -31,6 +35,11 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
public bool HasTarget() { return currentTarget != null; }
|
||||
public Transform GetCurrentTarget() => currentTarget;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Simple selection logic (self-contained)
|
||||
private Transform FindBestTarget()
|
||||
{
|
||||
|
|
@ -60,6 +69,7 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
}
|
||||
|
||||
// NL tests sometimes add comments above Update() as an anchor
|
||||
// Build marker OK
|
||||
private void Update()
|
||||
{
|
||||
if (reachOrigin == null) return;
|
||||
|
|
@ -94,11 +104,11 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
return new Vector3(bx, by, 0f);
|
||||
}
|
||||
|
||||
private void ApplyBlend(Vector3 blend)
|
||||
private void ApplyBlend(Vector3 blend) // safe animation
|
||||
{
|
||||
if (animator == null) return;
|
||||
animator.SetFloat("reachX", blend.x);
|
||||
animator.SetFloat("reachY", blend.y);
|
||||
if (animator == null) return; // safety check
|
||||
animator.SetFloat(BlendXHash, blend.x);
|
||||
animator.SetFloat(BlendYHash, blend.y);
|
||||
}
|
||||
|
||||
public void TickBlendOnce()
|
||||
|
|
@ -747,6 +757,19 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
|
|||
}
|
||||
private void Pad0240()
|
||||
{
|
||||
// Tail test A
|
||||
// Tail test B
|
||||
// Tail test C
|
||||
// idempotency test marker
|
||||
|
||||
void TestHelper() { /* placeholder */ }
|
||||
void IncrementCounter() { padAccumulator++; }
|
||||
// end of test modifications
|
||||
// path test marker A
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
private void Pad0241()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,573 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using MCPForUnity.Editor.Tools;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Resources.Scene;
|
||||
using UnityEngine.TestTools;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace MCPForUnityTests.Editor.Tools
|
||||
{
|
||||
/// <summary>
|
||||
/// Stress tests for the GameObject API redesign.
|
||||
/// Tests volume operations, pagination, and performance with large datasets.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GameObjectAPIStressTests
|
||||
{
|
||||
private List<GameObject> _createdObjects = new List<GameObject>();
|
||||
private const int SMALL_BATCH = 10;
|
||||
private const int MEDIUM_BATCH = 50;
|
||||
private const int LARGE_BATCH = 100;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_createdObjects.Clear();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in _createdObjects)
|
||||
{
|
||||
if (go != null)
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
_createdObjects.Clear();
|
||||
}
|
||||
|
||||
private GameObject CreateTestObject(string name)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
_createdObjects.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static JObject ToJObject(object result)
|
||||
{
|
||||
if (result == null) return new JObject();
|
||||
return result as JObject ?? JObject.FromObject(result);
|
||||
}
|
||||
|
||||
#region Bulk GameObject Creation
|
||||
|
||||
[Test]
|
||||
public void BulkCreate_SmallBatch_AllSucceed()
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
for (int i = 0; i < SMALL_BATCH; i++)
|
||||
{
|
||||
var result = ToJObject(ManageGameObject.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["name"] = $"BulkTest_{i}"
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to create object {i}");
|
||||
|
||||
// Track for cleanup
|
||||
int instanceId = result["data"]?["instanceID"]?.Value<int>() ?? 0;
|
||||
if (instanceId != 0)
|
||||
{
|
||||
var go = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
|
||||
if (go != null) _createdObjects.Add(go);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
Debug.Log($"[BulkCreate] Created {SMALL_BATCH} objects in {sw.ElapsedMilliseconds}ms");
|
||||
Assert.Less(sw.ElapsedMilliseconds, 5000, "Bulk create took too long");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BulkCreate_MediumBatch_AllSucceed()
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
for (int i = 0; i < MEDIUM_BATCH; i++)
|
||||
{
|
||||
var result = ToJObject(ManageGameObject.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["name"] = $"MediumBulk_{i}"
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to create object {i}");
|
||||
|
||||
int instanceId = result["data"]?["instanceID"]?.Value<int>() ?? 0;
|
||||
if (instanceId != 0)
|
||||
{
|
||||
var go = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
|
||||
if (go != null) _createdObjects.Add(go);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
Debug.Log($"[BulkCreate] Created {MEDIUM_BATCH} objects in {sw.ElapsedMilliseconds}ms");
|
||||
Assert.Less(sw.ElapsedMilliseconds, 15000, "Medium batch create took too long");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Find GameObjects Pagination
|
||||
|
||||
[Test]
|
||||
public void FindGameObjects_LargeBatch_PaginatesCorrectly()
|
||||
{
|
||||
// Create many objects with a unique marker component for reliable search
|
||||
for (int i = 0; i < LARGE_BATCH; i++)
|
||||
{
|
||||
var go = CreateTestObject($"Searchable_{i:D3}");
|
||||
go.AddComponent<GameObjectAPIStressTestMarker>();
|
||||
}
|
||||
|
||||
// Find by searching for a specific object first
|
||||
var firstResult = ToJObject(FindGameObjects.HandleCommand(new JObject
|
||||
{
|
||||
["searchTerm"] = "Searchable_000",
|
||||
["searchMethod"] = "by_name",
|
||||
["pageSize"] = 10
|
||||
}));
|
||||
|
||||
Assert.IsTrue(firstResult["success"]?.Value<bool>() ?? false, "Should find specific named object");
|
||||
var firstData = firstResult["data"] as JObject;
|
||||
var firstIds = firstData?["instanceIDs"] as JArray;
|
||||
Assert.IsNotNull(firstIds);
|
||||
Assert.AreEqual(1, firstIds.Count, "Should find exactly one object with exact name match");
|
||||
|
||||
Debug.Log($"[FindGameObjects] Found object by exact name. Testing pagination with a unique marker component.");
|
||||
|
||||
// Now test pagination by searching for only the objects created by this test
|
||||
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
|
||||
{
|
||||
["searchTerm"] = typeof(GameObjectAPIStressTestMarker).FullName,
|
||||
["searchMethod"] = "by_component",
|
||||
["pageSize"] = 25
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
Assert.IsNotNull(data);
|
||||
|
||||
var instanceIds = data["instanceIDs"] as JArray;
|
||||
Assert.IsNotNull(instanceIds);
|
||||
Assert.AreEqual(25, instanceIds.Count, "First page should have 25 items");
|
||||
|
||||
int totalCount = data["totalCount"]?.Value<int>() ?? 0;
|
||||
Assert.AreEqual(LARGE_BATCH, totalCount, $"Should find exactly {LARGE_BATCH} objects created by this test");
|
||||
|
||||
bool hasMore = data["hasMore"]?.Value<bool>() ?? false;
|
||||
Assert.IsTrue(hasMore, "Should have more pages");
|
||||
|
||||
Debug.Log($"[FindGameObjects] Found {totalCount} objects, first page has {instanceIds.Count}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FindGameObjects_PaginateThroughAll()
|
||||
{
|
||||
// Create objects - all will have a unique marker component
|
||||
for (int i = 0; i < MEDIUM_BATCH; i++)
|
||||
{
|
||||
var go = CreateTestObject($"Paginate_{i:D3}");
|
||||
go.AddComponent<GameObjectAPIStressTestMarker>();
|
||||
}
|
||||
|
||||
// Track IDs we've created for verification
|
||||
var createdIds = new HashSet<int>();
|
||||
foreach (var go in _createdObjects)
|
||||
{
|
||||
if (go != null && go.name.StartsWith("Paginate_"))
|
||||
{
|
||||
createdIds.Add(go.GetInstanceID());
|
||||
}
|
||||
}
|
||||
|
||||
int pageSize = 10;
|
||||
int cursor = 0;
|
||||
int foundFromCreated = 0;
|
||||
int pageCount = 0;
|
||||
|
||||
// Search by the unique marker component and check our created objects
|
||||
while (true)
|
||||
{
|
||||
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
|
||||
{
|
||||
["searchTerm"] = typeof(GameObjectAPIStressTestMarker).FullName,
|
||||
["searchMethod"] = "by_component",
|
||||
["pageSize"] = pageSize,
|
||||
["cursor"] = cursor
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
var instanceIds = data["instanceIDs"] as JArray;
|
||||
|
||||
// Count how many of our created objects are in this page
|
||||
foreach (var id in instanceIds)
|
||||
{
|
||||
if (createdIds.Contains(id.Value<int>()))
|
||||
{
|
||||
foundFromCreated++;
|
||||
}
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
bool hasMore = data["hasMore"]?.Value<bool>() ?? false;
|
||||
if (!hasMore) break;
|
||||
|
||||
cursor = data["nextCursor"]?.Value<int>() ?? cursor + pageSize;
|
||||
|
||||
// Safety limit
|
||||
if (pageCount > 50) break;
|
||||
}
|
||||
|
||||
Assert.AreEqual(MEDIUM_BATCH, foundFromCreated, $"Should find all {MEDIUM_BATCH} created objects across pages");
|
||||
Debug.Log($"[Pagination] Found {foundFromCreated} created objects across {pageCount} pages");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Component Operations at Scale
|
||||
|
||||
[Test]
|
||||
public void AddComponents_MultipleToSingleObject()
|
||||
{
|
||||
var go = CreateTestObject("ComponentHost");
|
||||
|
||||
string[] componentTypeNames = new[]
|
||||
{
|
||||
"BoxCollider",
|
||||
"Rigidbody",
|
||||
"Light",
|
||||
"Camera"
|
||||
};
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var compType in componentTypeNames)
|
||||
{
|
||||
var result = ToJObject(ManageComponents.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "add",
|
||||
["target"] = go.GetInstanceID().ToString(),
|
||||
["searchMethod"] = "by_id",
|
||||
["componentType"] = compType // Correct parameter name
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to add {compType}: {result["message"]}");
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
Debug.Log($"[AddComponents] Added {componentTypeNames.Length} components in {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
// Verify all components present
|
||||
Assert.AreEqual(componentTypeNames.Length + 1, go.GetComponents<Component>().Length); // +1 for Transform
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetComponents_ObjectWithManyComponents()
|
||||
{
|
||||
var go = CreateTestObject("HeavyComponents");
|
||||
|
||||
// Add many components - but skip AudioSource as it triggers deprecated API warnings
|
||||
go.AddComponent<BoxCollider>();
|
||||
go.AddComponent<SphereCollider>();
|
||||
go.AddComponent<CapsuleCollider>();
|
||||
go.AddComponent<MeshCollider>();
|
||||
go.AddComponent<Rigidbody>();
|
||||
go.AddComponent<Light>();
|
||||
go.AddComponent<Camera>();
|
||||
go.AddComponent<AudioListener>();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Use the resource handler for getting components
|
||||
var result = ToJObject(GameObjectComponentsResource.HandleCommand(new JObject
|
||||
{
|
||||
["instanceID"] = go.GetInstanceID(),
|
||||
["includeProperties"] = true,
|
||||
["pageSize"] = 50
|
||||
}));
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"GetComponents failed: {result["message"]}");
|
||||
var data = result["data"] as JObject;
|
||||
var components = data?["components"] as JArray;
|
||||
|
||||
Assert.IsNotNull(components);
|
||||
Assert.AreEqual(9, components.Count); // 8 added + Transform
|
||||
|
||||
Debug.Log($"[GetComponents] Retrieved {components.Count} components with properties in {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetComponentProperties_ComplexRigidbody()
|
||||
{
|
||||
var go = CreateTestObject("RigidbodyTest");
|
||||
go.AddComponent<Rigidbody>();
|
||||
|
||||
var result = ToJObject(ManageComponents.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "set_property",
|
||||
["target"] = go.GetInstanceID().ToString(),
|
||||
["searchMethod"] = "by_id",
|
||||
["componentType"] = "Rigidbody", // Correct parameter name
|
||||
["properties"] = new JObject // Correct parameter name
|
||||
{
|
||||
["mass"] = 10.5f,
|
||||
["drag"] = 0.5f,
|
||||
["angularDrag"] = 0.1f,
|
||||
["useGravity"] = false,
|
||||
["isKinematic"] = true
|
||||
}
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Set property failed: {result["message"]}");
|
||||
|
||||
var rb = go.GetComponent<Rigidbody>();
|
||||
Assert.AreEqual(10.5f, rb.mass, 0.01f);
|
||||
Assert.AreEqual(0.5f, rb.drag, 0.01f);
|
||||
Assert.AreEqual(0.1f, rb.angularDrag, 0.01f);
|
||||
Assert.IsFalse(rb.useGravity);
|
||||
Assert.IsTrue(rb.isKinematic);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deep Hierarchy Operations
|
||||
|
||||
[Test]
|
||||
public void CreateDeepHierarchy_FindByPath()
|
||||
{
|
||||
// Create a deep hierarchy: Root/Level1/Level2/Level3/Target
|
||||
var root = CreateTestObject("DeepRoot");
|
||||
var current = root;
|
||||
|
||||
for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
var child = CreateTestObject($"Level{i}");
|
||||
child.transform.SetParent(current.transform);
|
||||
current = child;
|
||||
}
|
||||
|
||||
var target = CreateTestObject("DeepTarget");
|
||||
target.transform.SetParent(current.transform);
|
||||
|
||||
// Find by path
|
||||
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
|
||||
{
|
||||
["searchTerm"] = "DeepRoot/Level1/Level2/Level3/Level4/Level5/DeepTarget",
|
||||
["searchMethod"] = "by_path"
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
var ids = data?["instanceIDs"] as JArray;
|
||||
|
||||
Assert.IsNotNull(ids);
|
||||
Assert.AreEqual(1, ids.Count);
|
||||
Assert.AreEqual(target.GetInstanceID(), ids[0].Value<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetHierarchy_LargeScene_Paginated()
|
||||
{
|
||||
// Create flat hierarchy with many objects
|
||||
for (int i = 0; i < MEDIUM_BATCH; i++)
|
||||
{
|
||||
CreateTestObject($"HierarchyItem_{i:D3}");
|
||||
}
|
||||
|
||||
var result = ToJObject(ManageScene.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "get_hierarchy",
|
||||
["pageSize"] = 20,
|
||||
["maxNodes"] = 100
|
||||
}));
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
var items = data?["items"] as JArray;
|
||||
|
||||
Assert.IsNotNull(items);
|
||||
Assert.GreaterOrEqual(items.Count, 1);
|
||||
|
||||
// Verify componentTypes is included
|
||||
var firstItem = items[0] as JObject;
|
||||
Assert.IsNotNull(firstItem?["componentTypes"], "Should include componentTypes");
|
||||
|
||||
Debug.Log($"[GetHierarchy] Retrieved {items.Count} items from hierarchy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Resource Read Performance
|
||||
|
||||
[Test]
|
||||
public void GameObjectResource_ReadComplexObject()
|
||||
{
|
||||
var go = CreateTestObject("ComplexObject");
|
||||
go.tag = "Player";
|
||||
go.layer = 8;
|
||||
go.isStatic = true;
|
||||
|
||||
// Add components - AudioSource is OK here since we're only reading component types, not serializing properties
|
||||
go.AddComponent<Rigidbody>();
|
||||
go.AddComponent<BoxCollider>();
|
||||
go.AddComponent<AudioSource>();
|
||||
|
||||
// Add children
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var child = CreateTestObject($"Child_{i}");
|
||||
child.transform.SetParent(go.transform);
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Call the resource directly (no action param needed)
|
||||
var result = ToJObject(GameObjectResource.HandleCommand(new JObject
|
||||
{
|
||||
["instanceID"] = go.GetInstanceID()
|
||||
}));
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
|
||||
Assert.AreEqual("ComplexObject", data?["name"]?.Value<string>());
|
||||
Assert.AreEqual("Player", data?["tag"]?.Value<string>());
|
||||
Assert.AreEqual(8, data?["layer"]?.Value<int>());
|
||||
|
||||
var componentTypes = data?["componentTypes"] as JArray;
|
||||
Assert.IsNotNull(componentTypes);
|
||||
Assert.AreEqual(4, componentTypes.Count); // Transform + 3 added
|
||||
|
||||
var children = data?["children"] as JArray;
|
||||
Assert.IsNotNull(children);
|
||||
Assert.AreEqual(5, children.Count);
|
||||
|
||||
Debug.Log($"[GameObjectResource] Read complex object in {sw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComponentsResource_ReadAllWithFullSerialization()
|
||||
{
|
||||
var go = CreateTestObject("FullSerialize");
|
||||
|
||||
var rb = go.AddComponent<Rigidbody>();
|
||||
rb.mass = 5.5f;
|
||||
rb.drag = 1.2f;
|
||||
|
||||
var col = go.AddComponent<BoxCollider>();
|
||||
col.size = new Vector3(2, 3, 4);
|
||||
col.center = new Vector3(0.5f, 0.5f, 0.5f);
|
||||
|
||||
// Skip AudioSource to avoid deprecated API warnings
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Use the components resource handler
|
||||
var result = ToJObject(GameObjectComponentsResource.HandleCommand(new JObject
|
||||
{
|
||||
["instanceID"] = go.GetInstanceID(),
|
||||
["includeProperties"] = true
|
||||
}));
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
|
||||
var data = result["data"] as JObject;
|
||||
var components = data?["components"] as JArray;
|
||||
|
||||
Assert.IsNotNull(components);
|
||||
Assert.AreEqual(3, components.Count); // Transform + Rigidbody + BoxCollider
|
||||
|
||||
Debug.Log($"[ComponentsResource] Full serialization of {components.Count} components in {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
// Verify serialized data includes properties
|
||||
bool foundRigidbody = false;
|
||||
foreach (JObject comp in components)
|
||||
{
|
||||
var typeName = comp["typeName"]?.Value<string>();
|
||||
if (typeName != null && typeName.Contains("Rigidbody"))
|
||||
{
|
||||
foundRigidbody = true;
|
||||
// GameObjectSerializer puts properties inside a "properties" nested object
|
||||
var props = comp["properties"] as JObject;
|
||||
Assert.IsNotNull(props, $"Rigidbody should have properties. Component data: {comp}");
|
||||
float massValue = props["mass"]?.Value<float>() ?? 0;
|
||||
Assert.AreEqual(5.5f, massValue, 0.01f, $"Mass should be 5.5");
|
||||
}
|
||||
}
|
||||
Assert.IsTrue(foundRigidbody, "Should find Rigidbody with serialized properties");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent-Like Operations
|
||||
|
||||
[Test]
|
||||
public void RapidFireOperations_CreateModifyDelete()
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
for (int i = 0; i < SMALL_BATCH; i++)
|
||||
{
|
||||
// Create
|
||||
var createResult = ToJObject(ManageGameObject.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "create",
|
||||
["name"] = $"RapidFire_{i}"
|
||||
}));
|
||||
Assert.IsTrue(createResult["success"]?.Value<bool>() ?? false, $"Create failed: {createResult["message"]}");
|
||||
|
||||
int instanceId = createResult["data"]?["instanceID"]?.Value<int>() ?? 0;
|
||||
Assert.AreNotEqual(0, instanceId, "Instance ID should not be 0");
|
||||
|
||||
// Modify - use layer 0 (Default) to avoid layer name issues
|
||||
var modifyResult = ToJObject(ManageGameObject.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = instanceId.ToString(),
|
||||
["searchMethod"] = "by_id",
|
||||
["name"] = $"RapidFire_Modified_{i}", // Use name modification instead
|
||||
["setActive"] = true
|
||||
}));
|
||||
Assert.IsTrue(modifyResult["success"]?.Value<bool>() ?? false, $"Modify failed: {modifyResult["message"]}");
|
||||
|
||||
// Delete
|
||||
var deleteResult = ToJObject(ManageGameObject.HandleCommand(new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = instanceId.ToString(),
|
||||
["searchMethod"] = "by_id"
|
||||
}));
|
||||
Assert.IsTrue(deleteResult["success"]?.Value<bool>() ?? false, $"Delete failed: {deleteResult["message"]}");
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
Debug.Log($"[RapidFire] {SMALL_BATCH} create-modify-delete cycles in {sw.ElapsedMilliseconds}ms");
|
||||
Assert.Less(sw.ElapsedMilliseconds, 10000, "Rapid fire operations took too long");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker component used for isolating component-based searches to objects created by this test fixture.
|
||||
/// </summary>
|
||||
public sealed class GameObjectAPIStressTestMarker : MonoBehaviour { }
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7392c46e26c4649479cce9912fa94c1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ec38858ff125347778a30792e4bb1c3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
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 "delete" action.
|
||||
/// These tests capture existing behavior before API redesign.
|
||||
/// </summary>
|
||||
public class ManageGameObjectDeleteTests
|
||||
{
|
||||
private List<GameObject> testObjects = new List<GameObject>();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in testObjects)
|
||||
{
|
||||
if (go != null)
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
testObjects.Clear();
|
||||
}
|
||||
|
||||
private GameObject CreateTestObject(string name)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
testObjects.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
#region Basic Delete Tests
|
||||
|
||||
[Test]
|
||||
public void Delete_ByName_DeletesObject()
|
||||
{
|
||||
var target = CreateTestObject("DeleteTargetByName");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "DeleteTargetByName",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify object is deleted
|
||||
var found = GameObject.Find("DeleteTargetByName");
|
||||
Assert.IsNull(found, "Object should be deleted");
|
||||
|
||||
// Remove from our tracking list since it's deleted
|
||||
testObjects.Remove(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_ByInstanceID_DeletesObject()
|
||||
{
|
||||
var target = CreateTestObject("DeleteTargetByID");
|
||||
int instanceID = target.GetInstanceID();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = instanceID,
|
||||
["searchMethod"] = "by_id"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify object is deleted
|
||||
var found = GameObject.Find("DeleteTargetByID");
|
||||
Assert.IsNull(found, "Object should be deleted");
|
||||
|
||||
testObjects.Remove(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_NonExistentObject_ReturnsError()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "NonExistentObject12345",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail for non-existent object");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_WithoutTarget_ReturnsError()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without target");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Search Method Tests
|
||||
|
||||
[Test]
|
||||
public void Delete_ByTag_DeletesMatchingObjects()
|
||||
{
|
||||
// Current behavior: delete action finds first matching object and deletes it.
|
||||
// This test verifies at least one tagged object is deleted.
|
||||
var target1 = CreateTestObject("DeleteByTag1");
|
||||
var target2 = CreateTestObject("DeleteByTag2");
|
||||
|
||||
// Use built-in tag
|
||||
target1.tag = "MainCamera";
|
||||
target2.tag = "MainCamera";
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "MainCamera",
|
||||
["searchMethod"] = "by_tag"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify at least one object was deleted (current behavior deletes first match)
|
||||
bool target1Deleted = target1 == null; // Unity Object == null check
|
||||
bool target2Deleted = target2 == null;
|
||||
Assert.IsTrue(target1Deleted || target2Deleted, "At least one tagged object should be deleted");
|
||||
|
||||
// Check response data for deletion info
|
||||
var data = resultObj["data"];
|
||||
Assert.IsNotNull(data, "Response should include data");
|
||||
|
||||
// Clean up only surviving objects from tracking
|
||||
if (!target1Deleted) testObjects.Remove(target1);
|
||||
if (!target2Deleted) testObjects.Remove(target2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_ByLayer_DeletesMatchingObjects()
|
||||
{
|
||||
var target = CreateTestObject("DeleteByLayer");
|
||||
target.layer = LayerMask.NameToLayer("UI");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "UI",
|
||||
["searchMethod"] = "by_layer"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify the object was actually deleted
|
||||
bool targetDeleted = target == null; // Unity Object == null check
|
||||
Assert.IsTrue(targetDeleted, "Object on UI layer should be deleted");
|
||||
Assert.IsFalse(testObjects.Contains(target) && target != null, "Deleted object should not be findable");
|
||||
|
||||
// Only remove from tracking if not already destroyed
|
||||
if (!targetDeleted) testObjects.Remove(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_ByPath_DeletesObject()
|
||||
{
|
||||
var parent = CreateTestObject("DeleteParent");
|
||||
var child = CreateTestObject("DeleteChild");
|
||||
child.transform.SetParent(parent.transform);
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "DeleteParent/DeleteChild",
|
||||
["searchMethod"] = "by_path"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Capture current behavior
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
|
||||
testObjects.Remove(child);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hierarchy Tests
|
||||
|
||||
[Test]
|
||||
public void Delete_Parent_DeletesChildren()
|
||||
{
|
||||
var parent = CreateTestObject("DeleteParentWithChildren");
|
||||
var child1 = CreateTestObject("Child1");
|
||||
var child2 = CreateTestObject("Child2");
|
||||
var grandchild = CreateTestObject("Grandchild");
|
||||
|
||||
child1.transform.SetParent(parent.transform);
|
||||
child2.transform.SetParent(parent.transform);
|
||||
grandchild.transform.SetParent(child1.transform);
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "DeleteParentWithChildren",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// All should be deleted
|
||||
Assert.IsNull(GameObject.Find("DeleteParentWithChildren"), "Parent should be deleted");
|
||||
Assert.IsNull(GameObject.Find("Child1"), "Child1 should be deleted");
|
||||
Assert.IsNull(GameObject.Find("Child2"), "Child2 should be deleted");
|
||||
Assert.IsNull(GameObject.Find("Grandchild"), "Grandchild should be deleted");
|
||||
|
||||
testObjects.Remove(parent);
|
||||
testObjects.Remove(child1);
|
||||
testObjects.Remove(child2);
|
||||
testObjects.Remove(grandchild);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_Child_DoesNotDeleteParent()
|
||||
{
|
||||
var parent = CreateTestObject("ParentShouldSurvive");
|
||||
var child = CreateTestObject("ChildToDelete");
|
||||
child.transform.SetParent(parent.transform);
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "ChildToDelete",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Child deleted, parent survives
|
||||
Assert.IsNull(GameObject.Find("ChildToDelete"), "Child should be deleted");
|
||||
Assert.IsNotNull(GameObject.Find("ParentShouldSurvive"), "Parent should survive");
|
||||
|
||||
testObjects.Remove(child);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Structure Tests
|
||||
|
||||
[Test]
|
||||
public void Delete_Success_ReturnsDeletedCount()
|
||||
{
|
||||
var target = CreateTestObject("DeleteCountTest");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "DeleteCountTest",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify object was actually deleted
|
||||
bool targetDeleted = target == null;
|
||||
Assert.IsTrue(targetDeleted, "Object should be deleted");
|
||||
|
||||
// Check for deleted count in response
|
||||
var data = resultObj["data"];
|
||||
Assert.IsNotNull(data, "Response should include data");
|
||||
|
||||
// Verify the actual count if present
|
||||
if (data is JObject dataObj && dataObj.ContainsKey("deletedCount"))
|
||||
{
|
||||
Assert.AreEqual(1, dataObj.Value<int>("deletedCount"), "Should report 1 deleted object");
|
||||
}
|
||||
|
||||
// Only remove from tracking if not already destroyed
|
||||
if (!targetDeleted) testObjects.Remove(target);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Test]
|
||||
public void Delete_InactiveObject_StillDeletes()
|
||||
{
|
||||
var target = CreateTestObject("InactiveDeleteTarget");
|
||||
target.SetActive(false);
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "InactiveDeleteTarget",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Capture current behavior for inactive objects
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
|
||||
testObjects.Remove(target);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Delete_MultipleObjectsSameName_DeletesCorrectly()
|
||||
{
|
||||
// Expected behavior: delete action with by_name finds the FIRST matching object
|
||||
// and deletes only that one. This is consistent with Unity's GameObject.Find behavior.
|
||||
var target1 = CreateTestObject("DuplicateName");
|
||||
var target2 = CreateTestObject("DuplicateName");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "delete",
|
||||
["target"] = "DuplicateName",
|
||||
["searchMethod"] = "by_name"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
|
||||
// Verify deletion occurred - at least one should be deleted
|
||||
bool target1Deleted = target1 == null;
|
||||
bool target2Deleted = target2 == null;
|
||||
Assert.IsTrue(target1Deleted || target2Deleted, "At least one object should be deleted");
|
||||
|
||||
// Count remaining objects with the name to verify behavior
|
||||
int remainingCount = 0;
|
||||
if (!target1Deleted) remainingCount++;
|
||||
if (!target2Deleted) remainingCount++;
|
||||
|
||||
// Document the actual behavior: first match is deleted, second survives
|
||||
// If both are deleted, that's also acceptable (bulk delete mode)
|
||||
Assert.IsTrue(remainingCount <= 1, $"Expected at most 1 remaining, got {remainingCount}");
|
||||
|
||||
// Clean up only survivors from tracking
|
||||
if (!target1Deleted) testObjects.Remove(target1);
|
||||
if (!target2Deleted) testObjects.Remove(target2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e74a6d8990a344fd6a1e4b175d411be1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
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 "modify" action.
|
||||
/// These tests capture existing behavior before API redesign.
|
||||
/// </summary>
|
||||
public class ManageGameObjectModifyTests
|
||||
{
|
||||
private List<GameObject> testObjects = new List<GameObject>();
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
// Create a standard test object for each test
|
||||
var go = new GameObject("ModifyTestObject");
|
||||
go.transform.position = Vector3.zero;
|
||||
go.transform.rotation = Quaternion.identity;
|
||||
go.transform.localScale = Vector3.one;
|
||||
testObjects.Add(go);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
foreach (var go in testObjects)
|
||||
{
|
||||
if (go != null)
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
testObjects.Clear();
|
||||
}
|
||||
|
||||
private GameObject CreateTestObject(string name)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
testObjects.Add(go);
|
||||
return go;
|
||||
}
|
||||
|
||||
#region Target Resolution Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_ByName_FindsAndModifiesObject()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["searchMethod"] = "by_name",
|
||||
["position"] = new JArray { 10.0f, 0.0f, 0.0f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(new Vector3(10f, 0f, 0f), testObjects[0].transform.position);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_ByInstanceID_FindsAndModifiesObject()
|
||||
{
|
||||
int instanceID = testObjects[0].GetInstanceID();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = instanceID,
|
||||
["searchMethod"] = "by_id",
|
||||
["position"] = new JArray { 20.0f, 0.0f, 0.0f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(new Vector3(20f, 0f, 0f), testObjects[0].transform.position);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_WithNameAlias_UsesNameAsTarget()
|
||||
{
|
||||
// When target is missing but name is provided, should use name as target
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["name"] = "ModifyTestObject",
|
||||
["position"] = new JArray { 30.0f, 0.0f, 0.0f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(new Vector3(30f, 0f, 0f), testObjects[0].transform.position);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_NonExistentTarget_ReturnsError()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "NonExistentObject12345",
|
||||
["searchMethod"] = "by_name",
|
||||
["position"] = new JArray { 0.0f, 0.0f, 0.0f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail for non-existent object");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_WithoutTarget_ReturnsError()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["position"] = new JArray { 0.0f, 0.0f, 0.0f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without target");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Transform Modification Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_Position_SetsNewPosition()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["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());
|
||||
Assert.AreEqual(new Vector3(1f, 2f, 3f), testObjects[0].transform.position);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_Rotation_SetsNewRotation()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["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());
|
||||
Assert.AreEqual(90f, testObjects[0].transform.eulerAngles.y, 0.1f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_Scale_SetsNewScale()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["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());
|
||||
Assert.AreEqual(new Vector3(2f, 3f, 4f), testObjects[0].transform.localScale);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_AllTransformProperties_SetsAll()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["position"] = new JArray { 5.0f, 6.0f, 7.0f },
|
||||
["rotation"] = new JArray { 45.0f, 45.0f, 45.0f },
|
||||
["scale"] = new JArray { 0.5f, 0.5f, 0.5f }
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(new Vector3(5f, 6f, 7f), testObjects[0].transform.position);
|
||||
Assert.AreEqual(new Vector3(0.5f, 0.5f, 0.5f), testObjects[0].transform.localScale);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rename Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_Name_RenamesObject()
|
||||
{
|
||||
// Get instanceID first since name will change
|
||||
int instanceID = testObjects[0].GetInstanceID();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = instanceID,
|
||||
["searchMethod"] = "by_id",
|
||||
["name"] = "RenamedObject" // Uses 'name' parameter, not 'newName'
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual("RenamedObject", testObjects[0].name);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_NameToEmpty_HandlesGracefully()
|
||||
{
|
||||
int instanceID = testObjects[0].GetInstanceID();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = instanceID,
|
||||
["searchMethod"] = "by_id",
|
||||
["name"] = "" // Empty name
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Capture current behavior - may reject or allow empty name
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reparenting Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_Parent_ReparentsObject()
|
||||
{
|
||||
var parent = CreateTestObject("NewParent");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["parent"] = "NewParent"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(parent.transform, testObjects[0].transform.parent);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_ParentToNull_UnparentsObject()
|
||||
{
|
||||
// First parent the object
|
||||
var parent = CreateTestObject("TempParent");
|
||||
testObjects[0].transform.SetParent(parent.transform);
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["parent"] = JValue.CreateNull()
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Capture current behavior for null parent
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_ParentToNonExistent_HandlesGracefully()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["parent"] = "NonExistentParent12345"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Should fail or handle gracefully
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Active State Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_SetActive_DeactivatesObject()
|
||||
{
|
||||
Assert.IsTrue(testObjects[0].activeSelf, "Object should start active");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["setActive"] = false
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.IsFalse(testObjects[0].activeSelf, "Object should be deactivated");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_SetActive_ActivatesObject()
|
||||
{
|
||||
testObjects[0].SetActive(false);
|
||||
Assert.IsFalse(testObjects[0].activeSelf, "Object should start inactive");
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["setActive"] = true
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.IsTrue(testObjects[0].activeSelf, "Object should be activated");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tag and Layer Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_Tag_SetsNewTag()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["tag"] = "MainCamera"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual("MainCamera", testObjects[0].tag);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_Layer_SetsNewLayer()
|
||||
{
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["layer"] = "UI"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual(LayerMask.NameToLayer("UI"), testObjects[0].layer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modify_InvalidTag_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"] = "modify",
|
||||
["target"] = "ModifyTestObject",
|
||||
["tag"] = "NonExistentTag12345"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
// Current behavior: logs error but continues
|
||||
Assert.IsNotNull(result, "Should return a result");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multiple Modifications Tests
|
||||
|
||||
[Test]
|
||||
public void Modify_MultipleProperties_AppliesAll()
|
||||
{
|
||||
var parent = CreateTestObject("MultiModifyParent");
|
||||
int instanceID = testObjects[0].GetInstanceID();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "modify",
|
||||
["target"] = instanceID,
|
||||
["searchMethod"] = "by_id",
|
||||
["name"] = "MultiModifiedObject", // Uses 'name' not 'newName'
|
||||
["position"] = new JArray { 100.0f, 200.0f, 300.0f },
|
||||
["scale"] = new JArray { 5.0f, 5.0f, 5.0f },
|
||||
["parent"] = "MultiModifyParent",
|
||||
["tag"] = "MainCamera"
|
||||
};
|
||||
|
||||
var result = ManageGameObject.HandleCommand(p);
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
|
||||
Assert.AreEqual("MultiModifiedObject", testObjects[0].name);
|
||||
Assert.AreEqual(parent.transform, testObjects[0].transform.parent);
|
||||
Assert.AreEqual("MainCamera", testObjects[0].tag);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 042aca01b843348a3bc9ac86475e6293
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -69,81 +69,6 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetComponents_ReturnsPagedMetadataByDefault()
|
||||
{
|
||||
// Arrange
|
||||
testGameObject.AddComponent<Rigidbody>();
|
||||
testGameObject.AddComponent<BoxCollider>();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "get_components",
|
||||
["target"] = testGameObject.name,
|
||||
["searchMethod"] = "by_name",
|
||||
["pageSize"] = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var raw = ManageGameObject.HandleCommand(p);
|
||||
var result = raw as JObject ?? JObject.FromObject(raw);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
var data = result["data"] as JObject;
|
||||
Assert.IsNotNull(data, "Expected data payload object.");
|
||||
Assert.AreEqual(false, data.Value<bool>("includeProperties"));
|
||||
|
||||
var items = data["items"] as JArray;
|
||||
Assert.IsNotNull(items, "Expected items array.");
|
||||
Assert.AreEqual(2, items.Count, "Expected exactly pageSize items.");
|
||||
|
||||
var first = items[0] as JObject;
|
||||
Assert.IsNotNull(first, "Expected item to be an object.");
|
||||
Assert.IsNotNull(first["typeName"]);
|
||||
Assert.IsNotNull(first["instanceID"]);
|
||||
Assert.IsNull(first["properties"], "Metadata response should not include heavy serialized properties by default.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetComponents_CanIncludePropertiesButStillPages()
|
||||
{
|
||||
// Arrange
|
||||
testGameObject.AddComponent<Rigidbody>();
|
||||
testGameObject.AddComponent<BoxCollider>();
|
||||
|
||||
var p = new JObject
|
||||
{
|
||||
["action"] = "get_components",
|
||||
["target"] = testGameObject.name,
|
||||
["searchMethod"] = "by_name",
|
||||
["pageSize"] = 2,
|
||||
["includeProperties"] = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var raw = ManageGameObject.HandleCommand(p);
|
||||
var result = raw as JObject ?? JObject.FromObject(raw);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
|
||||
var data = result["data"] as JObject;
|
||||
Assert.IsNotNull(data);
|
||||
Assert.AreEqual(true, data.Value<bool>("includeProperties"));
|
||||
|
||||
var items = data["items"] as JArray;
|
||||
Assert.IsNotNull(items);
|
||||
Assert.IsTrue(items.Count > 0);
|
||||
|
||||
var first = items[0] as JObject;
|
||||
Assert.IsNotNull(first);
|
||||
Assert.IsNotNull(first["typeName"]);
|
||||
Assert.IsNotNull(first["instanceID"]);
|
||||
|
||||
// Heuristic: property-including payload should have more than just typeName/instanceID.
|
||||
Assert.Greater(first.Properties().Count(), 2, "Expected richer component payload when includeProperties=true.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ComponentResolver_Integration_WorksWithRealComponents()
|
||||
{
|
||||
|
|
@ -630,137 +555,5 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
UnityEngine.Object.DestroyImmediate(material2);
|
||||
UnityEngine.Object.DestroyImmediate(testObject);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddComponent_StringArrayFormat_AppliesComponentProperties()
|
||||
{
|
||||
// Arrange - Create a GameObject to add component to
|
||||
var testObject = new GameObject("AddComponentTestObject");
|
||||
|
||||
// Create params using string array format with top-level componentProperties
|
||||
var addComponentParams = new JObject
|
||||
{
|
||||
["action"] = "add_component",
|
||||
["target"] = testObject.name,
|
||||
["search_method"] = "by_name",
|
||||
["componentsToAdd"] = new JArray { "Rigidbody" },
|
||||
["componentProperties"] = new JObject
|
||||
{
|
||||
["Rigidbody"] = new JObject
|
||||
{
|
||||
["mass"] = 7.5f,
|
||||
["useGravity"] = false,
|
||||
["drag"] = 2.0f
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(addComponentParams);
|
||||
|
||||
// Assert - Verify component was added
|
||||
var rigidbody = testObject.GetComponent<Rigidbody>();
|
||||
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
|
||||
|
||||
// Verify properties were set correctly during component creation
|
||||
Assert.AreEqual(7.5f, rigidbody.mass, 0.001f,
|
||||
"Mass should be set to 7.5 via componentProperties during add_component");
|
||||
Assert.AreEqual(false, rigidbody.useGravity,
|
||||
"UseGravity should be set to false via componentProperties during add_component");
|
||||
Assert.AreEqual(2.0f, rigidbody.drag, 0.001f,
|
||||
"Drag should be set to 2.0 via componentProperties during add_component");
|
||||
|
||||
// Verify result indicates success
|
||||
Assert.IsNotNull(result, "Should return a result object");
|
||||
var resultObj = result as JObject ?? JObject.FromObject(result);
|
||||
Assert.IsTrue(resultObj.Value<bool>("success"),
|
||||
"Result should indicate success when adding component with properties");
|
||||
|
||||
// Clean up
|
||||
UnityEngine.Object.DestroyImmediate(testObject);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddComponent_ObjectFormat_StillAppliesComponentProperties()
|
||||
{
|
||||
// Arrange - Create a GameObject to add component to
|
||||
var testObject = new GameObject("AddComponentObjectFormatTestObject");
|
||||
|
||||
// Create params using object array format (existing behavior)
|
||||
var addComponentParams = new JObject
|
||||
{
|
||||
["action"] = "add_component",
|
||||
["target"] = testObject.name,
|
||||
["search_method"] = "by_name",
|
||||
["componentsToAdd"] = new JArray
|
||||
{
|
||||
new JObject
|
||||
{
|
||||
["typeName"] = "Rigidbody",
|
||||
["properties"] = new JObject
|
||||
{
|
||||
["mass"] = 3.5f,
|
||||
["useGravity"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(addComponentParams);
|
||||
|
||||
// Assert - Verify component was added
|
||||
var rigidbody = testObject.GetComponent<Rigidbody>();
|
||||
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
|
||||
|
||||
// Verify properties were set correctly
|
||||
Assert.AreEqual(3.5f, rigidbody.mass, 0.001f,
|
||||
"Mass should be set to 3.5 via inline properties");
|
||||
Assert.AreEqual(true, rigidbody.useGravity,
|
||||
"UseGravity should be set to true via inline properties");
|
||||
|
||||
// Clean up
|
||||
UnityEngine.Object.DestroyImmediate(testObject);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddComponent_ComponentNameFormat_AppliesComponentProperties()
|
||||
{
|
||||
// Arrange - Create a GameObject to add component to
|
||||
var testObject = new GameObject("AddComponentNameFormatTestObject");
|
||||
|
||||
// Create params using componentName format (existing behavior)
|
||||
var addComponentParams = new JObject
|
||||
{
|
||||
["action"] = "add_component",
|
||||
["target"] = testObject.name,
|
||||
["search_method"] = "by_name",
|
||||
["componentName"] = "Rigidbody",
|
||||
["componentProperties"] = new JObject
|
||||
{
|
||||
["Rigidbody"] = new JObject
|
||||
{
|
||||
["mass"] = 5.0f,
|
||||
["drag"] = 1.5f
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ManageGameObject.HandleCommand(addComponentParams);
|
||||
|
||||
// Assert - Verify component was added
|
||||
var rigidbody = testObject.GetComponent<Rigidbody>();
|
||||
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
|
||||
|
||||
// Verify properties were set correctly
|
||||
Assert.AreEqual(5.0f, rigidbody.mass, 0.001f,
|
||||
"Mass should be set to 5.0 via componentName format");
|
||||
Assert.AreEqual(1.5f, rigidbody.drag, 0.001f,
|
||||
"Drag should be set to 1.5 via componentName format");
|
||||
|
||||
// Clean up
|
||||
UnityEngine.Object.DestroyImmediate(testObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue