完成自动化测试套件实施(W1-W11)
W1-W3: 基线修复与测试基础设施搭建 - 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突 - 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL - 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures - 建立 docker-compose.test.yml 与测试数据隔离方案 W4-W5: 单元测试补齐(阶段 2) - 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试 - 补齐 gym-coach/manage-sys 模块单元测试 - 前端 utils/composables/stores 单元测试,37 文件 502 项测试 - JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过 W6-W7: 集成与契约测试(阶段 3) - Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过 - Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径 - 网关集成测试:JWT/RBAC/签名/限流/重试 - Flyway 迁移测试:验证迁移脚本可重复执行 - OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败 - 跨模块契约测试:会员-支付-团课数据一致性 W8-W9: E2E 与用户旅程测试(阶段 4) - 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置 - 小程序会员端核心页面 E2E:购卡/预约/签到 - 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败 W10: 变异测试与质量门禁(阶段 5) - 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45% - P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36% - 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83% - Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建 W11: 持续运行与改进(阶段 6) - 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器 - Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环 - 测试资产定期评审流程:月度/季度/事件驱动三级机制 - 快速参考指南 docs/testing/quick-reference.md - 累计 10 份测试文档,7 个里程碑全部达成
This commit was merged in pull request #54.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试质量看板生成器
|
||||
=====================
|
||||
从 collect-test-metrics.py 收集的历史指标数据生成可视化 HTML 看板。
|
||||
|
||||
看板包含:
|
||||
- 概览区:最新构建通过率、覆盖率、变异分数、E2E 通过率
|
||||
- 趋势图:覆盖率随时间变化曲线(内联 SVG)
|
||||
- 模块健康度:各模块测试通过情况
|
||||
- 构建历史:最近 10 次构建摘要
|
||||
|
||||
用法:
|
||||
python3 scripts/generate-dashboard.py [--history-dir <path>] [--output <path>]
|
||||
|
||||
依赖:Python 3.8+(标准库,无外部依赖)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="生成测试质量看板")
|
||||
parser.add_argument(
|
||||
"--history-dir",
|
||||
default="target/test-metrics/history",
|
||||
help="历史数据目录(默认: target/test-metrics/history)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="target/test-metrics/dashboard.html",
|
||||
help="输出 HTML 文件路径(默认: target/test-metrics/dashboard.html)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
default=".",
|
||||
help="项目根目录(默认: 当前目录)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_history(history_dir: str) -> List[Dict[str, Any]]:
|
||||
"""加载历史指标数据"""
|
||||
history_file = os.path.join(history_dir, "metrics-history.json")
|
||||
if not os.path.exists(history_file):
|
||||
print(f"⚠️ 历史数据文件不存在: {history_file}")
|
||||
return []
|
||||
try:
|
||||
with open(history_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"❌ 无法解析历史数据: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def safe_get(d: Dict[str, Any], *keys: str, default: Any = "N/A") -> Any:
|
||||
"""安全地从嵌套字典取值"""
|
||||
current = d
|
||||
for key in keys:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key, {})
|
||||
else:
|
||||
return default
|
||||
if isinstance(current, dict) and not current:
|
||||
return default
|
||||
return current if current is not None else default
|
||||
|
||||
|
||||
def generate_svg_trend(
|
||||
history: List[Dict[str, Any]],
|
||||
data_keys: List[str],
|
||||
labels: List[str],
|
||||
colors: List[str],
|
||||
width: int = 800,
|
||||
height: int = 300,
|
||||
) -> str:
|
||||
"""
|
||||
生成内联 SVG 趋势图。
|
||||
|
||||
使用纯 SVG 折线图,无需外部依赖。
|
||||
"""
|
||||
if len(history) < 2:
|
||||
return '<div class="no-data">数据不足,无法生成趋势图(需要至少 2 个数据点)</div>'
|
||||
|
||||
# 提取数据序列
|
||||
series: List[List[Optional[float]]] = [[] for _ in data_keys]
|
||||
for entry in history:
|
||||
for i, keys in enumerate(data_keys):
|
||||
val = safe_get(entry, *keys.split("."), default=None)
|
||||
try:
|
||||
series[i].append(float(val) if val is not None else None)
|
||||
except (ValueError, TypeError):
|
||||
series[i].append(None)
|
||||
|
||||
# 只保留至少有一个非空值的索引
|
||||
valid_indices = set(range(len(history)))
|
||||
for s in series:
|
||||
valid_indices = {i for i in valid_indices if i < len(s) and s[i] is not None}
|
||||
|
||||
if len(valid_indices) < 2:
|
||||
return '<div class="no-data">有效数据点不足 2 个</div>'
|
||||
|
||||
valid_indices = sorted(valid_indices)
|
||||
n = len(valid_indices)
|
||||
|
||||
# 计算全局范围
|
||||
all_values = []
|
||||
for s in series:
|
||||
for i in valid_indices:
|
||||
if i < len(s) and s[i] is not None:
|
||||
all_values.append(s[i])
|
||||
if not all_values:
|
||||
return '<div class="no-data">无有效数据</div>'
|
||||
|
||||
min_val = min(all_values)
|
||||
max_val = max(all_values)
|
||||
val_range = max_val - min_val if max_val != min_val else 10
|
||||
padding = val_range * 0.1
|
||||
min_val -= padding
|
||||
max_val += padding
|
||||
|
||||
# 图表区域
|
||||
margin = {"top": 20, "right": 20, "bottom": 40, "left": 60}
|
||||
plot_w = width - margin["left"] - margin["right"]
|
||||
plot_h = height - margin["top"] - margin["bottom"]
|
||||
|
||||
def x_pos(idx: int) -> float:
|
||||
return margin["left"] + (idx / (n - 1)) * plot_w if n > 1 else margin["left"] + plot_w / 2
|
||||
|
||||
def y_pos(val: float) -> float:
|
||||
return margin["top"] + plot_h - ((val - min_val) / val_range) * plot_h
|
||||
|
||||
# Y 轴刻度
|
||||
y_ticks = 5
|
||||
svg_parts = [
|
||||
f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" '
|
||||
f'style="font-family: \'SF Mono\', Monaco, Consolas, monospace; font-size: 11px;">',
|
||||
f'<rect width="{width}" height="{height}" fill="var(--bg-card)" rx="8"/>',
|
||||
]
|
||||
|
||||
# 网格线 & Y 轴标签
|
||||
for i in range(y_ticks + 1):
|
||||
y = margin["top"] + (plot_h / y_ticks) * i
|
||||
val = max_val - (val_range / y_ticks) * i
|
||||
svg_parts.append(
|
||||
f'<line x1="{margin["left"]}" y1="{y:.1f}" x2="{width - margin["right"]}" y2="{y:.1f}" '
|
||||
f'stroke="var(--border)" stroke-width="1" stroke-dasharray="4,4"/>'
|
||||
)
|
||||
svg_parts.append(
|
||||
f'<text x="{margin["left"] - 8}" y="{y:.1f + 4}" text-anchor="end" fill="var(--text-muted)">'
|
||||
f"{val:.1f}%</text>"
|
||||
)
|
||||
|
||||
# 绘制折线
|
||||
for s_idx, s in enumerate(series):
|
||||
points = []
|
||||
for i_idx, vi in enumerate(valid_indices):
|
||||
if vi < len(s) and s[vi] is not None:
|
||||
points.append(f"{x_pos(i_idx):.1f},{y_pos(s[vi]):.1f}")
|
||||
if len(points) >= 2:
|
||||
svg_parts.append(
|
||||
f'<polyline points="{" ".join(points)}" fill="none" '
|
||||
f'stroke="{colors[s_idx % len(colors)]}" stroke-width="2" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
)
|
||||
# 数据点
|
||||
for p in points:
|
||||
cx, cy = p.split(",")
|
||||
svg_parts.append(
|
||||
f'<circle cx="{cx}" cy="{cy}" r="3" fill="{colors[s_idx % len(colors)]}" '
|
||||
f'stroke="var(--bg-card)" stroke-width="1.5"/>'
|
||||
)
|
||||
|
||||
# X 轴标签(构建号或时间)
|
||||
x_step = max(1, n // 10)
|
||||
for i, vi in enumerate(valid_indices):
|
||||
if i % x_step == 0 or i == n - 1:
|
||||
label = safe_get(history[vi], "build_number", default=str(vi))
|
||||
if len(str(label)) > 8:
|
||||
label = str(label)[-8:]
|
||||
svg_parts.append(
|
||||
f'<text x="{x_pos(i):.1f}" y="{height - 10}" text-anchor="end" '
|
||||
f'transform="rotate(-30, {x_pos(i):.1f}, {height - 10})" fill="var(--text-muted)">{label}</text>'
|
||||
)
|
||||
|
||||
# 图例
|
||||
legend_x = margin["left"]
|
||||
legend_y = height - margin["bottom"] + 18
|
||||
for i, (label, color) in enumerate(zip(labels, colors)):
|
||||
lx = legend_x + i * 160
|
||||
svg_parts.append(
|
||||
f'<line x1="{lx}" y1="{legend_y}" x2="{lx + 12}" y2="{legend_y}" '
|
||||
f'stroke="{color}" stroke-width="2"/>'
|
||||
)
|
||||
svg_parts.append(
|
||||
f'<text x="{lx + 16}" y="{legend_y + 4}" fill="var(--text-muted)">{label}</text>'
|
||||
)
|
||||
|
||||
svg_parts.append("</svg>")
|
||||
return "\n".join(svg_parts)
|
||||
|
||||
|
||||
def generate_dashboard(history: List[Dict[str, Any]], output_path: str):
|
||||
"""生成 HTML 看板"""
|
||||
latest = history[-1] if history else {}
|
||||
|
||||
# 提取最新指标
|
||||
jacoco_instr = safe_get(latest, "backend", "jacoco", "instruction_coverage", default="N/A")
|
||||
branch_cov = safe_get(latest, "backend", "jacoco", "branch_coverage", default="N/A")
|
||||
unit_pass = safe_get(latest, "backend", "unit_tests", "pass_rate", default="N/A")
|
||||
unit_total = safe_get(latest, "backend", "unit_tests", "total", default="N/A")
|
||||
pit_score = safe_get(latest, "pit", "overall", "mutation_coverage", default="N/A")
|
||||
fe_ut_pass = safe_get(latest, "frontend", "unit_tests", "pass_rate", default="N/A")
|
||||
fe_ut_total = safe_get(latest, "frontend", "unit_tests", "total", default="N/A")
|
||||
fe_cov = safe_get(latest, "frontend", "coverage", "statements_coverage", default="N/A")
|
||||
e2e_pass = safe_get(latest, "e2e", "pass_rate", default="N/A")
|
||||
e2e_total = safe_get(latest, "e2e", "total", default="N/A")
|
||||
build_number = safe_get(latest, "build_number", default="N/A")
|
||||
timestamp = safe_get(latest, "timestamp", default="N/A")
|
||||
|
||||
# 生成趋势图
|
||||
cov_trend_svg = generate_svg_trend(
|
||||
history,
|
||||
["backend.jacoco.instruction_coverage", "backend.jacoco.branch_coverage", "frontend.coverage.statements_coverage"],
|
||||
["指令覆盖率", "分支覆盖率", "前端语句覆盖率"],
|
||||
["#4CAF50", "#2196F3", "#FF9800"],
|
||||
)
|
||||
pit_trend_svg = generate_svg_trend(
|
||||
history,
|
||||
["pit.overall.mutation_coverage", "backend.unit_tests.pass_rate", "e2e.pass_rate"],
|
||||
["PIT 变异覆盖率", "后端通过率", "E2E 通过率"],
|
||||
["#9C27B0", "#4CAF50", "#F44336"],
|
||||
)
|
||||
|
||||
# 构建历史表
|
||||
build_rows = ""
|
||||
for entry in reversed(history[-20:]):
|
||||
bn = safe_get(entry, "build_number", default="N/A")
|
||||
ts = safe_get(entry, "timestamp", default="")
|
||||
if ts != "N/A":
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%m-%d %H:%M")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
be = safe_get(entry, "backend", "unit_tests", "pass_rate", default="-")
|
||||
fe = safe_get(entry, "frontend", "unit_tests", "pass_rate", default="-")
|
||||
e2e = safe_get(entry, "e2e", "pass_rate", default="-")
|
||||
jc = safe_get(entry, "backend", "jacoco", "instruction_coverage", default="-")
|
||||
pt = safe_get(entry, "pit", "overall", "mutation_coverage", default="-")
|
||||
|
||||
def cell(v, good_threshold=100):
|
||||
try:
|
||||
fv = float(v)
|
||||
cls = "cell-good" if fv >= good_threshold else ("cell-warn" if fv >= 50 else "cell-bad")
|
||||
return f'<td class="{cls}">{fv:.1f}%</td>'
|
||||
except (ValueError, TypeError):
|
||||
return f"<td>{v}</td>"
|
||||
|
||||
build_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{bn}</td>"
|
||||
f"<td>{ts}</td>"
|
||||
f"{cell(be, 100)}"
|
||||
f"{cell(fe, 100)}"
|
||||
f"{cell(e2e, 100)}"
|
||||
f"{cell(jc, 55)}"
|
||||
f"{cell(pt, 45)}"
|
||||
f"</tr>\n"
|
||||
)
|
||||
|
||||
# PIT 模块详情
|
||||
pit_modules = safe_get(latest, "pit", "modules", default={})
|
||||
if isinstance(pit_modules, dict):
|
||||
pit_rows = ""
|
||||
for mod_name, mod_data in pit_modules.items():
|
||||
mc = safe_get(mod_data, "mutation_coverage", default="N/A")
|
||||
total = safe_get(mod_data, "total_mutations", default="N/A")
|
||||
killed = safe_get(mod_data, "killed_mutations", default="N/A")
|
||||
pit_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{mod_name}</td>"
|
||||
f"<td>{total}</td>"
|
||||
f"<td>{killed}</td>"
|
||||
f"{cell(mc, 45)}"
|
||||
f"</tr>\n"
|
||||
)
|
||||
else:
|
||||
pit_rows = "<tr><td colspan='4'>无数据</td></tr>"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gym Manage 测试质量看板</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--primary: #4f46e5;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}}
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 24px;
|
||||
}}
|
||||
.header {{
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 24px; padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px; color: white;
|
||||
}}
|
||||
.header h1 {{ font-size: 24px; font-weight: 600; }}
|
||||
.header .meta {{ font-size: 13px; opacity: 0.9; text-align: right; }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }}
|
||||
.card {{
|
||||
background: var(--bg-card); border-radius: 10px; padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: 1px solid var(--border);
|
||||
}}
|
||||
.card .label {{ font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
|
||||
.card .value {{ font-size: 28px; font-weight: 700; }}
|
||||
.card .value.good {{ color: var(--success); }}
|
||||
.card .value.warn {{ color: var(--warning); }}
|
||||
.card .value.bad {{ color: var(--danger); }}
|
||||
.card .sub {{ font-size: 12px; color: var(--text-muted); margin-top: 4px; }}
|
||||
.section {{ margin-bottom: 24px; }}
|
||||
.section h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--text); }}
|
||||
table {{
|
||||
width: 100%; border-collapse: collapse; background: var(--bg-card);
|
||||
border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
border: 1px solid var(--border);
|
||||
}}
|
||||
th, td {{ padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }}
|
||||
th {{ background: #f9fafb; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px; }}
|
||||
tr:last-child td {{ border-bottom: none; }}
|
||||
.cell-good {{ color: var(--success); font-weight: 600; }}
|
||||
.cell-warn {{ color: var(--warning); font-weight: 600; }}
|
||||
.cell-bad {{ color: var(--danger); font-weight: 600; }}
|
||||
.no-data {{ padding: 40px; text-align: center; color: var(--text-muted); }}
|
||||
.trend-container {{ overflow-x: auto; }}
|
||||
@media (max-width: 768px) {{
|
||||
body {{ padding: 12px; }}
|
||||
.grid {{ grid-template-columns: repeat(2, 1fr); }}
|
||||
.header {{ flex-direction: column; text-align: center; }}
|
||||
.header .meta {{ text-align: center; margin-top: 8px; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🧪 Gym Manage 测试质量看板</h1>
|
||||
<div style="font-size: 13px; opacity: 0.85; margin-top: 4px;">构建 #{build_number}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>更新时间: {timestamp}</div>
|
||||
<div>历史数据点: {len(history)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">后端指令覆盖率</div>
|
||||
<div class="value {'good' if isinstance(jacoco_instr, (int, float)) and jacoco_instr >= 55 else 'warn' if isinstance(jacoco_instr, (int, float)) else ''}">{jacoco_instr if isinstance(jacoco_instr, str) else f'{jacoco_instr:.1f}%'}</div>
|
||||
<div class="sub">阈值: 55%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">后端分支覆盖率</div>
|
||||
<div class="value {'good' if isinstance(branch_cov, (int, float)) and branch_cov >= 40 else 'warn' if isinstance(branch_cov, (int, float)) else ''}">{branch_cov if isinstance(branch_cov, str) else f'{branch_cov:.1f}%'}</div>
|
||||
<div class="sub">阈值: 40%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">后端单元测试通过率</div>
|
||||
<div class="value {'good' if isinstance(unit_pass, (int, float)) and unit_pass >= 100 else 'warn' if isinstance(unit_pass, (int, float)) else ''}">{unit_pass if isinstance(unit_pass, str) else f'{unit_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {unit_total} 项</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">PIT 变异覆盖率</div>
|
||||
<div class="value {'good' if isinstance(pit_score, (int, float)) and pit_score >= 45 else 'warn' if isinstance(pit_score, (int, float)) else ''}">{pit_score if isinstance(pit_score, str) else f'{pit_score:.1f}%'}</div>
|
||||
<div class="sub">阈值: 45%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">前端语句覆盖率</div>
|
||||
<div class="value">{fe_cov if isinstance(fe_cov, str) else f'{fe_cov:.1f}%'}</div>
|
||||
<div class="sub">阈值: 40%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">前端单元测试通过率</div>
|
||||
<div class="value {'good' if isinstance(fe_ut_pass, (int, float)) and fe_ut_pass >= 100 else 'warn' if isinstance(fe_ut_pass, (int, float)) else ''}">{fe_ut_pass if isinstance(fe_ut_pass, str) else f'{fe_ut_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {fe_ut_total} 项</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">E2E 测试通过率</div>
|
||||
<div class="value {'good' if isinstance(e2e_pass, (int, float)) and e2e_pass >= 100 else 'bad' if isinstance(e2e_pass, (int, float)) else ''}">{e2e_pass if isinstance(e2e_pass, str) else f'{e2e_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {e2e_total} 项</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 覆盖率趋势</h2>
|
||||
<div class="card trend-container">
|
||||
{cov_trend_svg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 通过率与变异分数趋势</h2>
|
||||
<div class="card trend-container">
|
||||
{pit_trend_svg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🧬 PIT 变异测试模块详情</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模块</th>
|
||||
<th>总变异数</th>
|
||||
<th>已杀死</th>
|
||||
<th>变异覆盖率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pit_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🏗️ 构建历史(最近 20 次)</h2>
|
||||
<div style="overflow-x: auto;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>构建号</th>
|
||||
<th>时间</th>
|
||||
<th>后端通过率</th>
|
||||
<th>前端通过率</th>
|
||||
<th>E2E 通过率</th>
|
||||
<th>指令覆盖率</th>
|
||||
<th>PIT 分数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{build_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✅ 看板已生成: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
project_dir = os.path.abspath(args.project_dir)
|
||||
history_dir = os.path.join(project_dir, args.history_dir)
|
||||
output_path = os.path.join(project_dir, args.output)
|
||||
|
||||
print("📊 生成测试质量看板...")
|
||||
history = load_history(history_dir)
|
||||
if not history:
|
||||
print("⚠️ 无历史数据,生成空看板")
|
||||
# 生成一个初始数据点,避免空看板
|
||||
history = [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"build_number": "initial",
|
||||
"backend": {},
|
||||
"frontend": {},
|
||||
"e2e": {},
|
||||
"pit": {},
|
||||
}]
|
||||
|
||||
generate_dashboard(history, output_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user