diff --git a/.claude/prompts/nl-gameobject-suite.md b/.claude/prompts/nl-gameobject-suite.md new file mode 100644 index 000000000..d3a1f5092 --- /dev/null +++ b/.claude/prompts/nl-gameobject-suite.md @@ -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 `` XML fragment per test to `reports/_results.xml` + +**CRITICAL XML FORMAT REQUIREMENTS:** +- Each file must contain EXACTLY one `` root element +- NO prologue, epilogue, code fences, or extra characters +- Use this exact shape: + + + + + +- If test fails, include: `` +- 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 + +--- + diff --git a/.claude/prompts/nl-unity-suite-nl.md b/.claude/prompts/nl-unity-suite-nl.md index 18c2ecffb..58e138eb9 100644 --- a/.claude/prompts/nl-unity-suite-nl.md +++ b/.claude/prompts/nl-unity-suite-nl.md @@ -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 `` XML fragment per test to `reports/_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 diff --git a/.claude/prompts/nl-unity-suite-t.md b/.claude/prompts/nl-unity-suite-t.md index c7f780316..37b57bd94 100644 --- a/.claude/prompts/nl-unity-suite-t.md +++ b/.claude/prompts/nl-unity-suite-t.md @@ -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 `` XML fragment per test to `reports/_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 `` 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 `` must start with `T-J`. - **Expected final state**: State H + verified idempotent behavior diff --git a/.github/workflows/claude-gameobject-suite.yml b/.github/workflows/claude-gameobject-suite.yml new file mode 100644 index 000000000..43b35dd68 --- /dev/null +++ b/.github/workflows/claude-gameobject-suite.yml @@ -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 '' "$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' + + + + Bootstrap placeholder; suite will append real tests. + + + 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 }} + diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 8dee76c3c..7b63628fe 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -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 diff --git a/.gitignore b/.gitignore index 104328886..6fecb4737 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs new file mode 100644 index 000000000..ce3ec2a24 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs @@ -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 +{ + /// + /// Utility class for finding and looking up GameObjects in the scene. + /// Provides search functionality by name, tag, layer, component, path, and instance ID. + /// + public static class GameObjectLookup + { + /// + /// Supported search methods for finding GameObjects. + /// + public enum SearchMethod + { + ByName, + ByTag, + ByLayer, + ByComponent, + ByPath, + ById + } + + /// + /// Parses a search method string into the enum value. + /// + 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 + }; + } + + /// + /// Finds a single GameObject based on the target and search method. + /// + /// The target identifier (name, ID, path, etc.) + /// The search method to use + /// Whether to include inactive objects + /// The found GameObject or null + 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; + } + + /// + /// Finds a GameObject by its instance ID. + /// + public static GameObject FindById(int instanceId) + { + return EditorUtility.InstanceIDToObject(instanceId) as GameObject; + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method string (by_name, by_tag, etc.) + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var method = ParseSearchMethod(searchMethod); + return SearchGameObjects(method, searchTerm, includeInactive, maxResults); + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var results = new List(); + + 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 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 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 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 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 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; + } + } + } + + /// + /// Gets all GameObjects in the current scene. + /// + public static IEnumerable 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 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; + } + } + } + + /// + /// Finds a component type by name, searching loaded assemblies. + /// + 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; + } + + /// + /// Gets the hierarchical path of a GameObject. + /// + 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; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta new file mode 100644 index 000000000..82fed7a18 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4964205faa8dd4f8a960e58fd8c0d4f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/MCPForUnity/Editor/Helpers/ParamCoercion.cs new file mode 100644 index 000000000..68f1fba69 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -0,0 +1,160 @@ +using System; +using System.Globalization; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for coercing JSON parameter values to strongly-typed values. + /// Handles various input formats (strings, numbers, booleans) gracefully. + /// + public static class ParamCoercion + { + /// + /// Coerces a JToken to an integer value, handling strings and floats. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced integer value or default + 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(); + + 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; + } + + /// + /// Coerces a JToken to a boolean value, handling strings like "true", "1", etc. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced boolean value or default + 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(); + + 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; + } + + /// + /// Coerces a JToken to a float value, handling strings and integers. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced float value or default + 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(); + + 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; + } + + /// + /// Coerces a JToken to a string value, with null handling. + /// + /// The JSON token to coerce + /// Default value if null or empty + /// The string value or default + 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; + } + + /// + /// Coerces a JToken to an enum value, handling strings. + /// + /// The enum type + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced enum value or default + public static T CoerceEnum(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(s, ignoreCase: true, out var result)) + return result; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta b/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta new file mode 100644 index 000000000..36b9ec685 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db54fbbe3ac7f429fbf808f72831374a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs b/MCPForUnity/Editor/Helpers/VectorParsing.cs new file mode 100644 index 000000000..60bb7e818 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs @@ -0,0 +1,296 @@ +using System; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// 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}. + /// + public static class VectorParsing + { + /// + /// Parses a JToken (array or object) into a Vector3. + /// + /// The JSON token to parse + /// The parsed Vector3 or null if parsing fails + 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(), + array[1].ToObject(), + array[2].ToObject() + ); + } + + // 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(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Vector3, returning a default value if parsing fails. + /// + public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default) + { + return ParseVector3(token) ?? defaultValue; + } + + /// + /// Parses a JToken (array or object) into a Vector2. + /// + /// The JSON token to parse + /// The parsed Vector2 or null if parsing fails + 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(), + array[1].ToObject() + ); + } + + // Object format: {x: 1, y: 2} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) + { + return new Vector2( + obj["x"].ToObject(), + obj["y"].ToObject() + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Vector2 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// 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. + /// + /// The JSON token to parse + /// If true, treats 3-element arrays as euler angles + /// The parsed Quaternion or null if parsing fails + 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(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + + // Euler angles: [x, y, z] + if (array.Count >= 3 && asEulerAngles) + { + return Quaternion.Euler( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + } + + // 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(), + obj["y"].ToObject(), + obj["z"].ToObject(), + obj["w"].ToObject() + ); + } + + // 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(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// 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. + /// + /// The JSON token to parse + /// The parsed Color or null if parsing fails + 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(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + if (array.Count >= 3) + { + return new Color( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + 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() : 1f; + return new Color( + obj["r"].ToObject(), + obj["g"].ToObject(), + obj["b"].ToObject(), + a + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Rect. + /// Supports {x, y, width, height} format. + /// + 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(), + obj["y"].ToObject(), + obj["width"].ToObject(), + obj["height"].ToObject() + ); + } + + // Array format: [x, y, width, height] + if (token is JArray array && array.Count >= 4) + { + return new Rect( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Bounds. + /// Supports {center: {x,y,z}, size: {x,y,z}} format. + /// + 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; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta b/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta new file mode 100644 index 000000000..f9b8b5fab --- /dev/null +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca2205caede3744aebda9f6da2fa2c22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene.meta b/MCPForUnity/Editor/Resources/Scene.meta new file mode 100644 index 000000000..20d08dbf8 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 563f6050485b445449a1db200bfba51c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs new file mode 100644 index 000000000..c509edcd7 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs @@ -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 +{ + /// + /// Resource handler for reading GameObject data. + /// Provides read-only access to GameObject information without component serialization. + /// + /// URI: unity://scene/gameobject/{instanceID} + /// + [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}"); + } + } + + /// + /// Serializes a GameObject without component details. + /// For component data, use GetComponents or GetComponent resources. + /// + 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() + .Where(c => c != null) + .Select(c => c.GetType().Name) + .ToList(); + + // Get children instance IDs (not full serialization) + var childrenIds = new List(); + 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 }; + } + } + + /// + /// Resource handler for reading all components on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/components + /// + [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().Where(c => c != null).ToList(); + int total = allComponents.Count; + + var pagedComponents = allComponents.Skip(cursor).Take(pageSize).ToList(); + + var componentData = new List(); + 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}"); + } + } + } + + /// + /// Resource handler for reading a single component on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/component/{componentName} + /// + [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()) + { + 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}"); + } + } + } +} + diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta new file mode 100644 index 000000000..5fb61f249 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ee79050d9f6d42798a0757cc7672517 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs b/MCPForUnity/Editor/Tools/FindGameObjects.cs new file mode 100644 index 000000000..b1c9498d4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -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 +{ + /// + /// 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. + /// + [McpForUnityTool("find_gameobjects")] + public static class FindGameObjects + { + /// + /// Handles the find_gameobjects command. + /// + /// Command parameters + /// Paginated list of instance IDs + 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}"); + } + } + } +} + diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta b/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta new file mode 100644 index 000000000..8d6f2b121 --- /dev/null +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4511082b395b14922b34e90f7a23027e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageComponents.cs b/MCPForUnity/Editor/Tools/ManageComponents.cs new file mode 100644 index 000000000..921729e7e --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageComponents.cs @@ -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 +{ + /// + /// 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. + /// + [McpForUnityTool("manage_components")] + public static class ManageComponents + { + /// + /// Handles the manage_components command. + /// + /// Command parameters + /// Result of the component operation + 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(); + + 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(); + 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); + } + + /// + /// Finds a component type by name. Delegates to GameObjectLookup.FindComponentType. + /// + 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(); + 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)}"); + } + } + + /// + /// Attempts to set a property or field on a component. + /// Note: Property/field lookup is case-insensitive for better usability with external callers. + /// + 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 + } +} + diff --git a/MCPForUnity/Editor/Tools/ManageComponents.cs.meta b/MCPForUnity/Editor/Tools/ManageComponents.cs.meta new file mode 100644 index 000000000..8ed3f7456 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageComponents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6f476359563842c79eda2c180566c98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index f519a44de..5c3fb14d0 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -73,10 +73,6 @@ public static object HandleCommand(JObject @params) string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; - // --- Add parameter for controlling non-public field inclusion --- - bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? 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 @@ public static object HandleCommand(JObject @params) { 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(); - 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(); - 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 @@ private static object DeleteGameObject(JToken targetToken, string searchMethod) } } - private static object FindGameObjects( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - bool findAll = @params["findAll"]?.ToObject() ?? false; - List foundObjects = FindObjectsInternal( - targetToken, - searchMethod, - findAll, - @params - ); - - if (foundObjects.Count == 0) - { - return new SuccessResponse("No matching GameObjects found.", new List()); - } - - // 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(); - var components = new List(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(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 - { - { "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(); - 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 --- /// diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 488716c37..7bdf57948 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -396,7 +396,7 @@ private static object CaptureScreenshot(string fileName, int? superSize) Camera cam = Camera.main; if (cam == null) { - var cams = UnityEngine.Object.FindObjectsOfType(); + var cams = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); cam = cams.FirstOrDefault(); } @@ -629,6 +629,24 @@ private static object BuildGameObjectSummary(GameObject go, bool includeTransfor 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(); + try + { + var components = go.GetComponents(); + if (components != null) + { + foreach (var c in components) + { + if (c != null) + { + componentTypes.Add(c.GetType().Name); + } + } + } + } + catch { } + var d = new Dictionary { { "name", go.name }, @@ -643,6 +661,7 @@ private static object BuildGameObjectSummary(GameObject go, bool includeTransfor { "childrenTruncated", childrenTruncated }, { "childrenCursor", childCount > 0 ? "0" : null }, { "childrenPageSizeDefault", maxChildrenPerNode }, + { "componentTypes", componentTypes }, // NEW: Lightweight component type list }; if (includeTransform && go.transform != null) diff --git a/README.md b/README.md index 08d845df7..131e78b1d 100644 --- a/README.md +++ b/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. @@ -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. --- @@ -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. diff --git a/Server/src/services/resources/gameobject.py b/Server/src/services/resources/gameobject.py new file mode 100644 index 000000000..491471db9 --- /dev/null +++ b/Server/src/services/resources/gameobject.py @@ -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) + diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index 6f2f748c7..529f7fb18 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -17,11 +17,10 @@ @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", diff --git a/Server/src/services/tools/find_gameobjects.py b/Server/src/services/tools/find_gameobjects.py new file mode 100644 index 000000000..b8945583e --- /dev/null +++ b/Server/src/services/tools/find_gameobjects.py @@ -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}"} + diff --git a/Server/src/services/tools/manage_asset.py b/Server/src/services/tools/manage_asset.py index 47147e8cc..896308a90 100644 --- a/Server/src/services/tools/manage_asset.py +++ b/Server/src/services/tools/manage_asset.py @@ -17,6 +17,41 @@ 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) diff --git a/Server/src/services/tools/manage_components.py b/Server/src/services/tools/manage_components.py new file mode 100644 index 000000000..ff1fa2745 --- /dev/null +++ b/Server/src/services/tools/manage_components.py @@ -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}"} + diff --git a/Server/src/services/tools/manage_gameobject.py b/Server/src/services/tools/manage_gameobject.py index 48cbaa674..218a56647 100644 --- a/Server/src/services/tools/manage_gameobject.py +++ b/Server/src/services/tools/manage_gameobject.py @@ -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.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 - - # 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 vector parameters using robust helper --- + position = _normalize_vector(position) + rotation = _normalize_vector(rotation) + scale = _normalize_vector(scale) + offset = _normalize_vector(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 @@ def _to_vec3(parts): 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 diff --git a/Server/src/services/tools/manage_material.py b/Server/src/services/tools/manage_material.py index 689fe7b0d..0022a41c4 100644 --- a/Server/src/services/tools/manage_material.py +++ b/Server/src/services/tools/manage_material.py @@ -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) + # --- Normalize color with validation --- + color, color_error = _normalize_color(color) + if color_error: + return {"success": False, "message": color_error} + + # --- 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}'"} - # 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 slot to int --- + slot = coerce_int(slot) # Prepare parameters for the C# handler params_dict = { diff --git a/Server/tests/integration/conftest.py b/Server/tests/integration/conftest.py index 1f409bbcb..216ca946a 100644 --- a/Server/tests/integration/conftest.py +++ b/Server/tests/integration/conftest.py @@ -90,38 +90,5 @@ class _DummyMiddlewareContext: 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. diff --git a/Server/tests/integration/test_find_gameobjects.py b/Server/tests/integration/test_find_gameobjects.py new file mode 100644 index 000000000..0235169ac --- /dev/null +++ b/Server/tests/integration/test_find_gameobjects.py @@ -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" + diff --git a/Server/tests/integration/test_gameobject_resources.py b/Server/tests/integration/test_gameobject_resources.py new file mode 100644 index 000000000..decca0812 --- /dev/null +++ b/Server/tests/integration/test_gameobject_resources.py @@ -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 "") + diff --git a/Server/tests/integration/test_manage_asset_json_parsing.py b/Server/tests/integration/test_manage_asset_json_parsing.py index e8477e678..852f3e9fc 100644 --- a/Server/tests/integration/test_manage_asset_json_parsing.py +++ b/Server/tests/integration/test_manage_asset_json_parsing.py @@ -57,7 +57,7 @@ async def fake_async(cmd, params, **kwargs): # 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): diff --git a/Server/tests/integration/test_manage_components.py b/Server/tests/integration/test_manage_components.py new file mode 100644 index 000000000..7e777088e --- /dev/null +++ b/Server/tests/integration/test_manage_components.py @@ -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" + diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs index 916a0f949..413fc7e2b 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs @@ -20,6 +20,10 @@ public class LongUnityScriptClaudeTest : MonoBehaviour // Accumulators used by padding methods to avoid complete no-ops 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")] @@ -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 @@ private void LateUpdate() } // NL tests sometimes add comments above Update() as an anchor +// Build marker OK private void Update() { if (reachOrigin == null) return; @@ -94,12 +104,12 @@ private Vector3 AccumulateBlend(Transform t) return new Vector3(bx, by, 0f); } - private void ApplyBlend(Vector3 blend) - { - if (animator == null) return; - animator.SetFloat("reachX", blend.x); - animator.SetFloat("reachY", blend.y); - } +private void ApplyBlend(Vector3 blend) // safe animation + { + if (animator == null) return; // safety check + animator.SetFloat(BlendXHash, blend.x); + animator.SetFloat(BlendYHash, blend.y); + } public void TickBlendOnce() { @@ -747,6 +757,19 @@ private void Pad0239() } 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() { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs new file mode 100644 index 000000000..3fdc28328 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs @@ -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 +{ + /// + /// Stress tests for the GameObject API redesign. + /// Tests volume operations, pagination, and performance with large datasets. + /// + [TestFixture] + public class GameObjectAPIStressTests + { + private List _createdObjects = new List(); + 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() ?? false, $"Failed to create object {i}"); + + // Track for cleanup + int instanceId = result["data"]?["instanceID"]?.Value() ?? 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() ?? false, $"Failed to create object {i}"); + + int instanceId = result["data"]?["instanceID"]?.Value() ?? 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(); + } + + // 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() ?? 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() ?? 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() ?? 0; + Assert.AreEqual(LARGE_BATCH, totalCount, $"Should find exactly {LARGE_BATCH} objects created by this test"); + + bool hasMore = data["hasMore"]?.Value() ?? 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(); + } + + // Track IDs we've created for verification + var createdIds = new HashSet(); + 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() ?? 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())) + { + foundFromCreated++; + } + } + pageCount++; + + bool hasMore = data["hasMore"]?.Value() ?? false; + if (!hasMore) break; + + cursor = data["nextCursor"]?.Value() ?? 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() ?? 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().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(); + go.AddComponent(); + go.AddComponent(); + go.AddComponent(); + go.AddComponent(); + go.AddComponent(); + go.AddComponent(); + go.AddComponent(); + + 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() ?? 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(); + + 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() ?? false, $"Set property failed: {result["message"]}"); + + var rb = go.GetComponent(); + 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() ?? 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()); + } + + [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() ?? 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(); + go.AddComponent(); + go.AddComponent(); + + // 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() ?? false); + var data = result["data"] as JObject; + + Assert.AreEqual("ComplexObject", data?["name"]?.Value()); + Assert.AreEqual("Player", data?["tag"]?.Value()); + Assert.AreEqual(8, data?["layer"]?.Value()); + + 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(); + rb.mass = 5.5f; + rb.drag = 1.2f; + + var col = go.AddComponent(); + 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() ?? 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(); + 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() ?? 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() ?? false, $"Create failed: {createResult["message"]}"); + + int instanceId = createResult["data"]?["instanceID"]?.Value() ?? 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() ?? 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() ?? 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 + } + + /// + /// Marker component used for isolating component-based searches to objects created by this test fixture. + /// + public sealed class GameObjectAPIStressTestMarker : MonoBehaviour { } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs.meta new file mode 100644 index 000000000..95aac3383 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7392c46e26c4649479cce9912fa94c1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs new file mode 100644 index 000000000..fe240641a --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs @@ -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 +{ + /// + /// Comprehensive baseline tests for ManageGameObject "create" action. + /// These tests capture existing behavior before API redesign. + /// + public class ManageGameObjectCreateTests + { + private List createdObjects = new List(); + + [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("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("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("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("success"), resultObj.ToString()); + + var created = FindAndTrack("TestCube"); + Assert.IsNotNull(created, "Cube should be created"); + Assert.IsNotNull(created.GetComponent(), "Cube should have MeshFilter"); + Assert.IsNotNull(created.GetComponent(), "Cube should have MeshRenderer"); + Assert.IsNotNull(created.GetComponent(), "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("success"), resultObj.ToString()); + + var created = FindAndTrack("TestSphere"); + Assert.IsNotNull(created, "Sphere should be created"); + Assert.IsNotNull(created.GetComponent(), "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("success"), resultObj.ToString()); + + var created = FindAndTrack("TestCapsule"); + Assert.IsNotNull(created, "Capsule should be created"); + Assert.IsNotNull(created.GetComponent(), "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("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("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("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("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("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("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("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("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("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("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("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() ?? data["InstanceID"]?.Value(); + 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("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 + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs.meta new file mode 100644 index 000000000..44a1ddece --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec38858ff125347778a30792e4bb1c3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs new file mode 100644 index 000000000..82aa94db5 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs @@ -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 +{ + /// + /// Comprehensive baseline tests for ManageGameObject "delete" action. + /// These tests capture existing behavior before API redesign. + /// + public class ManageGameObjectDeleteTests + { + private List testObjects = new List(); + + [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("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("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("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("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("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("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("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("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("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("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("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 + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs.meta new file mode 100644 index 000000000..38ad0c559 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e74a6d8990a344fd6a1e4b175d411be1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs new file mode 100644 index 000000000..c0ebb6d14 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs @@ -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 +{ + /// + /// Comprehensive baseline tests for ManageGameObject "modify" action. + /// These tests capture existing behavior before API redesign. + /// + public class ManageGameObjectModifyTests + { + private List testObjects = new List(); + + [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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("success"), resultObj.ToString()); + Assert.AreEqual("MultiModifiedObject", testObjects[0].name); + Assert.AreEqual(parent.transform, testObjects[0].transform.parent); + Assert.AreEqual("MainCamera", testObjects[0].tag); + } + + #endregion + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs.meta new file mode 100644 index 000000000..9319bb891 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 042aca01b843348a3bc9ac86475e6293 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs index 3527f5d3c..df70a0a18 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -69,81 +69,6 @@ public void HandleCommand_ProcessesValidCreateAction() } } - [Test] - public void GetComponents_ReturnsPagedMetadataByDefault() - { - // Arrange - testGameObject.AddComponent(); - testGameObject.AddComponent(); - - 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("success"), result.ToString()); - var data = result["data"] as JObject; - Assert.IsNotNull(data, "Expected data payload object."); - Assert.AreEqual(false, data.Value("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(); - testGameObject.AddComponent(); - - 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("success"), result.ToString()); - var data = result["data"] as JObject; - Assert.IsNotNull(data); - Assert.AreEqual(true, data.Value("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 @@ public void GetComponentData_WorksWithMultipleMaterials() 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(); - 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("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(); - 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(); - 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); - } } }