d4c3d66efc
- 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 导出
272 lines
10 KiB
Python
272 lines
10 KiB
Python
"""将 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()
|