Files
maestro/test/metrics.test.ts
T
wangjia 3696604e13 feat: 指标统计(执行时长/成功率/复审通过率/重试率/额度估算)tsk_lDx6zd-EbA00
从 runs/tasks/events 聚合健康度与成本指标,看板新增指标小面板。

- Store.metrics(projectId?):执行时长(avg/p50/p95,未结束与负时长不计)、
  executor 成功率、复审/安全 verdict 通过率、failed→queued 重试率 +
  needs_attention、按模型额度粗估(时长×档位权重,标注估算)
- src/model/metrics.ts:类型 + 纯函数(percentile/ratio/estimateCostUnits/emptyMetrics)
- API:GET /api/metrics?project=
- web:归档区上方指标面板(数字卡 + 按模型分布),失败/空数据静默隐藏
- test/metrics.test.ts:9 个用例(纯函数/空数据/时长/成功率/复审/重试/按模型/项目过滤)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:55:56 +08:00

195 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store/index.js';
import {
percentile, ratio, estimateCostUnits, emptyMetrics, costPerMin, DEFAULT_COST_PER_MIN,
} from '../src/model/metrics.js';
import type { RunStatus } from '../src/model/types.js';
function fresh(): Store { return new Store(':memory:'); }
const BASE = 1_700_000_000_000; // 固定基准时刻,避免依赖真实时间
/** 造一条 executor run 并精确设置起止时间(durMs=null → 未结束)。 */
function mkRun(
s: Store, taskId: string,
opts: { status?: RunStatus; startMs?: number; durMs?: number | null } = {},
): string {
const status = opts.status ?? 'succeeded';
const r = s.startRun(taskId, 'executor');
if (status !== 'started') s.finishRun(r.id, status);
const started = new Date(BASE + (opts.startMs ?? 0)).toISOString();
const ended = opts.durMs == null ? null : new Date(BASE + (opts.startMs ?? 0) + opts.durMs).toISOString();
s.db.prepare(`UPDATE runs SET started_at = @s, ended_at = @e WHERE id = @id`)
.run({ s: started, e: ended, id: r.id });
return r.id;
}
// ──────────────── 纯函数 ────────────────
test('metrics 纯函数:percentile / ratio / estimateCostUnits', () => {
assert.equal(percentile([], 50), 0);
assert.equal(percentile([10], 95), 10);
assert.equal(percentile([10, 20, 30, 40], 50), 20); // ceil(0.5*4)=2 → idx 1
assert.equal(percentile([10, 20, 30, 40], 95), 40); // ceil(0.95*4)=4 → idx 3
assert.equal(ratio(1, 2), 0.5);
assert.equal(ratio(2, 3), 0.6667);
assert.equal(ratio(0, 0), null); // 无样本不除零
assert.equal(costPerMin('claude-fable-5'), 1.0);
assert.equal(costPerMin('未知模型'), DEFAULT_COST_PER_MIN);
assert.equal(estimateCostUnits(60_000, 'claude-fable-5'), 1.0); // 1 分钟 × 1.0
assert.equal(estimateCostUnits(120_000, 'claude-opus-4-8'), 1.2); // 2 分钟 × 0.6
});
// ──────────────── 空数据不崩 ────────────────
test('metrics 空数据:全 0 / null,不崩', () => {
const s = fresh();
const m = s.metrics();
assert.equal(m.projectId, null);
assert.equal(m.taskCount, 0);
assert.deepEqual(m.duration, { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 });
assert.equal(m.runs.successRate, null);
assert.equal(m.review.reviewRate, null);
assert.equal(m.review.securityRate, null);
assert.equal(m.retry.retryRate, null);
assert.equal(m.retry.needsAttention, 0);
assert.deepEqual(m.byModel, []);
assert.equal(m.estimated, true);
s.close();
});
test('metrics:不存在的项目 → emptyMetrics(带 projectId', () => {
const s = fresh();
const m = s.metrics('prj_does_not_exist');
assert.deepEqual(m, emptyMetrics('prj_does_not_exist'));
s.close();
});
// ──────────────── 执行时长 ────────────────
test('metrics 执行时长:avg/p50/p95;未结束与负时长不计入', () => {
const s = fresh();
const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 1000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 10_000, durMs: 2000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 20_000, durMs: 3000 });
mkRun(s, t.id, { status: 'started', startMs: 30_000, durMs: null }); // 未结束 → 不计
mkRun(s, t.id, { status: 'failed', startMs: 40_000, durMs: -500 }); // 负时长 → 不计
const m = s.metrics(p.id);
assert.equal(m.duration.count, 3);
assert.equal(m.duration.avgMs, 2000);
assert.equal(m.duration.p50Ms, 2000);
assert.equal(m.duration.p95Ms, 3000);
assert.equal(m.duration.maxMs, 3000);
s.close();
});
// ──────────────── 成功率 ────────────────
test('metrics 成功率:仅 succeeded/failed 计入,started/cancelled 不计', () => {
const s = fresh();
const p = s.createProject({ name: 's', repoPath: '/tmp/s-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, t.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, t.id, { status: 'failed', durMs: 1000 });
mkRun(s, t.id, { status: 'cancelled', durMs: 1000 });
mkRun(s, t.id, { status: 'started', durMs: null });
const m = s.metrics(p.id);
assert.equal(m.runs.total, 3);
assert.equal(m.runs.succeeded, 2);
assert.equal(m.runs.failed, 1);
assert.equal(m.runs.successRate, 0.6667);
s.close();
});
// ──────────────── 复审通过率 ────────────────
test('metrics 复审通过率:verdict / securityVerdict = approve 占比', () => {
const s = fresh();
const p = s.createProject({ name: 'r', repoPath: '/tmp/r-' + Math.random() });
const base = { branch: null, worktree: null, diffSummary: null, commits: [], prUrl: null, summary: null, securitySummary: null, mergeTaskId: null };
const t1 = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' });
const t2 = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' });
const t3 = s.createTask({ projectId: p.id, title: 'c', complexity: 'easy' });
s.setResult(t1.id, { ...base, verdict: 'approve', securityVerdict: 'approve' });
s.setResult(t2.id, { ...base, verdict: 'approve', securityVerdict: 'reject' });
s.setResult(t3.id, { ...base, verdict: 'reject', securityVerdict: null }); // 安全未审 → 不计
const m = s.metrics(p.id);
assert.equal(m.review.reviewTotal, 3);
assert.equal(m.review.reviewApprove, 2);
assert.equal(m.review.reviewRate, 0.6667);
assert.equal(m.review.securityTotal, 2);
assert.equal(m.review.securityApprove, 1);
assert.equal(m.review.securityRate, 0.5);
s.close();
});
// ──────────────── 重试率 / needs_attention ────────────────
test('metrics 重试率:failed→queued 次数 / 任务数;needs_attention 计数', () => {
const s = fresh();
const p = s.createProject({ name: 'rt', repoPath: '/tmp/rt-' + Math.random() });
// 任务 A:跑两轮失败重试(failed→queued ×2
const a = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' }); // ready
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'failed');
s.transition(a.id, 'queued'); // 重试 1
s.transition(a.id, 'executing');
s.transition(a.id, 'failed');
s.transition(a.id, 'queued'); // 重试 2
// 任务 B:失败后升级 needs_attention(不是 failed→queued,不计重试)
const b = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' });
s.transition(b.id, 'queued');
s.transition(b.id, 'executing');
s.transition(b.id, 'failed');
s.transition(b.id, 'needs_attention');
const m = s.metrics(p.id);
assert.equal(m.retry.retries, 2);
assert.equal(m.retry.taskCount, 2);
assert.equal(m.retry.retryRate, 1); // 2 / 2
assert.equal(m.retry.needsAttention, 1);
s.close();
});
// ──────────────── 按模型额度估算 ────────────────
test('metrics 按模型额度:时长×档位 粗估,estimated=true', () => {
const s = fresh();
const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random(), model: 'claude-opus-4-8' });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 60_000 });
mkRun(s, t.id, { status: 'succeeded', startMs: 100_000, durMs: 60_000 });
const m = s.metrics(p.id);
assert.equal(m.byModel.length, 1);
assert.deepEqual(m.byModel[0], {
model: 'claude-opus-4-8', runs: 2, durationMs: 120_000, estCostUnits: 1.2, estimated: true,
});
assert.equal(m.estimated, true);
s.close();
});
// ──────────────── 项目过滤 ────────────────
test('metrics 项目过滤:只统计指定项目', () => {
const s = fresh();
const pa = s.createProject({ name: 'A', repoPath: '/tmp/A-' + Math.random() });
const pb = s.createProject({ name: 'B', repoPath: '/tmp/B-' + Math.random() });
const ta = s.createTask({ projectId: pa.id, title: 'a', complexity: 'easy' });
const tb = s.createTask({ projectId: pb.id, title: 'b', complexity: 'easy' });
mkRun(s, ta.id, { status: 'succeeded', durMs: 1000 });
mkRun(s, tb.id, { status: 'failed', durMs: 1000 });
const ma = s.metrics(pa.id);
assert.equal(ma.taskCount, 1);
assert.equal(ma.runs.succeeded, 1);
assert.equal(ma.runs.failed, 0);
const all = s.metrics();
assert.equal(all.taskCount, 2);
assert.equal(all.runs.total, 2);
s.close();
});