# Section 05: node_ops Python Implementation

## Overview

This section creates the Python-side implementation for the `godotiq_node_ops` bridge tool. The module is a thin async wrapper that validates the operations list, enforces a 50-operation limit, and forwards the request to the Godot addon via the WebSocket bridge.

**File to create:** `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/node_ops.py`

**Dependencies:** This section depends on section-04 (node_ops GDScript handler) being available on the Godot side for live testing, and on section-01 (protocol/plugin wiring) having added the `"node_ops": 10.0` timeout to `TOOL_TIMEOUTS` in `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/bridge/protocol.py`. However, the Python module itself can be implemented and unit-tested in isolation using mocks.

## Tests First

Tests go in `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_node_ops.py`. They follow the established bridge tool test pattern: mock `get_bridge()` to return an `AsyncMock`, then verify parameter validation, correct bridge forwarding, and return value passthrough.

```python
"""Tests for node_ops bridge tool implementation."""

from __future__ import annotations

import pytest
from unittest.mock import AsyncMock, patch


from godotiq.tools.bridge.node_ops import node_ops

MAX_OPS = 50


class TestNodeOps:
    """node_ops parameter validation and bridge delegation."""

    async def test_valid_operations_forwarded(self) -> None:
        """A valid operations list is forwarded to bridge.request('node_ops', ...)."""
        ops = [{"op": "move", "node": "Player", "position": [1, 2, 3]}]
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {
            "results": [{"op": "move", "node": "Player", "status": "ok"}]
        }
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            result = await node_ops(operations=ops)

        mock_bridge.request.assert_called_once_with(
            "node_ops", params={"operations": ops}
        )
        assert result["results"][0]["status"] == "ok"

    async def test_empty_operations_rejected(self) -> None:
        """An empty operations list raises ValueError."""
        with pytest.raises(ValueError, match="[Ee]mpty|[Nn]o operations|at least"):
            await node_ops(operations=[])

    async def test_limit_enforced(self) -> None:
        """An operations list exceeding 50 items raises ValueError."""
        ops = [{"op": "move", "node": f"N{i}", "position": [0, 0, 0]} for i in range(MAX_OPS + 1)]
        with pytest.raises(ValueError, match="50|limit|exceed"):
            await node_ops(operations=ops)

    async def test_exactly_50_operations_allowed(self) -> None:
        """Exactly 50 operations should be accepted (boundary test)."""
        ops = [{"op": "move", "node": f"N{i}", "position": [0, 0, 0]} for i in range(MAX_OPS)]
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {"results": []}
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            await node_ops(operations=ops)

        mock_bridge.request.assert_called_once()

    async def test_returns_bridge_response(self) -> None:
        """The bridge response dict is returned as-is."""
        expected = {"results": [{"op": "delete", "node": "Enemy", "status": "ok"}]}
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = expected
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            result = await node_ops(operations=[{"op": "delete", "node": "Enemy"}])

        assert result == expected

    async def test_bridge_connection_error_propagates(self) -> None:
        """BridgeConnectionError from get_bridge propagates to caller."""
        from godotiq.bridge.connection import BridgeConnectionError

        with patch(
            "godotiq.tools.bridge.node_ops.get_bridge",
            side_effect=BridgeConnectionError("Not connected"),
        ):
            with pytest.raises(BridgeConnectionError):
                await node_ops(operations=[{"op": "move", "node": "X", "position": [0, 0, 0]}])

    async def test_bridge_timeout_error_propagates(self) -> None:
        """BridgeTimeoutError from bridge.request propagates to caller."""
        from godotiq.bridge.connection import BridgeTimeoutError

        mock_bridge = AsyncMock()
        mock_bridge.request.side_effect = BridgeTimeoutError("Timed out")
        with patch("godotiq.tools.bridge.node_ops.get_bridge", return_value=mock_bridge):
            with pytest.raises(BridgeTimeoutError):
                await node_ops(operations=[{"op": "move", "node": "X", "position": [0, 0, 0]}])
```

Key testing decisions:

- **Mocking pattern**: Uses `patch("godotiq.tools.bridge.node_ops.get_bridge", ...)` to intercept the bridge import within the module, consistent with how `test_tools.py` mocks `get_bridge` for screenshot, run, etc.
- **Validation errors**: Both empty list and over-limit list raise `ValueError` -- these are validated locally before any bridge call.
- **Error propagation**: `BridgeConnectionError` and `BridgeTimeoutError` are not caught in the implementation layer; they propagate up to the MCP registration wrapper in `__init__.py` which handles them uniformly.
- **Boundary test**: Exactly 50 operations must be accepted (the limit is strictly greater than 50).

## Implementation

Create `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/node_ops.py`.

The module follows the established bridge tool pattern seen in `run.py`, `screenshot.py`, etc.:

1. Import `get_bridge` from `godotiq.bridge.session`
2. Define an async function with typed parameters and docstring
3. Validate inputs locally (before touching the bridge)
4. Call `await get_bridge()` to obtain the connection
5. Forward via `bridge.request("node_ops", params={...})`
6. Return the bridge response dict

```python
"""Node operations tool -- batch node editing with undo/redo support."""

from __future__ import annotations

from godotiq.bridge.session import get_bridge

MAX_OPERATIONS = 50


async def node_ops(operations: list[dict]) -> dict:
    """Execute batch node operations in the Godot editor.

    Each operation is a dict with an 'op' key and operation-specific params.
    All operations are grouped as a single undo action in the editor.

    Supported operations:
        move, rotate, scale, set_property, add_child, delete, duplicate, reparent

    Args:
        operations: List of operation dicts. Must be non-empty and at most 50 items.

    Returns:
        Dict with 'results' list containing per-operation status dicts.

    Raises:
        ValueError: If operations list is empty or exceeds the 50-operation limit.
    """
    if not operations:
        raise ValueError("No operations provided; at least one operation is required.")
    if len(operations) > MAX_OPERATIONS:
        raise ValueError(
            f"Too many operations ({len(operations)}); limit is {MAX_OPERATIONS} per call."
        )

    bridge = await get_bridge()
    return await bridge.request("node_ops", params={"operations": operations})
```

### Design Rationale

- **No parameter transformation**: Unlike `scene_tree` which clamps depth and fills default include lists, `node_ops` passes the operations list through to the GDScript handler without modification. The GDScript side is responsible for interpreting and validating individual operation fields (op type, node paths, values). The Python layer only enforces structural constraints (non-empty, size limit).

- **`bridge.request()` not `request_with_retry()`**: Node operations are destructive and non-idempotent. Retrying a failed batch could cause duplicate mutations. The single-attempt `request()` is correct here. This matches the pattern used by `run.py` (also destructive) rather than `screenshot.py` (which uses retry).

- **Error propagation**: `BridgeConnectionError`, `BridgeTimeoutError`, and `BridgeError` all propagate to the caller. The MCP registration layer in `__init__.py` (section-08) catches these and returns user-friendly error dicts. This separation keeps the implementation layer clean and testable.

- **Timeout**: The `bridge.request()` call will use `get_timeout("node_ops")` which resolves to `10.0` seconds (added in section-01). This is double the default 5.0s to accommodate large batches.

- **MAX_OPERATIONS constant**: Defined as a module-level constant (50) for easy adjustment and test reference. The limit prevents unbounded processing on the GDScript side and keeps bridge request payloads reasonable.

### How Operations Flow End-to-End

For context on what the bridge does with the forwarded operations (implemented in section-04):

1. Python sends `{"method": "node_ops", "params": {"operations": [...]}}` over WebSocket
2. GDScript `_handle_node_ops()` receives the operations array
3. Opens a single `EditorUndoRedoManager` action
4. Iterates each operation, dispatching to type-specific handlers (move, rotate, etc.)
5. Commits the action (all ops become one Ctrl+Z entry)
6. Returns `{"results": [{"op": "...", "status": "ok"|"error", ...}, ...]}` back over WebSocket
7. Python receives and returns this dict as-is

The Python side does not need to know the specifics of each operation type -- it is a transparent passthrough after validation.

---

## Implementation Notes

**Status:** Complete

**Files created:**
- `src/godotiq/tools/bridge/node_ops.py` (36 lines)
- `tests/test_bridge/test_node_ops.py` (91 lines)

**Deviations from plan:**
- Added `test_bridge_error_propagates` test (8th test) to cover `BridgeError` propagation, which the original plan omitted.
- Fixed double blank line in test imports for consistency with sibling `test_scene_tree.py`.

**Review decisions:**
- No `isinstance` type validation added — user chose to keep thin wrapper matching sibling pattern.
- No per-operation `op` key validation — delegated to GDScript as per plan.

**Final test count:** 8 (7 planned + 1 added during review)
