b2a035c4f9
- 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
360 lines
11 KiB
Python
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()
|