forked from admin/PlantAI-rag-new
- 生信进度:后端新增 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
389 lines
17 KiB
JavaScript
389 lines
17 KiB
JavaScript
// @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));
|
||
});
|
||
});
|