174 lines
8.4 KiB
JavaScript
174 lines
8.4 KiB
JavaScript
// @vitest-environment jsdom
|
||
// tests/frontend/unit/bioflow/composer.bioflow.test.js —— Composer 生信分流单测(L4)
|
||
// 覆盖:bioflow 意图走 chat.sendBioflow(用户消息落库 + 骨架 + bioflowChatStream)、
|
||
// 未命中词典走原 send 路径(漏判安全)、bioflow_qa 走 sendBioflow、
|
||
// 生信附件上传走 uploadBioflowFileToJob(XHR 进度分支)、docgen/edit/export 分支不受影响。
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount, flushPromises } from '@vue/test-utils';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
|
||
vi.mock('../../../../ui/src/api/sessions.js', () => {
|
||
const calls = { messages: [] };
|
||
return {
|
||
createMessage: vi.fn(async (sid, body) => {
|
||
calls.messages.push({ sid, body });
|
||
return { id: `mid-${calls.messages.length}`, ...body };
|
||
}),
|
||
updateMessage: vi.fn(async () => ({})),
|
||
createSession: vi.fn(async () => ({ id: 'sess-1', title: '新会话', status: 'idle', created_at: 'x', updated_at: 'x', message_count: 0, meta: {} })),
|
||
updateSession: vi.fn(async () => ({})),
|
||
deleteSession: vi.fn(async () => {}),
|
||
listSessions: vi.fn(async () => []),
|
||
getSession: vi.fn(),
|
||
getSessionSources: vi.fn(async () => ({ total: 0, sources: [] })),
|
||
_calls: calls,
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../../ui/src/api/qa.js', () => ({
|
||
chatStream: vi.fn(async () => { throw new Error('unused'); }),
|
||
search: vi.fn(async () => { throw new Error('unused'); }),
|
||
}));
|
||
|
||
vi.mock('../../../../ui/src/api/docgen.js', () => ({
|
||
uploadLibrary: vi.fn(async () => ({ items: [{ library_id: 'lib-1' }] })),
|
||
generate: vi.fn(async () => { throw new Error('unused'); }),
|
||
}));
|
||
|
||
let chatFrames = [];
|
||
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
|
||
bioflowChatStream: vi.fn(async () => {
|
||
const enc = new TextEncoder();
|
||
const frames = chatFrames.map((f) => enc.encode(`event: ${f.event}\ndata: ${JSON.stringify(f.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' } });
|
||
}),
|
||
uploadBioflowFile: vi.fn(async () => ({ ok: true, data: { file_id: 'f1', name: 'a.fa', size: 3 } })),
|
||
bioflowHealth: vi.fn(async () => ({ ok: true })),
|
||
createBioflowJob: vi.fn(async () => ({ ok: true, data: { id: 'job-1', stages: [] } })),
|
||
listBioflowJobs: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||
listBioflowArchives: vi.fn(async () => ({ ok: true, data: { items: [], total: 0 } })),
|
||
getBioflowJob: vi.fn(async () => ({ ok: true, data: {} })),
|
||
getBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
deleteBioflowJob: vi.fn(async () => {}),
|
||
deleteBioflowArchive: vi.fn(async () => {}),
|
||
runBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
createBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
|
||
getBioflowStageManifest: vi.fn(async () => ({ ok: true, data: {} })),
|
||
confirmBioflowStage: vi.fn(async () => ({ ok: true, data: {} })),
|
||
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: {} })),
|
||
bioflowCatalog: vi.fn(async () => ({ ok: true, data: { stages: [] } })),
|
||
bioflowJobEvents: vi.fn(async () => { throw new Error('unused'); }),
|
||
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 Composer from '../../../../ui/src/components/chat/Composer.vue';
|
||
import { useChatStore } from '../../../../ui/src/stores/chat.js';
|
||
import { useBioflowStore } from '../../../../ui/src/stores/bioflow.js';
|
||
import { useSessionsStore } from '../../../../ui/src/stores/sessions.js';
|
||
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
|
||
import * as docgenApi from '../../../../ui/src/api/docgen.js';
|
||
|
||
function setup() {
|
||
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';
|
||
const bioflow = useBioflowStore();
|
||
bioflow.availability = 'online';
|
||
chatFrames = [];
|
||
return { chat, bioflow };
|
||
}
|
||
|
||
async function submit(w, text) {
|
||
await w.find('textarea').setValue(text);
|
||
await w.find('.send-btn').trigger('click');
|
||
await flushPromises();
|
||
}
|
||
|
||
describe('Composer 生信分流(漏判安全)', () => {
|
||
beforeEach(() => {
|
||
setup();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('bioflow 意图:走 chat.sendBioflow(用户消息 kind=bioflow + path 块)', async () => {
|
||
chatFrames = [{
|
||
event: 'plan_ready',
|
||
data: { candidates: [{ purpose: 'p', stages: ['identify'] }], source: 'llm_only', auto_filled: [], reuse_archive: null, reuse_name: null, fallback: null },
|
||
}];
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
await submit(w, '帮我跑水稻 SPL 家族鉴定');
|
||
expect(bioflowApi.bioflowChatStream).toHaveBeenCalledTimes(1);
|
||
const chat = useChatStore();
|
||
expect(chat.messages).toHaveLength(2);
|
||
expect(chat.messages[0].kind).toBe('bioflow');
|
||
expect(chat.messages[1].blocks[0].type).toBe('bioflow_path');
|
||
});
|
||
|
||
it('未命中词典:走原 send 路径(不产生 bioflow 请求;TC-BIO-STORE-002)', async () => {
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
const emitted = [];
|
||
await w.find('textarea').setValue('什么是基因家族?');
|
||
await w.find('.send-btn').trigger('click');
|
||
await flushPromises();
|
||
// QA 原路径:emit('send')(本测试未 mock chat.send 的 qa 流,验证 emit 即可)
|
||
expect(w.emitted('send')).toBeTruthy();
|
||
expect(w.emitted('send')[0][0]).toContain('什么是基因家族');
|
||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('流程咨询无生信上下文:qa(词典用例表 #18 漏判安全)', async () => {
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
await w.find('textarea').setValue('跑 SPL 家族鉴定需要准备什么输入?');
|
||
await w.find('.send-btn').trigger('click');
|
||
await flushPromises();
|
||
expect(w.emitted('send')).toBeTruthy();
|
||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('docgen 分支不受影响:走 sendDocgen(uploadLibrary 依赖 mock)', async () => {
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
await w.find('textarea').setValue('帮我写一份水稻分析报告');
|
||
await w.find('.send-btn').trigger('click');
|
||
await flushPromises();
|
||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||
// sendDocgen 会调 docgen generate(mock 抛错 → 错误标注,不崩溃)
|
||
const chat = useChatStore();
|
||
expect(chat.messages.length).toBeGreaterThanOrEqual(2);
|
||
expect(chat.messages[0].kind).toBe('docgen');
|
||
});
|
||
|
||
it('非文档类型附件(.fa)被预检拦截:不入主向量库(生信文件由 FlowFileCard 引导上传)', async () => {
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
const file = new File(['>seq'], 'SPL.fa', { type: 'text/plain' });
|
||
await w.find('.composer-wrap').trigger('drop', { dataTransfer: { types: ['Files'], files: [file] } });
|
||
await flushPromises();
|
||
// .fa 不在 docgen 附件白名单(PDF/DOCX/TXT):预检拒绝提示,不调 uploadLibrary
|
||
expect(docgenApi.uploadLibrary).not.toHaveBeenCalled();
|
||
expect(w.find('.attach-note').text()).toContain('仅支持');
|
||
});
|
||
});
|
||
|
||
describe('探活显隐(FR-BIO-025)', () => {
|
||
it('offline 时生信分流关闭:bioflow 句走 qa 兜底', async () => {
|
||
setActivePinia(createPinia());
|
||
const bioflow = useBioflowStore();
|
||
bioflow.availability = 'offline';
|
||
const w = mount(Composer, { props: { mode: 'ask', busy: false, disabled: false } });
|
||
await w.find('textarea').setValue('帮我跑水稻 SPL 家族鉴定');
|
||
await w.find('.send-btn').trigger('click');
|
||
await flushPromises();
|
||
expect(bioflowApi.bioflowChatStream).not.toHaveBeenCalled();
|
||
expect(w.emitted('send')).toBeTruthy();
|
||
});
|
||
});
|