Files
adminandClaude Fable 5 53069d4940 fix(ui): 复测缺陷 r3-07-01/r3-52-02 + b3 测试债
- 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>
2026-09-10 18:01:01 +08:00

265 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});
});