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:
xst
2026-07-13 09:56:50 +08:00
parent acdb441507
commit d4c3d66efc
4 changed files with 295 additions and 8 deletions
+271
View File
@@ -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()