Files
PlantAI-rag-new/tests/frontend/unit/bioflow/panes.test.js
T
admin 0282cfeeeb feat(bioflow): 阶段进度条+滚动日志+点击定位全链;图片注入三修;QA启动就绪等待
- 生信进度:后端新增 stage_progress 事件帧+进度解析白名单(rnaseq_qc/sample_qc),
  kernel 日志流式落盘(20行/1s flush+fsync),logs 分页端点(offset/limit/eof),
  事件 seq 跨重启扫描续增(2026-09-17 断流修复);前端 FlowProgressCard 确定性/
  不确定两态进度条+点击定位到生信任务 TAB,BioJobsPane DAG 行展开滚动日志区
  (3s 轮询),log_tail 4000 截断,重跑 stage_started 清零进度
- 图片注入:figures 三源重构(标准DOI图库主路径/遗留体系兜底/PMC兜底),
  [[FIG:]] 宽松归一化识别 PMC/PMID/DOI 三种形态,引文兜底扩展至
  [PMCID:]/[DOI:] 且标记未满配额继续注入,注入预算 90s/10篇(175 实测校准),
  PMC graphic 无 content-type 宽松正则,空结果不缓存(176 冷连接三连败修复)
- QA 启动自愈:start.sh 启动前等 Qdrant readyz(30min)+Neo4j TCP(10min);
  backend_guard 标签校验改子集判定
- 样本配对:同 inode 去重优先真实文件,R1/R2 归一配对一对一认领
- gitignore 新增 *.bak-*/*.before-*/bioflow_service/bioflow.db

测试:bioflow_service pytest 358/358,前端 bioflow vitest 205/205
2026-09-24 16:48:52 +08:00

334 lines
14 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/panes.test.js —— P5 生信面板组件测试(L4)
// 覆盖 TC-BIO-JOB-001~016(BioJobsPane)与 TC-BIO-PANE-001~016(BioArchivePane):
// 任务列表 session-item 形态/阶段 DAG 取色/manifest+日志浏览/重跑/删除确认/running 禁用/
// 探活失败隐藏 TAB(bench store 断言由 benchpanel 层测试覆盖)/复用发起预填表单。
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/bioflow.js', () => ({
listBioflowJobs: vi.fn(async () => ({
ok: true,
data: {
items: [
{ id: 'job-1', title: '水稻 SPL 鉴定', status: 'running', progress: 33, stages: [{ stage: 'identify', status: 'done' }, { stage: 'align', status: 'running' }, { stage: 'phylogeny', status: 'waiting' }] },
{ id: 'job-2', title: '玉米 bZIP 分析', status: 'done', progress: 100, stages: [{ stage: 'identify', status: 'done' }] },
{ id: 'job-3', title: '小麦 WRKY 失败任务', status: 'failed', progress: 50, stages: [{ stage: 'identify', status: 'done' }, { stage: 'align', status: 'failed' }] },
],
total: 3,
},
})),
getBioflowJob: vi.fn(async () => ({
ok: true,
data: {
id: 'job-1', title: '水稻 SPL 鉴定', status: 'running',
stages: [{ stage: 'identify', status: 'done' }, { stage: 'align', status: 'running' }, { stage: 'phylogeny', status: 'waiting' }],
},
})),
deleteBioflowJob: vi.fn(async () => {}),
rerunBioflowStage: vi.fn(async () => ({ ok: true, data: {} })),
getBioflowStageManifest: vi.fn(async () => ({
ok: true,
data: { manifest: { status: 'success', artifacts: [] }, log_tail: 'log-line-1\nlog-line-2' },
})),
getBioflowStageLogs: vi.fn(async () => ({
ok: true,
data: { lines: ['log-line-1', 'log-line-2'], offset: 2, limit: 500, eof: true, log_tail: '' },
})),
listBioflowArchives: vi.fn(async () => ({
ok: true,
data: {
items: [
{ id: 'arc-1', name: 'SPL 水稻鉴定', description: '描述', size_bytes: 2048, reused_count: 1, created_at: '2026-09-06T00:00:00Z', report_artifact: '/d/report.html' },
{ id: 'arc-2', name: 'bZIP 分析', size_bytes: 1024, created_at: '2026-09-05T00:00:00Z' },
],
total: 2,
},
})),
getBioflowArchive: vi.fn(async () => ({
ok: true,
data: {
id: 'arc-1', name: 'SPL 水稻鉴定', stage_chain: ['identify', 'align', 'phylogeny'],
params: { identify: { prefix: 'Ft.SPL' } },
products: [{ name: 'report.html', size: 2048 }, { name: 'final.zip', size: 1024 }],
report_artifact: '/d/report.html',
},
})),
deleteBioflowArchive: vi.fn(async () => {}),
runBioflowArchive: vi.fn(async () => ({ ok: true, data: { id: 'job-9', stages: [] } })),
bioflowHealth: vi.fn(async () => ({ ok: true })),
createBioflowJob: vi.fn(async () => ({ ok: true, data: { id: 'j1', stages: [] } })),
confirmBioflowStage: 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: {} })),
createBioflowArchive: vi.fn(async () => ({ ok: true, data: {} })),
bioflowCatalog: vi.fn(async () => ({ ok: true, data: { stages: [] } })),
uploadBioflowFile: vi.fn(async () => ({ ok: true, data: {} })),
bioflowChatStream: vi.fn(async () => { throw new Error('unused'); }),
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: {} })),
}));
vi.mock('../../../../ui/src/api/sessions.js', () => ({
createMessage: vi.fn(async () => ({ id: 'm1' })),
updateMessage: vi.fn(async () => ({ id: 'm1' })),
createSession: vi.fn(async () => ({ id: 'sess-1', title: 'x', 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: [] })),
}));
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/extraction.js', () => ({
listJobs: vi.fn(async () => ({ jobs: [], count: 0 })),
getJob: vi.fn(),
uploadPaper: vi.fn(),
getRunLog: vi.fn(async () => ({ text: '' })),
getProcessingStatus: vi.fn(),
getSummary: vi.fn(),
}));
vi.mock('../../../../ui/src/api/kg.js', () => ({
kgStatus: vi.fn(async () => ({ ok: true })),
kgOverview: vi.fn(),
searchEntities: vi.fn(),
getEntity: vi.fn(),
getNeighborhood: vi.fn(),
}));
import BioJobsPane from '../../../../ui/src/components/bench/BioJobsPane.vue';
import BioArchivePane from '../../../../ui/src/components/bench/BioArchivePane.vue';
import { useBioflowStore } from '../../../../ui/src/stores/bioflow.js';
import { useChatStore } from '../../../../ui/src/stores/chat.js';
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
function setup() {
setActivePinia(createPinia());
const bioflow = useBioflowStore();
const chat = useChatStore();
chat.sessionId = 'sess-1';
chat.messages = [{ id: 'a1', role: 'assistant', kind: 'bioflow', status: 'done', blocks: [], created_at: 'x', updated_at: 'x' }];
return { bioflow, chat };
}
describe('BioJobsPane —— TC-BIO-JOB-001~016(L4)', () => {
beforeEach(() => {
setup();
vi.clearAllMocks();
});
it('任务列表渲染 3 个 session-item 形态列表项(标题 13px + 状态徽章;TC-BIO-JOB-002)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
const items = w.findAll('.bj-item');
expect(items.length).toBe(3);
expect(items[0].text()).toContain('水稻 SPL 鉴定');
expect(items[0].find('.badge').exists()).toBe(true);
expect(items[0].find('.bj-progress').text()).toBe('33%');
});
it('点击任务项:选中并展开详情(阶段 DAG 显示;TC-BIO-JOB-004/ACT-090)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
await w.findAll('.bj-item')[0].trigger('click');
await flushPromises();
expect(w.find('.bj-detail').exists()).toBe(true);
const dag = w.findAll('.bj-dag-row');
expect(dag.length).toBe(3);
// DAG 状态取色(TC-BIO-JOB-005:done/running/waiting 三色点)
expect(dag[0].classes()).toContain('done');
expect(dag[1].classes()).toContain('running');
expect(dag[2].classes()).toContain('waiting');
});
it('DAG 节点 8px 圆点按阶段状态取色(TC-BIO-JOB-005 细节)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
await w.findAll('.bj-item')[0].trigger('click');
await flushPromises();
const dots = w.findAll('.bj-dag-dot');
expect(dots[0].text()).toBe('✓');
expect(dots[1].text()).toBe('●');
expect(dots[2].text()).toBe('◌');
});
it('点击 DAG 阶段行:展开滚动日志区(logs 端点轮询;log 区头部 manifest 按钮切 manifest)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
await w.findAll('.bj-item')[0].trigger('click');
await flushPromises();
await w.findAll('.bj-dag-row')[0].trigger('click');
await flushPromises();
// 新交互:DAG 行点击 → 展开日志滚动区(getBioflowStageLogs 分页取数)
expect(bioflowApi.getBioflowStageLogs).toHaveBeenCalledWith('job-1', 'identify', { offset: 0, limit: 500 });
expect(w.find('.bj-log').exists()).toBe(true);
const code = w.find('.bj-log .code-block');
expect(code.text()).toContain('log-line-1');
expect(code.text()).toContain('log-line-2');
// log 区头部 manifest 按钮 → 切到 manifest 区
await w.find('.bj-log-actions .pane-btn').trigger('click');
await flushPromises();
expect(w.find('.bj-manifest').exists()).toBe(true);
expect(w.find('.bj-manifest .code-block').text()).toContain('manifest');
});
it('重跑按钮:选中含 failed 阶段任务时发出 rerun(TC-BIO-JOB-007/ACT-063)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
// job-3 含 failed 阶段
await w.findAll('.bj-item')[2].trigger('click');
await flushPromises();
const rerunBtn = w.find('.bj-rerun');
expect(rerunBtn.exists()).toBe(true);
await rerunBtn.trigger('click');
await flushPromises();
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-3', 'align', null);
});
it('删除:弹二次确认 → 确认后 DELETE 并移除(TC-BIO-JOB-008/ACT-064)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
// job-2 状态 done 可删除
const delBtn = w.findAll('.bj-del')[1];
await delBtn.trigger('click');
expect(w.find('.bj-confirm').exists()).toBe(true);
await w.find('.pane-btn.danger').trigger('click');
await flushPromises();
expect(bioflowApi.deleteBioflowJob).toHaveBeenCalledWith('job-2');
});
it('删除二次确认取消:无 DELETE 请求(TC-BIO-JOB/ACT-067 语义)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
await w.findAll('.bj-del')[1].trigger('click');
await w.find('.bj-confirm .pane-btn').trigger('click'); // 取消
expect(bioflowApi.deleteBioflowJob).not.toHaveBeenCalled();
});
it('running 任务删除按钮禁用(TC-BIO-JOB-009/ACT-068)', async () => {
const w = mount(BioJobsPane);
await flushPromises();
const delBtn = w.findAll('.bj-del')[0]; // job-1 running
expect(delBtn.attributes('disabled')).toBeDefined();
});
it('空任务列表:空态文案显示(TC-BIO-JOB-010)', async () => {
bioflowApi.listBioflowJobs.mockResolvedValueOnce({ ok: true, data: { items: [], total: 0 } });
const w = mount(BioJobsPane);
await flushPromises();
expect(w.find('.bj-empty').text()).toContain('暂无生信任务');
});
it('列表加载失败:错误态 + 重新加载(失败必须可见)', async () => {
bioflowApi.listBioflowJobs.mockRejectedValueOnce(new Error('down'));
const w = mount(BioJobsPane);
await flushPromises();
expect(w.find('.bj-state.error').exists()).toBe(true);
});
});
describe('BioArchivePane —— TC-BIO-PANE-001~016(L4)', () => {
beforeEach(() => {
setup();
vi.clearAllMocks();
});
it('档案列表渲染 2 项(标题+大小;TC-BIO-PANE-002)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
const items = w.findAll('.bap-item');
expect(items.length).toBe(2);
expect(items[0].text()).toContain('SPL 水稻鉴定');
expect(items[0].text()).toContain('KB');
});
it('点击档案项:详情显示阶段链/参数/产物/报告(TC-BIO-PANE-003/004/005)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
await w.findAll('.bap-item')[0].trigger('click');
await flushPromises();
expect(w.find('.bap-detail').exists()).toBe(true);
expect(w.find('.bap-stages').text()).toContain('identify');
expect(w.find('.bap-stages').text()).toContain('phylogeny');
expect(w.find('.bap-products').text()).toContain('report.html');
expect(w.find('.bap-report').exists()).toBe(false); // 未点查看报告
// 查看报告按钮存在(report_artifact 非空)
const btns = w.findAll('.bap-detail .pane-btn');
expect(btns.some((b) => b.text().includes('查看报告'))).toBe(true);
});
it('查看报告:iframe 打开归档报告(TC-BIO-PANE-004)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
await w.findAll('.bap-item')[0].trigger('click');
await flushPromises();
const viewBtn = w.findAll('.bap-detail .pane-btn').find((b) => b.text().includes('查看报告'));
await viewBtn.trigger('click');
expect(w.find('.bap-report-frame').exists()).toBe(true);
expect(w.find('.bap-report-frame').attributes('src')).toContain('/api/bioflow/archives/arc-1/report');
});
it('删除归档:二次确认 → DELETE 并移除(TC-BIO-PANE-006)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
await w.findAll('.bap-item')[0].trigger('click');
await flushPromises();
const delBtn = w.find('.bap-del');
await delBtn.trigger('click');
expect(w.find('.bap-confirm').exists()).toBe(true);
await w.find('.pane-btn.danger').trigger('click');
await flushPromises();
expect(bioflowApi.deleteBioflowArchive).toHaveBeenCalledWith('arc-1');
});
it('删除取消:无 DELETE 请求(TC-BIO-PANE-007)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
await w.findAll('.bap-item')[0].trigger('click');
await flushPromises();
await w.find('.bap-del').trigger('click');
await w.find('.bap-confirm .pane-btn').trigger('click'); // 取消
expect(bioflowApi.deleteBioflowArchive).not.toHaveBeenCalled();
});
it('复用发起:装载预填表单并挂到消息流(TC-BIO-PANE-008/009/ACT-070)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
await w.findAll('.bap-item')[0].trigger('click');
await flushPromises();
const reuseBtn = w.find('.bap-reuse');
await reuseBtn.trigger('click');
await flushPromises();
// chat.showReuseForm 把 bioflow_reuse 块挂到助手消息
const chat = useChatStore();
const blk = chat.messages.find((m) => m.role === 'assistant').blocks.find((b) => b.type === 'bioflow_reuse');
expect(blk).toBeTruthy();
expect(blk.archiveId).toBe('arc-1');
expect(blk.archiveName).toBe('SPL 水稻鉴定');
});
it('空档案列表:空态文案(TC-BIO-PANE-011)', async () => {
bioflowApi.listBioflowArchives.mockResolvedValueOnce({ ok: true, data: { items: [], total: 0 } });
const w = mount(BioArchivePane);
await flushPromises();
expect(w.find('.bap-empty').text()).toContain('暂无生信档案');
});
it('档案大小人类可读格式(TC-BIO-PANE-012)', async () => {
const w = mount(BioArchivePane);
await flushPromises();
const sizes = w.findAll('.bap-size');
expect(sizes[0].text()).toContain('KB');
});
});