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
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user