import argparse from datetime import datetime from pathlib import Path import re from openpyxl import Workbook from openpyxl.styles import Alignment, Font REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_INPUT_DIR = REPO_ROOT / "output" / "test_cases" DEFAULT_OUTPUT_DIR = REPO_ROOT / "output" / "excel_reports" REQUIRED_COLUMNS = [ "用例编号", "模块", "用例标题", "优先级", "类型", "前置条件", "测试步骤", "测试数据", "预期结果", "备注", ] LEGACY_COLUMNS = [ "用例编号", "模块", "用例标题", "优先级", "前置条件", "测试步骤", "测试数据", "预期结果", "备注", ] SUPPORTED_COLUMN_SETS = [REQUIRED_COLUMNS, LEGACY_COLUMNS] YUNXIAO_HEADERS = [ "标题", "编号", "目录", "创建时间", "前置条件", "步骤描述", "预期结果", "优先级", "类型", "URL", ] TYPE_ENUMS = [ "功能测试", "性能测试", "兼容性测试", "易用性测试", "安全性测试", "冒烟测试", "回归测试", "其他", ] SEPARATOR_RE = re.compile(r"^\|\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$") MODULE_PATH_SEPARATOR_RE = re.compile(r"\s*[--—–]\s*") BR_TAG_RE = re.compile(r"", re.IGNORECASE) def headers_supported(headers: list[str]) -> bool: return headers in SUPPORTED_COLUMN_SETS def find_latest_markdown_file(directory: Path) -> Path | None: files = sorted(directory.glob("*.md"), key=lambda item: item.stat().st_mtime) return files[-1] if files else None 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 normalize_module_path(value: str) -> str: stripped = value.strip() if not stripped: return stripped if "|" in stripped: parts = [part.strip() for part in stripped.split("|") if part.strip()] return "|".join(parts) if MODULE_PATH_SEPARATOR_RE.search(stripped): parts = [part.strip() for part in MODULE_PATH_SEPARATOR_RE.split(stripped) if part.strip()] if len(parts) > 1: return "|".join(parts) return stripped def load_markdown_table(source_file: Path) -> tuple[list[str], list[list[str]]]: content = source_file.read_text(encoding="utf-8") table_lines: list[str] = [] for line in content.splitlines(): if line.strip().startswith("|"): table_lines.append(line.rstrip()) elif table_lines: break if len(table_lines) < 3: raise ValueError(f"未在 {source_file} 中找到有效的 Markdown 表格。") headers = split_markdown_row(table_lines[0]) if not SEPARATOR_RE.match(table_lines[1].strip()): raise ValueError(f"{source_file} 的第二行不是合法的 Markdown 表头分隔线。") module_index = headers.index("模块") if "模块" in headers else None rows: list[list[str]] = [] for line in table_lines[2:]: row = split_markdown_row(line) if len(row) != len(headers): raise ValueError( f"{source_file} 中存在列数不一致的行,表头 {len(headers)} 列,实际 {len(row)} 列。" ) if module_index is not None: row[module_index] = normalize_module_path(row[module_index]) rows.append(row) if not rows: raise ValueError(f"{source_file} 的 Markdown 表格没有数据行。") return headers, rows def infer_case_type(case: dict[str, str]) -> str: explicit = case.get("类型", "").strip() if explicit in TYPE_ENUMS: return explicit combined = " ".join( [ case.get("用例标题", ""), case.get("模块", ""), case.get("备注", ""), case.get("测试步骤", ""), case.get("预期结果", ""), ] ) if re.search(r"性能|RT|响应时间|P95|吞吐|压测|并发量", combined, re.IGNORECASE): return "性能测试" if re.search(r"越权|鉴权|权限|抓包|篡改|注入|XSS|CSRF|SQL", combined, re.IGNORECASE): return "安全性测试" if re.search(r"兼容|浏览器|小程序|H5|多端", combined, re.IGNORECASE): return "兼容性测试" if re.search(r"易用|交互|文案|可读|展示", combined, re.IGNORECASE): return "易用性测试" if re.search(r"冒烟|主流程", combined, re.IGNORECASE): return "冒烟测试" if re.search(r"历史缺陷|回归|AI修正", combined, re.IGNORECASE): return "回归测试" return "功能测试" def normalize_multiline_text(value: str) -> str: text = BR_TAG_RE.sub("\n", value or "") return text.replace("\\n", "\n").strip() def normalize_topic_name(text: str) -> str: normalized = text.strip() changed = True while changed: changed = False for token in ("测试用例", "需求", "分析"): if normalized.endswith(token): normalized = normalized[: -len(token)].strip() changed = True normalized = re.sub(r"[_\-\s|]+$", "", normalized) return normalized or text.strip() def build_directory_name(source_path: Path, module_value: str) -> str: topic = normalize_topic_name(source_path.stem) module = normalize_module_path(module_value) if not module: return topic if module.startswith(topic): return module return f"{topic}|{module}" def normalize_case_rows(headers: list[str], rows: list[list[str]]) -> list[dict[str, str]]: cases: list[dict[str, str]] = [] for row in rows: current = {header: (row[index] or "").strip() for index, header in enumerate(headers)} if "类型" not in current: current["类型"] = infer_case_type(current) elif current["类型"] not in TYPE_ENUMS: current["类型"] = infer_case_type(current) cases.append(current) return cases def build_yunxiao_rows(source_path: Path, headers: list[str], rows: list[list[str]]) -> list[list[object]]: created_time = datetime.fromtimestamp(source_path.stat().st_mtime).replace(microsecond=0) cases = normalize_case_rows(headers, rows) output_rows: list[list[object]] = [] for case in cases: steps_text = normalize_multiline_text(case.get("测试步骤", "")) data_text = normalize_multiline_text(case.get("测试数据", "")) if data_text: steps_text = f"{steps_text}\n[测试数据]\n{data_text}" if steps_text else f"[测试数据]\n{data_text}" output_rows.append( [ case.get("用例标题", ""), case.get("用例编号", ""), build_directory_name(source_path, case.get("模块", "")), created_time, normalize_multiline_text(case.get("前置条件", "")), steps_text, normalize_multiline_text(case.get("预期结果", "")), case.get("优先级", ""), infer_case_type(case), "", ] ) return output_rows 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" latest = find_latest_markdown_file(input_dir) if latest is None: raise FileNotFoundError(f"在 {input_dir} 下没有找到任何 Markdown 测试用例文件。") return latest def build_current_excel_path(source_path: Path, output_dir: Path) -> Path: return output_dir / f"{source_path.stem}.xlsx" def export_markdown_to_excel( source_path: Path, output_dir: Path, output_path: Path | None = None, ) -> Path: headers, rows = load_markdown_table(source_path) if not headers_supported(headers): raise ValueError( "测试用例表头不符合支持格式。\n" f"支持: {REQUIRED_COLUMNS} 或 {LEGACY_COLUMNS}\n" f"实际: {headers}" ) output_dir.mkdir(parents=True, exist_ok=True) if output_path is None: output_path = build_current_excel_path(source_path, output_dir) workbook = Workbook() worksheet = workbook.active worksheet.title = "testcase items" worksheet.append(YUNXIAO_HEADERS) for row in build_yunxiao_rows(source_path, headers, rows): worksheet.append(row) header_font = Font(bold=True) wrap_alignment = Alignment(vertical="top", wrap_text=True) for cell in worksheet[1]: cell.font = header_font cell.alignment = wrap_alignment for row in worksheet.iter_rows(min_row=2): for cell in row: cell.alignment = wrap_alignment worksheet.freeze_panes = "A2" worksheet.auto_filter.ref = f"A1:J{worksheet.max_row}" for column_cells in worksheet.columns: max_length = max(len(str(cell.value or "")) for cell in column_cells) adjusted_width = min(max_length + 4, 60) worksheet.column_dimensions[column_cells[0].column_letter].width = adjusted_width workbook.save(output_path) return output_path def main() -> None: parser = argparse.ArgumentParser(description="将 Markdown 测试用例表格导出为 Excel。") parser.add_argument("--source-file", help="指定测试用例 Markdown 文件路径。") parser.add_argument("--base-name", help="按 BASE_NAME 查找 output/test_cases/{BASE_NAME}_测试用例.md。") parser.add_argument( "--input-dir", default=str(DEFAULT_INPUT_DIR), help="测试用例 Markdown 所在目录,默认 output/test_cases。", ) parser.add_argument( "--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="Excel 输出目录,默认 output/excel_reports。", ) args = parser.parse_args() 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_excel(source_path, output_dir) headers, rows = load_markdown_table(source_path) print(f"📄 来源文件: {source_path}") print(f"🧩 Markdown 表头字段: {', '.join(headers)}") print(f"📊 用例数量: {len(rows)}") print(f"✅ Excel 已生成: {output_path}") if __name__ == "__main__": main()