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

174 lines
8.4 KiB
JavaScript
Raw 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/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();
});
});