| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559 |
- # -*- coding: utf-8 -*-
- """
- 通风模型算法 MCP 接口全面测试脚本
- 测试 server: vent-model-tools @ http://39.97.59.228:8071/mcp
- """
- import asyncio
- import json
- import time
- import os
- from datetime import datetime
- from fastmcp import Client
- MCP_URL = "http://39.97.59.228:8071/mcp"
- MODEL_ID = "2012326636757958658"
- # ── 测试用例定义 ──
- # 按文档中的 15 个接口定义
- TEST_CASES = [
- # ═══ 故障诊断类 ═══
- {
- "id": "TC-01",
- "category": "故障诊断",
- "tool": "check_model_connect_status",
- "params": {"model_id": MODEL_ID},
- "desc": "模型网络连通检查",
- },
- {
- "id": "TC-02",
- "category": "故障诊断",
- "tool": "check_model_one_dir_cycle",
- "params": {"model_id": MODEL_ID},
- "desc": "模型循环风路检查",
- },
- {
- "id": "TC-03",
- "category": "故障诊断",
- "tool": "check_model_one_dir_node",
- "params": {"model_id": MODEL_ID},
- "desc": "模型单向节点检查",
- },
- {
- "id": "TC-04",
- "category": "故障诊断",
- "tool": "check_model_diagonal_structure",
- "params": {"model_id": MODEL_ID},
- "desc": "模型角联结构诊断(计算重,耗时较长)",
- },
- {
- "id": "TC-05",
- "category": "故障诊断",
- "tool": "get_model_fault_diagnosis",
- "params": {"model_id": MODEL_ID, "include_diagonal": False},
- "desc": "模型故障诊断(聚合,不含角联)",
- },
- # ═══ 避灾路线类 ═══
- {
- "id": "TC-06",
- "category": "避灾路线",
- "tool": "get_escape_path",
- "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6"},
- "desc": "避灾路线模拟",
- "note": "使用硬编码隧道ID 4/6,实际应取自 get_out_shafts",
- },
- {
- "id": "TC-07",
- "category": "避灾路线",
- "tool": "get_escape_path_each_exit",
- "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6", "co_per": 2000.0, "during_time": 30.0},
- "desc": "避灾路线模拟(各出口,含CO参数)",
- },
- # ═══ 关键阻力类 ═══
- {
- "id": "TC-08",
- "category": "关键阻力",
- "tool": "get_out_shafts",
- "params": {"model_id": MODEL_ID},
- "desc": "获取回风井巷道ID列表",
- },
- {
- "id": "TC-09",
- "category": "关键阻力",
- "tool": "get_in_shafts",
- "params": {"model_id": MODEL_ID},
- "desc": "获取进风井巷道ID列表",
- },
- # get_max_resistance_path - need node_id, will be filled dynamically
- {
- "id": "TC-10",
- "category": "关键阻力",
- "tool": "get_max_resistance_path",
- "params": {"model_id": MODEL_ID, "node_id": None}, # filled dynamically
- "desc": "最大阻力路线",
- "dynamic": True,
- },
- {
- "id": "TC-11",
- "category": "关键阻力",
- "tool": "get_three_area_distribution",
- "params": {"model_id": MODEL_ID},
- "desc": "三区阻力分布",
- },
- {
- "id": "TC-12",
- "category": "关键阻力",
- "tool": "get_key_path_decision",
- "params": {"model_id": MODEL_ID},
- "desc": "关键路径决策",
- },
- # ═══ 压能/解算类 ═══
- # get_path_press_power - need node IDs, will be filled dynamically
- {
- "id": "TC-13",
- "category": "压能/解算",
- "tool": "get_path_press_power",
- "params": {"model_id": MODEL_ID, "id_from": None, "id_to": None}, # filled dynamically
- "desc": "节点压能图",
- "dynamic": True,
- },
- {
- "id": "TC-14",
- "category": "压能/解算",
- "tool": "net_cal",
- "params": {"model_id": MODEL_ID},
- "desc": "网络解算(耗时较长)",
- },
- {
- "id": "TC-15",
- "category": "压能/解算",
- "tool": "net_cal_for_plan",
- "params": {"model_id": MODEL_ID, "plan": "{}"},
- "desc": "方案模拟解算",
- "note": "plan 参数不能为空字符串,使用空JSON对象作为最小有效方案",
- },
- ]
- def safe_parse_json(text: str) -> dict:
- """安全解析 JSON,支持多层嵌套"""
- try:
- return json.loads(text) if isinstance(text, str) else text
- except (json.JSONDecodeError, TypeError):
- return {"raw": str(text)[:2000]}
- def extract_deep_result(data: dict) -> dict:
- """尝试提取深层 result"""
- # 尝试多层解包
- for _ in range(5):
- if isinstance(data, dict):
- if "result" in data and isinstance(data["result"], str):
- try:
- data = json.loads(data["result"])
- continue
- except (json.JSONDecodeError, TypeError):
- pass
- if "result" in data and isinstance(data["result"], dict):
- data = data["result"]
- continue
- break
- return data
- def count_result_size(data) -> str:
- """估算结果大小"""
- s = json.dumps(data, ensure_ascii=False, default=str)
- size = len(s)
- if size < 1024:
- return f"{size} B"
- elif size < 1024 * 1024:
- return f"{size / 1024:.1f} KB"
- else:
- return f"{size / (1024 * 1024):.1f} MB"
- def summarize_structure(data, depth=0) -> str:
- """概括数据结构"""
- if depth > 5:
- return "..."
- if isinstance(data, dict):
- keys = list(data.keys())
- if len(keys) <= 8:
- parts = []
- for k in keys:
- v = data[k]
- if isinstance(v, (dict, list)):
- parts.append(f"{k}: {summarize_structure(v, depth + 1)}")
- elif isinstance(v, str) and len(v) > 100:
- parts.append(f"{k}: str({len(v)})")
- else:
- parts.append(f"{k}: {type(v).__name__}")
- return "{" + ", ".join(parts[:10]) + ("..." if len(parts) > 10 else "") + "}"
- else:
- return f"{{...{len(keys)} keys...}}"
- elif isinstance(data, list):
- if len(data) == 0:
- return "[]"
- return f"[{len(data)} items, first: {summarize_structure(data[0], depth + 1)}]"
- elif isinstance(data, str):
- return f'str({len(data)})'
- else:
- return type(data).__name__
- async def call_single_tool(client: Client, case: dict) -> dict:
- """调用单个工具并返回统一格式结果"""
- result = {
- "id": case["id"],
- "tool": case["tool"],
- "category": case["category"],
- "desc": case["desc"],
- "params": {k: v for k, v in case["params"].items() if v is not None},
- "timestamp": datetime.now().isoformat(),
- }
-
- start = time.perf_counter()
- try:
- raw = await client.call_tool(case["tool"], result["params"])
- elapsed = time.perf_counter() - start
-
- # 提取文本内容
- if raw.content and len(raw.content) > 0:
- text = raw.content[0].text
- data = safe_parse_json(text)
- else:
- text = ""
- data = {}
-
- result.update({
- "success": True,
- "elapsed_ms": round(elapsed * 1000, 2),
- "has_content": len(raw.content) > 0,
- "content_len": len(text),
- "size": count_result_size(data),
- "structure": summarize_structure(data),
- "data_preview": json.dumps(data, ensure_ascii=False, default=str)[:500],
- "deep_success": None, # will check below
- })
-
- # 检查深层 success 字段
- # 注意: bSucced=0 对于故障检测类接口表示"未发现故障"(正常), bSucced=1 表示"检测到故障"
- # 对于计算类接口, bSucced=1 表示计算成功, bSucced=0 表示计算失败
- deep = extract_deep_result(data)
- if isinstance(deep, dict):
- # 优先检查 outer success + code 200
- outer_ok = data.get("success") and data.get("code") == 200
- # 检查 inner result 是否有 bSucced 字段 (1=成功/发现, 0=失败/未发现)
- has_bSucced = "bSucced" in deep
- bSucced_val = deep.get("bSucced")
- # 如果有 error 字段且非空,标记业务异常
- has_error = bool(deep.get("error") or data.get("error"))
- if has_error:
- result["deep_success"] = False
- elif outer_ok:
- result["deep_success"] = True
- elif has_bSucced:
- # bSucced 存在时,都视为业务层面正常(0或1都有意义)
- result["deep_success"] = True
- else:
- result["deep_success"] = data.get("code") == 200
-
- except Exception as e:
- elapsed = time.perf_counter() - start
- result.update({
- "success": False,
- "elapsed_ms": round(elapsed * 1000, 2),
- "error": str(e),
- "error_type": type(e).__name__,
- })
-
- return result
- async def discover_node_and_tunnel_ids(client: Client) -> dict:
- """发现可用的 node_id 和 tunnel_id,用于后续动态测试"""
- discovered = {"node_ids": [], "out_shaft_tuns": [], "in_shaft_tuns": []}
-
- # 获取回风井
- try:
- r = await client.call_tool("get_out_shafts", {"model_id": MODEL_ID})
- text = r.content[0].text
- data = safe_parse_json(text)
- deep = extract_deep_result(data)
- # 尝试找到隧道ID列表
- if isinstance(deep, dict):
- for key in ["outShafts", "tunIds", "ids", "data"]:
- if key in deep and isinstance(deep[key], list):
- discovered["out_shaft_tuns"] = deep[key][:5]
- break
- discovered["out_shafts_raw"] = summarize_structure(data)
- except Exception as e:
- discovered["out_shafts_error"] = str(e)
-
- # 获取进风井
- try:
- r = await client.call_tool("get_in_shafts", {"model_id": MODEL_ID})
- text = r.content[0].text
- data = safe_parse_json(text)
- deep = extract_deep_result(data)
- if isinstance(deep, dict):
- for key in ["inShafts", "tunIds", "ids", "data"]:
- if key in deep and isinstance(deep[key], list):
- discovered["in_shaft_tuns"] = deep[key][:5]
- break
- discovered["in_shafts_raw"] = summarize_structure(data)
- except Exception as e:
- discovered["in_shafts_error"] = str(e)
-
- return discovered
- async def main():
- print("=" * 80)
- print(" 通风模型算法 MCP 接口测试")
- print(f" Server: {MCP_URL}")
- print(f" Model: {MODEL_ID}")
- print(f" 时间: {datetime.now().isoformat()}")
- print("=" * 80)
- print()
-
- client = Client(MCP_URL)
- results = []
-
- async with client:
- # ── 第一阶段:发现动态参数 ──
- print("🔍 第一阶段:发现动态参数(回风井/进风井/节点ID)...")
- discovered = await discover_node_and_tunnel_ids(client)
- print(f" 回风井: {json.dumps(discovered.get('out_shaft_tuns', []), ensure_ascii=False)}")
- print(f" 进风井: {json.dumps(discovered.get('in_shaft_tuns', []), ensure_ascii=False)}")
-
- # 尝试用已知隧道ID获取更多节点信息(需要调用 get_tun_list_by_modelid 或 net_cal)
- node_id_from = None
- node_id_to = None
- node_id_max_res = None
-
- # 先尝试从回风井结果中提取节点ID
- if discovered.get("out_shaft_tuns"):
- # 假设可以从 net_cal 结果中获取节点信息
- pass
-
- # 使用固定节点ID(文档中的示例值)
- node_id_max_res = "3958"
- node_id_from = "2"
- node_id_to = "3958"
-
- # ── 更新动态测试用例 ──
- for case in TEST_CASES:
- if case["tool"] == "get_max_resistance_path" and case.get("dynamic"):
- case["params"]["node_id"] = node_id_max_res
- print(f"\n 📍 TC-10 使用 node_id={node_id_max_res}")
- elif case["tool"] == "get_path_press_power" and case.get("dynamic"):
- case["params"]["id_from"] = node_id_from
- case["params"]["id_to"] = node_id_to
- print(f" 📍 TC-13 使用 id_from={node_id_from}, id_to={node_id_to}")
-
- # ── 第二阶段:逐个测试 ──
- print(f"\n{'=' * 80}")
- print(f" 第二阶段:执行 {len(TEST_CASES)} 个测试用例")
- print(f"{'=' * 80}\n")
-
- for i, case in enumerate(TEST_CASES):
- print(f"[{i+1:02d}/{len(TEST_CASES)}] {case['tool']} ... ", end="", flush=True)
- result = await call_single_tool(client, case)
- results.append(result)
-
- status = "✅" if result["success"] else "❌"
- elapsed = result.get("elapsed_ms", 0)
- size = result.get("size", "N/A")
- print(f"{status} {elapsed:.0f}ms {size}")
-
- if not result["success"]:
- print(f" ⚠️ 错误: {result.get('error', 'N/A')[:120]}")
- elif result.get("data_preview"):
- preview = result["data_preview"][:120].replace("\n", " ")
- print(f" 📄 {preview}")
-
- # ── 第三阶段:生成报告 ──
- print(f"\n{'=' * 80}")
- print(f" 第三阶段:生成测试报告")
- print(f"{'=' * 80}\n")
-
- # 统计
- total = len(results)
- passed = sum(1 for r in results if r["success"])
- failed = sum(1 for r in results if not r["success"])
- # 业务层面统计
- biz_ok = sum(1 for r in results if r["success"] and r.get("deep_success"))
- biz_timeout = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 408') > 0)
- biz_500 = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 500') > 0)
- total_time = sum(r.get("elapsed_ms", 0) for r in results)
-
- # 生成 JSON 报告
- report_json_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.json")
- report = {
- "title": "通风模型算法 MCP 接口测试报告",
- "server": MCP_URL,
- "model_id": MODEL_ID,
- "timestamp": datetime.now().isoformat(),
- "summary": {
- "total": total,
- "passed": passed,
- "failed": failed,
- "pass_rate": f"{passed / total * 100:.1f}%" if total > 0 else "N/A",
- "biz_ok": biz_ok,
- "biz_timeout": biz_timeout,
- "biz_500": biz_500,
- "total_elapsed_ms": round(total_time, 2),
- "avg_elapsed_ms": round(total_time / total, 2) if total > 0 else 0,
- },
- "results": results,
- }
-
- with open(report_json_path, "w", encoding="utf-8") as f:
- json.dump(report, f, ensure_ascii=False, indent=2, default=str)
- print(f" JSON 报告已保存: {report_json_path}")
-
- # 生成 Markdown 报告
- md_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.md")
- md_lines = []
- md_lines.append("# 通风模型算法 MCP 接口测试报告\n")
- md_lines.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} \n")
- md_lines.append(f"**MCP 服务地址**: `{MCP_URL}` \n")
- md_lines.append(f"**测试模型 ID**: `{MODEL_ID}` \n")
- md_lines.append(f"**传输协议**: `streamable-http` \n")
- md_lines.append(f"**客户端库**: `fastmcp` \n")
- md_lines.append("")
-
- # 汇总
- md_lines.append("## 📊 测试汇总\n")
- md_lines.append("### 传输层结果\n")
- md_lines.append("| 指标 | 数值 |")
- md_lines.append("|:---|---:|")
- md_lines.append(f"| 接口总数 | {total} |")
- md_lines.append(f"| ✅ 传输层通过 | {passed} |")
- md_lines.append(f"| ❌ 传输层失败 | {failed} |")
- md_lines.append(f"| 传输层通过率 | {passed / total * 100:.1f}% |")
- md_lines.append(f"| 总耗时 | {total_time:.0f} ms |")
- md_lines.append(f"| 平均响应 | {total_time / total:.0f} ms |")
- md_lines.append("")
- md_lines.append("### 业务层结果\n")
- md_lines.append("| 指标 | 数值 |")
- md_lines.append("|:---|---:|")
- md_lines.append(f"| ✅ 业务正常 | {biz_ok} |")
- md_lines.append(f"| ⏱️ 服务端超时 (408) | {biz_timeout} |")
- md_lines.append(f"| 🔴 服务端错误 (500) | {biz_500} |")
- md_lines.append(f"| ❌ 客户端校验失败 | {failed} |")
- md_lines.append(f"| 综合可用率 | {biz_ok / total * 100:.1f}% |")
- md_lines.append("")
-
- # 分类统计
- md_lines.append("### 分类统计\n")
- md_lines.append("| 分类 | 总数 | 通过 | 失败 | 平均耗时 |")
- md_lines.append("|:---|---:|---:|---:|---:|")
- categories = {}
- for r in results:
- cat = r["category"]
- if cat not in categories:
- categories[cat] = {"total": 0, "passed": 0, "failed": 0, "times": []}
- categories[cat]["total"] += 1
- if r["success"]:
- categories[cat]["passed"] += 1
- else:
- categories[cat]["failed"] += 1
- categories[cat]["times"].append(r.get("elapsed_ms", 0))
-
- for cat, stats in categories.items():
- avg_t = sum(stats["times"]) / len(stats["times"]) if stats["times"] else 0
- md_lines.append(f"| {cat} | {stats['total']} | {stats['passed']} | {stats['failed']} | {avg_t:.0f} ms |")
- md_lines.append("")
-
- # 详细结果
- md_lines.append("## 📋 详细测试结果\n")
-
- for r in results:
- # 确定状态图标
- if not r["success"]:
- status_icon = "❌"
- biz_label = "客户端参数校验失败"
- elif r.get("deep_success") is False:
- preview = r.get("data_preview", "")
- if '"code": 408' in preview or "408" in preview:
- status_icon = "⏱️"
- biz_label = "服务端超时 (408)"
- elif '"code": 500' in preview or "500" in preview:
- status_icon = "🔴"
- biz_label = "服务端内部错误 (500)"
- elif '"code": 503' in preview or "503" in preview:
- status_icon = "🔴"
- biz_label = "服务不可用 (503)"
- else:
- status_icon = "⚠️"
- biz_label = "业务异常"
- else:
- status_icon = "✅"
- biz_label = "正常"
-
- md_lines.append(f"### {status_icon} {r['id']}: {r['desc']} (`{r['tool']}`)\n")
- md_lines.append(f"- **分类**: {r['category']}")
- md_lines.append(f"- **传输状态**: {'通过' if r['success'] else '失败'} | **业务状态**: {biz_label}")
- md_lines.append(f"- **耗时**: {r.get('elapsed_ms', 'N/A')} ms")
-
- if r["success"]:
- md_lines.append(f"- **结果大小**: {r.get('size', 'N/A')}")
- md_lines.append(f"- **数据结构**: `{r.get('structure', 'N/A')}`")
- else:
- md_lines.append(f"- **错误类型**: `{r.get('error_type', 'N/A')}`")
- md_lines.append(f"- **错误信息**: {r.get('error', 'N/A')[:300]}")
-
- # 参数
- params_str = ", ".join(f"`{k}={v}`" for k, v in r["params"].items())
- md_lines.append(f"- **调用参数**: {params_str}")
-
- # 数据预览
- if r.get("data_preview"):
- preview = r["data_preview"]
- if len(preview) > 500:
- preview = preview[:500] + "..."
- md_lines.append(f"\n<details>\n<summary>数据预览</summary>\n\n```json\n{preview}\n```\n</details>")
-
- md_lines.append("")
-
- # 建议
- md_lines.append("## 💡 建议与注意事项\n")
- md_lines.append(f"### ⚠️ 需要修复的问题({biz_timeout + biz_500} 个)\n")
- md_lines.append("| 接口 | 问题 | 建议 |")
- md_lines.append("|:---|:---|:---|")
-
- for r in results:
- preview = r.get("data_preview", "")
- if '"code": 408' in preview or "408" in preview:
- md_lines.append(f"| `{r['tool']}` | 服务端超时 (408),耗时 {r.get('elapsed_ms', 0):.0f}ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |")
- elif '"code": 500' in preview or "500" in preview:
- md_lines.append(f"| `{r['tool']}` | 服务端内部错误 (500) - Connection prematurely closed | 检查后端 netty 连接池配置,可能需要增大响应超时 |")
- elif '"code": 503' in preview or "503" in preview:
- md_lines.append(f"| `{r['tool']}` | 服务不可用 (503) - Unable to find instance | 检查微服务 `ventanaly-model` 实例是否在线/注册中心状态 |")
-
- md_lines.append("")
- md_lines.append("### 📝 通用建议\n")
- md_lines.append("1. **响应时间差异大**:快速接口(进/回风井查询、循环风路检查)约 0.8-1.5s,重计算接口(压能图、避灾路线)约 12-35s,超时接口约 180s。建议按接口类型设置差异化超时。\n")
- md_lines.append("2. **数据量大**:`get_three_area_distribution` (366KB) 和 `get_path_press_power` (3.8MB) 返回数据量大,移动端需考虑分页或压缩。\n")
- md_lines.append("3. **参数校验**:`net_cal_for_plan` 的 `plan` 参数不能为空字符串,建议后端明确 `plan` 格式规范或提供示例。\n")
- md_lines.append("4. **返回值统一性**:外层统一为 `{success, code, result}`,但 `get_model_fault_diagnosis` 直接在外层返回 `modelID`,略有不一致。\n")
- md_lines.append("5. **节点 vs 隧道 ID**:`get_max_resistance_path` 和 `get_path_press_power` 使用 `node_id`(节点ID),其他接口使用 `tun_id`(隧道ID),文档已说明,但调用时容易混淆。\n")
- md_lines.append("6. **CO 参数**:`get_escape_path_each_exit` 的 `co_per` 默认 2000.0,`during_time` 默认 0.0,合理默认值便于调用。\n")
-
- with open(md_path, "w", encoding="utf-8") as f:
- f.write("\n".join(md_lines))
- print(f" Markdown 报告已保存: {md_path}")
-
- # ── 终端输出汇总 ──
- print(f"\n{'=' * 80}")
- print(f" 📊 测试汇总")
- print(f" 总计: {total} | ✅ 通过: {passed} | ❌ 失败: {failed} | 通过率: {passed / total * 100:.1f}%")
- print(f" 总耗时: {total_time:.0f} ms | 平均: {total_time / total:.0f} ms")
- print(f"{'=' * 80}")
- if __name__ == "__main__":
- asyncio.run(main())
|