Files
PlantAI-rag-new/tests/frontend/unit/bioflow/bioflow.store.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

389 lines
17 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/bioflow.store.test.js —— 生信 store 状态机单测(L3)
// 覆盖:applyEvent 逐帧推进(waiting→running→done 逐帧一致)、乱序容错(终态不回退)、
// 快照整体替换(引用变化)、日志尾追加、job_created 装载、存档提示生成、
// 探活(200→online / 500→offline)、面板任务/档案列表、复用预填来源标注、动作分发。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import {
useBioflowStore,
applyEvent,
applySnapshot,
capLogTail,
STAGE_STATUS_BY_EVENT,
} from '../../../../ui/src/stores/bioflow.js';
// mock api/bioflow.js(SSE 用 mock,不依赖生产服务;vi.mock 工厂内不得引用顶层变量)
vi.mock('../../../../ui/src/api/bioflow.js', () => ({
bioflowHealth: vi.fn(async () => ({ ok: true, status: 'ok', db: { connected: true } })),
createBioflowJob: vi.fn(async (body) => ({
ok: true,
data: {
id: 'job-1', title: body.title, status: 'created', session_id: body.session_id,
stages: body.stages.map((s) => ({ stage: s, status: 'waiting' })),
auto_continue: body.auto_continue, archive_id: body.archive_id || null,
},
})),
listBioflowJobs: vi.fn(async () => ({
ok: true,
data: {
items: [
{ id: 'job-2', title: 'B', status: 'running', progress: 33 },
{ id: 'job-1', title: 'A', status: 'done', progress: 100 },
],
total: 2,
},
})),
getBioflowJob: vi.fn(async () => ({
ok: true,
data: {
id: 'job-1', title: 'A', status: 'running',
stages: [{ stage: 'identify', status: 'running' }, { stage: 'align', status: 'waiting' }],
},
})),
deleteBioflowJob: vi.fn(async () => {}),
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' } })),
getBioflowStageManifest: vi.fn(async () => ({
ok: true,
data: { manifest: { status: 'success' }, log_tail: 'line1\nline2' },
})),
getBioflowStageLogs: vi.fn(async (jobId, stage, params) => ({
ok: true,
data: { lines: ['L0', 'L1', 'L2'], offset: 3, limit: 500, eof: false, log_tail: '' },
})),
listBioflowArchives: vi.fn(async () => ({
ok: true,
data: {
items: [
{ id: 'arc-1', name: 'SPL 水稻鉴定', description: 'd', size_bytes: 1024, reused_count: 1 },
],
total: 1,
},
})),
getBioflowArchive: vi.fn(async () => ({
ok: true,
data: {
id: 'arc-1', name: 'SPL 水稻鉴定', stage_chain: ['identify', 'align'],
params: { identify: { prefix: 'Ft.SPL' } },
},
})),
deleteBioflowArchive: vi.fn(async () => {}),
runBioflowArchive: vi.fn(async () => ({
ok: true,
data: { id: 'job-9', title: 'SPL 水稻鉴定(复用)', status: 'created', stages: [] },
})),
createBioflowArchive: vi.fn(async () => ({
ok: true,
data: { id: 'arc-1', name: 'SPL 水稻鉴定', disk_path: '/data/archives/arc-1', size_bytes: 2048 },
})),
}));
import * as bioflowApi from '../../../../ui/src/api/bioflow.js';
function setup() {
setActivePinia(createPinia());
const store = useBioflowStore();
return store;
}
const ev = (type, data, seq) => ({ type, data, seq: seq ?? 0 });
describe('applyEvent —— 阶段状态机逐帧推进', () => {
it('waiting → running → done 逐帧一致(TC-BIO-STORE-005)', () => {
let s = { jobId: 'j1', jobStatus: 'created', stages: [{ stage: 'identify', status: 'waiting' }], lastSeq: 0 };
s = applyEvent(s, ev('stage_started', { job_id: 'j1', stage: 'identify', attempt: 1 }, 1));
expect(s.stages[0].status).toBe('running');
expect(s.jobStatus).toBe('running');
s = applyEvent(s, ev('stage_log', { job_id: 'j1', stage: 'identify', log: 'aaa' }, 2));
expect(s.stages[0].status).toBe('running');
expect(s.stages[0].log_tail).toBe('aaa');
s = applyEvent(s, ev('stage_done', { job_id: 'j1', stage: 'identify', exit_code: 0 }, 3));
expect(s.stages[0].status).toBe('done');
// 任务级终态由 job_done 事件驱动(后端状态机语义)
s = applyEvent(s, ev('job_done', { job_id: 'j1', progress: 100 }, 4));
expect(s.jobStatus).toBe('done');
});
it('失败事件置 failed 且带 error 文案;任务保持 running 等待三选一(TC-BIO-STORE-006)', () => {
let s = { jobId: 'j1', jobStatus: 'running', stages: [{ stage: 'identify', status: 'running' }], lastSeq: 0 };
s = applyEvent(s, ev('stage_failed', { job_id: 'j1', stage: 'identify', exit_code: 1, reason: 'manifest_failed' }, 2));
expect(s.stages[0].status).toBe('failed');
expect(s.stages[0].error).toBe('manifest_failed');
expect(s.jobStatus).toBe('running');
});
it('日志尾追加为整体,长度=三帧之和(TC-BIO-STORE-007)', () => {
let s = { jobId: 'j1', jobStatus: 'running', stages: [{ stage: 'a', status: 'running', log_tail: '' }], lastSeq: 0 };
s = applyEvent(s, ev('stage_log', { job_id: 'j1', stage: 'a', log: 'x' }, 1));
s = applyEvent(s, ev('stage_log', { job_id: 'j1', stage: 'a', log: 'y' }, 2));
s = applyEvent(s, ev('stage_log', { job_id: 'j1', stage: 'a', log: 'z' }, 3));
expect(s.stages[0].log_tail).toBe('xyz');
});
it('乱序容错:done 不再变 running(TC-BIO-STORE-020)', () => {
let s = { jobId: 'j1', jobStatus: 'running', stages: [{ stage: 'a', status: 'done' }], lastSeq: 3 };
s = applyEvent(s, ev('stage_started', { job_id: 'j1', stage: 'a' }, 2)); // 旧序号乱序
expect(s.stages[0].status).toBe('done');
});
it('job_created 装载阶段链与 auto_continue', () => {
let s = { jobId: null, jobStatus: null, stages: [], lastSeq: 0 };
s = applyEvent(s, ev('job_created', {
job_id: 'j1', title: 'SPL', auto_continue: true,
stages: [{ stage: 'identify', status: 'waiting' }, { stage: 'align', status: 'waiting' }],
}, 1));
expect(s.jobId).toBe('j1');
expect(s.stages).toHaveLength(2);
expect(s.stages.every((x) => x.status === 'waiting')).toBe(true);
});
it('job_paused/job_done 任务状态映射', () => {
let s = { jobId: 'j1', jobStatus: 'running', stages: [], lastSeq: 0 };
s = applyEvent(s, ev('job_paused', { job_id: 'j1', current_stage: 'a' }, 5));
expect(s.jobStatus).toBe('paused');
s = applyEvent(s, ev('job_done', { job_id: 'j1', progress: 100 }, 6));
expect(s.jobStatus).toBe('done');
});
});
describe('applySnapshot —— 快照整体替换(TC-BIO-STORE-008)', () => {
it('阶段数组被快照整体替换(引用变化)', () => {
const prev = {
jobId: 'j1', jobStatus: 'running', lastSeq: 5,
stages: [{ stage: 'a', status: 'done' }, { stage: 'b', status: 'running' }],
};
const next = applySnapshot({
job_id: 'j1',
job: { status: 'running' },
stages: [{ stage: 'a', status: 'done' }, { stage: 'b', status: 'running' }, { stage: 'c', status: 'waiting' }],
last_seq: 8,
}, prev);
expect(next.stages).not.toBe(prev.stages);
expect(next.stages.map((s) => [s.stage, s.status])).toEqual([
['a', 'done'], ['b', 'running'], ['c', 'waiting'],
]);
expect(next.lastSeq).toBe(8);
});
});
describe('bioflow store —— 探活 / 动作 / 面板 / 复用', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
it('探活 200 → online;失败 → offline(TC-BIO-STORE-016)', async () => {
const store = useBioflowStore();
bioflowApi.bioflowHealth.mockResolvedValueOnce({ ok: true });
await store.probe({ force: true });
expect(store.bioflowAvailable).toBe(true);
const s2 = useBioflowStore();
s2.availability = 'unknown';
bioflowApi.bioflowHealth.mockRejectedValueOnce(new Error('down'));
await s2.probe({ force: true });
expect(s2.availability).toBe('offline');
expect(s2.bioflowAvailable).toBe(false);
});
it('applyChatEvent plan_ready 装载候选路径(TC-BIO-STORE-003)', () => {
const store = useBioflowStore();
store.applyChatEvent('plan_ready', {
candidates: [
{ purpose: '标准流程', stages: ['identify', 'align'] },
{ purpose: '精简流程', stages: ['identify'] },
],
source: 'llm_rag', auto_filled: ['align'], reuse_archive: null, reuse_name: null, fallback: null,
});
expect(store.currentJob.plan.candidates).toHaveLength(2);
expect(store.currentJob.plan.autoFilled).toEqual(['align']);
});
it('createJobFromPlan 创建任务并写入 store(TC-BIO-STORE-004)', async () => {
const store = useBioflowStore();
await store.createJobFromPlan({
sessionId: 's1', title: 'SPL', stages: ['identify', 'align'],
params: {}, autoContinue: true, archiveId: null,
});
expect(store.currentJob.jobId).toBe('job-1');
expect(store.currentJob.jobStatus).toBe('created');
expect(store.currentJob.stages).toHaveLength(2);
expect(bioflowApi.createBioflowJob).toHaveBeenCalledWith(
expect.objectContaining({ session_id: 's1', stages: ['identify', 'align'], auto_continue: true }),
);
});
it('confirmStage/rerunStage/skipStage/pause/resume 动作分发(TC-BIO-STORE-011/013/014)', async () => {
const store = useBioflowStore();
store.currentJob.jobId = 'j1';
await store.confirmStage('identify', { cpu: 8 });
expect(bioflowApi.confirmBioflowStage).toHaveBeenCalledWith('j1', 'identify', { cpu: 8 });
await store.rerunStage('identify', null);
expect(bioflowApi.rerunBioflowStage).toHaveBeenCalledWith('j1', 'identify', null);
await store.skipStage('identify');
expect(bioflowApi.skipBioflowStage).toHaveBeenCalledWith('j1', 'identify');
await store.pauseJob();
expect(bioflowApi.pauseBioflowJob).toHaveBeenCalledWith('j1');
await store.resumeJob();
expect(bioflowApi.resumeBioflowJob).toHaveBeenCalledWith('j1');
});
it('job_done 生成存档提示(FR-BIO-020);dismiss 后状态 dismissed', () => {
const store = useBioflowStore();
store.applyJobEvent(ev('job_created', { job_id: 'j1', stages: [{ stage: 'a', status: 'waiting' }] }));
store.applyJobEvent(ev('job_done', { job_id: 'j1', progress: 100 }));
expect(store.archiveSuggestion.j1).toBeTruthy();
expect(store.archiveSuggestion.j1.status).toBe('pending');
store.dismissArchive('j1');
expect(store.archiveSuggestion.j1.status).toBe('dismissed');
});
it('archiveJob 存档结果三要素写入(TC-BIO-STORE-017)', async () => {
const store = useBioflowStore();
store.archiveSuggestion.j1 = { jobId: 'j1', status: 'pending', name: '', description: '' };
const result = await store.archiveJob('j1', { name: 'SPL 水稻鉴定', description: 'd' });
expect(result.name).toBe('SPL 水稻鉴定');
expect(result.disk_path).toBeTruthy();
expect(result.size_bytes).toBeGreaterThan(0);
expect(store.archiveSuggestion.j1.status).toBe('done');
});
it('loadJobs 面板任务列表(TC-BIO-STORE-018)', async () => {
const store = useBioflowStore();
await store.loadJobs({ force: true });
expect(store.jobs).toHaveLength(2);
expect(store.jobsTotal).toBe(2);
});
it('loadArchives 档案列表(TC-BIO-STORE-019)', async () => {
const store = useBioflowStore();
await store.loadArchives({ force: true });
expect(store.archives).toHaveLength(1);
expect(store.archives[0].name).toBe('SPL 水稻鉴定');
});
it('loadReusePrefill 复用预填:参数来源标注=存档名(TC-BIO-STORE-015)', async () => {
const store = useBioflowStore();
await store.loadReusePrefill('arc-1');
expect(store.reusePrefill.stageChain).toEqual(['identify', 'align']);
expect(store.reusePrefill.params.identify.prefix.value).toBe('Ft.SPL');
expect(store.reusePrefill.params.identify.prefix.source).toBe('archive');
expect(store.reusePrefill.params.identify.prefix.archiveName).toBe('SPL 水稻鉴定');
});
it('loadManifest 缓存 manifest+日志尾', async () => {
const store = useBioflowStore();
const data = await store.loadManifest('j1', 'identify');
expect(data.manifest.status).toBe('success');
expect(data.log_tail).toContain('line1');
expect(store.manifests['j1:identify'].loading).toBe(false);
});
it('loadStageLogs 分页增量追加 + 轮询启停', async () => {
const store = useBioflowStore();
const rec = await store.loadStageLogs('j1', 'identify');
expect(rec.lines).toEqual(['L0', 'L1', 'L2']);
expect(rec.offset).toBe(3);
expect(rec.eof).toBe(false);
// 轮询启动(fake timers)
vi.useFakeTimers();
store.startStageLogPolling('j1', 'identify');
expect(store.stageLogs['j1:identify'].timer).toBeTruthy();
await vi.advanceTimersByTimeAsync(3100);
vi.useRealTimers();
store.stopStageLogPolling('j1', 'identify');
expect(store.stageLogs['j1:identify'].timer).toBeNull();
});
it('requestPanelLocate/clearPanelLocate 定位请求(nonce 递增)', () => {
const store = useBioflowStore();
store.requestPanelLocate({ jobId: 'j1', stage: 'identify' });
expect(store.panelLocate).toEqual({ jobId: 'j1', stage: 'identify', nonce: 1 });
store.requestPanelLocate({ jobId: 'j1', stage: 'align' });
expect(store.panelLocate.nonce).toBe(2);
store.clearPanelLocate();
expect(store.panelLocate).toBeNull();
});
it('会话切换 resetSession 隔离(TC-BIO-STORE-021)', () => {
const store = useBioflowStore();
store.currentJob.jobId = 'j1';
store.currentJob.stages = [{ stage: 'a', status: 'running' }];
store.reusePrefill = { archive: {} };
store.resetSession();
expect(store.currentJob.jobId).toBeNull();
expect(store.currentJob.stages).toHaveLength(0);
expect(store.reusePrefill).toBeNull();
});
it('connectJobEvents 快照首帧整体刷新(TC-BIO-STORE-009)', () => {
const store = useBioflowStore();
store.currentJob.jobId = 'j1';
store.currentJob.stages = [{ stage: 'a', status: 'waiting' }];
let listeners = {};
class FakeES {
constructor(url) { this.url = url; this.onclose = null; this.onerror = null; }
addEventListener(t, fn) { listeners[t] = fn; }
close() { this.closed = true; }
}
store.connectJobEvents('j1', 's1', { sourceFactory: FakeES });
expect(store.jobEventsSource.s1).toBeTruthy();
// 快照帧
listeners.snapshot({ data: JSON.stringify({ job_id: 'j1', job: { status: 'running' }, stages: [{ stage: 'a', status: 'done' }, { stage: 'b', status: 'running' }], last_seq: 4 }) });
expect(store.currentJob.stages.map((s) => s.status)).toEqual(['done', 'running']);
expect(store.currentJob.jobStatus).toBe('running');
store.disconnectJobEvents('s1');
expect(store.jobEventsSource.s1).toBeUndefined();
});
});
describe('STAGE_STATUS_BY_EVENT —— 事件类型全集映射', () => {
it('12 类事件中的阶段事件映射完整', () => {
expect(Object.keys(STAGE_STATUS_BY_EVENT)).toEqual([
'stage_started', 'stage_log', 'stage_progress', 'stage_done', 'stage_failed',
'stage_skipped', 'stage_await_confirm', 'stage_await_files',
]);
});
it('stage_progress 事件推进进度条(防回退 + 100 清空 message)', () => {
const state = {
jobId: 'j1', jobStatus: 'running', lastSeq: 0,
stages: [{ stage: 'rnaseq_qc', status: 'running', progress: null, progressMessage: null }],
};
let s = applyEvent(state, { type: 'stage_progress', seq: 1, data: { job_id: 'j1', stage: 'rnaseq_qc', percent: 33, message: 'SRR1' } });
expect(s.stages[0].progress).toBe(33);
expect(s.stages[0].progressMessage).toBe('SRR1');
expect(s.stages[0].status).toBe('running');
// 回退帧忽略
s = applyEvent(s, { type: 'stage_progress', seq: 2, data: { job_id: 'j1', stage: 'rnaseq_qc', percent: 10, message: 'old' } });
expect(s.stages[0].progress).toBe(33);
// 100 → message 清空
s = applyEvent(s, { type: 'stage_progress', seq: 3, data: { job_id: 'j1', stage: 'rnaseq_qc', percent: 100, message: '' } });
expect(s.stages[0].progress).toBe(100);
expect(s.stages[0].progressMessage).toBe('');
});
it('stage_started 清零进度(重跑新 attempt)', () => {
const state = {
jobId: 'j1', jobStatus: 'running', lastSeq: 0,
stages: [{ stage: 'rnaseq_qc', status: 'failed', progress: 80, progressMessage: 'x' }],
};
// failed → rerun → stage_started(attempt 2)
const s = applyEvent(state, { type: 'stage_started', seq: 1, data: { job_id: 'j1', stage: 'rnaseq_qc', attempt: 2 } });
expect(s.stages[0].status).toBe('running');
expect(s.stages[0].progress).toBeNull();
expect(s.stages[0].progressMessage).toBeNull();
});
it('log_tail 4000 字符截断(capLogTail)', () => {
expect(capLogTail('abc')).toBe('abc');
const long = 'x'.repeat(5000);
expect(capLogTail(long).length).toBe(4000);
expect(capLogTail(long)).toBe('x'.repeat(4000));
});
});