152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
import sys
|
|
import pytest
|
|
import pathlib
|
|
import importlib.util
|
|
import types
|
|
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
# stub mcp.server.fastmcp
|
|
mcp_pkg = types.ModuleType("mcp")
|
|
server_pkg = types.ModuleType("mcp.server")
|
|
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
class _D: pass
|
|
fastmcp_pkg.FastMCP = _D
|
|
fastmcp_pkg.Context = _D
|
|
server_pkg.fastmcp = fastmcp_pkg
|
|
mcp_pkg.server = server_pkg
|
|
sys.modules.setdefault("mcp", mcp_pkg)
|
|
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_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod_guard")
|
|
|
|
|
|
class DummyMCP:
|
|
def __init__(self): self.tools = {}
|
|
def tool(self, *args, **kwargs):
|
|
def deco(fn): self.tools[fn.__name__] = fn; return fn
|
|
return deco
|
|
|
|
|
|
def setup_tools():
|
|
mcp = DummyMCP()
|
|
manage_script_edits.register_manage_script_edits_tools(mcp)
|
|
return mcp.tools
|
|
|
|
|
|
def test_regex_delete_structural_guard(monkeypatch):
|
|
tools = setup_tools()
|
|
apply = tools["script_apply_edits"]
|
|
|
|
# Craft a minimal C# snippet with a method; a bad regex that deletes only the header and '{'
|
|
# will unbalance braces and should be rejected by preflight.
|
|
bad_pattern = r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{"
|
|
contents = (
|
|
"using UnityEngine;\n\n"
|
|
"public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n"
|
|
"private void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n"
|
|
"}\n"
|
|
)
|
|
|
|
def fake_send(cmd, params):
|
|
# Only the initial read should be invoked; provide contents
|
|
if cmd == "manage_script" and params.get("action") == "read":
|
|
return {"success": True, "data": {"contents": contents}}
|
|
# If preflight failed as intended, no write should be attempted; return a marker if called
|
|
return {"success": True, "message": "SHOULD_NOT_WRITE"}
|
|
|
|
monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send)
|
|
|
|
resp = apply(
|
|
ctx=None,
|
|
name="LongUnityScriptClaudeTest",
|
|
path="Assets/Scripts",
|
|
edits=[{"op": "regex_replace", "pattern": bad_pattern, "replacement": ""}],
|
|
options={"validate": "standard"},
|
|
)
|
|
|
|
assert isinstance(resp, dict)
|
|
assert resp.get("success") is False
|
|
assert resp.get("code") == "validation_failed"
|
|
data = resp.get("data", {})
|
|
assert data.get("status") == "validation_failed"
|
|
# Helpful hint to prefer structured delete
|
|
assert "delete_method" in (data.get("hint") or "")
|
|
|
|
|
|
# Parameterized robustness cases
|
|
BRACE_CONTENT = (
|
|
"using UnityEngine;\n\n"
|
|
"public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n"
|
|
"private void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n"
|
|
"}\n"
|
|
)
|
|
|
|
ATTR_CONTENT = (
|
|
"using UnityEngine;\n\n"
|
|
"public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n"
|
|
"[ContextMenu(\"PS\")]\nprivate void PrintSeries()\n{\n Debug.Log(\"1,2,3\");\n}\n"
|
|
"}\n"
|
|
)
|
|
|
|
EXPR_CONTENT = (
|
|
"using UnityEngine;\n\n"
|
|
"public class LongUnityScriptClaudeTest : MonoBehaviour\n{\n"
|
|
"private void PrintSeries() => Debug.Log(\"1\");\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"contents,pattern,repl,expect_success",
|
|
[
|
|
# Unbalanced deletes (should fail with validation_failed)
|
|
(BRACE_CONTENT, r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{", "", False),
|
|
# Remove method closing brace only (leaves class closing brace) -> unbalanced
|
|
(BRACE_CONTENT, r"\n\}\n(?=\s*\})", "\n", False),
|
|
(ATTR_CONTENT, r"(?m)^\s*private\s+void\s+PrintSeries\s*\(\s*\)\s*\{", "", False),
|
|
# Expression-bodied: remove only '(' in header -> paren mismatch
|
|
(EXPR_CONTENT, r"(?m)private\s+void\s+PrintSeries\s*\(", "", False),
|
|
# Safe changes (should succeed)
|
|
(BRACE_CONTENT, r"(?m)^\s*Debug\.Log\(.*?\);\s*$", "", True),
|
|
(EXPR_CONTENT, r"Debug\.Log\(\"1\"\)", "Debug.Log(\"2\")", True),
|
|
],
|
|
)
|
|
def test_regex_delete_variants(monkeypatch, contents, pattern, repl, expect_success):
|
|
tools = setup_tools()
|
|
apply = tools["script_apply_edits"]
|
|
|
|
def fake_send(cmd, params):
|
|
if cmd == "manage_script" and params.get("action") == "read":
|
|
return {"success": True, "data": {"contents": contents}}
|
|
return {"success": True, "message": "WRITE"}
|
|
|
|
monkeypatch.setattr(manage_script_edits, "send_command_with_retry", fake_send)
|
|
|
|
resp = apply(
|
|
ctx=None,
|
|
name="LongUnityScriptClaudeTest",
|
|
path="Assets/Scripts",
|
|
edits=[{"op": "regex_replace", "pattern": pattern, "replacement": repl}],
|
|
options={"validate": "standard"},
|
|
)
|
|
|
|
if expect_success:
|
|
assert isinstance(resp, dict) and resp.get("success") is True
|
|
else:
|
|
assert isinstance(resp, dict) and resp.get("success") is False and resp.get("code") == "validation_failed"
|
|
|
|
|