331 lines
16 KiB
JavaScript
331 lines
16 KiB
JavaScript
// @vitest-environment jsdom
|
||
// tests/frontend/unit/bioflow/chat.bioflow.test.js —— chat store 生信分支单测(L3)
|
||
// 覆盖:sendBioflow 用户消息落库 + 生信骨架(path 块)+ chat/stream SSE 消费
|
||
// → plan_ready/job_created/stage_await_confirm/job_done 事件 patch 消息 blocks、
|
||
// 消息更新器(path/progress/form/file/archive 五类块)、断线网络错误标注、
|
||
// retry 走生信路径、分流未命中不触发生信请求、红线文件不 import(本文件零断言)。
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
import { setActivePinia, createPinia } from 'pinia';
|
||
|
||
// ---- mock api/qa.js(sendBioflow 不经过 qa 流;空实现) ----
|
||
vi.mock('../../../../ui/src/api/qa.js', () => ({
|
||
chatStream: vi.fn(async () => { throw new Error('unused'); }),
|
||
search: vi.fn(async () => { throw new Error('unused'); }),
|
||
}));
|
||
|
||
// ---- mock api/sessions.js(工厂内建 calls 收集器,工厂内不得引用顶层变量) ----
|
||
vi.mock('../../../../ui/src/api/sessions.js', () => {
|
||
const calls = { messages: [], patches: [] };
|
||
return {
|
||
createMessage: vi.fn(async (sid, body) => {
|
||
calls.messages.push({ sid, body });
|
||
return { id: `mid-${calls.messages.length}`, ...body, created_at: '2026-09-06T00:00:00Z', updated_at: '2026-09-06T00:00:00Z' };
|
||
}),
|
||
updateMessage: vi.fn(async (sid, mid, body) => {
|
||
calls.patches.push({ sid, mid, body });
|
||
return { id: mid, ...body, updated_at: '2026-09-06T00:00:05Z' };
|
||
}),
|
||
createSession: vi.fn(async () => ({ id: 'sess-1', title: '新会话', status: 'idle', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} })),
|
||
updateSession: vi.fn(async (sid, body) => ({ id: sid, ...body })),
|
||
deleteSession: vi.fn(async () => {}),
|
||
listSessions: vi.fn(async () => []),
|
||
getSession: vi.fn(),
|
||
getSessionSources: vi.fn(async () => ({ session_id: 's1', total: 0, sources: [] })),
|
||
_calls: calls,
|
||
};
|
||
});
|
||
|
||
// ---- mock api/bioflow.js(bioflowChatStream 返回可消费的 SSE Response) ----
|
||
let chatFrames = [];
|
||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||
bioflowChatStream: vi.fn(async (body) => {
|
||
const enc = new TextEncoder();
|
||
const frames = chatFrames.map((f) => {
|
||
const data = JSON.stringify(f.data);
|
||
return enc.encode(`event: ${f.event}\ndata: ${data}\n\n`);
|
||
});
|
||
const full = frames.reduce((a, b) => new Uint8Array([...a, ...b]), new Uint8Array(0));
|
||
return new Response(full, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
||
}),
|
||
createBioflowJob: vi.fn(async () => ({ ok: true, data: { id: 'job-1', status: 'created', stages: [] } })),
|
||
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||
listBioflowArchives: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||
bioflowHealth: vi.fn(async () => ({ ok: true })),
|
||
confirmBioflowStage: vi.fn(async () => ({ ok: true, data: { status: 'running' } })),
|
||
rerunBioflowStage: vi.fn(async () => ({ ok: true, data: {} })),
|
||
skipBioflowStage: vi.fn(async () => ({ ok: true, data: {} })),
|
||
pauseBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
|
||
resumeBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
|
||
getBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
|
||
deleteBioflowJob: vi.fn(async () => {}),
|
||
getBioflowStageManifest: vi.fn(async () => ({ ok: true, data: {} })),
|
||
getBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
deleteBioflowArchive: vi.fn(async () => {}),
|
||
runBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
createBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
uploadBioflowFile: vi.fn(async () => ({ ok: true, data: { file_id: 'f1', name: 'a.fa', size: 3 } })),
|
||
bioflowCatalog: vi.fn(async () => ({ ok: true, data: { stages: [] } })),
|
||
bioflowJobEvents: vi.fn(async () => new Response('', { status: 200, headers: { 'Content-Type': 'text/event-stream' } })),
|
||
bioflowAsk: vi.fn(async () => ({ ok: true, answer: { citations: [] } })),
|
||
reviseBioflowReport: vi.fn(async () => ({ ok: true, data: {} })),
|
||
registerBioflowReport: vi.fn(async () => ({ ok: true, data: {} })),
|
||
runBioflowArchiveByName: vi.fn(async () => ({ ok: true, data: {} })),
|
||
}));
|
||
|
||
import { useChatStore, newBioflowSkeletonMessage, patchBioflowPlan, patchBioflowEvent, patchBioflowSnapshot, patchBioflowForm, patchBioflowFile, patchBioflowArchiveSuggest, patchBioflowQa } from '../../../../ui/src/stores/chat.js';
|
||
import { useSessionsStore } from '../../../../ui/src/stores/sessions.js';
|
||
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
|
||
import * as sessApi from '../../../../ui/src/api/sessions.js';
|
||
|
||
const sessionCalls = sessApi._calls;
|
||
|
||
function frame(event, data) {
|
||
return { event, data };
|
||
}
|
||
|
||
async function setupChat() {
|
||
setActivePinia(createPinia());
|
||
const chat = useChatStore();
|
||
const sessions = useSessionsStore();
|
||
sessions.items = [{ id: 'sess-1', title: '新会话', status: 'idle', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} }];
|
||
sessions.activeId = 'sess-1';
|
||
chat.sessionId = 'sess-1';
|
||
chatFrames = [];
|
||
sessionCalls.messages = [];
|
||
sessionCalls.patches = [];
|
||
return { chat, sessions };
|
||
}
|
||
|
||
describe('消息更新器 —— 五类块 patch', () => {
|
||
it('newBioflowSkeletonMessage 骨架含 path 块(TC-BIO-STORE-001)', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
expect(m.kind).toBe('bioflow');
|
||
expect(m.blocks[0].type).toBe('bioflow_path');
|
||
expect(m.blocks[0].status).toBe('running');
|
||
});
|
||
|
||
it('patchBioflowPlan 候选写入 path 块(2-3 条候选;TC-BIO-STORE-003)', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowPlan(m, {
|
||
candidates: [
|
||
{ purpose: '标准流程', stages: ['identify', 'align', 'phylogeny'] },
|
||
{ purpose: '精简流程', stages: ['identify'] },
|
||
],
|
||
auto_filled: ['align'],
|
||
source: 'llm_rag',
|
||
});
|
||
const blk = m.blocks.find((b) => b.type === 'bioflow_path');
|
||
expect(blk.candidates).toHaveLength(2);
|
||
expect(blk.candidates[0].stages).toContain('identify');
|
||
expect(blk.autoFilled).toEqual(['align']);
|
||
expect(blk.status).toBe('done');
|
||
});
|
||
|
||
it('patchBioflowEvent job_created 建进度块;stage 事件推进;job_done 收尾', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowEvent(m, { type: 'job_created', data: { job_id: 'j1', title: 'SPL', auto_continue: false, stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }] } });
|
||
patchBioflowEvent(m, { type: 'stage_started', data: { job_id: 'j1', stage: 'identify', attempt: 1 } });
|
||
patchBioflowEvent(m, { type: 'stage_log', data: { job_id: 'j1', stage: 'identify', log: 'log-a' } });
|
||
patchBioflowEvent(m, { type: 'stage_done', data: { job_id: 'j1', stage: 'identify' } });
|
||
patchBioflowEvent(m, { type: 'job_done', data: { job_id: 'j1', progress: 100 } });
|
||
const blk = m.blocks.find((b) => b.type === 'bioflow_progress');
|
||
expect(blk.jobId).toBe('j1');
|
||
expect(blk.stages.map((s) => s.status)).toEqual(['done', 'waiting']);
|
||
expect(blk.stages[0].log_tail).toBe('log-a');
|
||
expect(blk.jobStatus).toBe('done');
|
||
});
|
||
|
||
it('patchBioflowSnapshot 快照整体刷新(stages 引用变化;TC-BIO-STORE-008)', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowEvent(m, { type: 'job_created', data: { job_id: 'j1', stages: [{ stage: 'a', status: 'waiting' }] } });
|
||
const before = m.blocks.find((b) => b.type === 'bioflow_progress');
|
||
const beforeStages = before.stages;
|
||
patchBioflowSnapshot(m, {
|
||
job_id: 'j1',
|
||
job: { status: 'running' },
|
||
stages: [{ stage: 'a', status: 'done', log_tail: 'tail' }, { stage: 'b', status: 'running' }],
|
||
last_seq: 9,
|
||
});
|
||
const after = m.blocks.find((b) => b.type === 'bioflow_progress');
|
||
expect(after.stages).not.toBe(beforeStages); // 阶段数组整体替换(引用变化)
|
||
expect(after.stages.map((s) => [s.stage, s.status])).toEqual([['a', 'done'], ['b', 'running']]);
|
||
expect(after.lastSeq).toBe(9);
|
||
});
|
||
|
||
it('patchBioflowForm 表单块带 form_defaults;rerun 标记', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowForm(m, { stage: 'identify', form_defaults: [{ name: 'cpu', default: 4, source: 'contract' }] });
|
||
const blk = m.blocks.find((b) => b.type === 'bioflow_form');
|
||
expect(blk.stage).toBe('identify');
|
||
expect(blk.fields).toHaveLength(1);
|
||
expect(blk.rerun).toBe(false);
|
||
const m2 = newBioflowSkeletonMessage();
|
||
patchBioflowForm(m2, { stage: 'align', rerun_with_params: true, form_defaults: [] });
|
||
expect(m2.blocks.find((b) => b.type === 'bioflow_form').rerun).toBe(true);
|
||
});
|
||
|
||
it('patchBioflowFile 文件块带 required_files 与 message(FR-BIO-005 点名)', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowFile(m, { stage: 'identify', required_files: ['SPL.fa', 'SPL.cds'], message: '请将数据文件拖入窗口' });
|
||
const blk = m.blocks.find((b) => b.type === 'bioflow_file');
|
||
expect(blk.requiredFiles).toEqual(['SPL.fa', 'SPL.cds']);
|
||
expect(blk.message).toBe('请将数据文件拖入窗口');
|
||
});
|
||
|
||
it('patchBioflowArchiveSuggest 每任务只出一张存档卡;done 写回三要素', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
const b1 = patchBioflowArchiveSuggest(m, 'j1');
|
||
const b2 = patchBioflowArchiveSuggest(m, 'j1');
|
||
expect(b1).toBe(b2);
|
||
expect(m.blocks.filter((b) => b.type === 'bioflow_archive')).toHaveLength(1);
|
||
b1.status = 'done';
|
||
b1.result = { name: 'SPL 水稻鉴定', disk_path: '/d', size_bytes: 2048 };
|
||
expect(b1.result.name).toBe('SPL 水稻鉴定');
|
||
});
|
||
|
||
it('patchBioflowQa 引用块携带 citations', () => {
|
||
const m = newBioflowSkeletonMessage();
|
||
patchBioflowQa(m, { question: 'identify 需要什么输入', citations: [{ source: 'doc', text: '原文', title_path: 'x.md' }], degraded: false });
|
||
const blk = m.blocks.find((b) => b.type === 'bioflow_qa');
|
||
expect(blk.citations).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('sendBioflow —— 全流程(SSE 用 mock,不依赖生产服务)', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.restoreAllMocks();
|
||
});
|
||
afterEach(() => {
|
||
chatFrames = [];
|
||
});
|
||
|
||
it('plan_ready + job_created + stage_await_confirm + job_done 事件流 patch 消息 blocks(TC-BIO-STORE-003/004/005)', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [
|
||
frame('plan_ready', { candidates: [{ purpose: '标准流程', stages: ['identify', 'align'] }], source: 'llm_rag', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||
frame('job_created', { job_id: 'j1', session_id: 'sess-1', title: 'SPL', stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }], auto_continue: false }),
|
||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||
frame('stage_await_confirm', { job_id: 'j1', stage: 'align', form_defaults: [{ name: 'cpu', default: 4, source: 'contract' }] }),
|
||
frame('job_done', { job_id: 'j1', progress: 100 }),
|
||
];
|
||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||
expect(chat.messages).toHaveLength(2);
|
||
expect(chat.messages[0].role).toBe('user');
|
||
expect(chat.messages[0].kind).toBe('bioflow');
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.kind).toBe('bioflow');
|
||
expect(aMsg.status).toBe('done');
|
||
const types = aMsg.blocks.map((b) => b.type);
|
||
expect(types).toContain('bioflow_path');
|
||
expect(types).toContain('bioflow_progress');
|
||
expect(types).toContain('bioflow_form');
|
||
expect(types).toContain('bioflow_archive');
|
||
const path = aMsg.blocks.find((b) => b.type === 'bioflow_path');
|
||
expect(path.candidates).toHaveLength(1);
|
||
const prog = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||
expect(prog.jobId).toBe('j1');
|
||
expect(prog.stages.map((s) => s.status)).toEqual(['running', 'waiting_confirm']);
|
||
// 用户消息落库(骨架 + 终态 patch)
|
||
expect(sessionCalls.messages.length).toBeGreaterThanOrEqual(2);
|
||
expect(sessionCalls.messages[0].body.role).toBe('user');
|
||
});
|
||
|
||
it('plan_ready 后心跳流断连(无任务事件):path 卡保留,消息收口 done', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [
|
||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||
];
|
||
await chat.sendBioflow('跑 SPL 家族');
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.status).toBe('done');
|
||
expect(aMsg.blocks.find((b) => b.type === 'bioflow_path').candidates).toHaveLength(1);
|
||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||
});
|
||
|
||
it('message_done flow_qa 分支:引用块渲染(FR-BIO-026 不新建任务)', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [
|
||
frame('message_done', { kind: 'flow_qa', citations: [{ source: 'flow_docs', title_path: 'identify.md', text: '输入 fasta 与 hmm' }], degraded: false }),
|
||
];
|
||
await chat.sendBioflow('identify 需要什么输入');
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.status).toBe('done');
|
||
const qa = aMsg.blocks.find((b) => b.type === 'bioflow_qa');
|
||
expect(qa.citations).toHaveLength(1);
|
||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||
});
|
||
|
||
it('错误帧(统一信封):标注 error + 可重试', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [
|
||
frame('error', { code: 'UPSTREAM_DOWN', message: '生信服务当前不可用' }),
|
||
];
|
||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.status).toBe('error');
|
||
expect(aMsg.errText).toBe('生信服务当前不可用');
|
||
expect(chat.lastResult.ok).toBe(false);
|
||
expect(chat.lastResult.error.code).toBe('UPSTREAM_DOWN');
|
||
});
|
||
|
||
it('网络中断:标注 disconnected;任务状态保留(快照兜底不丢进度)', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [
|
||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||
frame('job_created', { job_id: 'j1', stages: [{ stage: 'identify', status: 'waiting' }], auto_continue: false }),
|
||
frame('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }),
|
||
];
|
||
// 让 fetch 在流消费中途失败:mock 抛网络错误
|
||
bioflowApi.bioflowChatStream.mockRejectedValueOnce(new Error('network'));
|
||
await chat.sendBioflow('帮我跑水稻 SPL 家族鉴定');
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.status).toBe('error');
|
||
expect(aMsg.streamErr).toBe('disconnected');
|
||
expect(chat.lastResult.ok).toBe(false);
|
||
expect(chat.lastResult.error.retryable).toBe(true);
|
||
});
|
||
|
||
it('retry:bioflow 失败消息同位置替换走生信路径(TC-BIO-STORE-022 语义)', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [frame('error', { code: 'UPSTREAM_DOWN', message: 'x' })];
|
||
await chat.sendBioflow('跑 SPL 家族');
|
||
expect(chat.messages).toHaveLength(2);
|
||
// 重试成功路径
|
||
chatFrames = [
|
||
frame('plan_ready', { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null }),
|
||
];
|
||
await chat.retry();
|
||
expect(chat.messages).toHaveLength(2); // 同位置替换不新增
|
||
const aMsg = chat.messages[1];
|
||
expect(aMsg.status).toBe('done');
|
||
expect(aMsg.kind).toBe('bioflow');
|
||
});
|
||
});
|
||
|
||
describe('分流纪律(漏判安全)', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('sendBioflow 不经过 qa 流(bioflowChatStream 独立端点)', async () => {
|
||
const { chat } = await setupChat();
|
||
chatFrames = [frame('plan_ready', { candidates: [], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null })];
|
||
await chat.sendBioflow('跑 SPL 家族');
|
||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||
const body = bioflowApi.bioflowChatStream.mock.calls[0][0];
|
||
expect(body.session_id).toBe('sess-1');
|
||
expect(body.message).toBe('跑 SPL 家族');
|
||
expect(body).toHaveProperty('has_bioflow_context');
|
||
});
|
||
|
||
it('busy 时不重复发起(并发防护)', async () => {
|
||
const { chat } = await setupChat();
|
||
vi.clearAllMocks();
|
||
chat.streamCtx['sess-1'] = { busy: true };
|
||
await chat.sendBioflow('跑 SPL 家族');
|
||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||
});
|
||
});
|