# Section 09: Unit Tests for All Sprint 4b Tools

## Overview

This section adds comprehensive unit tests for the four tools introduced in Sprint 4b: `scene_tree`, `node_ops`, `script_ops`, and `file_ops`. It also adds tests for the MCP registration wrappers in `__init__.py` that verify error handling for each tool.

**Test files to create:**

| File | Tool | Test Strategy |
|------|------|---------------|
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_scene_tree.py` | scene_tree | AsyncMock + patch for `get_bridge` |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_node_ops.py` | node_ops | AsyncMock + patch for `get_bridge` |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_script_ops.py` | script_ops | `tmp_path` + `monkeypatch.setenv` |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_file_ops.py` | file_ops | `tmp_path` + `monkeypatch.setenv` |

**Dependencies:** All four tool implementation modules must exist (sections 03, 05, 06, 07), and the MCP registration wrappers must be in place (section 08). The tests themselves can be written and run independently of a live Godot instance -- bridge tools are fully mocked, and filesystem tools use temporary directories.

**Testing stack:** pytest with `asyncio_mode = "auto"` (configured in `/Users/salvatorefarruggio/Desktop/godotiq/pyproject.toml`), `AsyncMock` + `patch` from `unittest.mock`, `tmp_path` and `monkeypatch` from pytest.

---

## Established Test Patterns

All tests follow the conventions established in the existing test file `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_tools.py`. Key patterns:

**Bridge tool tests** mock `get_bridge` at the module where it is imported (not at `godotiq.bridge.session`):

```python
from unittest.mock import AsyncMock, patch

mock_bridge = AsyncMock()
mock_bridge.request.return_value = {"key": "value"}
with patch("godotiq.tools.bridge.<module>.get_bridge", return_value=mock_bridge):
    result = await some_tool()
mock_bridge.request.assert_called_once_with("method", params={...})
```

**Filesystem tool tests** use `tmp_path` for isolated filesystem state and `monkeypatch.setenv` for `GODOTIQ_PROJECT_ROOT`:

```python
async def test_example(self, tmp_path, monkeypatch):
    monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
    (tmp_path / "test.gd").write_text("extends Node\n", encoding="utf-8")
    result = await some_tool(op="read", path="test.gd")
    assert result["status"] == "OK"
```

**Error classes** are imported from `godotiq.bridge.connection`:

```python
from godotiq.bridge.connection import BridgeConnectionError, BridgeTimeoutError, BridgeError
```

---

## Test File 1: `test_scene_tree.py`

**File:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_scene_tree.py`

This file tests the `scene_tree` function from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/scene_tree.py`. The function clamps `depth` to [1, 10], defaults `include` to `["transform", "script", "groups", "visibility"]`, and forwards all parameters to `bridge.request("scene_tree", params={...})`.

```python
"""Tests for the scene_tree bridge tool."""

from __future__ import annotations

import pytest
from unittest.mock import AsyncMock, patch

from godotiq.tools.bridge.scene_tree import scene_tree

DEFAULT_INCLUDE = ["transform", "script", "groups", "visibility"]


class TestSceneTree:
    """scene_tree parameter validation and bridge delegation."""

    async def test_default_params(self) -> None:
        """Default call forwards depth=3 and default include list to bridge."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {"root": "Root", "tree": []}
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            result = await scene_tree()

        mock_bridge.request.assert_called_once_with(
            "scene_tree",
            params={
                "root": "",
                "depth": 3,
                "filter_type": "",
                "include": DEFAULT_INCLUDE,
            },
        )
        assert result == {"root": "Root", "tree": []}

    async def test_depth_clamped_below_1(self) -> None:
        """Depth values below 1 are clamped to 1."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {}
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(depth=0)

        assert mock_bridge.request.call_args.kwargs["params"]["depth"] == 1

    async def test_depth_clamped_above_10(self) -> None:
        """Depth values above 10 are clamped to 10."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {}
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(depth=50)

        assert mock_bridge.request.call_args.kwargs["params"]["depth"] == 10

    async def test_root_param_forwarded(self) -> None:
        """Custom root path is forwarded to the bridge."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {}
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(root="Level/Enemies")

        assert mock_bridge.request.call_args.kwargs["params"]["root"] == "Level/Enemies"

    async def test_filter_type_forwarded(self) -> None:
        """filter_type string is forwarded to the bridge."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {}
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(filter_type="MeshInstance3D")

        assert mock_bridge.request.call_args.kwargs["params"]["filter_type"] == "MeshInstance3D"

    async def test_custom_include_list(self) -> None:
        """A caller-supplied include list overrides the default."""
        mock_bridge = AsyncMock()
        mock_bridge.request.return_value = {}
        custom = ["transform", "script"]
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            await scene_tree(include=custom)

        assert mock_bridge.request.call_args.kwargs["params"]["include"] == custom

    async def test_returns_bridge_response(self) -> None:
        """The bridge response dict is returned unmodified."""
        mock_bridge = AsyncMock()
        expected = {
            "root": "Main",
            "scene_path": "res://main.tscn",
            "total_nodes": 42,
            "returned_nodes": 10,
            "tree": [{"n": "Player", "t": "CharacterBody3D"}],
        }
        mock_bridge.request.return_value = expected
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            result = await scene_tree()

        assert result == expected

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

        with patch(
            "godotiq.tools.bridge.scene_tree.get_bridge",
            side_effect=BridgeConnectionError("not connected"),
        ):
            with pytest.raises(BridgeConnectionError):
                await scene_tree()

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

        mock_bridge = AsyncMock()
        mock_bridge.request.side_effect = BridgeTimeoutError("timed out")
        with patch("godotiq.tools.bridge.scene_tree.get_bridge", return_value=mock_bridge):
            with pytest.raises(BridgeTimeoutError):
                await scene_tree()
```

### What each test verifies

| Test | Checks |
|------|--------|
| `test_default_params` | Default depth=3, default include list, empty root/filter_type, bridge called once |
| `test_depth_clamped_below_1` | `depth=0` becomes `depth=1` in the bridge call |
| `test_depth_clamped_above_10` | `depth=50` becomes `depth=10` in the bridge call |
| `test_root_param_forwarded` | Custom root string passed through to bridge params |
| `test_filter_type_forwarded` | Custom filter_type string passed through to bridge params |
| `test_custom_include_list` | Caller-supplied include list replaces the default |
| `test_returns_bridge_response` | Bridge response dict returned without modification |
| `test_raises_bridge_connection_error` | BridgeConnectionError propagates (not caught at implementation layer) |
| `test_raises_bridge_timeout_error` | BridgeTimeoutError propagates (not caught at implementation layer) |

---

## Test File 2: `test_node_ops.py`

**File:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_node_ops.py`

This file tests the `node_ops` function from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/node_ops.py`. The function validates that the operations list is non-empty and does not exceed 50 items, then forwards to `bridge.request("node_ops", params={"operations": operations})`.

```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]}])
```

### What each test verifies

| Test | Checks |
|------|--------|
| `test_valid_operations_forwarded` | Single valid op is forwarded correctly, response returned |
| `test_empty_operations_rejected` | Empty list raises ValueError before any bridge call |
| `test_limit_enforced` | 51 operations raises ValueError before any bridge call |
| `test_exactly_50_operations_allowed` | Boundary: exactly 50 ops are accepted |
| `test_returns_bridge_response` | Bridge response dict returned without modification |
| `test_bridge_connection_error_propagates` | BridgeConnectionError not caught at implementation layer |
| `test_bridge_timeout_error_propagates` | BridgeTimeoutError not caught at implementation layer |

---

## Test File 3: `test_script_ops.py`

**File:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_script_ops.py`

This file tests the `script_ops` function and its helper functions from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/script_ops.py`. The tool is entirely filesystem-based -- no bridge mocking is needed. Instead, tests use `tmp_path` for real filesystem operations and `monkeypatch.setenv` for `GODOTIQ_PROJECT_ROOT`.

```python
"""Tests for script_ops filesystem tool."""

from __future__ import annotations

import pytest
from pathlib import Path

from godotiq.tools.bridge.script_ops import script_ops, _resolve_path, _is_protected, _validate_gdscript


class TestResolvePath:
    """Path resolution and containment validation."""

    def test_res_prefix_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with res:// prefix resolves relative to GODOTIQ_PROJECT_ROOT."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = _resolve_path("res://scripts/player.gd")
        assert result == tmp_path / "scripts" / "player.gd"

    def test_relative_path_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with relative path resolves relative to GODOTIQ_PROJECT_ROOT."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = _resolve_path("scripts/player.gd")
        assert result == tmp_path / "scripts" / "player.gd"

    def test_path_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with ../../etc/passwd raises error (containment check)."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        with pytest.raises(ValueError, match="outside project"):
            _resolve_path("../../etc/passwd")

    def test_res_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
        """_resolve_path with res://../.. traversal raises error."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        with pytest.raises(ValueError, match="outside project"):
            _resolve_path("res://../../etc/passwd")


class TestIsProtected:
    """Protected file pattern matching."""

    def test_project_godot_is_protected(self) -> None:
        """_is_protected returns True for project.godot."""
        assert _is_protected("project.godot") is True

    def test_godot_dir_is_protected(self) -> None:
        """_is_protected returns True for paths inside .godot/."""
        assert _is_protected(".godot/imported/thing.res") is True

    def test_import_files_are_protected(self) -> None:
        """_is_protected returns True for .import files."""
        assert _is_protected("icon.png.import") is True

    def test_saves_dir_is_protected(self) -> None:
        """_is_protected returns True for paths inside saves/."""
        assert _is_protected("saves/game.sav") is True

    def test_regular_gd_file_not_protected(self) -> None:
        """_is_protected returns False for regular .gd files."""
        assert _is_protected("scripts/player.gd") is False


class TestReadMode:
    """Read operation tests."""

    async def test_read_returns_content_and_metadata(self, tmp_path: Path, monkeypatch) -> None:
        """Read mode returns file content, line count, and size."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("extends Node\n\nfunc _ready():\n\tpass\n", encoding="utf-8")
        result = await script_ops(op="read", path="test.gd")
        assert result["status"] == "OK"
        assert "extends Node" in result["content"]
        assert result["lines"] == 4
        assert result["size_bytes"] > 0

    async def test_read_missing_file_returns_error(self, tmp_path: Path, monkeypatch) -> None:
        """Read mode raises error for missing file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="read", path="nonexistent.gd")
        assert result["status"] == "ERROR"
        assert "not found" in result["error"].lower() or "Not found" in result["error"]


class TestWriteMode:
    """Write operation tests."""

    async def test_write_creates_file(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode creates/overwrites file with content."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="new_script.gd", content="extends Node\n", validate_before_write=False)
        assert result["status"] == "OK"
        assert result["written"] is True
        assert (tmp_path / "new_script.gd").read_text() == "extends Node\n"

    async def test_write_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode rejects protected files."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="project.godot", content="test")
        assert result["status"] == "BLOCKED"

    async def test_write_with_validation(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode with validate_before_write=True runs GDScript validation."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        content = "extends Node\nvar speed = 10\n"
        result = await script_ops(op="write", path="test.gd", content=content, validate_before_write=True)
        assert result["status"] == "OK"
        assert "validation_issues" in result

    async def test_write_dry_run_does_not_write(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode with dry_run=True validates but doesn't write."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="test.gd", content="extends Node\n", dry_run=True, validate_before_write=False)
        assert result["status"] == "DRY_RUN"
        assert result["written"] is False
        assert not (tmp_path / "test.gd").exists()

    async def test_write_path_traversal_blocked(self, tmp_path: Path, monkeypatch) -> None:
        """Write mode path traversal blocked."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="write", path="../../etc/evil.gd", content="test")
        assert result["status"] == "ERROR"


class TestPatchMode:
    """Patch operation tests."""

    async def test_patch_single_replacement(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode applies single find-and-replace."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("var speed = 10\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "speed = 10", "replace": "speed: float = 20.0"}],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["written"] is True
        assert "speed: float = 20.0" in f.read_text()

    async def test_patch_multiple_sequential(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode applies multiple patches sequentially."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("var a = 1\nvar b = 2\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[
                {"search": "var a = 1", "replace": "var a: int = 1"},
                {"search": "var b = 2", "replace": "var b: int = 2"},
            ],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        content = f.read_text()
        assert "var a: int = 1" in content
        assert "var b: int = 2" in content

    async def test_patch_rejects_non_unique_search(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode rejects non-unique search string (appears 0 or 2+ times)."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("pass\npass\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "pass", "replace": "return"}],
            validate_before_write=False,
        )
        assert result["status"] == "ERROR"
        assert len(result["failed"]) > 0

    async def test_patch_line_numbers_before_replacement(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode returns line numbers calculated BEFORE replacement."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("line1\nTARGET\nline3\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="test.gd",
            patches=[{"search": "TARGET", "replace": "REPLACED"}],
            validate_before_write=False,
        )
        assert result["status"] == "OK"
        assert result["applied"][0]["line"] == 2

    async def test_patch_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode rejects protected files."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "project.godot"
        f.write_text("config_version=5\n", encoding="utf-8")
        result = await script_ops(
            op="patch", path="project.godot",
            patches=[{"search": "5", "replace": "4"}],
        )
        assert result["status"] == "BLOCKED"

    async def test_patch_empty_patches_returns_no_changes(self, tmp_path: Path, monkeypatch) -> None:
        """Patch mode with empty patches list returns unchanged file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        f = tmp_path / "test.gd"
        f.write_text("extends Node\n", encoding="utf-8")
        result = await script_ops(op="patch", path="test.gd", patches=[])
        assert result["written"] is False or result["status"] == "ERROR"


class TestCreateMode:
    """Create operation tests."""

    async def test_create_new_file(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode creates new file."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="create", path="new.gd", content="extends Node\n")
        assert result["status"] == "OK"
        assert result["created"] is True
        assert (tmp_path / "new.gd").exists()

    async def test_create_fails_if_exists(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode fails if file already exists."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        (tmp_path / "existing.gd").write_text("old", encoding="utf-8")
        result = await script_ops(op="create", path="existing.gd", content="new")
        assert result["status"] == "ERROR"
        assert "exists" in result["error"].lower()

    async def test_create_rejects_protected_paths(self, tmp_path: Path, monkeypatch) -> None:
        """Create mode rejects protected paths."""
        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
        result = await script_ops(op="create", path=".godot/cache.gd", content="test")
        assert result["status"] == "BLOCKED"


class TestValidation:
    """GDScript convention validation."""

    def test_validate_warns_on_missing_type_hints(self) -> None:
        """_validate_gdscript checks for type hints (basic convention check)."""
        content = "extends Node\nvar speed = 10\n"
        issues = _validate_gdscript(content, "test.gd")
        assert len(issues) > 0
        assert any(i["rule"] == "type_hint" for i in issues)
```

### Key testing notes for script_ops

- **`_resolve_path` tests are synchronous**: The helper function is not async; it reads `GODOTIQ_PROJECT_ROOT` from `os.environ` and performs `Path` operations. The `monkeypatch.setenv` fixture ensures the env var is set and cleaned up.

- **`_is_protected` tests are synchronous**: Pure string-matching logic with no I/O.

- **Return value conventions**: The `script_ops` function returns dicts with a `status` field. Expected values: `"OK"` (success), `"ERROR"` (failure), `"BLOCKED"` (protected file), `"DRY_RUN"` (dry run mode), `"PARTIAL"` (some patches applied, some failed).

- **Path traversal tests at two layers**: Both `_resolve_path` (raises ValueError) and the full `script_ops` function (catches the error, returns `{"status": "ERROR"}`) are tested. The helper raises; the main function catches and wraps in a return dict.

- **Patch sequential behavior**: The `test_patch_multiple_sequential` test verifies that two patches applied in order both take effect. The first patch modifies the file content, and the second patch's search string is validated against the modified content.

- **Patch uniqueness test**: The `test_patch_rejects_non_unique_search` test writes `"pass\npass\n"` (search string `"pass"` appears twice), so the patch should fail with a uniqueness error. The response should include a `"failed"` list with details.

---

## Test File 4: `test_file_ops.py`

**File:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_file_ops.py`

This file tests the `file_ops` function and its helper functions from `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/file_ops.py`. Like `script_ops`, this is a filesystem-only tool. Tests use a shared `project` fixture that creates a realistic Godot project directory structure in `tmp_path`.

```python
"""Tests for file_ops filesystem tool."""

from __future__ import annotations

import pytest
from pathlib import Path

from godotiq.tools.bridge.file_ops import file_ops, _resolve, _is_protected, _matches_type_filter


@pytest.fixture
def project(tmp_path, monkeypatch):
    """Set up a fake Godot project directory with sample files."""
    monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))

    # Create project.godot so it looks like a real project
    (tmp_path / "project.godot").write_text("[gd_resource]")

    # Create sample files
    (tmp_path / "main.tscn").write_text('[gd_scene format=3]')
    (tmp_path / "player.gd").write_text('extends Node\nfunc _ready():\n\tpass\n')
    (tmp_path / "icon.png").write_bytes(b'\x89PNG\r\n\x1a\n' + b'\x00' * 100)

    # Create subdirectory structure
    scripts_dir = tmp_path / "scripts"
    scripts_dir.mkdir()
    (scripts_dir / "enemy.gd").write_text('extends CharacterBody3D\nvar speed: float = 5.0\n')
    (scripts_dir / "utils.gd").write_text('class_name Utils\nstatic func helper() -> void:\n\tpass\n')

    scenes_dir = tmp_path / "scenes"
    scenes_dir.mkdir()
    (scenes_dir / "level1.tscn").write_text('[gd_scene format=3]')

    # Create excluded directories
    godot_dir = tmp_path / ".godot"
    godot_dir.mkdir()
    (godot_dir / "cache.bin").write_bytes(b'\x00' * 10)

    git_dir = tmp_path / ".git"
    git_dir.mkdir()
    (git_dir / "config").write_text("[core]")

    # Create saves dir (protected)
    saves_dir = tmp_path / "saves"
    saves_dir.mkdir()
    (saves_dir / "save1.tres").write_text("[resource]")

    return tmp_path


class TestResolve:
    """Path resolution and containment checks."""

    def test_res_prefix(self, project, monkeypatch):
        """res:// paths resolve relative to project root."""
        result = _resolve("res://scripts/enemy.gd")
        assert result == project / "scripts" / "enemy.gd"

    def test_relative_path(self, project, monkeypatch):
        """Relative paths resolve against project root."""
        result = _resolve("player.gd")
        assert result == project / "player.gd"

    def test_path_traversal_blocked(self, project, monkeypatch):
        """Path traversal outside project root raises ValueError."""
        with pytest.raises(ValueError):
            _resolve("../../etc/passwd")


class TestIsProtected:
    """Protected file detection."""

    def test_protected_files(self, project):
        """Known protected patterns are correctly identified."""
        assert _is_protected(project / "project.godot") is True
        assert _is_protected(project / ".godot" / "cache.bin") is True
        assert _is_protected(project / "icon.png.import") is True
        assert _is_protected(project / "saves" / "game.sav") is True
        assert _is_protected(project / "player.gd") is False
        assert _is_protected(project / "scripts" / "enemy.gd") is False


class TestListOperation:
    """The 'list' operation."""

    async def test_list_root(self, project):
        """Listing project root returns top-level entries."""
        result = await file_ops(op="list", path="")
        assert "entries" in result
        names = [e["name"] for e in result["entries"]]
        assert "player.gd" in names
        assert "main.tscn" in names

    async def test_list_filter_scripts(self, project):
        """Type filter narrows results to matching extensions."""
        result = await file_ops(op="list", path="", filter="scripts", recursive=True)
        entries = result["entries"]
        for entry in entries:
            if entry.get("type") == "file":
                assert entry["name"].endswith(".gd")

    async def test_list_recursive(self, project):
        """Recursive listing includes files in subdirectories."""
        result = await file_ops(op="list", path="", recursive=True)
        names = [e["name"] for e in result["entries"]]
        assert "enemy.gd" in names or any("enemy" in n for n in names)

    async def test_list_excludes_system_dirs(self, project):
        """System/hidden directories are automatically excluded."""
        result = await file_ops(op="list", path="", recursive=True)
        names = [e["name"] for e in result["entries"]]
        assert ".godot" not in names
        assert ".git" not in names
        assert "cache.bin" not in names

    async def test_list_missing_dir(self, project):
        """Listing a nonexistent directory returns an error."""
        result = await file_ops(op="list", path="nonexistent_dir")
        assert "error" in result


class TestReadOperation:
    """The 'read' operation."""

    async def test_read_text_file(self, project):
        """Reading a text file returns its content."""
        result = await file_ops(op="read", path="player.gd")
        assert "content" in result
        assert "extends Node" in result["content"]

    async def test_read_binary_rejected(self, project):
        """Attempting to read a binary file returns an error."""
        result = await file_ops(op="read", path="icon.png")
        assert "error" in result

    async def test_read_missing_file(self, project):
        """Reading a nonexistent file returns an error."""
        result = await file_ops(op="read", path="ghost.gd")
        assert "error" in result


class TestWriteOperation:
    """The 'write' operation."""

    async def test_write_creates_file(self, project):
        """Writing to a new path creates the file."""
        result = await file_ops(op="write", path="data/config.cfg", content="[config]\nkey=value\n")
        assert "error" not in result
        assert (project / "data" / "config.cfg").exists()

    async def test_write_protected_rejected(self, project):
        """Writing to a protected file returns an error."""
        result = await file_ops(op="write", path="project.godot", content="overwrite!")
        assert "error" in result

    async def test_write_traversal_blocked(self, project):
        """Write with path traversal is rejected."""
        result = await file_ops(op="write", path="../../etc/evil", content="bad")
        assert "error" in result


class TestMoveOperation:
    """The 'move' operation."""

    async def test_move_renames_file(self, project):
        """Moving a file to a new name renames it on disk."""
        result = await file_ops(op="move", path="player.gd", destination="player_old.gd")
        assert "error" not in result
        assert not (project / "player.gd").exists()
        assert (project / "player_old.gd").exists()

    async def test_move_protected_source_rejected(self, project):
        """Cannot move a protected source file."""
        result = await file_ops(op="move", path="project.godot", destination="old_project.godot")
        assert "error" in result

    async def test_move_protected_destination_rejected(self, project):
        """Cannot move into a protected destination path."""
        result = await file_ops(op="move", path="player.gd", destination="saves/player.gd")
        assert "error" in result

    async def test_move_traversal_blocked(self, project):
        """Path traversal is blocked for both source and destination."""
        result = await file_ops(op="move", path="../../etc/passwd", destination="stolen.txt")
        assert "error" in result
        result2 = await file_ops(op="move", path="player.gd", destination="../../etc/evil.gd")
        assert "error" in result2


class TestDeleteOperation:
    """The 'delete' operation."""

    async def test_delete_removes_file(self, project):
        """Deleting an existing file removes it from disk."""
        assert (project / "player.gd").exists()
        result = await file_ops(op="delete", path="player.gd")
        assert "error" not in result
        assert not (project / "player.gd").exists()

    async def test_delete_protected_rejected(self, project):
        """Cannot delete a protected file."""
        result = await file_ops(op="delete", path="project.godot")
        assert "error" in result
        assert (project / "project.godot").exists()

    async def test_delete_traversal_blocked(self, project):
        """Delete with path traversal is rejected."""
        result = await file_ops(op="delete", path="../../etc/passwd")
        assert "error" in result


class TestSearchOperation:
    """The 'search' operation."""

    async def test_search_finds_matches(self, project):
        """Search returns files containing the query string."""
        result = await file_ops(op="search", query="extends")
        assert "matches" in result
        assert len(result["matches"]) > 0

    async def test_search_context_lines(self, project):
        """Context lines are included around each match."""
        result = await file_ops(op="search", query="speed", context_lines=2)
        assert "matches" in result
        if result["matches"]:
            match = result["matches"][0]
            assert "context_before" in match or "context_after" in match

    async def test_search_with_filter(self, project):
        """Type filter restricts which files are searched."""
        result = await file_ops(op="search", query="extends", filter="scripts")
        assert "matches" in result
        for match in result["matches"]:
            assert match["file"].endswith(".gd")

    async def test_search_excludes_system_dirs(self, project):
        """System directories are skipped during search."""
        # Write searchable content inside .godot
        (project / ".godot" / "search_target.txt").write_text("extends Node\n")
        result = await file_ops(op="search", query="extends")
        for match in result["matches"]:
            assert ".godot" not in match["file"]

    async def test_search_max_matches_cap(self, project):
        """Results are capped at 50 matches maximum."""
        # Create many files with matching content
        bulk_dir = project / "bulk"
        bulk_dir.mkdir()
        for i in range(60):
            (bulk_dir / f"file_{i}.gd").write_text(f"extends Node  # file {i}\n")
        result = await file_ops(op="search", query="extends")
        assert len(result["matches"]) <= 50


class TestTreeOperation:
    """The 'tree' operation."""

    async def test_tree_with_depth(self, project):
        """Tree respects max_depth parameter."""
        result = await file_ops(op="tree", max_depth=1)
        assert "tree" in result

    async def test_tree_with_sizes(self, project):
        """File sizes are included when requested."""
        result = await file_ops(op="tree", show_sizes=True)
        assert "tree" in result
        # The tree should contain size info for files
        tree = result["tree"]
        # Verify at least one entry has a size field by walking the tree
        def has_size(node):
            if "size" in node:
                return True
            for child in node.get("children", []):
                if has_size(child):
                    return True
            return False
        assert has_size(tree)

    async def test_tree_excludes_system_dirs(self, project):
        """System directories (.godot, .git, etc.) are excluded from tree."""
        result = await file_ops(op="tree", max_depth=3)
        tree = result["tree"]
        def names_in_tree(node):
            result = [node.get("name", "")]
            for child in node.get("children", []):
                result.extend(names_in_tree(child))
            return result
        all_names = names_in_tree(tree)
        assert ".godot" not in all_names
        assert ".git" not in all_names


class TestTypeFilters:
    """Type filter matching logic."""

    def test_scene_filter(self):
        """Scene filter matches .tscn and .scn extensions."""
        assert _matches_type_filter("level.tscn", "scenes") is True
        assert _matches_type_filter("level.scn", "scenes") is True
        assert _matches_type_filter("script.gd", "scenes") is False

    def test_script_filter(self):
        """Script filter matches .gd extension."""
        assert _matches_type_filter("player.gd", "scripts") is True
        assert _matches_type_filter("level.tscn", "scripts") is False

    def test_all_filter(self):
        """The 'all' filter matches any file."""
        assert _matches_type_filter("anything.xyz", "all") is True
        assert _matches_type_filter("script.gd", "all") is True
        assert _matches_type_filter("image.png", "all") is True
```

### Key testing notes for file_ops

- **`project` fixture**: Creates a realistic Godot project structure with scene files, scripts, images, subdirectories, excluded directories (`.godot`, `.git`), and protected directories (`saves/`). This fixture is reused across all operation test classes.

- **`_is_protected` accepts a `Path`**: Unlike `script_ops` where `_is_protected` takes a string, `file_ops` may accept a `Path` object. The implementer should check the actual signature and adjust tests accordingly if needed. The key behavior is the same: match against `project.godot`, `.godot/`, `*.import`, and `saves/`.

- **Error handling convention**: `file_ops` returns error dicts with an `"error"` key (e.g., `{"error": "File not found: ..."}`), not a `"status"` key. This is different from `script_ops` which uses `status`. Tests check for the presence of the `"error"` key.

- **Binary file rejection in read**: The `icon.png` file in the fixture starts with the PNG magic bytes (`\x89PNG`). The `read` operation should detect this as binary (via UTF-8 decode failure) and return an error.

- **Search cap test**: Creates 60 files with matching content and verifies the result contains at most 50 matches, matching the `MAX_SEARCH_MATCHES = 50` constant.

- **Tree size verification**: The `test_tree_with_sizes` test walks the nested tree structure to find at least one node with a `"size"` key. This accounts for the tree being a nested dict structure.

---

## MCP Registration Wrapper Tests

These tests verify that the `@mcp.tool()` wrappers in `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/__init__.py` correctly catch exceptions and return user-friendly error dicts. They can be added to the existing `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_tools.py` file or placed in a new file.

The recommended approach is to add them to the existing `test_tools.py` file, which already tests the Sprint 4a tool wrappers (screenshot, run, perf_snapshot, editor_context). Add the following test classes at the end of that file.

**File to modify:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_tools.py`

Add imports for the new tool implementations and the following test classes:

```python
from godotiq.tools.bridge.scene_tree import scene_tree as _scene_tree_impl
from godotiq.tools.bridge.node_ops import node_ops as _node_ops_impl
from godotiq.tools.bridge.script_ops import script_ops as _script_ops_impl
from godotiq.tools.bridge.file_ops import file_ops as _file_ops_impl


class TestSceneTreeWrapper:
    """scene_tree MCP wrapper error handling."""

    async def test_catches_bridge_connection_error(self) -> None:
        """BridgeConnectionError is caught and returns user-friendly message."""
        from godotiq.bridge.connection import BridgeConnectionError

        with patch(
            "godotiq.tools.bridge.scene_tree.get_bridge",
            side_effect=BridgeConnectionError("not connected"),
        ):
            # Import the MCP wrapper (not the implementation)
            from godotiq.tools.bridge import godotiq_scene_tree
            result = await godotiq_scene_tree()

        assert "error" in result
        assert "hint" in result


class TestNodeOpsWrapper:
    """node_ops MCP wrapper error handling."""

    async def test_catches_bridge_connection_error(self) -> None:
        """BridgeConnectionError is caught and returns user-friendly message."""
        from godotiq.bridge.connection import BridgeConnectionError

        with patch(
            "godotiq.tools.bridge.node_ops.get_bridge",
            side_effect=BridgeConnectionError("not connected"),
        ):
            from godotiq.tools.bridge import godotiq_node_ops
            result = await godotiq_node_ops(
                operations=[{"op": "move", "node": "X", "position": [0, 0, 0]}]
            )

        assert "error" in result
        assert "hint" in result


class TestScriptOpsWrapper:
    """script_ops MCP wrapper error handling."""

    async def test_catches_generic_exception(self) -> None:
        """Generic Exception is caught and returns error message."""
        with patch(
            "godotiq.tools.bridge.script_ops.script_ops",
            side_effect=RuntimeError("unexpected"),
        ):
            from godotiq.tools.bridge import godotiq_script_ops
            result = await godotiq_script_ops(op="read", path="test.gd")

        assert "error" in result


class TestFileOpsWrapper:
    """file_ops MCP wrapper error handling."""

    async def test_catches_generic_exception(self) -> None:
        """Generic Exception is caught and returns error message."""
        with patch(
            "godotiq.tools.bridge.file_ops.file_ops",
            side_effect=RuntimeError("unexpected"),
        ):
            from godotiq.tools.bridge import godotiq_file_ops
            result = await godotiq_file_ops(op="list", path="")

        assert "error" in result
```

### Notes on wrapper tests

- The MCP wrapper functions are the `@mcp.tool()`-decorated functions in `__init__.py` (e.g., `godotiq_scene_tree`, `godotiq_node_ops`). They import and call the implementation functions from submodules.
- Bridge tool wrappers catch `BridgeConnectionError`, `BridgeTimeoutError`, and `BridgeError`, returning dicts with `"error"` and `"hint"` keys.
- Filesystem tool wrappers catch generic `Exception` and return dicts with an `"error"` key.
- The exact import path for wrapper functions depends on how section 08 names them. The pattern above assumes `godotiq_scene_tree`, `godotiq_node_ops`, `godotiq_script_ops`, `godotiq_file_ops` as function names, matching the Sprint 4a convention (e.g., `godotiq_screenshot`, `godotiq_run`).

---

## File Listing

| File | Action |
|------|--------|
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_scene_tree.py` | Create |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_node_ops.py` | Create |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_script_ops.py` | Create |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_file_ops.py` | Create |
| `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_tools.py` | Modify (append wrapper tests) |

## Verification

After all files are created, run the full test suite:

```bash
cd /Users/salvatorefarruggio/Desktop/godotiq && uv run pytest tests/test_bridge/ -v
```

Expected results:
- `test_scene_tree.py`: 9 tests passing (parameter validation, bridge delegation, error propagation)
- `test_node_ops.py`: 7 tests passing (validation, limit enforcement, bridge delegation, error propagation)
- `test_script_ops.py`: 18 tests passing (path resolution, protected files, read/write/patch/create modes, validation)
- `test_file_ops.py`: 24 tests passing (resolution, protected files, all 7 operations, type filters)
- `test_tools.py` (new additions): 4 tests passing (wrapper error handling)

Total new tests: **62 tests**.

All tests run without a live Godot editor connection. Bridge tools use `AsyncMock` to simulate the WebSocket bridge; filesystem tools use `tmp_path` for isolated temporary directories.

---

## Implementation Notes

**Status:** Complete (pre-implemented by sections 03-08)

All tests specified in this section were already created during TDD implementation of sections 03-08. No additional test files needed.

**Actual test counts (exceed spec):**
- `test_scene_tree.py`: 9 tests (spec: 9)
- `test_node_ops.py`: 8 tests (spec: 7 — extra BridgeError propagation test)
- `test_script_ops.py`: 28 tests (spec: 18 — extra dry_run, unknown op, and additional coverage)
- `test_file_ops.py`: 33 tests (spec: 24 — extra context, cap, and edge case tests)
- `test_registration.py`: 9 tests (spec: 4 wrapper tests — extra BridgeError, ValueError, and per-tool coverage)

**Total: 121 bridge tests, all passing.** No code changes required for this section.