Files
plantai-desktop/ui/tests/r3debug/bioflowPathConfirm.test.js

192 lines
7.7 KiB
JavaScript

// 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();
});
});