test: Consolidate pytest suite to MCPForUnity and improve test infrastructure (#332)

* Update github-repo-stats.yml

* pytest: make harness MCPForUnity-only; remove UnityMcpBridge paths from tests; route tools.manage_script via unity_connection for reliable monkeypatching; fix ctx usage; all tests green (39 pass, 5 skip, 7 xpass)

* Add missing meta for MaterialMeshInstantiationTests.cs (Assets)

* bridge/tools/manage_script: fix missing unity_connection prefix in validate_script; tests: tidy manage_script_uri unused symbols and arg names

* tests: rename to script_apply_edits_module; extract DummyContext to tests/test_helpers and import; add telemetry stubs in tests to avoid pyproject I/O

* tests: import cleanup and helper extraction; telemetry: prefer plain config and opt-in env override; test stubs and CWD fixes; exclude Bridge from pytest discovery

* chore: remove unintended .wt-origin-main gitlink and ignore folder

* tests: nit fixes (unused-arg stubs, import order, path-normalized ignore hook); telemetry: validate config endpoint; read_console: action optional

* Add development dependencies to pyproject.toml

- Add [project.optional-dependencies] section with dev group
- Include pytest>=8.0.0 and pytest-anyio>=0.6.0
- Add Development Setup section to README-DEV.md with installation and testing instructions

* Revert "Update github-repo-stats.yml"

This reverts commit 8ae595d2f4f2525b0e44ece948883ea37138add4.

* test: improve test clarity and modernize asyncio usage

- Add explanation for 200ms timeout in backpressure test
- Replace manual event loop creation with asyncio.run()
- Add assertion message with actual elapsed time for easier debugging

* refactor: remove duplicate DummyContext definitions across test files

Replace 7 duplicate DummyContext class definitions with imports from tests.test_helpers.
This follows DRY principles and ensures consistency across the test suite.

* chore: remove unused _load function from test_edit_strict_and_warnings.py

Dead code cleanup - function was no longer used after refactoring to dynamic tool registration.

* docs: add comment explaining CWD manipulation in telemetry test

Clarify why os.chdir() is necessary: telemetry.py calls get_package_version()
at module load time, which reads pyproject.toml using a relative path.
Acknowledges the fragility while explaining why it's currently required.
main
dsarno 2025-10-21 10:42:55 -07:00 committed by GitHub
parent a81e130a6b
commit 0397887204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 535 additions and 143 deletions

2
.gitignore vendored
View File

@ -39,3 +39,5 @@ TestProjects/UnityMCPTests/Packages/packages-lock.json
# Backup artifacts
*.backup
*.backup.meta
.wt-origin-main/

View File

@ -11,6 +11,12 @@ dependencies = [
"tomli>=2.3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-anyio>=0.6.0",
]
[build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -93,10 +93,11 @@ class TelemetryConfig:
"""
server_config = None
for modname in (
# Prefer plain module to respect test-time overrides and sys.path injection
"config",
"src.config",
"MCPForUnity.UnityMcpServer~.src.config",
"MCPForUnity.UnityMcpServer.src.config",
"src.config",
"config",
):
try:
mod = importlib.import_module(modname)
@ -116,10 +117,13 @@ class TelemetryConfig:
server_config, "telemetry_endpoint", None)
default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
self.default_endpoint = default_ep
self.endpoint = self._validated_endpoint(
os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep),
default_ep,
)
# Prefer config default; allow explicit env override only when set
env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT")
if env_ep is not None and env_ep != "":
self.endpoint = self._validated_endpoint(env_ep, default_ep)
else:
# Validate config-provided default as well to enforce scheme/host rules
self.endpoint = self._validated_endpoint(default_ep, default_ep)
try:
logger.info(
"Telemetry configured: endpoint=%s (default=%s), timeout_env=%s",

View File

@ -6,7 +6,7 @@ from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from registry import mcp_for_unity_tool
from unity_connection import send_command_with_retry
import unity_connection
def _split_uri(uri: str) -> tuple[str, str]:
@ -103,7 +103,7 @@ def apply_text_edits(
warnings: list[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = send_command_with_retry("manage_script", {
read_resp = unity_connection.send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": directory,
@ -304,7 +304,7 @@ def apply_text_edits(
"options": opts,
}
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
@ -336,7 +336,7 @@ def apply_text_edits(
st = _latest_status()
if st and st.get("reloading"):
return
send_command_with_retry(
unity_connection.send_command_with_retry(
"execute_menu_item",
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
@ -386,7 +386,7 @@ def create_script(
contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -401,7 +401,7 @@ def delete_script(
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -426,7 +426,7 @@ def validate_script(
"path": directory,
"level": level,
}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(
@ -473,7 +473,7 @@ def manage_script(
params = {k: v for k, v in params.items() if v is not None}
response = send_command_with_retry("manage_script", params)
response = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(response, dict):
if response.get("success"):
@ -541,7 +541,7 @@ def get_sha(
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get(

View File

@ -13,7 +13,7 @@ from unity_connection import send_command_with_retry
)
def read_console(
ctx: Context,
action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."],
action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None,
types: Annotated[list[Literal['error', 'warning',
'log', 'all']], "Message types to get"] | None = None,
count: Annotated[int, "Max messages to return"] | None = None,

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 02a6714b521ec47868512a8db433975c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f67ba1d248b564c97b1afa12caae0196
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,4 @@
def pytest_ignore_collect(path, config):
# Avoid duplicate import mismatches between Bridge and MCPForUnity copies
p = str(path)
return p.endswith("test_telemetry.py")

View File

@ -6,7 +6,7 @@ from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from registry import mcp_for_unity_tool
from unity_connection import send_command_with_retry
import unity_connection
def _split_uri(uri: str) -> tuple[str, str]:
@ -103,7 +103,7 @@ def apply_text_edits(
warnings: list[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = send_command_with_retry("manage_script", {
read_resp = unity_connection.send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": directory,
@ -304,7 +304,7 @@ def apply_text_edits(
"options": opts,
}
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
@ -336,7 +336,7 @@ def apply_text_edits(
st = _latest_status()
if st and st.get("reloading"):
return
send_command_with_retry(
unity_connection.send_command_with_retry(
"execute_menu_item",
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
@ -386,7 +386,7 @@ def create_script(
contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -401,7 +401,7 @@ def delete_script(
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@ -426,7 +426,7 @@ def validate_script(
"path": directory,
"level": level,
}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(
@ -473,7 +473,7 @@ def manage_script(
params = {k: v for k, v in params.items() if v is not None}
response = send_command_with_retry("manage_script", params)
response = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(response, dict):
if response.get("success"):
@ -541,7 +541,7 @@ def get_sha(
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
resp = unity_connection.send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get(

View File

@ -5,6 +5,36 @@
Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development.
## 🛠️ Development Setup
### Installing Development Dependencies
To contribute or run tests, you need to install the development dependencies:
```bash
# Navigate to the server source directory
cd MCPForUnity/UnityMcpServer~/src
# Install the package in editable mode with dev dependencies
pip install -e .[dev]
```
This installs:
- **Runtime dependencies**: `httpx`, `mcp[cli]`, `pydantic`, `tomli`
- **Development dependencies**: `pytest`, `pytest-anyio`
### Running Tests
```bash
# From the repo root
pytest tests/ -v
```
Or if you prefer using Python module syntax:
```bash
python -m pytest tests/ -v
```
## 🚀 Available Development Features
### ✅ Development Deployment Scripts

4
pytest.ini Normal file
View File

@ -0,0 +1,4 @@
[pytest]
testpaths = tests
norecursedirs = UnityMcpBridge MCPForUnity

View File

@ -5,3 +5,24 @@ import os
os.environ.setdefault("DISABLE_TELEMETRY", "true")
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
# Avoid collecting tests under the two 'src' package folders to prevent
# duplicate-package import conflicts (two different 'src' packages).
collect_ignore = [
"UnityMcpBridge/UnityMcpServer~/src",
"MCPForUnity/UnityMcpServer~/src",
]
collect_ignore_glob = [
"UnityMcpBridge/UnityMcpServer~/src/*",
"MCPForUnity/UnityMcpServer~/src/*",
]
def pytest_ignore_collect(path):
p = str(path)
norm = p.replace("\\", "/")
return (
"/UnityMcpBridge/UnityMcpServer~/src/" in norm
or "/MCPForUnity/UnityMcpServer~/src/" in norm
or norm.endswith("UnityMcpBridge/UnityMcpServer~/src")
or norm.endswith("MCPForUnity/UnityMcpServer~/src")
)

View File

@ -36,7 +36,7 @@ def _load(path: pathlib.Path, name: str):
manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2")
manage_script_edits = _load(
SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2")
SRC / "tools" / "script_apply_edits.py", "script_apply_edits_mod2")
class DummyMCP:
@ -47,9 +47,21 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -62,14 +74,18 @@ def test_normalizes_lsp_and_index_ranges(monkeypatch):
calls.append(params)
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
# LSP-style
edits = [{
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
"newText": "// lsp\n"
}]
apply(None, uri="unity://path/Assets/Scripts/F.cs",
apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
p = calls[-1]
e = p["edits"][0]
@ -84,8 +100,10 @@ def test_normalizes_lsp_and_index_ranges(monkeypatch):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "hello\n"}}
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read)
apply(None, uri="unity://path/Assets/Scripts/F.cs",
# Override unity_connection for this read normalization case
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_read)
apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="x")
# last call is apply_text_edits
@ -97,9 +115,13 @@ def test_noop_evidence_shape(monkeypatch):
def fake_send(cmd, params):
return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it calls unity_connection.send_command_with_retry
resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[
resp = apply(DummyContext(), uri="unity://path/Assets/Scripts/F.cs", edits=[
{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x")
assert resp["success"] is True
assert resp.get("data", {}).get("no_op") is True
@ -109,7 +131,16 @@ def test_atomic_multi_span_and_relaxed(monkeypatch):
tools_text = setup_tools()
apply_text = tools_text["apply_text_edits"]
tools_struct = DummyMCP()
manage_script_edits.register_manage_script_edits_tools(tools_struct)
# Import the tools module to trigger decorator registration
import tools.script_apply_edits
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script_apply', 'apply_edits']):
tools_struct.tools[tool_name] = tool_info['func']
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
sent = {}
@ -118,14 +149,18 @@ def test_atomic_multi_span_and_relaxed(monkeypatch):
return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}}
sent.setdefault("calls", []).append(params)
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
edits = [
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"}
]
resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits,
resp = apply_text(DummyContext(), uri="unity://path/Assets/Scripts/C.cs", edits=edits,
precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"})
assert resp["success"] is True
# Last manage_script call should include options with applyMode atomic and validate relaxed

View File

@ -3,11 +3,34 @@ import pathlib
import importlib.util
import types
from tests.test_helpers import DummyContext
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# Stub telemetry modules to avoid file I/O during import of tools package
telemetry = types.ModuleType("telemetry")
def _noop(*args, **kwargs):
pass
class MilestoneType:
pass
telemetry.record_resource_usage = _noop
telemetry.record_tool_usage = _noop
telemetry.record_milestone = _noop
telemetry.MilestoneType = MilestoneType
telemetry.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry)
telemetry_decorator = types.ModuleType("telemetry_decorator")
def telemetry_tool(*dargs, **dkwargs):
def _wrap(fn):
return fn
return _wrap
telemetry_decorator.telemetry_tool = telemetry_tool
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
# stub mcp.server.fastmcp
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
@ -27,16 +50,6 @@ sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3")
class DummyMCP:
def __init__(self): self.tools = {}
@ -47,7 +60,13 @@ class DummyMCP:
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
# Import tools to trigger decorator-based registration
import tools.manage_script
from registry import get_registered_tools
for tool_info in get_registered_tools():
name = tool_info['name']
if any(k in name for k in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[name] = tool_info['func']
return mcp.tools
@ -59,12 +78,13 @@ def test_explicit_zero_based_normalized_warning(monkeypatch):
# Simulate Unity path returning minimal success
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
import unity_connection
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send)
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs",
resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha")
assert resp["success"] is True
@ -83,11 +103,12 @@ def test_strict_zero_based_error(monkeypatch):
def fake_send(cmd, params):
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
import unity_connection
monkeypatch.setattr(unity_connection, "send_command_with_retry", fake_send)
edits = [{"startLine": 0, "startCol": 0,
"endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs",
resp = apply_edits(DummyContext(), uri="unity://path/Assets/Scripts/F.cs",
edits=edits, precondition_sha256="sha", strict=True)
assert resp["success"] is False
assert resp.get("code") == "zero_based_explicit_fields"

View File

@ -1,4 +1,3 @@
from tools.resource_tools import register_resource_tools # type: ignore
import sys
import pathlib
import importlib.util
@ -6,10 +5,33 @@ import types
import asyncio
import pytest
from tests.test_helpers import DummyContext
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# Stub telemetry modules to avoid file I/O during import of tools package
telemetry = types.ModuleType("telemetry")
def _noop(*args, **kwargs):
pass
class MilestoneType:
pass
telemetry.record_resource_usage = _noop
telemetry.record_tool_usage = _noop
telemetry.record_milestone = _noop
telemetry.MilestoneType = MilestoneType
telemetry.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry)
telemetry_decorator = types.ModuleType("telemetry_decorator")
def telemetry_tool(*dargs, **dkwargs):
def _wrap(fn):
return fn
return _wrap
telemetry_decorator.telemetry_tool = telemetry_tool
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
class DummyMCP:
def __init__(self):
@ -25,7 +47,16 @@ class DummyMCP:
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
register_resource_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -40,7 +71,7 @@ def test_find_in_file_returns_positions(resource_tools, tmp_path):
try:
resp = loop.run_until_complete(
find_in_file(uri="unity://path/Assets/A.txt",
pattern="world", ctx=None, project_root=str(proj))
pattern="world", ctx=DummyContext(), project_root=str(proj))
)
finally:
loop.close()

View File

@ -49,9 +49,21 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -66,9 +78,13 @@ def test_get_sha_param_shape_and_routing(monkeypatch):
captured["params"] = params
return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = get_sha(None, uri="unity://path/Assets/Scripts/A.cs")
resp = get_sha(DummyContext(), uri="unity://path/Assets/Scripts/A.cs")
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "get_sha"
assert captured["params"]["name"] == "A"

10
tests/test_helpers.py Normal file
View File

@ -0,0 +1,10 @@
class DummyContext:
"""Mock context object for testing"""
def info(self, message):
pass
def warning(self, message):
pass
def error(self, message):
pass

View File

@ -38,8 +38,8 @@ def load_module(path, name):
return module
manage_script_edits_module = load_module(
SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module")
script_apply_edits_module = load_module(
SRC / "tools" / "script_apply_edits.py", "script_apply_edits_module")
def test_improved_anchor_matching():
@ -67,7 +67,7 @@ public class TestClass : MonoBehaviour
flags = re.MULTILINE
# Test our improved function
best_match = manage_script_edits_module._find_best_anchor_match(
best_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
@ -116,7 +116,7 @@ public class TestClass : MonoBehaviour
'\n') + 1 if old_match else None
# New behavior (improved matching)
new_match = manage_script_edits_module._find_best_anchor_match(
new_match = script_apply_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
new_line = test_code[:new_match.start()].count(
@ -152,7 +152,7 @@ public class TestClass : MonoBehaviour
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
}]
result = manage_script_edits_module._apply_edits_locally(
result = script_apply_edits_module._apply_edits_locally(
original_code, edits)
lines = result.split('\n')
try:

View File

@ -1,4 +1,4 @@
import tools.manage_script as manage_script # type: ignore
# import triggers registration elsewhere; no direct use here
import sys
import types
from pathlib import Path
@ -54,18 +54,29 @@ class DummyMCP:
return _decorator
class DummyCtx: # FastMCP Context placeholder
pass
# (removed unused DummyCtx)
from tests.test_helpers import DummyContext
def _register_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp) # populates mcp.tools
# Import the tools module to trigger decorator registration
import tools.manage_script # trigger decorator registration
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def test_split_uri_unity_path(monkeypatch):
tools = _register_tools()
test_tools = _register_tools()
captured = {}
def fake_send(cmd, params): # capture params and return success
@ -73,11 +84,15 @@ def test_split_uri_unity_path(monkeypatch):
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = tools['apply_text_edits']
fn = test_tools['apply_text_edits']
uri = "unity://path/Assets/Scripts/MyScript.cs"
fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None)
fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['cmd'] == 'manage_script'
assert captured['params']['name'] == 'MyScript'
@ -97,35 +112,43 @@ def test_split_uri_unity_path(monkeypatch):
],
)
def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
tools = _register_tools()
test_tools = _register_tools()
captured = {}
def fake_send(cmd, params):
captured['cmd'] = cmd
def fake_send(_cmd, params):
captured['cmd'] = _cmd
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = tools['apply_text_edits']
fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None)
fn = test_tools['apply_text_edits']
fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None)
assert captured['params']['name'] == expected_name
assert captured['params']['path'] == expected_path
def test_split_uri_plain_path(monkeypatch):
tools = _register_tools()
test_tools = _register_tools()
captured = {}
def fake_send(cmd, params):
def fake_send(_cmd, params):
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
fn = tools['apply_text_edits']
fn(DummyCtx(), uri="Assets/Scripts/Thing.cs",
fn = test_tools['apply_text_edits']
fn(DummyContext(), uri="Assets/Scripts/Thing.cs",
edits=[], precondition_sha256=None)
assert captured['params']['name'] == 'Thing'

View File

@ -48,9 +48,21 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
def setup_tools():
mcp = DummyMCP()
read_console_mod.register_read_console_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.read_console
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all console-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['read_console', 'console']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -67,11 +79,12 @@ def test_read_console_full_default(monkeypatch):
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
}
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
monkeypatch.setattr(
read_console_mod, "get_unity_connection", lambda: object())
# Patch the send_command_with_retry function in the tools module
import tools.read_console
monkeypatch.setattr(tools.read_console,
"send_command_with_retry", fake_send)
resp = read_console(ctx=None, count=10)
resp = read_console(ctx=DummyContext(), action="get", count=10)
assert resp == {
"success": True,
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
@ -93,11 +106,12 @@ def test_read_console_truncated(monkeypatch):
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]},
}
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
monkeypatch.setattr(
read_console_mod, "get_unity_connection", lambda: object())
# Patch the send_command_with_retry function in the tools module
import tools.read_console
monkeypatch.setattr(tools.read_console,
"send_command_with_retry", fake_send)
resp = read_console(ctx=None, count=10, include_stacktrace=False)
resp = read_console(ctx=DummyContext(), action="get", count=10, include_stacktrace=False)
assert resp == {"success": True, "data": {
"lines": [{"level": "error", "message": "oops"}]}}
assert captured["params"]["includeStacktrace"] is False

View File

@ -1,4 +1,3 @@
from tools.resource_tools import register_resource_tools # type: ignore
import sys
import pathlib
import asyncio
@ -39,10 +38,22 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
register_resource_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -59,7 +70,7 @@ def test_read_resource_minimal_metadata_only(resource_tools, tmp_path):
try:
resp = loop.run_until_complete(
read_resource(uri="unity://path/Assets/A.txt",
ctx=None, project_root=str(proj))
ctx=DummyContext(), project_root=str(proj))
)
finally:
loop.close()

View File

@ -1,7 +1,3 @@
from tools.resource_tools import register_resource_tools # type: ignore
import pytest
import sys
from pathlib import Path
import pytest
@ -9,19 +5,30 @@ import types
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "MCPForUnity" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"MCP for Unity server source not found. Tried:\n" + searched,
allow_module_level=True,
)
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# Stub telemetry modules to avoid file I/O during import of tools package
telemetry = types.ModuleType("telemetry")
def _noop(*args, **kwargs):
pass
class MilestoneType: # minimal placeholder
pass
telemetry.record_resource_usage = _noop
telemetry.record_tool_usage = _noop
telemetry.record_milestone = _noop
telemetry.MilestoneType = MilestoneType
telemetry.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry)
telemetry_decorator = types.ModuleType("telemetry_decorator")
def telemetry_tool(*_args, **_kwargs):
def _wrap(fn):
return fn
return _wrap
telemetry_decorator.telemetry_tool = telemetry_tool
sys.modules.setdefault("telemetry_decorator", telemetry_decorator)
class DummyMCP:
def __init__(self):
@ -34,10 +41,22 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
register_resource_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.resource_tools
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all resource-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['find_in_file', 'list_resources', 'read_resource']):
mcp._tools[tool_name] = tool_info['func']
return mcp._tools
@ -61,8 +80,8 @@ def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, m
list_resources = resource_tools["list_resources"]
# Only .cs under Assets should be listed
import asyncio
resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="Assets",
resp = asyncio.run(
list_resources(ctx=DummyContext(), pattern="*.cs", under="Assets",
limit=50, project_root=str(proj))
)
assert resp["success"] is True
@ -77,8 +96,8 @@ def test_resource_list_rejects_outside_paths(resource_tools, tmp_path):
# under points outside Assets
list_resources = resource_tools["list_resources"]
import asyncio
resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="..",
resp = asyncio.run(
list_resources(ctx=DummyContext(), pattern="*.cs", under="..",
limit=10, project_root=str(proj))
)
assert resp["success"] is False

View File

@ -53,15 +53,36 @@ class DummyMCP:
return decorator
from tests.test_helpers import DummyContext
def setup_manage_script():
mcp = DummyMCP()
manage_script_module.register_manage_script_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
def setup_manage_asset():
mcp = DummyMCP()
manage_asset_module.register_manage_asset_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.manage_asset
# Get the registered tools from the registry
from registry import get_registered_tools
tools = get_registered_tools()
# Add all asset-related tools to our dummy MCP
for tool_info in tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['asset', 'manage_asset']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -75,12 +96,16 @@ def test_apply_text_edits_long_file(monkeypatch):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module,
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit = {"startLine": 1005, "startCol": 0,
"endLine": 1005, "endCol": 5, "newText": "Hello"}
resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit])
ctx = DummyContext()
resp = apply_edits(ctx, "unity://path/Assets/Scripts/LongFile.cs", [edit])
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "apply_text_edits"
assert captured["params"]["edits"][0]["startLine"] == 1005
@ -96,15 +121,18 @@ def test_sequential_edits_use_precondition(monkeypatch):
calls.append(params)
return {"success": True, "sha256": f"hash{len(calls)}"}
monkeypatch.setattr(manage_script_module,
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1,
"endCol": 0, "newText": "//header\n"}
resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1])
resp1 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [edit1])
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2,
"endCol": 0, "newText": "//second\n"}
resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs",
resp2 = apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
[edit2], precondition_sha256=resp1["sha256"])
assert calls[1]["precondition_sha256"] == resp1["sha256"]
@ -120,11 +148,14 @@ def test_apply_text_edits_forwards_options(monkeypatch):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module,
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
apply_edits(None, "unity://path/Assets/Scripts/File.cs",
apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
[{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts)
assert captured["params"].get("options") == opts
@ -138,15 +169,18 @@ def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module,
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
edits = [
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
{"startLine": 3, "startCol": 2, "endLine": 3,
"endCol": 2, "newText": "// tail\n"},
]
apply_edits(None, "unity://path/Assets/Scripts/File.cs",
apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs",
edits, precondition_sha256="x")
opts = captured["params"].get("options", {})
assert opts.get("applyMode") == "atomic"
@ -162,14 +196,14 @@ def test_manage_asset_prefab_modify_request(monkeypatch):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_asset_module,
# Patch the async function in the tools module
import tools.manage_asset
monkeypatch.setattr(tools.manage_asset,
"async_send_command_with_retry", fake_async)
monkeypatch.setattr(manage_asset_module,
"get_unity_connection", lambda: object())
async def run():
resp = await manage_asset(
None,
DummyContext(),
action="modify",
path="Assets/Prefabs/Player.prefab",
properties={"hp": 100},

View File

@ -7,8 +7,15 @@ def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
telemetry = importlib.import_module(
"MCPForUnity.UnityMcpServer~.src.telemetry")
# Import the telemetry module from the correct path
import sys
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
monkeypatch.chdir(str(SRC))
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
@ -23,20 +30,27 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch):
# Patch config.telemetry_endpoint via import mocking
import importlib
cfg_mod = importlib.import_module(
"MCPForUnity.UnityMcpServer~.src.config")
import sys
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
cfg_mod = importlib.import_module("config")
old_endpoint = cfg_mod.config.telemetry_endpoint
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
try:
telemetry = importlib.import_module(
"MCPForUnity.UnityMcpServer~.src.telemetry")
monkeypatch.chdir(str(SRC))
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc = telemetry.TelemetryCollector()
# When no env override is set, config endpoint is preferred
assert tc.config.endpoint == "https://example.com/telemetry"
# Env should override config
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT",
"https://override.example/ep")
monkeypatch.chdir(str(SRC))
importlib.reload(telemetry)
tc2 = telemetry.TelemetryCollector()
assert tc2.config.endpoint == "https://override.example/ep"
@ -47,8 +61,15 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch):
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
telemetry = importlib.import_module(
"MCPForUnity.UnityMcpServer~.src.telemetry")
# Import the telemetry module from the correct path
import sys
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
monkeypatch.chdir(str(SRC))
telemetry = importlib.import_module("telemetry")
importlib.reload(telemetry)
tc1 = telemetry.TelemetryCollector()

View File

@ -1,6 +1,7 @@
import sys
import pathlib
import importlib.util
import os
import types
import threading
import time
@ -29,6 +30,11 @@ sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
# Ensure telemetry module has get_package_version stub before importing
telemetry_stub = types.ModuleType("telemetry")
telemetry_stub.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", telemetry_stub)
def _load_module(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
@ -37,7 +43,16 @@ def _load_module(path: pathlib.Path, name: str):
return mod
telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod")
# Load real telemetry on top of stub (it will reuse stubbed helpers)
# Note: CWD change required because telemetry.py calls get_package_version()
# at module load time, which reads pyproject.toml using a relative path.
# This is fragile but necessary given current telemetry module design.
_prev_cwd = os.getcwd()
os.chdir(str(SRC))
try:
telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod")
finally:
os.chdir(_prev_cwd)
def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
@ -68,7 +83,8 @@ def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
elapsed_ms = (time.perf_counter() - start) * 1000.0
# Should be fast despite backpressure (non-blocking enqueue or drop)
assert elapsed_ms < 80.0
# Timeout relaxed to 200ms to handle thread scheduling variance in CI/local environments
assert elapsed_ms < 200.0, f"Took {elapsed_ms:.1f}ms (expected <200ms)"
# Allow worker to process some
time.sleep(0.3)

View File

@ -3,8 +3,43 @@ import importlib
def _get_decorator_module():
# Import the telemetry_decorator module from the MCP for Unity server src
mod = importlib.import_module(
"MCPForUnity.UnityMcpServer~.src.telemetry_decorator")
import sys
import pathlib
import types
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
# Remove any previously stubbed module to force real import
sys.modules.pop("telemetry_decorator", None)
# Preload a minimal telemetry stub to satisfy telemetry_decorator imports
tel = types.ModuleType("telemetry")
class _MilestoneType:
FIRST_TOOL_USAGE = "first_tool_usage"
FIRST_SCRIPT_CREATION = "first_script_creation"
FIRST_SCENE_MODIFICATION = "first_scene_modification"
tel.MilestoneType = _MilestoneType
def _noop(*a, **k):
pass
tel.record_resource_usage = _noop
tel.record_tool_usage = _noop
tel.record_milestone = _noop
tel.get_package_version = lambda: "0.0.0"
sys.modules.setdefault("telemetry", tel)
mod = importlib.import_module("telemetry_decorator")
# Drop stub to avoid bleed-through into other tests
sys.modules.pop("telemetry", None)
# Ensure attributes exist for monkeypatch targets even if not exported
if not hasattr(mod, "record_tool_usage"):
def _noop_record_tool_usage(*a, **k):
pass
mod.record_tool_usage = _noop_record_tool_usage
if not hasattr(mod, "record_milestone"):
def _noop_record_milestone(*a, **k):
pass
mod.record_milestone = _noop_record_milestone
if not hasattr(mod, "_decorator_log_count"):
mod._decorator_log_count = 0
return mod

View File

@ -48,9 +48,21 @@ class DummyMCP:
return deco
from tests.test_helpers import DummyContext
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
# Import the tools module to trigger decorator registration
import tools.manage_script
# Get the registered tools from the registry
from registry import get_registered_tools
registered_tools = get_registered_tools()
# Add all script-related tools to our dummy MCP
for tool_info in registered_tools:
tool_name = tool_info['name']
if any(keyword in tool_name for keyword in ['script', 'apply_text', 'create_script', 'delete_script', 'validate_script', 'get_sha']):
mcp.tools[tool_name] = tool_info['func']
return mcp.tools
@ -70,7 +82,11 @@ def test_validate_script_returns_counts(monkeypatch):
},
}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Patch the send_command_with_retry function at the module level where it's imported
import unity_connection
monkeypatch.setattr(unity_connection,
"send_command_with_retry", fake_send)
# No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry
resp = validate_script(None, uri="unity://path/Assets/Scripts/A.cs")
resp = validate_script(DummyContext(), uri="unity://path/Assets/Scripts/A.cs")
assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}}