- r3-07-01 「跳过」无效:补齐事件转发链 MessageList(新增 skip-reveal emit)→ ChatPanel(@skip-reveal 监听)→ store.skipReveal(); 新增 skipReveal.test.js 三层断言(点击定格终态/组件转发/ChatPanel 监听) - r3-52-02 终态文案残留:docgen 轮询 tick 的 done 分支把 generation 步 desc 还原 DOCGEN_STEPS 默认文案「按大纲逐节生成正文」;docgenPoll done 用例改两阶段 mock(running 进度文案 → done 还原)复现原缺陷 - 测试债:b3 bench.store.test.js 删除已下线 sessionSourceItems 引用, 按 #27 修复后语义改断言(后端去重结果原样透出) 自测:r3debug 32 项 + 全套 373 项 + private_docs 23 项全绿; playwright 实测(mock SSE/docgen 202):跳过按钮点击后全文定格、 skip-btn/cursor 消失;docgen done 后第 4 步 desc 还原默认文案;无 JS 错误。 未 push。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
// 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');
|
||
});
|
||
});
|