202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
||
"""探索工具 server(read_tools)的离线测试:不连真实库。
|
||
|
||
固定工具合同:五个只读工具按登记表执行、未知工具与非法参数拒绝、
|
||
结果有界截断、登记表 CLI 输出结构稳定;并校验派发器的工具开关门禁
|
||
(探索工具未启用工具 server 时失败关闭)与依赖清单产出。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import pathlib
|
||
import subprocess
|
||
import sys
|
||
import unittest
|
||
|
||
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||
SKILL_DIR = PROJECT_ROOT / "muse" / "authority" / "tools" / "read"
|
||
FRAMEWORK_EXTENSION = PROJECT_ROOT / "framework" / "adapters" / "pi" / "mcp_bridge.ts"
|
||
for path in (PROJECT_ROOT, SKILL_DIR):
|
||
if str(path) not in sys.path:
|
||
sys.path.insert(0, str(path))
|
||
|
||
from read_tools import TOOL_REGISTRY, execute_tool # noqa: E402
|
||
|
||
READ_TOOLS_PY = SKILL_DIR / "read_tools.py"
|
||
PYTHON = sys.executable
|
||
|
||
|
||
class FakeCursor:
|
||
def __init__(self, rows):
|
||
self._rows = rows
|
||
|
||
def fetchone(self):
|
||
return self._rows[0] if self._rows else None
|
||
|
||
def fetchall(self):
|
||
return self._rows
|
||
|
||
|
||
class FakeConn:
|
||
"""记录每条 SQL 与参数、按构造行返回的假连接。"""
|
||
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
self.queries = []
|
||
|
||
def execute(self, sql, params=None):
|
||
self.queries.append((sql, params))
|
||
return FakeCursor(self.rows)
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
|
||
class ToolBehaviourTest(unittest.TestCase):
|
||
def test_read_fine_outline_hit_and_miss(self):
|
||
conn = FakeConn([({"chapterGoal": "g"}, 3)])
|
||
result = execute_tool(
|
||
"read_fine_outline", {"work_id": 12, "target_chapter": 4}, connect=lambda: conn
|
||
)
|
||
self.assertTrue(result["found"])
|
||
self.assertEqual(result["version"], 3)
|
||
self.assertEqual(result["payload"], {"chapterGoal": "g"})
|
||
sql, params = conn.queries[0]
|
||
self.assertIn("state='confirmed'", sql)
|
||
self.assertEqual(params, (12, 4))
|
||
|
||
conn = FakeConn([])
|
||
result = execute_tool(
|
||
"read_fine_outline", {"work_id": 12, "target_chapter": 9}, connect=lambda: conn
|
||
)
|
||
self.assertFalse(result["found"])
|
||
|
||
def test_read_style_constraints_dict_projection(self):
|
||
conn = FakeConn([({"句式": "短句", "用词质感": "冷"},)])
|
||
result = execute_tool("read_style_constraints", {"work_id": 12}, connect=lambda: conn)
|
||
self.assertEqual(result["constraints"], ["句式:短句", "用词质感:冷"])
|
||
|
||
def test_read_style_falls_back_to_setting_row(self):
|
||
class TwoStepConn:
|
||
def __init__(self):
|
||
self._step = 0
|
||
|
||
def execute(self, sql, params=None):
|
||
self._step += 1
|
||
rows = [] if self._step == 1 else [({"style": "一句话文风"},)]
|
||
return FakeCursor(rows)
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
result = execute_tool("read_style_constraints", {"work_id": 12}, connect=TwoStepConn)
|
||
self.assertEqual(result["constraints"], ["一句话文风"])
|
||
|
||
def test_read_pattern_bindings(self):
|
||
conn = FakeConn([({"patternReferences": [{"id": 1}, {"x": 2}]},)])
|
||
result = execute_tool("read_pattern_bindings", {"work_id": 12}, connect=lambda: conn)
|
||
self.assertEqual(result["bindings"], [{"id": 1}, {"x": 2}])
|
||
conn = FakeConn([])
|
||
result = execute_tool("read_pattern_bindings", {"work_id": 12}, connect=lambda: conn)
|
||
self.assertEqual(result["bindings"], [])
|
||
|
||
def test_read_chapter_text_truncates(self):
|
||
class ChapterConn:
|
||
def __init__(self):
|
||
self._step = 0
|
||
|
||
def execute(self, sql, params=None):
|
||
self._step += 1
|
||
rows = [(1, 3, "第三章")] if self._step == 1 else [("字" * 40000,)]
|
||
return FakeCursor(rows)
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
result = execute_tool(
|
||
"read_chapter_text", {"work_id": 12, "chapter_order": 3}, connect=ChapterConn
|
||
)
|
||
self.assertTrue(result["found"])
|
||
self.assertTrue(result["truncated"])
|
||
self.assertEqual(len(result["text"]), 30000)
|
||
self.assertEqual(result["totalChars"], 40000)
|
||
|
||
def test_search_entities_like_and_bounds(self):
|
||
conn = FakeConn([("character", "林深", "主角", "active")])
|
||
result = execute_tool(
|
||
"search_entities", {"work_id": 12, "keyword": "林"}, connect=lambda: conn
|
||
)
|
||
self.assertEqual(result["entities"][0]["name"], "林深")
|
||
sql, params = conn.queries[0]
|
||
self.assertIn("ILIKE", sql)
|
||
self.assertEqual(params, (12, "%林%", 50))
|
||
|
||
def test_unknown_tool_and_bad_args_rejected(self):
|
||
with self.assertRaises(KeyError):
|
||
execute_tool("drop_table", {}, connect=lambda: FakeConn([]))
|
||
with self.assertRaises(ValueError):
|
||
execute_tool(
|
||
"search_entities", {"work_id": 12, "keyword": ""}, connect=lambda: FakeConn([])
|
||
)
|
||
with self.assertRaises(ValueError):
|
||
execute_tool(
|
||
"read_fine_outline", {"work_id": "x", "target_chapter": 1},
|
||
connect=lambda: FakeConn([]),
|
||
)
|
||
|
||
|
||
class RegistryCliTest(unittest.TestCase):
|
||
def test_registry_shape_and_cli_list(self):
|
||
self.assertEqual(
|
||
set(TOOL_REGISTRY),
|
||
{
|
||
"read_fine_outline",
|
||
"read_style_constraints",
|
||
"read_pattern_bindings",
|
||
"read_chapter_text",
|
||
"search_entities",
|
||
},
|
||
)
|
||
for entry in TOOL_REGISTRY.values():
|
||
self.assertTrue(entry["description"])
|
||
self.assertTrue(entry["tables"])
|
||
self.assertTrue(entry["args"])
|
||
proc = subprocess.run(
|
||
[PYTHON, str(READ_TOOLS_PY), "--list"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||
listing = json.loads(proc.stdout)
|
||
self.assertEqual(set(listing), set(TOOL_REGISTRY))
|
||
for name, item in listing.items():
|
||
self.assertEqual(item["args"], TOOL_REGISTRY[name]["args"])
|
||
|
||
def test_cli_rejects_unknown_tool_with_contract_exit_code(self):
|
||
proc = subprocess.run(
|
||
[PYTHON, str(READ_TOOLS_PY), "drop_table", "{}"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
self.assertEqual(proc.returncode, 2)
|
||
|
||
|
||
class ExtensionSyncTest(unittest.TestCase):
|
||
"""扩展不自带工具清单:它必须从登记表 --list 动态装载,防止双源漂移。"""
|
||
|
||
def test_extension_loads_registry_dynamically(self):
|
||
extension = FRAMEWORK_EXTENSION.read_text(encoding="utf-8")
|
||
self.assertIn("--list", extension)
|
||
self.assertIn("registerTool", extension)
|
||
self.assertIn("MUSE_READ_TOOLS_PYTHON", extension)
|
||
self.assertIn("MUSE_READ_TOOLS_SCRIPT", extension)
|
||
# 扩展不得硬编码登记表之外的工具名(只能出现动态注册语句)。
|
||
for name in ("read_fine_outline", "search_entities"):
|
||
self.assertNotIn(f'name: "{name}"', extension)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|