merge: maestro/tsk_zacmwH3chZpG [tsk_zacmwH3chZpG]

This commit is contained in:
maestro
2026-06-13 11:35:53 +08:00
7 changed files with 348 additions and 4 deletions
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildClassifyPrompt, parseClassification, type ClassifierFn } from '../src/executor/classify.js';
import { Store } from '../src/store/index.js';
import { buildServer } from '../src/api/server.js';
import type { Event } from '../src/model/types.js';
function freshStore(): Store {
return new Store(':memory:');
}
// ---------- parseClassification(映射 + 兜底) ----------
test('parseClassification:三档正常映射 + 取理由', () => {
for (const c of ['hard', 'medium', 'easy'] as const) {
const r = parseClassification(`COMPLEXITY: ${c}\nREASON: 因为如此`);
assert.equal(r.complexity, c);
assert.equal(r.reason, '因为如此');
}
});
test('parseClassification:大小写/空白宽容', () => {
const r = parseClassification(' complexity: HARD \nreason: 跨模块 ');
assert.equal(r.complexity, 'hard');
assert.equal(r.reason, '跨模块');
});
test('parseClassification:多个 COMPLEXITY 取最后一个', () => {
const r = parseClassification('COMPLEXITY: easy\n改判\nCOMPLEXITY: hard\nREASON: 复查后升级');
assert.equal(r.complexity, 'hard');
});
test('parseClassification:噪声/非法 → 兜底 medium', () => {
for (const noise of ['', '我也不知道', 'COMPLEXITY: trivial\nREASON: 乱写', '随便聊聊']) {
const r = parseClassification(noise);
assert.equal(r.complexity, 'medium', `输入「${noise}」应兜底 medium`);
assert.ok(r.reason.length > 0);
}
});
test('parseClassification:有档无理由 → 给占位理由', () => {
const r = parseClassification('COMPLEXITY: easy');
assert.equal(r.complexity, 'easy');
assert.ok(r.reason.includes('未给出'));
});
// ---------- buildClassifyPrompt ----------
test('buildClassifyPrompt:含三档判据 + 标题/说明 + 输出格式', () => {
const p = buildClassifyPrompt('改个文案', '把按钮文字改一下');
assert.match(p, /easy/);
assert.match(p, /medium/);
assert.match(p, /hard/);
assert.match(p, /改个文案/);
assert.match(p, /把按钮文字改一下/);
assert.match(p, /COMPLEXITY:/);
assert.match(p, /REASON:/);
});
test('buildClassifyPrompt:无说明时占位', () => {
const p = buildClassifyPrompt('只有标题');
assert.match(p, /说明:(无)/);
});
// ---------- Store.applyAutoComplexity(回填) ----------
test('applyAutoComplexitymedium 占位 → 回填 hard,状态重置 analyzing + 理由入 plan', () => {
const s = freshStore();
const events: Event[] = [];
s.subscribe((e) => events.push(e));
const p = s.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'medium' }); // speccing
assert.equal(t.status, 'speccing');
const u = s.applyAutoComplexity(t.id, 'hard', '需要跨模块拆解');
assert.equal(u.complexity, 'hard');
assert.equal(u.status, 'analyzing');
assert.match(u.plan ?? '', /需要跨模块拆解/);
// 广播了带理由的 task.updated + status.changed
const upd = events.filter((e) => e.type === 'task.updated' && e.payload.auto === 'classify').at(-1);
assert.equal(upd?.payload.complexity, 'hard');
assert.equal(upd?.payload.reason, '需要跨模块拆解');
assert.ok(events.some((e) => e.type === 'status.changed' && e.payload.to === 'analyzing'));
s.close();
});
test('applyAutoComplexity:回填 easy → 状态重置 ready + 理由入 operations', () => {
const s = freshStore();
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'medium' });
const u = s.applyAutoComplexity(t.id, 'easy', '单文件机械改动');
assert.equal(u.complexity, 'easy');
assert.equal(u.status, 'ready');
assert.match(u.operations ?? '', /单文件机械改动/);
s.close();
});
test('applyAutoComplexity:判定仍是 medium → 不变状态但记录理由 + 广播', () => {
const s = freshStore();
const events: Event[] = [];
s.subscribe((e) => events.push(e));
const p = s.createProject({ name: 'c', repoPath: '/tmp/c-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'z', complexity: 'medium' });
const u = s.applyAutoComplexity(t.id, 'medium', '范围清晰、跨几处');
assert.equal(u.complexity, 'medium');
assert.equal(u.status, 'speccing'); // 未变
assert.match(u.spec ?? '', /范围清晰/);
assert.ok(events.some((e) => e.type === 'task.updated' && e.payload.auto === 'classify'));
// medium===medium,不应产生 status.changed
assert.ok(!events.some((e) => e.type === 'status.changed'));
s.close();
});
// ---------- 建任务集成(complexity='auto' ----------
/** 等待一次满足条件的事件(带超时,避免测试挂死) */
function waitFor(store: Store, pred: (e: Event) => boolean, ms = 2000): Promise<Event> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { off(); reject(new Error('等待事件超时')); }, ms);
const off = store.subscribe((e) => {
if (pred(e)) { clearTimeout(timer); off(); resolve(e); }
});
});
}
test('POST tasks complexity=auto:先 medium 落库,模型判定后异步回填 hard', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv', repoPath: '/tmp/srv-' + Math.random() });
const classify: ClassifierFn = async (title) => {
assert.equal(title, '拆个大功能');
return { complexity: 'hard', reason: '跨子系统、有未知项' };
};
const app = buildServer({ store: s, classify });
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: '拆个大功能', complexity: 'auto' },
});
assert.equal(res.statusCode, 200);
const created = res.json();
assert.equal(created.complexity, 'medium'); // 占位
assert.equal(created.status, 'speccing');
await backfilled;
const after = s.getTask(created.id);
assert.equal(after?.complexity, 'hard'); // 已回填
assert.equal(after?.status, 'analyzing');
assert.match(after?.plan ?? '', /跨子系统/);
await app.close();
s.close();
});
test('POST tasks complexity=auto:模型不可用(兜底 medium)仍建成功且为 medium', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv2', repoPath: '/tmp/srv2-' + Math.random() });
// 模拟 classifyComplexity 内部兜底:返回 medium
const classify: ClassifierFn = async () => ({ complexity: 'medium', reason: '模型不可用,兜底 medium' });
const app = buildServer({ store: s, classify });
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: '不确定的任务', complexity: 'auto' },
});
assert.equal(res.statusCode, 200);
assert.equal(res.json().complexity, 'medium');
await backfilled;
const after = s.getTask(res.json().id);
assert.equal(after?.complexity, 'medium');
assert.match(after?.spec ?? '', /兜底 medium/);
await app.close();
s.close();
});
test('POST tasks:非法 complexity(非 auto/三档)被拒 400', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv3', repoPath: '/tmp/srv3-' + Math.random() });
const app = buildServer({ store: s, classify: async () => ({ complexity: 'medium', reason: '' }) });
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: 't', complexity: 'trivial' },
});
assert.equal(res.statusCode, 400);
await app.close();
s.close();
});