755 lines
34 KiB
JavaScript
755 lines
34 KiB
JavaScript
// @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); // 组件自身渲染,提示出现与否由消息流控制
|
||
});
|
||
});
|