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
parent
a81e130a6b
commit
0397887204
|
|
@ -39,3 +39,5 @@ TestProjects/UnityMCPTests/Packages/packages-lock.json
|
|||
# Backup artifacts
|
||||
*.backup
|
||||
*.backup.meta
|
||||
|
||||
.wt-origin-main/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 02a6714b521ec47868512a8db433975c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f67ba1d248b564c97b1afa12caae0196
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
norecursedirs = UnityMcpBridge MCPForUnity
|
||||
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue