chapter-read.test.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """
  2. MCP read_chapter 工具测试 + tools/list schema 字节断言
  3. 覆盖:
  4. 1. schema 总字节 < 1024(小智限制)
  5. 2. read_chapter 客户端参数校验
  6. 3. format=text → RESPONSE 模式
  7. 4. format=audio(已有 full 音频)→ 返回 audio_url
  8. 5. format=audio(on_demand)→ 返回 audio_source=on_demand
  9. 6. 后端 code=2002 → MCP 自动降级到 text
  10. 7. 后端 code=2001 → _err
  11. 8. TOOL_DISPATCH / TOOLS 注册正确
  12. """
  13. from __future__ import annotations
  14. import json
  15. import sys
  16. import os
  17. HERE = os.path.dirname(os.path.abspath(__file__))
  18. sys.path.insert(0, os.path.dirname(HERE))
  19. import audio_server as A
  20. def parse_result(r):
  21. """解 _ok 包装:{success, result: '<json-string>'} → dict"""
  22. if r.get("success") and r.get("result"):
  23. try:
  24. return json.loads(r["result"])
  25. except Exception:
  26. return {}
  27. return {}
  28. def fake_http_get(path, params=None):
  29. return {
  30. "code": fake_http_get.code,
  31. "message": fake_http_get.msg or "success",
  32. "data": fake_http_get.data,
  33. }
  34. A._http_get = fake_http_get
  35. def reset(code=0, data=None, msg=None):
  36. fake_http_get.code = code
  37. fake_http_get.data = data
  38. fake_http_get.msg = msg
  39. def assert_true(cond, msg):
  40. if not cond:
  41. raise AssertionError("FAIL: " + msg)
  42. print("[PASS]", msg)
  43. # ============ 测试用例 ============
  44. def test_schema_size():
  45. size = len(json.dumps(A.TOOLS, ensure_ascii=False).encode("utf-8"))
  46. assert_true(size < 1024, f"tools/list < 1024 bytes (actual {size})")
  47. def test_required_chapter_id():
  48. r = A.tool_read_chapter({})
  49. assert_true(r.get("success") is False, "no chapter_id -> _err")
  50. def test_invalid_format():
  51. r = A.tool_read_chapter({"chapter_id": 1, "format": "xml"})
  52. assert_true(r.get("success") is False, "invalid format -> _err")
  53. def test_format_text():
  54. reset(code=0, data={
  55. "mode": "text", "chapter_id": 100, "book_id": 1, "title": "DiYiHui",
  56. "text": "hua shuo tian xia da shi ...", "has_more": False, "next_offset": None,
  57. "nav": {"chapter_index": 1, "total_chapters": 5, "prev_chapter_id": None, "next_chapter_id": 101},
  58. })
  59. r = A.tool_read_chapter({"chapter_id": 100, "format": "text"})
  60. assert_true(r.get("action") == "RESPONSE", "format=text -> action=RESPONSE")
  61. assert_true("DiYiHui" in r["response"], "response contains title")
  62. assert_true(r.get("next_chapter_id") == 101, "nav next_chapter_id present")
  63. def test_format_audio_full():
  64. reset(code=0, data={
  65. "mode": "audio", "chapter_id": 100, "book_id": 1, "title": "T",
  66. "audio_url": "https://oss.example.com/full.mp3",
  67. "audio_duration": 320, "audio_source": "full",
  68. "nav": {"chapter_index": 1, "total_chapters": 5, "prev_chapter_id": None, "next_chapter_id": 101},
  69. })
  70. r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
  71. assert_true(r.get("success") is True, "format=audio -> success")
  72. inner = parse_result(r)
  73. assert_true(inner.get("audio_url") == "https://oss.example.com/full.mp3", "audio_url correct")
  74. assert_true(inner.get("audio_source") == "full", "audio_source=full")
  75. assert_true("read_chapter" in inner.get("action", ""), "nav action hint")
  76. def test_format_audio_on_demand():
  77. reset(code=0, data={
  78. "mode": "audio", "chapter_id": 100, "book_id": 1, "title": "T",
  79. "audio_url": "https://oss.example.com/sync.mp3",
  80. "audio_duration": 30, "audio_source": "on_demand",
  81. "nav": {"chapter_index": 1, "total_chapters": 1, "prev_chapter_id": None, "next_chapter_id": None},
  82. })
  83. r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
  84. inner = parse_result(r)
  85. assert_true(inner.get("audio_source") == "on_demand", "audio_source=on_demand")
  86. assert_true(inner.get("action", "") != "", "non-empty action for last chapter")
  87. def test_tts_fallback_to_text():
  88. """后端 code=2002 -> MCP 自动降级到 format=text"""
  89. call_count = [0]
  90. def mock_with_fallback(path, params=None):
  91. call_count[0] += 1
  92. if call_count[0] == 1:
  93. return {"code": 2002, "message": "TTS_FALLBACK_FAILED", "data": None}
  94. return {"code": 0, "message": "success", "data": {
  95. "mode": "text", "chapter_id": 100, "book_id": 1, "title": "T",
  96. "text": "fallback text", "has_more": False, "next_offset": None,
  97. "nav": {"chapter_index": 1, "total_chapters": 1, "prev_chapter_id": None, "next_chapter_id": None},
  98. }}
  99. A._http_get = mock_with_fallback
  100. r = A.tool_read_chapter({"chapter_id": 100, "format": "audio"})
  101. assert_true(r.get("action") == "RESPONSE", "2002 fallback -> RESPONSE mode")
  102. assert_true("fallback" in r.get("response", "").lower(), "fallback text content")
  103. assert_true(call_count[0] == 2, "called 2 times (retry after failure)")
  104. A._http_get = fake_http_get
  105. def test_other_error_returns_err():
  106. A._http_get = lambda p, q=None: {"code": 2001, "message": "CHAPTER_NOT_FOUND", "data": None}
  107. r = A.tool_read_chapter({"chapter_id": 999, "format": "audio"})
  108. assert_true(r.get("success") is False, "2001 -> _err")
  109. A._http_get = fake_http_get
  110. def test_dispatch_registered():
  111. assert_true("read_chapter" in A.TOOL_DISPATCH, "TOOL_DISPATCH has read_chapter")
  112. assert_true(any(t["name"] == "read_chapter" for t in A.TOOLS), "TOOLS has read_chapter")
  113. def main():
  114. tests = [
  115. test_schema_size, test_required_chapter_id, test_invalid_format,
  116. test_format_text, test_format_audio_full, test_format_audio_on_demand,
  117. test_tts_fallback_to_text, test_other_error_returns_err, test_dispatch_registered,
  118. ]
  119. passed = 0
  120. failed = 0
  121. for t in tests:
  122. try:
  123. t()
  124. passed += 1
  125. except AssertionError as e:
  126. failed += 1
  127. print("[FAIL]", t.__name__, "->", e)
  128. print(f"\n{passed} passed, {failed} failed")
  129. sys.exit(0 if failed == 0 else 1)
  130. if __name__ == "__main__":
  131. main()