// tests/r3debug/bioflowPathConfirm.test.js —— 生信路径卡确认创建后 UI 断链修复 // 缺陷:FlowPathCard.confirmCreate 创建任务成功后 emit('updated') 无人监听, // UI 无进度卡/无事件订阅/不落库(刷新即丢)。修复后走 store 一站式 confirmBioflowPath。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; 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 })), })); 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: [] })), })); const connectJobEventsSpy = vi.fn(); const createJobFromPlanSpy = vi.fn(); vi.mock('../../src/stores/bioflow.js', async () => { const actual = await vi.importActual('../../src/stores/bioflow.js'); const { useBioflowStore: useReal } = actual; return { ...actual, useBioflowStore: () => { const st = useReal(); st.connectJobEvents = connectJobEventsSpy; st.createJobFromPlan = createJobFromPlanSpy; return st; }, }; }); import { useChatStore } from '../../src/stores/chat.js'; import { useSessionsStore } from '../../src/stores/sessions.js'; import { useBioflowStore } from '../../src/stores/bioflow.js'; import { updateMessage } from '../../src/api/sessions.js'; function freshStores() { const pinia = createPinia(); setActivePinia(pinia); const chat = useChatStore(); const sessions = useSessionsStore(); chat.sessionId = 's1'; sessions.activeId = 's1'; sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }]; return { chat, sessions }; } describe('confirmBioflowPath 一站式任务创建', () => { beforeEach(() => { connectJobEventsSpy.mockReset(); createJobFromPlanSpy.mockReset(); vi.mocked(updateMessage).mockClear(); }); it('创建成功 → 挂 job_created 进度块 + 订阅任务事件 + 落库', async () => { const { chat } = freshStores(); createJobFromPlanSpy.mockResolvedValue({ id: 'job-1', title: '生信任务', status: 'created', stages: [ { stage: 'genome_prepare', status: 'waiting' }, { stage: 'identify', status: 'waiting' }, ], }); const aMsg = { id: 'am1', role: 'assistant', status: 'done', blocks: [ { type: 'bioflow_path', status: 'done', candidates: [{ stages: ['genome_prepare', 'identify'] }], selected: 0 }, ], raw: {}, }; chat.messages = [aMsg]; const detail = await chat.confirmBioflowPath({ title: '生信任务', stages: ['genome_prepare', 'identify'], aMsg, }); // 1. 返回 detail 与创建入参一致 expect(detail.id).toBe('job-1'); // 2. path 块落定 jobId + selected 固化 const pathBlk = aMsg.blocks.find((b) => b.type === 'bioflow_path'); expect(pathBlk.jobId).toBe('job-1'); expect(pathBlk.selected).toBe(0); // 3. 进度块挂上且 jobStatus=created、阶段 waiting const progBlk = aMsg.blocks.find((b) => b.type === 'bioflow_progress'); expect(progBlk).toBeTruthy(); expect(progBlk.jobId).toBe('job-1'); expect(progBlk.jobStatus).toBe('created'); expect(progBlk.stages.length).toBe(2); // 4. 订阅了任务事件流(带 onEvent 桥接) expect(connectJobEventsSpy).toHaveBeenCalledTimes(1); expect(connectJobEventsSpy.mock.calls[0][0]).toBe('job-1'); expect(typeof connectJobEventsSpy.mock.calls[0][2].onEvent).toBe('function'); // 5. 落库(updateMessage 调用) expect(updateMessage).toHaveBeenCalled(); }); it('onEvent 桥接把任务事件 patch 进消息进度块(阶段推进)', async () => { const { chat } = freshStores(); createJobFromPlanSpy.mockResolvedValue({ id: 'job-2', title: '生信任务', status: 'created', stages: [{ stage: 'identify', status: 'waiting' }], }); const aMsg = { id: 'am2', role: 'assistant', status: 'done', blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }], raw: {}, }; chat.messages = [aMsg]; await chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg }); const onEvent = connectJobEventsSpy.mock.calls[0][2].onEvent; onEvent('stage_started', { job_id: 'job-2', stage: 'identify' }); onEvent('stage_done', { job_id: 'job-2', stage: 'identify' }); const progBlk = aMsg.blocks.find((b) => b.type === 'bioflow_progress'); const st = progBlk.stages.find((s) => s.stage === 'identify'); expect(st.status).toBe('done'); // 终态不回退语义保持 }); it('job_done 事件 → 挂存档提示块', async () => { const { chat } = freshStores(); createJobFromPlanSpy.mockResolvedValue({ id: 'job-3', title: '生信任务', status: 'created', stages: [{ stage: 'identify', status: 'waiting' }], }); const aMsg = { id: 'am3', role: 'assistant', status: 'done', blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }], raw: {}, }; chat.messages = [aMsg]; await chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg }); connectJobEventsSpy.mock.calls[0][2].onEvent('job_done', { job_id: 'job-3', report_path: '/r.html', figures: [] }); const archBlk = aMsg.blocks.find((b) => b.type === 'bioflow_archive'); expect(archBlk).toBeTruthy(); expect(archBlk.jobId).toBe('job-3'); }); it('创建失败不挂块、不落库', async () => { const { chat } = freshStores(); createJobFromPlanSpy.mockRejectedValue(new Error('创建失败')); const aMsg = { id: 'am4', role: 'assistant', status: 'done', blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }], raw: {}, }; chat.messages = [aMsg]; await expect( chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg }), ).rejects.toThrow('创建失败'); expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false); expect(updateMessage).not.toHaveBeenCalled(); }); it('hydrateDetail 恢复订阅:含未终态进度块的消息重连事件流', async () => { const { chat, sessions } = freshStores(); const progressBlk = { type: 'bioflow_progress', jobId: 'job-9', jobStatus: 'running', stages: [] }; sessions.fetchDetail = vi.fn(async () => ({ messages: [ { id: 'u1', role: 'user', text: 'q' }, { id: 'a1', role: 'assistant', kind: 'qa', status: 'done', blocks: [progressBlk], raw: {} }, ], })); await chat.loadSession('s1'); expect(connectJobEventsSpy).toHaveBeenCalledWith('job-9', 's1', expect.anything()); // 终态不重连 connectJobEventsSpy.mockClear(); sessions.fetchDetail = vi.fn(async () => ({ messages: [ { id: 'a2', role: 'assistant', kind: 'qa', status: 'done', blocks: [{ type: 'bioflow_progress', jobId: 'job-10', jobStatus: 'done', stages: [] }], raw: {} }, ], })); await chat.loadSession('s1'); expect(connectJobEventsSpy).not.toHaveBeenCalled(); }); });