#!/usr/bin/env python3 """ 测试质量看板生成器 ===================== 从 collect-test-metrics.py 收集的历史指标数据生成可视化 HTML 看板。 看板包含: - 概览区:最新构建通过率、覆盖率、变异分数、E2E 通过率 - 趋势图:覆盖率随时间变化曲线(内联 SVG) - 模块健康度:各模块测试通过情况 - 构建历史:最近 10 次构建摘要 用法: python3 scripts/generate-dashboard.py [--history-dir ] [--output ] 依赖: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 '
数据不足,无法生成趋势图(需要至少 2 个数据点)
' # 提取数据序列 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 '
有效数据点不足 2 个
' 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 '
无有效数据
' 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'', f'', ] # 网格线 & 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'' ) svg_parts.append( f'' f"{val:.1f}%" ) # 绘制折线 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'' ) # 数据点 for p in points: cx, cy = p.split(",") svg_parts.append( f'' ) # 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'{label}' ) # 图例 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'' ) svg_parts.append( f'{label}' ) svg_parts.append("") 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'{fv:.1f}%' except (ValueError, TypeError): return f"{v}" build_rows += ( f"" f"{bn}" f"{ts}" f"{cell(be, 100)}" f"{cell(fe, 100)}" f"{cell(e2e, 100)}" f"{cell(jc, 55)}" f"{cell(pt, 45)}" f"\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"" f"{mod_name}" f"{total}" f"{killed}" f"{cell(mc, 45)}" f"\n" ) else: pit_rows = "无数据" html = f""" Gym Manage 测试质量看板

🧪 Gym Manage 测试质量看板

构建 #{build_number}
更新时间: {timestamp}
历史数据点: {len(history)}
后端指令覆盖率
{jacoco_instr if isinstance(jacoco_instr, str) else f'{jacoco_instr:.1f}%'}
阈值: 55%
后端分支覆盖率
{branch_cov if isinstance(branch_cov, str) else f'{branch_cov:.1f}%'}
阈值: 40%
后端单元测试通过率
{unit_pass if isinstance(unit_pass, str) else f'{unit_pass:.1f}%'}
共 {unit_total} 项
PIT 变异覆盖率
{pit_score if isinstance(pit_score, str) else f'{pit_score:.1f}%'}
阈值: 45%
前端语句覆盖率
{fe_cov if isinstance(fe_cov, str) else f'{fe_cov:.1f}%'}
阈值: 40%
前端单元测试通过率
{fe_ut_pass if isinstance(fe_ut_pass, str) else f'{fe_ut_pass:.1f}%'}
共 {fe_ut_total} 项
E2E 测试通过率
{e2e_pass if isinstance(e2e_pass, str) else f'{e2e_pass:.1f}%'}
共 {e2e_total} 项

📈 覆盖率趋势

{cov_trend_svg}

📈 通过率与变异分数趋势

{pit_trend_svg}

🧬 PIT 变异测试模块详情

{pit_rows}
模块 总变异数 已杀死 变异覆盖率

🏗️ 构建历史(最近 20 次)

{build_rows}
构建号 时间 后端通过率 前端通过率 E2E 通过率 指令覆盖率 PIT 分数
""" 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())