202 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()