feat: 集成 XMind 思维导图导出到 QE Fleet 工作流
- fleet_runner.py: _run_export() 在 Excel 导出后自动生成 XMind - fleet_runner.py: 新增 --skip-xmind 参数(run/export 命令均支持) - export_XMind.py: 按模块分组 → 用例编号/标题/优先级/前置条件/步骤/预期/备注树形结构 - XMind 输出目录: output/xmind_reports/ 用法: python scripts/fleet_runner.py export --requirement <需求> # Excel + XMind python scripts/fleet_runner.py export --requirement <需求> --skip-xmind # 仅 Excel python scripts/export_XMind.py --base-name <名称> # 独立 XMind 导出
This commit is contained in:
@@ -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"<br\s*/?>", "\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()
|
||||
+24
-8
@@ -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},跳过导出。请先解决阻断项。")
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user