Files
maestro/test/metrics.test.ts
wangjia a5a3c967ce fix: 全量替换 claude-fable-5 → claude-opus-4-8(模型不可用)
- models.ts: 所有档位默认值、回退链、conflict 固定值全部换为 opus-4-8
- 回退链从三项缩为两项 [opus-4-8, sonnet-4-6](删除重复项)
- metrics.ts: 额度权重 opus-4-8 升为 1.0(现为最高档)
- 测试同步更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 15:14:09 +08:00

195 lines
8.9 KiB
TypeScript
Raw Permalink 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-opus-4-8'), 1.0);
assert.equal(costPerMin('未知模型'), DEFAULT_COST_PER_MIN);
assert.equal(estimateCostUnits(60_000, 'claude-opus-4-8'), 1.0); // 1 分钟 × 1.0
assert.equal(estimateCostUnits(120_000, 'claude-sonnet-4-6'), 0.4); // 2 分钟 × 0.2
});
// ──────────────── 空数据不崩 ────────────────
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: 2.0, 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();
});