Files
PlantAI-rag-new/tests/frontend/unit/bioflow/chat.bioflow.test.js

331 lines
16 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.
// @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();
});
});