Files
QaAutomationHub/scripts/export_excel.py
T
xst b2a035c4f9 feat: Agentic QE Fleet v2.0.0 - 14-agent quality engineering platform
- 14 specialized AI agents across 5 battle zones (Prepare/Analyze/Design/Review/Monitor)
- New: risk-assessor, test-strategist, data-builder, coverage-auditor, quality-gatekeeper, execution-analyst, knowledge-curator
- New: fleet_runner.py orchestrator with multi-zone manifest pipeline
- New: fleet_config.yml for centralized configuration
- New: knowledge activation system (keyword + semantic matching)
- New: semantic conflict detection with severity grading (P0-P3)
- New: three-tier quality gate (PASS/PASS_WITH_FIX/BLOCKED)
- New: monitor zone for test execution analysis and auto knowledge curation
- Backward compatible: /case_generate alias, case_pipeline.py preserved
- Comprehensive docs: USER_GUIDE.md + MAINTENANCE_GUIDE.md
2026-07-09 14:29:11 +08:00

360 lines
11 KiB
Python

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"<br\s*/?>", 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()