Files
xst d4c3d66efc 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 导出
2026-07-13 09:56:50 +08:00

272 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""将 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()