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('applyAutoComplexity:medium 占位 → 回填 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 { 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(); });