test_mcp_interfaces.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. # -*- coding: utf-8 -*-
  2. """
  3. 通风模型算法 MCP 接口全面测试脚本
  4. 测试 server: vent-model-tools @ http://39.97.59.228:8071/mcp
  5. """
  6. import asyncio
  7. import json
  8. import time
  9. import os
  10. from datetime import datetime
  11. from fastmcp import Client
  12. MCP_URL = "http://39.97.59.228:8071/mcp"
  13. MODEL_ID = "2012326636757958658"
  14. # ── 测试用例定义 ──
  15. # 按文档中的 15 个接口定义
  16. TEST_CASES = [
  17. # ═══ 故障诊断类 ═══
  18. {
  19. "id": "TC-01",
  20. "category": "故障诊断",
  21. "tool": "check_model_connect_status",
  22. "params": {"model_id": MODEL_ID},
  23. "desc": "模型网络连通检查",
  24. },
  25. {
  26. "id": "TC-02",
  27. "category": "故障诊断",
  28. "tool": "check_model_one_dir_cycle",
  29. "params": {"model_id": MODEL_ID},
  30. "desc": "模型循环风路检查",
  31. },
  32. {
  33. "id": "TC-03",
  34. "category": "故障诊断",
  35. "tool": "check_model_one_dir_node",
  36. "params": {"model_id": MODEL_ID},
  37. "desc": "模型单向节点检查",
  38. },
  39. {
  40. "id": "TC-04",
  41. "category": "故障诊断",
  42. "tool": "check_model_diagonal_structure",
  43. "params": {"model_id": MODEL_ID},
  44. "desc": "模型角联结构诊断(计算重,耗时较长)",
  45. },
  46. {
  47. "id": "TC-05",
  48. "category": "故障诊断",
  49. "tool": "get_model_fault_diagnosis",
  50. "params": {"model_id": MODEL_ID, "include_diagonal": False},
  51. "desc": "模型故障诊断(聚合,不含角联)",
  52. },
  53. # ═══ 避灾路线类 ═══
  54. {
  55. "id": "TC-06",
  56. "category": "避灾路线",
  57. "tool": "get_escape_path",
  58. "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6"},
  59. "desc": "避灾路线模拟",
  60. "note": "使用硬编码隧道ID 4/6,实际应取自 get_out_shafts",
  61. },
  62. {
  63. "id": "TC-07",
  64. "category": "避灾路线",
  65. "tool": "get_escape_path_each_exit",
  66. "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6", "co_per": 2000.0, "during_time": 30.0},
  67. "desc": "避灾路线模拟(各出口,含CO参数)",
  68. },
  69. # ═══ 关键阻力类 ═══
  70. {
  71. "id": "TC-08",
  72. "category": "关键阻力",
  73. "tool": "get_out_shafts",
  74. "params": {"model_id": MODEL_ID},
  75. "desc": "获取回风井巷道ID列表",
  76. },
  77. {
  78. "id": "TC-09",
  79. "category": "关键阻力",
  80. "tool": "get_in_shafts",
  81. "params": {"model_id": MODEL_ID},
  82. "desc": "获取进风井巷道ID列表",
  83. },
  84. # get_max_resistance_path - need node_id, will be filled dynamically
  85. {
  86. "id": "TC-10",
  87. "category": "关键阻力",
  88. "tool": "get_max_resistance_path",
  89. "params": {"model_id": MODEL_ID, "node_id": None}, # filled dynamically
  90. "desc": "最大阻力路线",
  91. "dynamic": True,
  92. },
  93. {
  94. "id": "TC-11",
  95. "category": "关键阻力",
  96. "tool": "get_three_area_distribution",
  97. "params": {"model_id": MODEL_ID},
  98. "desc": "三区阻力分布",
  99. },
  100. {
  101. "id": "TC-12",
  102. "category": "关键阻力",
  103. "tool": "get_key_path_decision",
  104. "params": {"model_id": MODEL_ID},
  105. "desc": "关键路径决策",
  106. },
  107. # ═══ 压能/解算类 ═══
  108. # get_path_press_power - need node IDs, will be filled dynamically
  109. {
  110. "id": "TC-13",
  111. "category": "压能/解算",
  112. "tool": "get_path_press_power",
  113. "params": {"model_id": MODEL_ID, "id_from": None, "id_to": None}, # filled dynamically
  114. "desc": "节点压能图",
  115. "dynamic": True,
  116. },
  117. {
  118. "id": "TC-14",
  119. "category": "压能/解算",
  120. "tool": "net_cal",
  121. "params": {"model_id": MODEL_ID},
  122. "desc": "网络解算(耗时较长)",
  123. },
  124. {
  125. "id": "TC-15",
  126. "category": "压能/解算",
  127. "tool": "net_cal_for_plan",
  128. "params": {"model_id": MODEL_ID, "plan": "{}"},
  129. "desc": "方案模拟解算",
  130. "note": "plan 参数不能为空字符串,使用空JSON对象作为最小有效方案",
  131. },
  132. ]
  133. def safe_parse_json(text: str) -> dict:
  134. """安全解析 JSON,支持多层嵌套"""
  135. try:
  136. return json.loads(text) if isinstance(text, str) else text
  137. except (json.JSONDecodeError, TypeError):
  138. return {"raw": str(text)[:2000]}
  139. def extract_deep_result(data: dict) -> dict:
  140. """尝试提取深层 result"""
  141. # 尝试多层解包
  142. for _ in range(5):
  143. if isinstance(data, dict):
  144. if "result" in data and isinstance(data["result"], str):
  145. try:
  146. data = json.loads(data["result"])
  147. continue
  148. except (json.JSONDecodeError, TypeError):
  149. pass
  150. if "result" in data and isinstance(data["result"], dict):
  151. data = data["result"]
  152. continue
  153. break
  154. return data
  155. def count_result_size(data) -> str:
  156. """估算结果大小"""
  157. s = json.dumps(data, ensure_ascii=False, default=str)
  158. size = len(s)
  159. if size < 1024:
  160. return f"{size} B"
  161. elif size < 1024 * 1024:
  162. return f"{size / 1024:.1f} KB"
  163. else:
  164. return f"{size / (1024 * 1024):.1f} MB"
  165. def summarize_structure(data, depth=0) -> str:
  166. """概括数据结构"""
  167. if depth > 5:
  168. return "..."
  169. if isinstance(data, dict):
  170. keys = list(data.keys())
  171. if len(keys) <= 8:
  172. parts = []
  173. for k in keys:
  174. v = data[k]
  175. if isinstance(v, (dict, list)):
  176. parts.append(f"{k}: {summarize_structure(v, depth + 1)}")
  177. elif isinstance(v, str) and len(v) > 100:
  178. parts.append(f"{k}: str({len(v)})")
  179. else:
  180. parts.append(f"{k}: {type(v).__name__}")
  181. return "{" + ", ".join(parts[:10]) + ("..." if len(parts) > 10 else "") + "}"
  182. else:
  183. return f"{{...{len(keys)} keys...}}"
  184. elif isinstance(data, list):
  185. if len(data) == 0:
  186. return "[]"
  187. return f"[{len(data)} items, first: {summarize_structure(data[0], depth + 1)}]"
  188. elif isinstance(data, str):
  189. return f'str({len(data)})'
  190. else:
  191. return type(data).__name__
  192. async def call_single_tool(client: Client, case: dict) -> dict:
  193. """调用单个工具并返回统一格式结果"""
  194. result = {
  195. "id": case["id"],
  196. "tool": case["tool"],
  197. "category": case["category"],
  198. "desc": case["desc"],
  199. "params": {k: v for k, v in case["params"].items() if v is not None},
  200. "timestamp": datetime.now().isoformat(),
  201. }
  202. start = time.perf_counter()
  203. try:
  204. raw = await client.call_tool(case["tool"], result["params"])
  205. elapsed = time.perf_counter() - start
  206. # 提取文本内容
  207. if raw.content and len(raw.content) > 0:
  208. text = raw.content[0].text
  209. data = safe_parse_json(text)
  210. else:
  211. text = ""
  212. data = {}
  213. result.update({
  214. "success": True,
  215. "elapsed_ms": round(elapsed * 1000, 2),
  216. "has_content": len(raw.content) > 0,
  217. "content_len": len(text),
  218. "size": count_result_size(data),
  219. "structure": summarize_structure(data),
  220. "data_preview": json.dumps(data, ensure_ascii=False, default=str)[:500],
  221. "deep_success": None, # will check below
  222. })
  223. # 检查深层 success 字段
  224. # 注意: bSucced=0 对于故障检测类接口表示"未发现故障"(正常), bSucced=1 表示"检测到故障"
  225. # 对于计算类接口, bSucced=1 表示计算成功, bSucced=0 表示计算失败
  226. deep = extract_deep_result(data)
  227. if isinstance(deep, dict):
  228. # 优先检查 outer success + code 200
  229. outer_ok = data.get("success") and data.get("code") == 200
  230. # 检查 inner result 是否有 bSucced 字段 (1=成功/发现, 0=失败/未发现)
  231. has_bSucced = "bSucced" in deep
  232. bSucced_val = deep.get("bSucced")
  233. # 如果有 error 字段且非空,标记业务异常
  234. has_error = bool(deep.get("error") or data.get("error"))
  235. if has_error:
  236. result["deep_success"] = False
  237. elif outer_ok:
  238. result["deep_success"] = True
  239. elif has_bSucced:
  240. # bSucced 存在时,都视为业务层面正常(0或1都有意义)
  241. result["deep_success"] = True
  242. else:
  243. result["deep_success"] = data.get("code") == 200
  244. except Exception as e:
  245. elapsed = time.perf_counter() - start
  246. result.update({
  247. "success": False,
  248. "elapsed_ms": round(elapsed * 1000, 2),
  249. "error": str(e),
  250. "error_type": type(e).__name__,
  251. })
  252. return result
  253. async def discover_node_and_tunnel_ids(client: Client) -> dict:
  254. """发现可用的 node_id 和 tunnel_id,用于后续动态测试"""
  255. discovered = {"node_ids": [], "out_shaft_tuns": [], "in_shaft_tuns": []}
  256. # 获取回风井
  257. try:
  258. r = await client.call_tool("get_out_shafts", {"model_id": MODEL_ID})
  259. text = r.content[0].text
  260. data = safe_parse_json(text)
  261. deep = extract_deep_result(data)
  262. # 尝试找到隧道ID列表
  263. if isinstance(deep, dict):
  264. for key in ["outShafts", "tunIds", "ids", "data"]:
  265. if key in deep and isinstance(deep[key], list):
  266. discovered["out_shaft_tuns"] = deep[key][:5]
  267. break
  268. discovered["out_shafts_raw"] = summarize_structure(data)
  269. except Exception as e:
  270. discovered["out_shafts_error"] = str(e)
  271. # 获取进风井
  272. try:
  273. r = await client.call_tool("get_in_shafts", {"model_id": MODEL_ID})
  274. text = r.content[0].text
  275. data = safe_parse_json(text)
  276. deep = extract_deep_result(data)
  277. if isinstance(deep, dict):
  278. for key in ["inShafts", "tunIds", "ids", "data"]:
  279. if key in deep and isinstance(deep[key], list):
  280. discovered["in_shaft_tuns"] = deep[key][:5]
  281. break
  282. discovered["in_shafts_raw"] = summarize_structure(data)
  283. except Exception as e:
  284. discovered["in_shafts_error"] = str(e)
  285. return discovered
  286. async def main():
  287. print("=" * 80)
  288. print(" 通风模型算法 MCP 接口测试")
  289. print(f" Server: {MCP_URL}")
  290. print(f" Model: {MODEL_ID}")
  291. print(f" 时间: {datetime.now().isoformat()}")
  292. print("=" * 80)
  293. print()
  294. client = Client(MCP_URL)
  295. results = []
  296. async with client:
  297. # ── 第一阶段:发现动态参数 ──
  298. print("🔍 第一阶段:发现动态参数(回风井/进风井/节点ID)...")
  299. discovered = await discover_node_and_tunnel_ids(client)
  300. print(f" 回风井: {json.dumps(discovered.get('out_shaft_tuns', []), ensure_ascii=False)}")
  301. print(f" 进风井: {json.dumps(discovered.get('in_shaft_tuns', []), ensure_ascii=False)}")
  302. # 尝试用已知隧道ID获取更多节点信息(需要调用 get_tun_list_by_modelid 或 net_cal)
  303. node_id_from = None
  304. node_id_to = None
  305. node_id_max_res = None
  306. # 先尝试从回风井结果中提取节点ID
  307. if discovered.get("out_shaft_tuns"):
  308. # 假设可以从 net_cal 结果中获取节点信息
  309. pass
  310. # 使用固定节点ID(文档中的示例值)
  311. node_id_max_res = "3958"
  312. node_id_from = "2"
  313. node_id_to = "3958"
  314. # ── 更新动态测试用例 ──
  315. for case in TEST_CASES:
  316. if case["tool"] == "get_max_resistance_path" and case.get("dynamic"):
  317. case["params"]["node_id"] = node_id_max_res
  318. print(f"\n 📍 TC-10 使用 node_id={node_id_max_res}")
  319. elif case["tool"] == "get_path_press_power" and case.get("dynamic"):
  320. case["params"]["id_from"] = node_id_from
  321. case["params"]["id_to"] = node_id_to
  322. print(f" 📍 TC-13 使用 id_from={node_id_from}, id_to={node_id_to}")
  323. # ── 第二阶段:逐个测试 ──
  324. print(f"\n{'=' * 80}")
  325. print(f" 第二阶段:执行 {len(TEST_CASES)} 个测试用例")
  326. print(f"{'=' * 80}\n")
  327. for i, case in enumerate(TEST_CASES):
  328. print(f"[{i+1:02d}/{len(TEST_CASES)}] {case['tool']} ... ", end="", flush=True)
  329. result = await call_single_tool(client, case)
  330. results.append(result)
  331. status = "✅" if result["success"] else "❌"
  332. elapsed = result.get("elapsed_ms", 0)
  333. size = result.get("size", "N/A")
  334. print(f"{status} {elapsed:.0f}ms {size}")
  335. if not result["success"]:
  336. print(f" ⚠️ 错误: {result.get('error', 'N/A')[:120]}")
  337. elif result.get("data_preview"):
  338. preview = result["data_preview"][:120].replace("\n", " ")
  339. print(f" 📄 {preview}")
  340. # ── 第三阶段:生成报告 ──
  341. print(f"\n{'=' * 80}")
  342. print(f" 第三阶段:生成测试报告")
  343. print(f"{'=' * 80}\n")
  344. # 统计
  345. total = len(results)
  346. passed = sum(1 for r in results if r["success"])
  347. failed = sum(1 for r in results if not r["success"])
  348. # 业务层面统计
  349. biz_ok = sum(1 for r in results if r["success"] and r.get("deep_success"))
  350. biz_timeout = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 408') > 0)
  351. biz_500 = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 500') > 0)
  352. total_time = sum(r.get("elapsed_ms", 0) for r in results)
  353. # 生成 JSON 报告
  354. report_json_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.json")
  355. report = {
  356. "title": "通风模型算法 MCP 接口测试报告",
  357. "server": MCP_URL,
  358. "model_id": MODEL_ID,
  359. "timestamp": datetime.now().isoformat(),
  360. "summary": {
  361. "total": total,
  362. "passed": passed,
  363. "failed": failed,
  364. "pass_rate": f"{passed / total * 100:.1f}%" if total > 0 else "N/A",
  365. "biz_ok": biz_ok,
  366. "biz_timeout": biz_timeout,
  367. "biz_500": biz_500,
  368. "total_elapsed_ms": round(total_time, 2),
  369. "avg_elapsed_ms": round(total_time / total, 2) if total > 0 else 0,
  370. },
  371. "results": results,
  372. }
  373. with open(report_json_path, "w", encoding="utf-8") as f:
  374. json.dump(report, f, ensure_ascii=False, indent=2, default=str)
  375. print(f" JSON 报告已保存: {report_json_path}")
  376. # 生成 Markdown 报告
  377. md_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.md")
  378. md_lines = []
  379. md_lines.append("# 通风模型算法 MCP 接口测试报告\n")
  380. md_lines.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} \n")
  381. md_lines.append(f"**MCP 服务地址**: `{MCP_URL}` \n")
  382. md_lines.append(f"**测试模型 ID**: `{MODEL_ID}` \n")
  383. md_lines.append(f"**传输协议**: `streamable-http` \n")
  384. md_lines.append(f"**客户端库**: `fastmcp` \n")
  385. md_lines.append("")
  386. # 汇总
  387. md_lines.append("## 📊 测试汇总\n")
  388. md_lines.append("### 传输层结果\n")
  389. md_lines.append("| 指标 | 数值 |")
  390. md_lines.append("|:---|---:|")
  391. md_lines.append(f"| 接口总数 | {total} |")
  392. md_lines.append(f"| ✅ 传输层通过 | {passed} |")
  393. md_lines.append(f"| ❌ 传输层失败 | {failed} |")
  394. md_lines.append(f"| 传输层通过率 | {passed / total * 100:.1f}% |")
  395. md_lines.append(f"| 总耗时 | {total_time:.0f} ms |")
  396. md_lines.append(f"| 平均响应 | {total_time / total:.0f} ms |")
  397. md_lines.append("")
  398. md_lines.append("### 业务层结果\n")
  399. md_lines.append("| 指标 | 数值 |")
  400. md_lines.append("|:---|---:|")
  401. md_lines.append(f"| ✅ 业务正常 | {biz_ok} |")
  402. md_lines.append(f"| ⏱️ 服务端超时 (408) | {biz_timeout} |")
  403. md_lines.append(f"| 🔴 服务端错误 (500) | {biz_500} |")
  404. md_lines.append(f"| ❌ 客户端校验失败 | {failed} |")
  405. md_lines.append(f"| 综合可用率 | {biz_ok / total * 100:.1f}% |")
  406. md_lines.append("")
  407. # 分类统计
  408. md_lines.append("### 分类统计\n")
  409. md_lines.append("| 分类 | 总数 | 通过 | 失败 | 平均耗时 |")
  410. md_lines.append("|:---|---:|---:|---:|---:|")
  411. categories = {}
  412. for r in results:
  413. cat = r["category"]
  414. if cat not in categories:
  415. categories[cat] = {"total": 0, "passed": 0, "failed": 0, "times": []}
  416. categories[cat]["total"] += 1
  417. if r["success"]:
  418. categories[cat]["passed"] += 1
  419. else:
  420. categories[cat]["failed"] += 1
  421. categories[cat]["times"].append(r.get("elapsed_ms", 0))
  422. for cat, stats in categories.items():
  423. avg_t = sum(stats["times"]) / len(stats["times"]) if stats["times"] else 0
  424. md_lines.append(f"| {cat} | {stats['total']} | {stats['passed']} | {stats['failed']} | {avg_t:.0f} ms |")
  425. md_lines.append("")
  426. # 详细结果
  427. md_lines.append("## 📋 详细测试结果\n")
  428. for r in results:
  429. # 确定状态图标
  430. if not r["success"]:
  431. status_icon = "❌"
  432. biz_label = "客户端参数校验失败"
  433. elif r.get("deep_success") is False:
  434. preview = r.get("data_preview", "")
  435. if '"code": 408' in preview or "408" in preview:
  436. status_icon = "⏱️"
  437. biz_label = "服务端超时 (408)"
  438. elif '"code": 500' in preview or "500" in preview:
  439. status_icon = "🔴"
  440. biz_label = "服务端内部错误 (500)"
  441. elif '"code": 503' in preview or "503" in preview:
  442. status_icon = "🔴"
  443. biz_label = "服务不可用 (503)"
  444. else:
  445. status_icon = "⚠️"
  446. biz_label = "业务异常"
  447. else:
  448. status_icon = "✅"
  449. biz_label = "正常"
  450. md_lines.append(f"### {status_icon} {r['id']}: {r['desc']} (`{r['tool']}`)\n")
  451. md_lines.append(f"- **分类**: {r['category']}")
  452. md_lines.append(f"- **传输状态**: {'通过' if r['success'] else '失败'} | **业务状态**: {biz_label}")
  453. md_lines.append(f"- **耗时**: {r.get('elapsed_ms', 'N/A')} ms")
  454. if r["success"]:
  455. md_lines.append(f"- **结果大小**: {r.get('size', 'N/A')}")
  456. md_lines.append(f"- **数据结构**: `{r.get('structure', 'N/A')}`")
  457. else:
  458. md_lines.append(f"- **错误类型**: `{r.get('error_type', 'N/A')}`")
  459. md_lines.append(f"- **错误信息**: {r.get('error', 'N/A')[:300]}")
  460. # 参数
  461. params_str = ", ".join(f"`{k}={v}`" for k, v in r["params"].items())
  462. md_lines.append(f"- **调用参数**: {params_str}")
  463. # 数据预览
  464. if r.get("data_preview"):
  465. preview = r["data_preview"]
  466. if len(preview) > 500:
  467. preview = preview[:500] + "..."
  468. md_lines.append(f"\n<details>\n<summary>数据预览</summary>\n\n```json\n{preview}\n```\n</details>")
  469. md_lines.append("")
  470. # 建议
  471. md_lines.append("## 💡 建议与注意事项\n")
  472. md_lines.append(f"### ⚠️ 需要修复的问题({biz_timeout + biz_500} 个)\n")
  473. md_lines.append("| 接口 | 问题 | 建议 |")
  474. md_lines.append("|:---|:---|:---|")
  475. for r in results:
  476. preview = r.get("data_preview", "")
  477. if '"code": 408' in preview or "408" in preview:
  478. md_lines.append(f"| `{r['tool']}` | 服务端超时 (408),耗时 {r.get('elapsed_ms', 0):.0f}ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |")
  479. elif '"code": 500' in preview or "500" in preview:
  480. md_lines.append(f"| `{r['tool']}` | 服务端内部错误 (500) - Connection prematurely closed | 检查后端 netty 连接池配置,可能需要增大响应超时 |")
  481. elif '"code": 503' in preview or "503" in preview:
  482. md_lines.append(f"| `{r['tool']}` | 服务不可用 (503) - Unable to find instance | 检查微服务 `ventanaly-model` 实例是否在线/注册中心状态 |")
  483. md_lines.append("")
  484. md_lines.append("### 📝 通用建议\n")
  485. md_lines.append("1. **响应时间差异大**:快速接口(进/回风井查询、循环风路检查)约 0.8-1.5s,重计算接口(压能图、避灾路线)约 12-35s,超时接口约 180s。建议按接口类型设置差异化超时。\n")
  486. md_lines.append("2. **数据量大**:`get_three_area_distribution` (366KB) 和 `get_path_press_power` (3.8MB) 返回数据量大,移动端需考虑分页或压缩。\n")
  487. md_lines.append("3. **参数校验**:`net_cal_for_plan` 的 `plan` 参数不能为空字符串,建议后端明确 `plan` 格式规范或提供示例。\n")
  488. md_lines.append("4. **返回值统一性**:外层统一为 `{success, code, result}`,但 `get_model_fault_diagnosis` 直接在外层返回 `modelID`,略有不一致。\n")
  489. md_lines.append("5. **节点 vs 隧道 ID**:`get_max_resistance_path` 和 `get_path_press_power` 使用 `node_id`(节点ID),其他接口使用 `tun_id`(隧道ID),文档已说明,但调用时容易混淆。\n")
  490. md_lines.append("6. **CO 参数**:`get_escape_path_each_exit` 的 `co_per` 默认 2000.0,`during_time` 默认 0.0,合理默认值便于调用。\n")
  491. with open(md_path, "w", encoding="utf-8") as f:
  492. f.write("\n".join(md_lines))
  493. print(f" Markdown 报告已保存: {md_path}")
  494. # ── 终端输出汇总 ──
  495. print(f"\n{'=' * 80}")
  496. print(f" 📊 测试汇总")
  497. print(f" 总计: {total} | ✅ 通过: {passed} | ❌ 失败: {failed} | 通过率: {passed / total * 100:.1f}%")
  498. print(f" 总耗时: {total_time:.0f} ms | 平均: {total_time / total:.0f} ms")
  499. print(f"{'=' * 80}")
  500. if __name__ == "__main__":
  501. asyncio.run(main())