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

321 lines
14 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,
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' },
})),
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('会话切换 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_done', 'stage_failed', 'stage_skipped',
'stage_await_confirm', 'stage_await_files',
]);
});
});