// tests/r3debug/docgenPoll.test.js —— #52 docgen 异步轮询状态机 + #28 检索文案 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; // ---- mock 依赖(chat.js 顶层 import 链) ---- vi.mock('../../src/api/sessions.js', () => ({ createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })), updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })), })); vi.mock('../../src/api/qa.js', () => ({ chatStream: vi.fn(async () => {}), search: vi.fn(async () => ({ answer: '' })), })); vi.mock('../../src/api/private.js', () => ({ askPrivate: vi.fn(async () => ({ hit: false })), })); import { useChatStore } from '../../src/stores/chat.js'; import { generate, getTask } from '../../src/api/docgen.js'; // docgen generate/getTask 的 mock 控制在各用例内(vi.mocked) vi.mock('../../src/api/docgen.js', () => ({ generate: vi.fn(), getTask: vi.fn(), getArtifactContent: vi.fn(async () => ({ markdown: '' })), getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })), saveArtifactContent: vi.fn(async () => ({})), editArtifact: vi.fn(async () => ({})), exportArtifact: vi.fn(async () => ({})), uploadLibrary: vi.fn(), listLibrary: vi.fn(async () => ({ items: [] })), removeLibrary: vi.fn(async () => ({ ok: true })), listTasks: vi.fn(async () => ({ tasks: [] })), })); import { useSessionsStore } from '../../src/stores/sessions.js'; function freshStores() { const pinia = createPinia(); setActivePinia(pinia); const chat = useChatStore(); const sessions = useSessionsStore(); // 预置会话,跳过 create 网络路径 chat.sessionId = 's1'; sessions.activeId = 's1'; sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }]; return { chat, sessions }; } describe('#52 docgen 异步轮询状态机', () => { beforeEach(() => { vi.useFakeTimers(); vi.mocked(generate).mockReset(); vi.mocked(getTask).mockReset(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('202 异步:释放 busy 锁、消息块记录 task_id、2s 轮询', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-1', status: 'queued' }); vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-1', status: 'running', progress: [], artifact: null, error: null } }); const p = chat.sendDocgen('写一份报告', []); await vi.advanceTimersByTimeAsync(0); await p; // 核心缺陷:生成期间可继续对话 → busy 为 false(streamCtx 已清) expect(chat.busy).toBe(false); const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg).toBeTruthy(); expect(aMsg.task_id).toBe('task-1'); expect(aMsg.blocks[0].task_id).toBe('task-1'); expect(aMsg.blocks[0].taskStatus).toBe('queued'); expect(aMsg.status).toBe('streaming'); // 2s 轮询:advance 2s → getTask 被调 await vi.advanceTimersByTimeAsync(2000); expect(getTask).toHaveBeenCalledWith('task-1'); }); it('running 进度驱动 pipeline(progress → 生成中 x/y 节)', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-2', status: 'queued' }); vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-2', status: 'running', progress: [ { section: 1, title: 'a', state: 'done' }, { section: 2, title: 'b', state: 'done' }, { section: 3, title: 'c', state: 'running' }, { section: 4, title: 'd', state: 'pending' }, ], artifact: null, error: null, }, }); const p = chat.sendDocgen('写一份方案', []); await vi.advanceTimersByTimeAsync(0); await p; await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); const pipe = aMsg.blocks[0]; expect(pipe.taskStatus).toBe('running'); const gen = pipe.steps.find((st) => st.phase === 'generation'); expect(gen.state).toBe('running'); expect(gen.desc).toContain('生成中 2/4'); }); it('done:挂 artifact 块 + 落库 + adoptArtifact + 切文档 TAB', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-3', status: 'queued' }); // r3-52-02 复现:先 running(generation desc 被「生成中 x/y 节」覆盖)→ 再 done vi.mocked(getTask) .mockResolvedValueOnce({ ok: true, task: { id: 'task-3', status: 'running', progress: [ { section: 1, title: 'a', state: 'done' }, { section: 2, title: 'b', state: 'running' }, ], artifact: null, error: null, }, }) .mockResolvedValue({ ok: true, task: { id: 'task-3', status: 'done', progress: [], artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' }, error: null, }, }); const p = chat.sendDocgen('写一份方案', []); await vi.advanceTimersByTimeAsync(0); await p; // 第一 tick:running,desc 被进度文案覆盖 await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); let aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg.blocks[0].steps.find((s) => s.phase === 'generation').desc).toContain('生成中 1/2'); // 第二 tick:done,desc 必须还原默认文案(r3-52-02) await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0); // adoptArtifact 内部异步拉取 aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg.status).toBe('done'); expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-1')).toBe(true); expect(aMsg.blocks[0].status).toBe('done'); expect(aMsg.blocks[0].steps.every((s) => s.state === 'done')).toBe(true); const genStep = aMsg.blocks[0].steps.find((s) => s.phase === 'generation'); expect(genStep.desc).toBe('按大纲逐节生成正文'); expect(genStep.desc).not.toContain('生成中'); // 轮询注册自清(终态后不再有定时器) const { useDocgenStore } = await import('../../src/stores/docgen.js'); expect(useDocgenStore().activeArtifact?.artifact_id).toBe('art-1'); // 切 TAB const { useBenchStore } = await import('../../src/stores/bench.js'); expect(useBenchStore().activeTab).toBe('doc'); }); it('error:标失败 + 可重试(retry 走 docgen 路径)', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-4', status: 'queued' }); vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-4', status: 'error', progress: [], artifact: null, error: '生成失败:资料解析错误' } }); const p = chat.sendDocgen('写一份报告', []); await vi.advanceTimersByTimeAsync(0); await p; await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(0); const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg.status).toBe('error'); expect(aMsg.errText).toContain('生成失败'); expect(aMsg.blocks[0].status).toBe('error'); expect(chat.lastResult?.error?.retryable).toBe(true); }); it('200 同步兼容路径:行为与修复前一致(立即 done + artifact + 切 TAB)', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, artifact: { artifact_id: 'art-sync', title: '同步文档', template_name: '通用文档', status: 'done' }, }); await chat.sendDocgen('写一份文档', []); const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen'); expect(aMsg.status).toBe('done'); expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-sync')).toBe(true); expect(getTask).not.toHaveBeenCalled(); }); it('#42:templateId 传入 payload', async () => { const { chat } = freshStores(); vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-5', status: 'queued' }); vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-5', status: 'queued', progress: [], artifact: null, error: null } }); const p = chat.sendDocgen('写一份公文', [], { templateId: 'official' }); await vi.advanceTimersByTimeAsync(0); await p; expect(generate).toHaveBeenCalledWith(expect.objectContaining({ template_id: 'official' })); }); it('会话恢复:running 消息块含 task_id → 恢复轮询', async () => { const { chat } = freshStores(); const sessions = useSessionsStore(); vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-6', status: 'running', progress: [], artifact: null, error: null } }); sessions.fetchDetail = vi.fn(async () => ({ messages: [ { id: 'u1', role: 'user', kind: 'docgen', status: 'done', text: '写一份报告' }, { id: 'a1', role: 'assistant', kind: 'docgen', status: 'streaming', blocks: [{ type: 'pipeline', title: 't', status: 'running', task_id: 'task-6', capabilities: [], steps: [] }], }, ], })); await chat.loadSession('s1'); expect(chat.messages.find((m) => m.id === 'a1').interrupted).toBeUndefined(); // 不误标「已中断」 await vi.advanceTimersByTimeAsync(2000); expect(getTask).toHaveBeenCalledWith('task-6'); }); }); describe('#28 检索空态/降级文案(_runSearch)', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('正常空结果(sources 空且无 answer)→ searchNoAnswer 文案,done 态非错误', async () => { const { chat } = freshStores(); const { search } = await import('../../src/api/qa.js'); vi.mocked(search).mockResolvedValue({ answer: '', sources: [], degraded: false }); await chat.send('请问 1234567890qwertyuiop 这种字符串与植物有什么关系', { mode: 'search' }); const aMsg = chat.messages.find((m) => m.role === 'assistant'); expect(aMsg.status).toBe('done'); expect(aMsg.degradedNote).toBe('没有检索到此问题的有效回答'); expect(aMsg.streamErr).toBeNull(); }); it('超时降级(degraded:true)→ searchTimeoutEmpty 专属文案(与空结果区分)', async () => { const { chat } = freshStores(); const { search } = await import('../../src/api/qa.js'); vi.mocked(search).mockResolvedValue({ mode: 'search', answer: '', sources: [], degraded: true, reason: 'TIMEOUT' }); await chat.send('触发超时的问题', { mode: 'search' }); const aMsg = chat.messages.find((m) => m.role === 'assistant'); expect(aMsg.status).toBe('done'); expect(aMsg.degradedNote).toBe('检索超时,未获取到有效回答(已降级返回)'); }); it('有 answer/sources 时不挂任何空态文案', async () => { const { chat } = freshStores(); const { search } = await import('../../src/api/qa.js'); vi.mocked(search).mockResolvedValue({ answer: '正常答案', sources: [{ paper_id: 'p1', title: 't' }], degraded: false }); await chat.send('正常问题', { mode: 'search' }); const aMsg = chat.messages.find((m) => m.role === 'assistant'); expect(aMsg.degradedNote).toBeUndefined(); expect(aMsg.status).toBe('done'); }); });