diff --git a/output/xmind_reports/安徽运八需求.xmind b/output/xmind_reports/安徽运八需求.xmind new file mode 100644 index 0000000..a07c110 Binary files /dev/null and b/output/xmind_reports/安徽运八需求.xmind differ diff --git a/scripts/export_XMind.py b/scripts/export_XMind.py new file mode 100644 index 0000000..f38d323 --- /dev/null +++ b/scripts/export_XMind.py @@ -0,0 +1,271 @@ +"""将 Markdown 测试用例导出为 XMind 思维导图。 + +用法: + python scripts/export_XMind.py --base-name 运费核算 + python scripts/export_XMind.py --source-file output/test_cases/运费核算_测试用例.md +""" + +import argparse +import re +import uuid +import zipfile +from datetime import datetime +from pathlib import Path +from lxml import etree + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_INPUT_DIR = REPO_ROOT / "output" / "test_cases" +DEFAULT_OUTPUT_DIR = REPO_ROOT / "output" / "xmind_reports" + +XMAP_NS = "urn:xmind:xmap:xmlns:content:2.0" +FO_NS = "http://www.w3.org/1999/XSL/Format" +SVG_NS = "http://www.w3.org/2000/svg" +XHTML_NS = "http://www.w3.org/1999/xhtml" +XLINK_NS = "http://www.w3.org/1999/xlink" +MANIFEST_NS = "urn:xmind:xmap:xmlns:manifest:1.0" + +SEPARATOR_RE = re.compile(r"^\|\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$") + + +def _new_id(): + return str(uuid.uuid4()) + + +def _now_ms(): + return str(int(datetime.now().timestamp() * 1000)) + + +def split_markdown_row(line: str) -> list[str]: + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + cells: list[str] = [] + current: list[str] = [] + escaped = False + for char in stripped: + if escaped: + current.append(char) + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == "|": + cells.append("".join(current).strip()) + current = [] + continue + current.append(char) + if escaped: + current.append("\\") + cells.append("".join(current).strip()) + return cells + + +def load_markdown_table(source_file: Path) -> tuple[list[str], list[list[str]]]: + content = source_file.read_text(encoding="utf-8") + all_table_lines = [line.rstrip() for line in content.splitlines() if line.strip().startswith("|")] + + if len(all_table_lines) < 3: + raise ValueError(f"未在 {source_file} 中找到有效的 Markdown 表格。") + + separator_indices = [i for i, line in enumerate(all_table_lines) if SEPARATOR_RE.match(line.strip())] + if not separator_indices: + raise ValueError(f"{source_file} 中没有找到合法的 Markdown 表头分隔线。") + + headers = split_markdown_row(all_table_lines[0]) + header_values = split_markdown_row(all_table_lines[0]) + + rows: list[list[str]] = [] + for i, sep_idx in enumerate(separator_indices): + table_start = sep_idx - 1 + if table_start < 0: + continue + table_end = (separator_indices[i + 1] - 1) if i + 1 < len(separator_indices) else len(all_table_lines) + for line in all_table_lines[sep_idx + 1:table_end]: + row = split_markdown_row(line) + if len(row) != len(headers): + continue + if i > 0 and row == header_values: + continue + rows.append(row) + if not rows: + raise ValueError(f"{source_file} 的 Markdown 表格没有数据行。") + return headers, rows + + +def normalize_text(value: str) -> str: + return re.sub(r"", "\n", value or "").replace("\\n", "\n").strip() + + +def build_xmind_content(headers: list[str], rows: list[list[str]], topic_name: str) -> bytes: + ts = _now_ms() + nsmap = {None: XMAP_NS, "fo": FO_NS, "svg": SVG_NS, "xhtml": XHTML_NS, "xlink": XLINK_NS} + + def _el(tag, parent=None, **attrs): + elem = etree.Element(f"{{{XMAP_NS}}}{tag}", nsmap=nsmap, **attrs) + if parent is not None: + parent.append(elem) + return elem + + root = _el("xmap-content", version="2.0", timestamp=ts) + sheet = _el("sheet", parent=root, id=_new_id(), timestamp=ts) + _el("title", parent=sheet).text = "画布 1" + + root_topic = _el("topic", parent=sheet, id=_new_id(), timestamp=ts, + **{"structure-class": "org.xmind.ui.logic.right"}) + _el("title", parent=root_topic).text = topic_name + rc = _el("children", parent=root_topic) + rt = _el("topics", parent=rc, type="attached") + + # 按模块路径分组 + grouped: dict[str, list[dict[str, str]]] = {} + for row in rows: + case = {headers[i]: (row[i] or "").strip() for i in range(len(headers))} + module = case.get("模块", "") or "未分类" + grouped.setdefault(module, []).append(case) + + for module_name, cases in grouped.items(): + mtopic = _el("topic", parent=rt, id=_new_id(), timestamp=ts) + _el("title", parent=mtopic).text = module_name + mc = _el("children", parent=mtopic) + mtopics = _el("topics", parent=mc, type="attached") + + for case in cases: + case_id = case.get("用例编号", "") + case_title = case.get("用例标题", "") + priority = case.get("优先级", "") + case_type = case.get("类型", "") + + label_parts = ["tc", priority, case_type] + label = "-".join([p for p in label_parts if p]) + label = f"{label}:{case_title}" if case_title else label + + c_topic = _el("topic", parent=mtopics, id=_new_id(), timestamp=ts) + _el("title", parent=c_topic).text = label + cc = _el("children", parent=c_topic) + ct = _el("topics", parent=cc, type="attached") + + # 前置条件 + precond = normalize_text(case.get("前置条件", "")) + if precond: + pt = _el("topic", parent=ct, id=_new_id(), timestamp=ts) + _el("title", parent=pt).text = f"pc:{precond}" + + # 测试数据 + test_data = normalize_text(case.get("测试数据", "")) + if test_data: + dt = _el("topic", parent=ct, id=_new_id(), timestamp=ts) + _el("title", parent=dt).text = f"测试数据:{test_data}" + + # 步骤与预期结果 + steps_text = normalize_text(case.get("测试步骤", "")) + expected_text = normalize_text(case.get("预期结果", "")) + step_lines = [s.strip() for s in steps_text.split("\n") if s.strip()] if steps_text else [] + exp_lines = [e.strip() for e in expected_text.split("\n") if e.strip()] if expected_text else [] + max_len = max(len(step_lines), len(exp_lines), 1) + + for i in range(max_len): + step = step_lines[i] if i < len(step_lines) else "" + expected = exp_lines[i] if i < len(exp_lines) else "" + if step or expected: + st = _el("topic", parent=ct, id=_new_id(), timestamp=ts) + _el("title", parent=st).text = f"步骤:{step}" if step else "预期结果" + if expected: + sc = _el("children", parent=st) + sst = _el("topics", parent=sc, type="attached") + rst = _el("topic", parent=sst, id=_new_id(), timestamp=ts) + _el("title", parent=rst).text = f"预期:{expected}" + + # 备注 + remark = case.get("备注", "").strip() + if remark: + rmk = _el("topic", parent=ct, id=_new_id(), timestamp=ts) + _el("title", parent=rmk).text = f"备注:{remark}" + + return etree.tostring(root, encoding="utf-8", xml_declaration=True, pretty_print=True) + + +def build_manifest() -> bytes: + nsmap = {None: MANIFEST_NS} + + def _el(tag, parent=None, **attrs): + elem = etree.Element(f"{{{MANIFEST_NS}}}{tag}", nsmap=nsmap, **attrs) + if parent is not None: + parent.append(elem) + return elem + + root = _el("manifest", version="1.0") + _el("file-entry", parent=root, **{"full-path": "content.xml", "media-type": "text/xml"}) + return etree.tostring(root, encoding="utf-8", xml_declaration=True, pretty_print=True) + + +def build_meta() -> bytes: + nsmap = {None: XMAP_NS} + root = etree.Element(f"{{{XMAP_NS}}}meta", nsmap=nsmap, version="2.0") + return etree.tostring(root, encoding="utf-8", xml_declaration=True, pretty_print=True) + + +def export_markdown_to_xmind(source_path: Path, output_dir: Path, topic_name: str = "") -> Path: + headers, rows = load_markdown_table(source_path) + if not topic_name: + topic_name = source_path.stem.replace("_测试用例", "").replace("_分析", "") + + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{topic_name}.xmind" + + content_xml = build_xmind_content(headers, rows, topic_name) + manifest_xml = build_manifest() + meta_xml = build_meta() + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("content.xml", content_xml) + zf.writestr("meta.xml", meta_xml) + zf.writestr("META-INF/manifest.xml", manifest_xml) + + return output_path + + +def resolve_source_file(source_file: str | None, base_name: str | None, input_dir: Path) -> Path: + if source_file: + path = Path(source_file) + return path if path.is_absolute() else REPO_ROOT / path + if base_name: + return input_dir / f"{base_name}_测试用例.md" + raise ValueError("必须指定 --source-file 或 --base-name") + + +def main() -> None: + parser = argparse.ArgumentParser(description="将 Markdown 测试用例导出为 XMind 思维导图。") + parser.add_argument("--source-file", help="测试用例 Markdown 文件路径") + parser.add_argument("--base-name", help="按 BASE_NAME 查找 output/test_cases/{BASE_NAME}_测试用例.md") + parser.add_argument("--topic-name", help="XMind 根主题名称(默认从文件名推断)", default="") + parser.add_argument("--input-dir", default=str(DEFAULT_INPUT_DIR), help="Markdown 所在目录") + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="XMind 输出目录") + args = parser.parse_args() + + if not args.source_file and not args.base_name: + parser.error("必须指定 --source-file 或 --base-name") + + input_dir = Path(args.input_dir) + if not input_dir.is_absolute(): + input_dir = REPO_ROOT / input_dir + output_dir = Path(args.output_dir) + if not output_dir.is_absolute(): + output_dir = REPO_ROOT / output_dir + + source_path = resolve_source_file(args.source_file, args.base_name, input_dir) + if not source_path.exists(): + raise FileNotFoundError(f"测试用例文件不存在:{source_path}") + + output_path = export_markdown_to_xmind(source_path, output_dir, args.topic_name) + headers, rows = load_markdown_table(source_path) + print(f"来源文件: {source_path}") + print(f"用例数量: {len(rows)}") + print(f"根主题: {args.topic_name or source_path.stem}") + print(f"XMind 已生成: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fleet_runner.py b/scripts/fleet_runner.py index 7bd63c2..4639df8 100644 --- a/scripts/fleet_runner.py +++ b/scripts/fleet_runner.py @@ -51,6 +51,7 @@ from case_pipeline import ( PROJECT_PROFILE_FILE, ) from export_excel import export_markdown_to_excel +from export_XMind import export_markdown_to_xmind # Fleet 自有模块 from fleet_manifest import ( @@ -1494,8 +1495,8 @@ def _run_monitor_zone( # ── Export ───────────────────────────────────────────────────────────────── -def _run_export(requirement_path: Path) -> dict[str, Any]: - """导出 Excel 并创建版本快照。""" +def _run_export(requirement_path: Path, skip_xmind: bool = False) -> dict[str, Any]: + """导出 Excel + XMind 并创建版本快照。""" from case_pipeline import command_export base_name = requirement_path.stem @@ -1504,16 +1505,29 @@ def _run_export(requirement_path: Path) -> dict[str, Any]: if not test_cases_path.exists(): raise FileNotFoundError(f"测试用例文件不存在: {test_cases_path}") - # 调用现有 export 逻辑 + # Excel 导出 + 版本快照 command_export(str(requirement_path)) + # XMind 思维导图导出 + result: dict[str, Any] = {"export": "success", "base_name": base_name, "excel": True} + if not skip_xmind: + try: + xmind_dir = REPO_ROOT / "output" / "xmind_reports" + xmind_path = export_markdown_to_xmind(test_cases_path, xmind_dir, base_name) + result["xmind"] = str(xmind_path) + print(f"🧠 XMind 已生成: {to_repo_relative(xmind_path)}") + except Exception as exc: + print(f"⚠️ XMind 导出失败(不影响 Excel): {exc}") + result["xmind"] = None + result["xmind_error"] = str(exc) + # 更新 monitor manifest manifest = load_manifest_safe(base_name, "monitor") or {} manifest["export_completed"] = True manifest["export_timestamp"] = datetime.now(timezone.utc).isoformat() save_manifest(base_name, "monitor", manifest) - return {"export": "success", "base_name": base_name} + return result # ── Status ───────────────────────────────────────────────────────────────── @@ -1566,6 +1580,7 @@ def main() -> None: run_parser.add_argument("--requirement", required=True, help="需求文档路径") run_parser.add_argument("--zone", choices=ZONE_ORDER, help="仅运行到指定战区") run_parser.add_argument("--skip-export", action="store_true", help="跳过 Excel 导出") + run_parser.add_argument("--skip-xmind", action="store_true", help="跳过 XMind 导出") # prepare prepare_parser = subparsers.add_parser("prepare", help="仅准备战区") @@ -1584,8 +1599,9 @@ def main() -> None: review_parser.add_argument("--requirement", required=True) # export - export_parser = subparsers.add_parser("export", help="仅 Excel 导出") + export_parser = subparsers.add_parser("export", help="导出 Excel + XMind 思维导图") export_parser.add_argument("--requirement", required=True) + export_parser.add_argument("--skip-xmind", action="store_true", help="跳过 XMind 导出") # monitor monitor_parser = subparsers.add_parser("monitor", help="执行结果分析 + 知识沉淀") @@ -1631,7 +1647,7 @@ def main() -> None: return if args.command == "export": - _run_export(requirement_path) + _run_export(requirement_path, skip_xmind=getattr(args, "skip_xmind", False)) return # 确定要运行的战区 @@ -1685,8 +1701,8 @@ def main() -> None: if review_manifest: verdict = review_manifest.get("quality_verdict", {}).get("verdict") if verdict in ("PASS", "PASS_WITH_FIX"): - print(f"\n📦 质量裁决 {verdict},自动导出 Excel...") - _run_export(requirement_path) + print(f"\n📦 质量裁决 {verdict},自动导出 Excel + XMind...") + _run_export(requirement_path, skip_xmind=getattr(args, "skip_xmind", False)) else: print(f"\n🛑 质量裁决 {verdict},跳过导出。请先解决阻断项。") diff --git a/scripts/模板.xmind b/scripts/模板.xmind new file mode 100644 index 0000000..69cb409 Binary files /dev/null and b/scripts/模板.xmind differ