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

755 lines
34 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/cards.test.js —— 生信五卡片组件测试(L4)
// 覆盖 TC-BIO-PATH/FORM/FILE/PROG/ARCH 全状态 + UI 动作矩阵
// (点击/双击/悬停/键盘 Enter 与 Esc/拖入/悬停拖放/离开/聚焦/失焦/禁用/加载中,
// 每条 TC 一个元素×一个动作)。SSE 用 mock,不依赖生产服务。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
// mock api/bioflow.js
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
bioflowCatalog: vi.fn(async () => ({
ok: true,
data: {
data: { stage: { params: [{ name: 'cpu', type: 'int', default: 4, source: 'contract', help: '' }] } },
},
})),
createBioflowJob: vi.fn(async (body) => ({
ok: true,
data: {
id: 'job-1', title: body.title, status: 'created',
stages: body.stages.map((s) => ({ stage: s, status: 'waiting' })),
auto_continue: body.auto_continue || false,
},
})),
confirmBioflowStage: vi.fn(async () => ({ ok: true, data: { status: 'running' } })),
rerunBioflowStage: vi.fn(async () => ({ ok: true, data: { status: 'running' } })),
skipBioflowStage: vi.fn(async () => ({ ok: true, data: { status: 'skipped' } })),
pauseBioflowJob: vi.fn(async () => ({ ok: true, data: { status: 'paused' } })),
resumeBioflowJob: vi.fn(async () => ({ ok: true, data: { status: 'running' } })),
bioflowHealth: vi.fn(async () => ({ ok: true })),
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: { id: 'arc-1', name: 'SPL 水稻鉴定', disk_path: '/d/arc-1', size_bytes: 2048 },
})),
getBioflowStageManifest: vi.fn(async () => ({ ok: true, data: {} })),
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: {} })),
}));
// mock api/sessions.js(FlowPathCard 确认创建任务经 chat store 读 sessionId)
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: '新会话', 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'); }),
}));
import FlowPathCard from '../../../../ui/src/components/chat/FlowPathCard.vue';
import FlowFormCard from '../../../../ui/src/components/chat/FlowFormCard.vue';
import FlowFileCard from '../../../../ui/src/components/chat/FlowFileCard.vue';
import FlowProgressCard from '../../../../ui/src/components/chat/FlowProgressCard.vue';
import FlowArchiveCard from '../../../../ui/src/components/chat/FlowArchiveCard.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();
bioflow.currentJob.jobId = 'job-1';
const chat = useChatStore();
chat.sessionId = 'sess-1';
return { bioflow, chat };
}
const PATH_BLOCK = () => ({
type: 'bioflow_path',
status: 'done',
candidates: [
{ purpose: '标准流程', stages: ['identify', 'align', 'phylogeny'] },
{ purpose: '精简流程', stages: ['identify'] },
],
selected: null,
autoFilled: [],
reuseArchive: null,
reuseName: null,
fallback: null,
});
describe('FlowPathCard —— TC-BIO-PATH-001~022(L4)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('卡体存在;标题「🧬 分析路径选择」;候选行渲染 2 条含阶段序列(TC-BIO-PATH-001/002/003)', () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
expect(w.find('.flow-path-card').exists()).toBe(true);
expect(w.find('.fpc-title').text()).toContain('分析路径选择');
const rows = w.findAll('.fpc-row');
expect(rows.length).toBe(2);
expect(rows[0].text()).toContain('identify');
expect(rows[0].text()).toContain('align');
// 首阶段加粗(TC-BIO-PATH-003)
const first = rows[0].find('.stage.first');
expect(first.exists()).toBe(true);
expect(first.text()).toBe('identify');
});
it('点选候选行:选中态(TC-BIO-PATH-004/005)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[1].trigger('click');
const rows = w.findAll('.fpc-row');
expect(rows[1].classes()).toContain('selected');
expect(rows[0].classes()).not.toContain('selected');
});
it('双击候选行:状态保持选中、不产生重复回调(TC-BIO-ACT-002)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
const row = w.findAll('.fpc-row')[0];
await row.trigger('dblclick');
expect(w.findAll('.fpc-row')[0].classes()).toContain('selected');
});
it('悬停候选行:hover 类(TC-BIO-ACT-003 视觉反馈经 CSS,此处断言事件不破坏状态)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
const row = w.findAll('.fpc-row')[0];
await row.trigger('mouseenter');
await row.trigger('mouseleave');
expect(w.findAll('.fpc-row')[0].classes()).not.toContain('selected');
});
it('键盘 Enter 选中候选行(TC-BIO-ACT-005)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[0].trigger('keydown.enter');
expect(w.findAll('.fpc-row')[0].classes()).toContain('selected');
});
it('确认按钮无选中禁用 opacity .45(TC-BIO-PATH-007/ACT-012)', () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
const btn = w.find('.fpc-confirm');
expect(btn.attributes('disabled')).toBeDefined();
});
it('确认按钮选中后可点;点击进入确认视图(逐阶段三要素;TC-BIO-PATH-006/013)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[0].trigger('click');
const btn = w.find('.fpc-confirm');
expect(btn.attributes('disabled')).toBeUndefined();
await btn.trigger('click');
expect(w.find('.fpc-confirm-stages').exists()).toBe(true);
const csRows = w.findAll('.fpc-cs-row');
expect(csRows.length).toBe(3); // identify/align/phylogeny 逐阶段
expect(csRows[0].text()).toContain('identify');
});
it('确认创建任务:发出 POST /jobs;stages=选中链(TC-BIO-PATH-014/ACT-007)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[0].trigger('click');
await w.find('.fpc-confirm').trigger('click'); // 进确认视图
await w.findAll('.fpc-confirm')[0].trigger('click'); // 确认创建
await flushPromises();
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
const body = bioflowApi.createBioflowJob.mock.calls[0][0];
expect(body.stages).toEqual(['identify', 'align', 'phylogeny']);
});
it('确认按钮双击只触发一次创建(TC-BIO-ACT-008 防重复)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[0].trigger('click');
await w.find('.fpc-confirm').trigger('click');
const confirmBtn = w.findAll('.fpc-confirm')[0];
await confirmBtn.trigger('click');
await confirmBtn.trigger('click');
await flushPromises();
expect(bioflowApi.createBioflowJob).toHaveBeenCalledTimes(1);
});
it('键盘 Enter 触发确认(TC-BIO-ACT-010)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.findAll('.fpc-row')[0].trigger('click');
await w.find('.fpc-confirm').trigger('keydown.enter');
expect(w.find('.fpc-confirm-stages').exists()).toBe(true);
});
it('键盘 Esc 取消聚焦:无确认(TC-BIO-ACT-011)', async () => {
const w = mount(FlowPathCard, { props: { block: PATH_BLOCK() } });
await w.find('.fpc-confirm').trigger('keydown.esc');
expect(w.find('.fpc-confirm-stages').exists()).toBe(false);
});
it('空候选不渲染候选行且确认禁用(TC-BIO-PATH-010)', () => {
const w = mount(FlowPathCard, { props: { block: { ...PATH_BLOCK(), candidates: [] } } });
expect(w.findAll('.fpc-row').length).toBe(0);
expect(w.find('.fpc-confirm').attributes('disabled')).toBeDefined();
});
it('前置补全明示:auto_filled 显示「已自动补全」(TC-BIO-PATH-012)', () => {
const blk = PATH_BLOCK();
blk.autoFilled = ['align'];
const w = mount(FlowPathCard, { props: { block: blk } });
expect(w.find('.stage.autofill').text()).toContain('已自动补全');
});
it('复用句式:头部显示「复用存档 XXX 的方法」徽章(TC-BIO-PANE 复用展示)', () => {
const blk = { ...PATH_BLOCK(), reuseName: 'XXX', reuseArchive: 'arc-1', selected: 0 };
const w = mount(FlowPathCard, { props: { block: blk } });
expect(w.find('.badge.cap').text()).toContain('XXX');
});
it('规划降级明示:fallback 文案显示(失败必须可见)', () => {
const blk = { ...PATH_BLOCK(), fallback: '内置兜底链:identify' };
const w = mount(FlowPathCard, { props: { block: blk } });
expect(w.find('.fpc-fallback').text()).toContain('内置兜底链');
});
});
const FORM_BLOCK = () => ({
type: 'bioflow_form',
stage: 'identify',
fields: [
{ name: 'cpu', type: 'int', default: 4, source: 'contract', help: '' },
{ name: 'prefix', type: 'string', default: 'Ft.SPL', source: 'contract', help: '' },
{ name: 'species', type: 'string', default: '', source: 'user', help: '' },
],
autoContinue: false,
rerun: false,
submitting: false,
});
describe('FlowFormCard —— TC-BIO-FORM-001~024(L4)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('标题「⚙ identify 步骤 · 可调整参数」+ 前置文案(TC-BIO-FORM-001/002)', () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
expect(w.find('.flow-form-card').exists()).toBe(true);
expect(w.find('.ffc-title').text()).toContain('identify');
expect(w.find('.ffc-prefix').text()).toContain('可调整参数');
});
it('字段渲染 3 个字段行;默认值显示(TC-BIO-FORM-003/004)', () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
const fields = w.findAll('.ffc-field');
expect(fields.length).toBe(3);
const inputs = w.findAll('.ffc-input');
expect(inputs[0].element.value).toBe('4'); // cpu 默认 4
});
it('来源标注:契约文档来源文案(TC-BIO-FORM-005)', () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
expect(w.find('.ffc-source').text()).toContain('默认值来源:阶段契约文档');
});
it('编辑参数:v-model 值更新(TC-BIO-FORM-007)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
const cpu = w.findAll('.ffc-input')[0];
await cpu.setValue('8');
expect(cpu.element.value).toBe('8');
});
it('继续按钮:发出 confirm 请求 body.params.cpu=8(TC-BIO-FORM-008/ACT-020)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
await w.findAll('.ffc-input')[0].setValue('8');
await w.find('.ffc-continue').trigger('click');
await flushPromises();
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
const [jobId, stage, params] = bioflowApi.confirmBioflowStage.mock.calls[0];
expect(jobId).toBe('job-1');
expect(stage).toBe('identify');
expect(params.cpu).toBe(8);
});
it('继续按钮双击仅一次请求(TC-BIO-ACT-021 提交中禁用)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
const btn = w.find('.ffc-continue');
await btn.trigger('click');
await btn.trigger('click');
await flushPromises();
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
});
it('数字类型校验:输入 abc 拦截(TC-BIO-FORM-014)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
await w.findAll('.ffc-input')[0].setValue('abc');
await w.find('.ffc-continue').trigger('click');
await flushPromises();
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
expect(w.find('.ffc-err').text()).toContain('类型');
});
it('范围校验:min/max 越界拦截(TC-BIO-FORM-016)', async () => {
const blk = FORM_BLOCK();
blk.fields[0] = { name: 'cpu', type: 'int', default: 4, min: 1, max: 64, source: 'contract' };
const w = mount(FlowFormCard, { props: { block: blk } });
await w.findAll('.ffc-input')[0].setValue('999');
await w.find('.ffc-continue').trigger('click');
expect(bioflowApi.confirmBioflowStage).not.toHaveBeenCalled();
expect(w.find('.ffc-err').text()).toContain('1 ~ 64');
});
it('可选参数留空不发送(TC-BIO-FORM-017)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
// species 默认空字符串
await w.find('.ffc-continue').trigger('click');
await flushPromises();
const [, , params] = bioflowApi.confirmBioflowStage.mock.calls[0];
expect(params.species).toBeUndefined();
});
it('键盘 Enter 提交(TC-BIO-FORM-023/ACT-016)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
await w.findAll('.ffc-input')[0].trigger('keydown', { key: 'Enter' });
await flushPromises();
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledTimes(1);
});
it('键盘 Esc 恢复默认值(TC-BIO-FORM-024/ACT-017)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
const cpu = w.findAll('.ffc-input')[0];
await cpu.setValue('8');
await cpu.trigger('keydown', { key: 'Escape' });
expect(cpu.element.value).toBe('4');
});
it('自动连续开关:点击切换状态(TC-BIO-FORM-010/011/012/ACT-027/028)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
const pill = w.find('.mode-pill');
expect(pill.classes()).not.toContain('on');
await pill.trigger('click');
expect(pill.classes()).toContain('on');
expect(useBioflowStore().currentJob.autoContinue).toBe(true);
await pill.trigger('click');
expect(pill.classes()).not.toContain('on');
expect(useBioflowStore().currentJob.autoContinue).toBe(false);
});
it('自动连续开关键盘 Enter 切换(TC-BIO-ACT-031)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
await w.find('.mode-pill').trigger('keydown.enter');
expect(w.find('.mode-pill').classes()).toContain('on');
});
it('自动连续开关 Esc 状态不变(TC-BIO-ACT-032)', async () => {
const w = mount(FlowFormCard, { props: { block: FORM_BLOCK() } });
await w.find('.mode-pill').trigger('keydown.esc');
expect(w.find('.mode-pill').classes()).not.toContain('on');
});
it('改参数重跑模式:提交走 rerun 带参数(TC-BIO-FORM + FR-BIO-012 组合)', async () => {
const blk = { ...FORM_BLOCK(), rerun: true };
const w = mount(FlowFormCard, { props: { block: blk } });
await w.findAll('.ffc-input')[0].setValue('2');
await w.find('.ffc-continue').trigger('click');
await flushPromises();
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-1', 'identify', expect.objectContaining({ cpu: 2 }));
});
});
const FILE_BLOCK = () => ({
type: 'bioflow_file',
stage: 'identify',
requiredFiles: ['SPL.fa'],
message: '请将数据文件拖入窗口上传至服务器,或在聊天窗口中指定数据文件的具体路径',
files: [],
progress: 0,
});
describe('FlowFileCard —— TC-BIO-FILE-001~022(L4)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('缺失提示文案点名文件名 + 卡体存在(TC-BIO-FILE-001/002)', () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
expect(w.find('.flow-file-card').exists()).toBe(true);
expect(w.find('.ffile-required').text()).toContain('SPL.fa');
});
it('拖放区视觉规格类名(TC-BIO-FILE-016)', () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
expect(w.find('.ffile-drop').exists()).toBe(true);
});
it('拖入悬停:拖入态高亮(TC-BIO-FILE-017/ACT-036)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
const drop = w.find('.ffile-drop');
const dt = { types: ['Files'], files: [] };
await drop.trigger('dragover', { dataTransfer: dt });
expect(drop.classes()).toContain('active');
});
it('离开:拖入态移除(TC-BIO-ACT-037)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
const drop = w.find('.ffile-drop');
await drop.trigger('dragover', { dataTransfer: { types: ['Files'], files: [] } });
await drop.trigger('dragleave');
expect(drop.classes()).not.toContain('active');
});
it('拖入释放:触发上传(TC-BIO-FILE-003/ACT-038)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
const file = new File(['>seq'], 'SPL.fa', { type: 'text/plain' });
const drop = w.find('.ffile-drop');
await drop.trigger('drop', { dataTransfer: { types: ['Files'], files: [file] } });
await flushPromises();
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
});
it('点击拖放区:打开文件选择框(TC-BIO-FILE-006/ACT-034;file input click 触发)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
const input = w.find('input[type="file"]');
const clickSpy = vi.spyOn(input.element, 'click');
await w.find('.ffile-drop').trigger('click');
expect(clickSpy).toHaveBeenCalled();
});
it('路径输入行存在 + 登记路径按钮(TC-BIO-FILE-008)', () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
expect(w.find('.ffile-path-input').exists()).toBe(true);
expect(w.find('.ffile-path-row .pane-btn').exists()).toBe(true);
});
it('登记合法路径:发出 POST files {"server_path"}(TC-BIO-FILE-009/ACT-045)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
await w.find('.ffile-path-input').setValue('/data/examples/SPL.fa');
await w.find('.ffile-path-row .pane-btn').trigger('click');
await flushPromises();
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalledWith('job-1', { server_path: '/data/examples/SPL.fa' });
});
it('登记路径按钮空 input 禁用(TC-BIO-ACT-048)', () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
expect(w.find('.ffile-path-row .pane-btn').attributes('disabled')).toBeDefined();
});
it('路径输入 Enter 提交(TC-BIO-FILE-020/ACT-043)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
await w.find('.ffile-path-input').setValue('/data/SPL.fa');
await w.find('.ffile-path-input').trigger('keydown', { key: 'Enter' });
await flushPromises();
expect(bioflowApi.uploadBioflowFile).toHaveBeenCalled();
});
it('路径输入 Esc 清空(TC-BIO-ACT-044)', async () => {
const w = mount(FlowFileCard, { props: { block: FILE_BLOCK() } });
const input = w.find('.ffile-path-input');
await input.setValue('/data/SPL.fa');
await input.trigger('keydown', { key: 'Escape' });
expect(input.element.value).toBe('');
});
it('上传中拖放区禁用重复(TC-BIO-FILE-013/ACT-039)', async () => {
const blk = FILE_BLOCK();
blk.files = [{ name: 'a.fa', size: 10, status: 'uploading', progress: 0.4 }];
const w = mount(FlowFileCard, { props: { block: blk } });
expect(w.find('.ffile-drop').classes()).toContain('busy');
const file = new File(['x'], 'b.fa');
await w.find('.ffile-drop').trigger('drop', { dataTransfer: { types: ['Files'], files: [file] } });
expect(bioflowApi.uploadBioflowFile).not.toHaveBeenCalled();
});
it('上传中进度条可见:百分比 11px mono 旁注(TC-BIO-FILE-004/ACT-040)', () => {
const blk = FILE_BLOCK();
blk.files = [{ name: 'a.fa', size: 10, status: 'uploading', progress: 0.45 }];
const w = mount(FlowFileCard, { props: { block: blk } });
expect(w.find('.prog-track').exists()).toBe(true);
expect(w.find('.ffile-pct').text()).toBe('45%');
const fill = w.find('.prog-fill');
expect(fill.attributes('style')).toContain('45%');
});
it('上传失败显示错误(TC-BIO-FILE-012 失败可见)', () => {
const blk = FILE_BLOCK();
blk.files = [{ name: 'a.fa', size: 10, status: 'error', error: '上传失败' }];
const w = mount(FlowFileCard, { props: { block: blk } });
expect(w.find('.ffile-err').text()).toContain('上传失败');
});
});
const PROG_BLOCK = () => ({
type: 'bioflow_progress',
jobId: 'job-1',
jobStatus: 'running',
autoContinue: false,
stages: [
{ stage: 'identify', status: 'done', log_tail: '', error: null },
{ stage: 'align', status: 'running', log_tail: 'aligning...\nline2', error: null },
{ stage: 'phylogeny', status: 'waiting', log_tail: '', error: null },
],
});
describe('FlowProgressCard —— TC-BIO-PROG-001~024(L4)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('进度卡渲染:结构/状态点三态(done=✓ running=● waiting=◌;TC-BIO-PROG-001/002)', () => {
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
expect(w.find('.bioflow-progress-card').exists()).toBe(true);
const dots = w.findAll('.p-dot');
expect(dots[0].text()).toBe('✓');
expect(dots[1].text()).toBe('●');
expect(dots[2].text()).toBe('◌');
expect(w.findAll('.p-step')[0].classes()).toContain('done');
expect(w.findAll('.p-step')[1].classes()).toContain('running');
expect(w.findAll('.p-step')[2].classes()).toContain('waiting');
});
it('日志尾区仅 running/failed 展开;done 阶段收起(TC-BIO-PROG-003/005)', () => {
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
const logs = w.findAll('.pc-log');
expect(logs.length).toBe(1);
expect(logs[0].text()).toContain('align');
expect(logs[0].find('.pc-log-body').text()).toContain('aligning...');
});
it('日志尾 max-height 96px 滚动(TC-BIO-PROG-023;CSS 断言经 class)', () => {
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
expect(w.find('.pc-log-body').exists()).toBe(true);
});
it('失败标红 ✕ + 状态文字 error 色 + 三选一按钮出现(TC-BIO-PROG-006/007)', () => {
const blk = PROG_BLOCK();
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: 'error', error: 'manifest_failed' };
const w = mount(FlowProgressCard, { props: { block: blk } });
const failedStep = w.findAll('.p-step')[1];
expect(failedStep.classes()).toContain('failed');
expect(failedStep.find('.p-dot').text()).toBe('✕');
const actions = failedStep ? w.findAll('.pc-actions')[0] : null;
expect(actions.text()).toContain('重跑');
expect(actions.text()).toContain('跳过');
expect(actions.text()).toContain('改参数重跑');
});
it('重跑:发出 rerun 请求(TC-BIO-PROG-008/ACT-050)', async () => {
const blk = PROG_BLOCK();
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
const w = mount(FlowProgressCard, { props: { block: blk } });
const btn = w.findAll('.pc-actions .pane-btn')[0];
await btn.trigger('click');
await flushPromises();
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('job-1', 'align', null);
});
it('跳过:发出 skip 请求(TC-BIO-PROG-009/ACT-051)', async () => {
const blk = PROG_BLOCK();
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
const w = mount(FlowProgressCard, { props: { block: blk } });
await w.findAll('.pc-actions .pane-btn')[1].trigger('click');
await flushPromises();
expect(bioflowApi.skipBioflowStage).toHaveBeenCalledWith('job-1', 'align');
});
it('改参数重跑:弹出参数表单(TC-BIO-PROG-010/ACT-052)', async () => {
const blk = PROG_BLOCK();
blk.stages[1] = { stage: 'align', status: 'failed', log_tail: '', error: 'x' };
const w = mount(FlowProgressCard, { props: { block: blk } });
await w.findAll('.pc-actions .pane-btn')[2].trigger('click');
await flushPromises();
expect(w.find('.pc-rerun-form').exists()).toBe(true);
expect(bioflowApi.bioflowCatalog).toHaveBeenCalledWith({ stage: 'align' });
});
it('skipped 态视觉(TC-BIO-PROG-011)', () => {
const blk = PROG_BLOCK();
blk.stages[1] = { stage: 'align', status: 'skipped', log_tail: '', error: null };
const w = mount(FlowProgressCard, { props: { block: blk } });
expect(w.findAll('.p-step')[1].classes()).toContain('skipped');
expect(w.findAll('.p-dot')[1].text()).toBe('◌');
});
it('阶段 >10 自动折叠;组头展开可见全部(TC-BIO-PROG-012/013/ACT-058)', async () => {
const blk = PROG_BLOCK();
blk.stages = Array.from({ length: 15 }, (_, i) => ({
stage: `s${i + 1}`, status: i < 2 ? 'done' : i === 2 ? 'running' : 'waiting', log_tail: '', error: null,
}));
const w = mount(FlowProgressCard, { props: { block: blk } });
const heads = w.findAll('.pc-group-head');
expect(heads.length).toBeGreaterThan(0);
// 默认展开运行中所在组(15 阶段分 2 组:8+7;s3 running 在组 0,默认展开组 0)
const visibleSteps = w.findAll('.p-step');
expect(visibleSteps.length).toBe(8); // 组 0 展开 8 阶段
expect(visibleSteps.length).toBeLessThan(15);
// 点击未展开的组头(组 1)展开该组
await heads[1].trigger('click');
expect(w.findAll('.p-step').length).toBe(15);
// 再点组 0 组头收起
await heads[0].trigger('click');
expect(w.findAll('.p-step').length).toBe(7);
});
it('≤10 阶段不折叠(TC-BIO-PROG-014)', () => {
const blk = PROG_BLOCK();
blk.stages = Array.from({ length: 10 }, (_, i) => ({
stage: `s${i + 1}`, status: 'waiting', log_tail: '', error: null,
}));
const w = mount(FlowProgressCard, { props: { block: blk } });
expect(w.findAll('.pc-group-head').length).toBe(0);
expect(w.findAll('.p-step').length).toBe(10);
});
it('暂停/恢复按钮随任务状态显隐;点击发请求(TC-BIO-PROG-017/018)', async () => {
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
// running → 暂停按钮
let pauseBtn = w.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('暂停'));
expect(pauseBtn).toBeTruthy();
await pauseBtn.trigger('click');
await flushPromises();
expect(bioflowApi.pauseBioflowJob).toHaveBeenCalledWith('job-1');
// paused → 恢复按钮
const blk = { ...PROG_BLOCK(), jobStatus: 'paused' };
const w2 = mount(FlowProgressCard, { props: { block: blk } });
const resumeBtn = w2.findAll('.pc-head-right .pane-btn').find((b) => b.text().includes('继续'));
expect(resumeBtn).toBeTruthy();
await resumeBtn.trigger('click');
await flushPromises();
expect(bioflowApi.resumeBioflowJob).toHaveBeenCalledWith('job-1');
});
it('快照整体替换:块 stages 引用变化后自动整体刷新(TC-BIO-PROG-015)', async () => {
const w = mount(FlowProgressCard, { props: { block: PROG_BLOCK() } });
const before = w.findAll('.p-step').length;
const blk = PROG_BLOCK();
blk.stages = [
{ stage: 'identify', status: 'done', log_tail: '', error: null },
{ stage: 'align', status: 'running', log_tail: 'snap', error: null },
{ stage: 'phylogeny', status: 'waiting', log_tail: '', error: null },
{ stage: 'tree', status: 'waiting', log_tail: '', error: null },
];
await w.setProps({ block: blk });
expect(w.findAll('.p-step').length).toBe(4);
expect(before).toBe(3);
});
});
const ARCHIVE_BLOCK = () => ({
type: 'bioflow_archive',
jobId: 'job-1',
status: 'pending',
name: '',
description: '',
result: null,
error: null,
});
describe('FlowArchiveCard —— TC-BIO-ARCH-001~012(L4)', () => {
beforeEach(() => { setup(); vi.clearAllMocks(); });
it('存档提示文案 12.5px + 名称/描述输入(TC-BIO-ARCH-001/002/003)', () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
expect(w.find('.flow-archive-card').exists()).toBe(true);
expect(w.find('.fac-title').text()).toContain('本次生信任务已完成');
expect(w.findAll('.fac-input').length).toBe(2);
});
it('点存档:发出 POST archives 含 job_id/name/description(TC-BIO-ARCH-004/ACT-079)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.findAll('.fac-input')[0].setValue('SPL 水稻鉴定');
await w.findAll('.fac-input')[1].setValue('描述文字');
await w.find('.fac-archive').trigger('click');
await flushPromises();
expect(bioflowApi.createBioflowArchive).toHaveBeenCalledWith(
expect.objectContaining({ job_id: 'job-1', name: 'SPL 水稻鉴定', description: '描述文字' }),
);
});
it('存档按钮双击仅一次请求(TC-BIO-ACT-080)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.findAll('.fac-input')[0].setValue('SPL');
const btn = w.find('.fac-archive');
await btn.trigger('click');
await btn.trigger('click');
await flushPromises();
expect(bioflowApi.createBioflowArchive).toHaveBeenCalledTimes(1);
});
it('名称空:存档按钮禁用(TC-BIO-ARCH-008/ACT-083)', () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
expect(w.find('.fac-archive').attributes('disabled')).toBeDefined();
});
it('名称输入 Enter 触发存档(TC-BIO-ACT-076)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.findAll('.fac-input')[0].setValue('SPL');
await w.findAll('.fac-input')[0].trigger('keydown', { key: 'Enter' });
await flushPromises();
expect(bioflowApi.createBioflowArchive).toHaveBeenCalledTimes(1);
});
it('名称输入 Esc 清空(TC-BIO-ACT-077)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
const input = w.findAll('.fac-input')[0];
await input.setValue('SPL');
await input.trigger('keydown', { key: 'Escape' });
expect(input.element.value).toBe('');
});
it('放弃:卡片消失 + 无请求(TC-BIO-ARCH-006/ACT-085)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.find('.pane-btn').trigger('click');
expect(w.find('.flow-archive-card').exists()).toBe(false);
expect(bioflowApi.createBioflowArchive).not.toHaveBeenCalled();
});
it('放弃键盘 Enter(TC-BIO-ACT-087)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.find('.pane-btn').trigger('keydown.enter');
expect(w.find('.flow-archive-card').exists()).toBe(false);
});
it('放弃按钮 Esc 卡片保留(TC-BIO-ACT-088)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.find('.pane-btn').trigger('keydown.esc');
expect(w.find('.flow-archive-card').exists()).toBe(true);
});
it('存档中:存档按钮与放弃按钮 disabled(TC-BIO-ARCH-009/ACT-084/089)', () => {
const blk = { ...ARCHIVE_BLOCK(), status: 'archiving', name: 'SPL' };
const w = mount(FlowArchiveCard, { props: { block: blk } });
expect(w.find('.fac-archive').attributes('disabled')).toBeDefined();
expect(w.find('.pane-btn').attributes('disabled')).toBeDefined();
});
it('存档失败:错误文案 + 按钮恢复可重试(TC-BIO-ARCH-010)', async () => {
bioflowApi.createBioflowArchive.mockRejectedValueOnce({ message: '重名' });
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.findAll('.fac-input')[0].setValue('SPL');
await w.find('.fac-archive').trigger('click');
await flushPromises();
expect(w.find('.fac-error').text()).toContain('重名');
expect(w.find('.fac-archive').attributes('disabled')).toBeUndefined();
});
it('存档状态回复:名称/位置/大小三要素(TC-BIO-ARCH-007/ACT 存档完成态)', async () => {
const w = mount(FlowArchiveCard, { props: { block: ARCHIVE_BLOCK() } });
await w.findAll('.fac-input')[0].setValue('SPL');
await w.find('.fac-archive').trigger('click');
await flushPromises();
expect(w.find('.fac-title').text()).toContain('任务已存档');
expect(w.find('.fac-meta').text()).toContain('位置');
expect(w.find('.fac-meta').text()).toContain('大小');
});
it('任务未完成无提示(block 不存在即不渲染;TC-BIO-ARCH-012 由消息更新器保证)', () => {
const m = { type: 'text', html: 'x' };
const w = mount(FlowArchiveCard, { props: { block: m } });
expect(w.find('.flow-archive-card').exists()).toBe(true); // 组件自身渲染,提示出现与否由消息流控制
});
});