import type { AskEnd, AskStart, RunningAsk } from './asks' import type { Answer, AskRun } from './claudeAsk' import { describe, expect, it } from 'vitest' import { ClaudeCancelledError, ClaudeError } from '../../cc/spawnClaude' import { QaAsks } from './asks' /** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */ function fakeRunner(): { runner: (run: AskRun) => Promise, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } { const runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] = [] return { runs, runner: run => new Promise((resolve, reject) => { run.signal.addEventListener('abort', () => reject(new ClaudeCancelledError(''))) runs.push({ ...run, answer: resolve, fail: reject }) }), } } function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart { return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, images: [], cwd: '/work' } } } function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } { let done: () => void = () => {} const record = { updates: [] as RunningAsk[], ends: [] as [RunningAsk, AskEnd][], ended: new Promise((resolve) => { done = resolve }), update: (ask: RunningAsk) => record.updates.push(ask), end: (ask: RunningAsk, end: AskEnd) => { record.ends.push([ask, end]) done() }, } return record } describe('正在答的问题', () => { it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) const events = listener() expect(asks.start(start('1'), events)).toBeUndefined() expect(runs[0]).toMatchObject({ prompt: '完整的 1', cwd: '/work' }) runs[0].onEvent({ kind: 'session', sessionId: 's' }) runs[0].onEvent({ kind: 'activity', label: '读取 m.py' }) runs[0].onEvent({ kind: 'text', text: '答' }) expect(events.updates).toEqual([ { askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' }, { askId: '1', anchor: '1', question: '问 1', text: '答' }, ]) expect(asks.runningFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答' }]) runs[0].answer({ text: '答案', sessionId: 's' }) await events.ended expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案', sessionId: 's' } }]]) expect(asks.runningFor('file:///a.py')).toEqual([]) }) it('叫停:结束 claude,报 stopped,这块可以再问', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) const events = listener() asks.start(start('1'), events) asks.stop('1') await events.ended expect(runs[0].signal.aborted).toBe(true) expect(events.ends[0][1]).toEqual({ status: 'stopped' }) expect(asks.start(start('2', '1'), listener())).toBeUndefined() }) it('出错报 failed,带给人看的原因', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) const events = listener() asks.start(start('1'), events) runs[0].fail(new ClaudeError('Claude 退出码非零 (1): 网络断了', '')) await events.ended expect(events.ends[0][1]).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' }) }) it('同一块同时只答一个;别的文件里同样记号的块不算同一块', () => { const { runner } = fakeRunner() const asks = new QaAsks(runner) asks.start(start('1', 'function f'), listener()) expect(asks.start(start('2', 'function f'), listener())).toBe('这里已经在问了,等它答完或者先停下再问。') expect(asks.start(start('3', 'function f', 'file:///b.py'), listener())).toBeUndefined() expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。') }) it('不同块可以同时答,最多几个;答完一个又能问', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner, 2) const first = listener() asks.start(start('1'), first) asks.start(start('2'), listener()) expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。') runs[0].answer({ text: '答', sessionId: 's' }) await first.ended expect(asks.start(start('3'), listener())).toBeUndefined() }) it('全部叫停', async () => { const { runner, runs } = fakeRunner() const asks = new QaAsks(runner) const [a, b] = [listener(), listener()] asks.start(start('1'), a) asks.start(start('2'), b) asks.stopAll() await Promise.all([a.ended, b.ended]) expect(runs.every(run => run.signal.aborted)).toBe(true) }) })