- 生信进度:后端新增 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
334 lines
14 KiB
JavaScript
334 lines
14 KiB
JavaScript
// @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');
|
||
});
|
||
});
|