Files

634 lines
27 KiB
JavaScript
Raw Permalink Normal View History

// tests/r3debug/extensions.test.js —— 用户自定义扩展分析前端单测(dev/extension-frontend 归属)
// 覆盖:
// 1) §4 事件帧 → 块映射(applyExtensionFrame 全事件集幂等/追加/终态纪律)
// 2) sendExtension 流(用户消息落库 → 骨架 → SSE 帧分派 → 终态收口/错误标注)
// 3) uploadExtensionFile / trialRunExtension / confirmExtensionRun store 动作
// 4) 卡片组件 mount 冒烟(8 个扩展卡)
// 5) intent.js 创建意图词典(§5 A 层同表双跑)
//
// 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r3debug/extensions.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
// jsdom 无 scrollTo:polyfill(组件挂载用)
if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
window.Element.prototype.scrollTo = vi.fn();
}
// ---- mock 依赖链(与 bioflowPathConfirm.test.js 同款) ----
vi.mock('../../src/api/sessions.js', () => ({
createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
}));
vi.mock('../../src/api/qa.js', () => ({
chatStream: vi.fn(async () => {}),
search: vi.fn(async () => ({ answer: '' })),
}));
vi.mock('../../src/api/private.js', () => ({
askPrivate: vi.fn(async () => ({ hit: false })),
}));
vi.mock('../../src/api/docgen.js', () => ({
generate: vi.fn(), getTask: vi.fn(),
getArtifactContent: vi.fn(async () => ({ markdown: '' })),
getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
saveArtifactContent: vi.fn(async () => ({})), editArtifact: vi.fn(async () => ({})),
exportArtifact: vi.fn(async () => ({})), uploadLibrary: vi.fn(),
listLibrary: vi.fn(async () => ({ items: [] })), removeLibrary: vi.fn(async () => ({ ok: true })),
listTasks: vi.fn(async () => ({ tasks: [] })),
}));
// ---- 扩展 API 层 mock(观测调用 + 可控返回;vi.hoisted 供 mock factory 引用) ----
const apiMock = vi.hoisted(() => ({
createExtension: vi.fn(),
buildExtension: vi.fn(),
trialRunExtension: vi.fn(),
repairExtension: vi.fn(),
publishExtension: vi.fn(),
runExtension: vi.fn(),
listExtensions: vi.fn(async () => []),
getExtension: vi.fn(async () => ({})),
deleteExtension: vi.fn(async () => ({ ok: true })),
listExtensionRuns: vi.fn(async () => []),
extensionRunEvents: vi.fn(),
bioflowChatStream: vi.fn(),
}));
vi.mock('../../src/api/bioflow.js', () => apiMock);
// ---- bioflow store mock(sendExtension 需要 useBioflowStore;零调用断言) ----
vi.mock('../../src/stores/bioflow.js', () => ({
useBioflowStore: () => ({
resetSession: vi.fn(),
currentJob: { jobId: null, jobStatus: null, stages: [], lastSeq: 0, autoContinue: false },
connectJobEvents: vi.fn(),
applyJobEvent: vi.fn(),
applyChatEvent: vi.fn(),
registerReport: vi.fn(async () => {}),
}),
}));
// ---- 被测模块 ----
import {
useChatStore,
applyExtensionFrame,
patchExtensionUpload,
patchExtensionCreated,
patchExtensionBuild,
patchExtensionTrial,
patchExtensionRepair,
patchExtensionValidate,
patchExtensionPublish,
patchExtensionPublished,
patchExtensionCandidates,
patchExtensionConfirm,
patchExtensionRunEvent,
ensureExtensionUploadBlock,
ensureExtensionRunBlock,
} from '../../src/stores/chat.js';
import { useSessionsStore } from '../../src/stores/sessions.js';
import { useExtensionsStore } from '../../src/stores/extensions.js';
import { isExtensionCreateIntent } from '../../src/utils/intent.js';
import { consumeSseStream } from '../../src/sse/parser.js';
import ExtensionUploadCard from '../../src/components/chat/ExtensionUploadCard.vue';
import ExtensionBuildCard from '../../src/components/chat/ExtensionBuildCard.vue';
import ExtensionTrialCard from '../../src/components/chat/ExtensionTrialCard.vue';
import ExtensionValidateCard from '../../src/components/chat/ExtensionValidateCard.vue';
import ExtensionPublishCard from '../../src/components/chat/ExtensionPublishCard.vue';
import ExtensionCandidatesCard from '../../src/components/chat/ExtensionCandidatesCard.vue';
import ExtensionConfirmCard from '../../src/components/chat/ExtensionConfirmCard.vue';
import ExtensionRunCard from '../../src/components/chat/ExtensionRunCard.vue';
/** 构造一个最小 SSE Response(供 bioflowChatStream mock 返回) */
function makeSseResponse(frames) {
const events = frames.map((f) => {
const lines = [`event: ${f.event}`];
if (f.data !== undefined) lines.push(`data: ${JSON.stringify(f.data)}`);
return lines.join('\n');
}).join('\n\n');
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(events + (events ? '\n\n' : '')));
controller.close();
},
});
return { ok: true, status: 200, body: stream };
}
function freshStores() {
const pinia = createPinia();
setActivePinia(pinia);
const chat = useChatStore();
const sessions = useSessionsStore();
chat.sessionId = 's1';
sessions.activeId = 's1';
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
return { chat, sessions };
}
function plainMsg(blocks = []) {
return { id: 'am', role: 'assistant', status: 'streaming', blocks: [...blocks], raw: null };
}
describe('applyExtensionFrame §4 事件帧 → 块映射', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('extension_upload → 上传引导卡(缺则 push;重入幂等不重复建块)', () => {
const msg = plainMsg();
const b1 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1' });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].type).toBe('extension_upload');
expect(msg.blocks[0].sessionExtId).toBe('sess-ext-1');
const b2 = applyExtensionFrame(msg, 'extension_upload', { session_ext_id: 'sess-ext-1', message: 'm' });
expect(msg.blocks.length).toBe(1);
expect(b2 === b1).toBe(true);
expect(msg.blocks[0].message).toBe('m');
});
it('extension_created → 上传卡落定 extId;迟到帧不覆盖已有 extId', () => {
const msg = plainMsg();
patchExtensionCreated(msg, { ext_id: 'ext-1', display_name: '测序质量统计', status: 'draft' });
expect(msg.blocks[0].extId).toBe('ext-1');
expect(msg.blocks[0].displayName).toBe('测序质量统计');
expect(msg.blocks[0].status).toBe('created');
patchExtensionCreated(msg, { ext_id: 'ext-late', status: 'draft' });
expect(msg.blocks[0].extId).toBe('ext-1'); // 不覆盖
});
it('extension_build → 转换卡;同 ext_id 重入刷新,build_failed 只补错误不回退', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_build', {
ext_id: 'ext-1',
command_template: 'python {code_dir}/q.py --out {output_dir}',
requirements: { pip: ['pandas'], apt: [] },
runtime_image: 'bioflow-gfpy',
nl_triggers: ['质量统计'],
analysis_type: 'table_filter',
parameters: [{ name: 'fastq', type: 'file', required: true }],
status: 'built',
});
const blk = msg.blocks[0];
expect(blk.type).toBe('extension_build');
expect(blk.commandTemplate).toContain('{output_dir}');
expect(blk.requirements.pip).toEqual(['pandas']);
expect(blk.runtimeImage).toBe('bioflow-gfpy');
expect(blk.nlTriggers).toEqual(['质量统计']);
expect(blk.parameters.length).toBe(1);
// 重入刷新(同 ext_id 不新建块)
applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', nl_triggers: ['统计'] });
expect(msg.blocks.length).toBe(1);
expect(blk.nlTriggers).toEqual(['统计']);
// build_failed 不回退已展示内容
applyExtensionFrame(msg, 'extension_build', { ext_id: 'ext-1', status: 'build_failed', error: 'bad' });
expect(blk.status).toBe('build_failed');
expect(blk.error).toBe('bad');
expect(blk.commandTemplate).toContain('{output_dir}');
});
it('extension_trial → 试运行卡日志只追加;done 清错误;failed 展示错误与修复建议', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line1\n' });
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'running', log_tail: 'line2\n' });
const blk = msg.blocks[0];
expect(blk.logTail).toBe('line1\nline2\n'); // 追加不覆盖
expect(blk.status).toBe('running');
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'done', exit_code: 0 });
expect(blk.status).toBe('done');
expect(blk.exitCode).toBe(0);
expect(blk.error).toBeNull();
applyExtensionFrame(msg, 'extension_trial', { ext_id: 'ext-1', status: 'failed', exit_code: 1, error: 'boom', suggestion: 'try X' });
expect(blk.status).toBe('failed');
expect(blk.error).toBe('boom');
expect(blk.suggestion).toBe('try X');
});
it('extension_repair → 试运行卡补修复状态(repair_count/suggestion)', () => {
const msg = plainMsg();
patchExtensionRepair(msg, { ext_id: 'ext-1', repair_count: 2, suggestion: '加 pandas', status: 'repairing' });
expect(msg.blocks[0].type).toBe('extension_trial');
expect(msg.blocks[0].repairCount).toBe(2);
expect(msg.blocks[0].suggestion).toBe('加 pandas');
});
it('extension_validate → 金标/确认卡;ready_to_publish 同源挂发布卡', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_validate', {
ext_id: 'ext-1',
golden: { ok: true },
form_defaults: [{ name: 'fastq', default: '' }],
ready_to_publish: true,
display_name: '测序质量统计',
nl_triggers: ['质量统计'],
});
const v = msg.blocks.find((b) => b.type === 'extension_validate');
expect(v.golden.ok).toBe(true);
expect(v.formDefaults.length).toBe(1);
const p = msg.blocks.find((b) => b.type === 'extension_publish');
expect(p).toBeTruthy();
expect(p.nlTriggers).toEqual(['质量统计']);
});
it('extension_publish / extension_published → 发布卡与发布确认块(幂等去重)', () => {
const msg = plainMsg();
patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X', nl_triggers: ['a'] });
patchExtensionPublish(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] });
expect(msg.blocks.filter((b) => b.type === 'extension_publish').length).toBe(1);
expect(msg.blocks[0].nlTriggers).toEqual(['a', 'b']);
patchExtensionPublished(msg, { ext_id: 'ext-1', display_name: 'X2', nl_triggers: ['a', 'b'] });
expect(msg.blocks.filter((b) => b.type === 'extension_published').length).toBe(1);
expect(msg.blocks.find((b) => b.type === 'extension_publish').published).toBe(true);
});
it('extension_candidates → 候选卡;重复帧整体替换(幂等)', () => {
const msg = plainMsg();
applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }] });
applyExtensionFrame(msg, 'extension_candidates', { candidates: [{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }] });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].candidates).toEqual([{ ext_id: 'e2', display_name: 'B', purpose: 'p2' }]);
});
it('extension_confirm → 参数确认卡;同 ext_id 重入只刷新(块幂等)', () => {
const msg = plainMsg();
patchExtensionConfirm(msg, { ext_id: 'ext-1', params_schema: [{ name: 'fastq' }], extracted_params: { fastq: '/a.fq' }, files_required: ['a.fq'] });
patchExtensionConfirm(msg, { ext_id: 'ext-1', extracted_params: { fastq: '/b.fq' } });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].extractedParams.fastq).toBe('/b.fq');
expect(msg.blocks[0].filesRequired).toEqual(['a.fq']);
});
it('extension_run_* → 运行卡:run_id 换新则新块;日志追加;终态不回退', () => {
const msg = plainMsg();
patchExtensionRunEvent(msg, { type: 'extension_run_started', run_id: 'r1', ext_id: 'ext-1' });
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'a\n' });
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'b\n' });
expect(msg.blocks.length).toBe(1);
expect(msg.blocks[0].log).toBe('a\nb\n');
expect(msg.blocks[0].status).toBe('running');
patchExtensionRunEvent(msg, { type: 'extension_run_done', run_id: 'r1', outputs: ['out/x'] });
expect(msg.blocks[0].status).toBe('done');
expect(msg.blocks[0].outputs).toEqual(['out/x']);
// 终态不回退:迟到的 log 帧不改变 done、不追加日志
patchExtensionRunEvent(msg, { type: 'extension_run_log', run_id: 'r1', log: 'late' });
expect(msg.blocks[0].status).toBe('done');
expect(msg.blocks[0].log).toBe('a\nb\n');
// 失败帧
patchExtensionRunEvent(msg, { type: 'extension_run_failed', run_id: 'r2', ext_id: 'ext-1', exit_code: 3, error: 'empty', log_tail: 'tail' });
const f = msg.blocks.find((b) => b.runId === 'r2');
expect(f.status).toBe('failed');
expect(f.exitCode).toBe(3);
expect(f.error).toBe('empty');
// 新 run 不覆盖旧块
expect(msg.blocks.length).toBe(2);
});
it('未知 extension 事件忽略;无 run_id/无 ext_id 防御返回 null', () => {
const msg = plainMsg();
expect(applyExtensionFrame(msg, 'extension_unknown', {})).toBeNull();
expect(msg.blocks.length).toBe(0);
expect(patchExtensionRunEvent(msg, { type: 'extension_run_log', log: 'x' })).toBeNull();
expect(patchExtensionBuild(msg, { command_template: 'x' })).toBeNull();
});
it('ensure 块函数幂等(upload/run 各一次)', () => {
const msg = plainMsg();
const a = ensureExtensionUploadBlock(msg);
const b = ensureExtensionUploadBlock(msg);
expect(a === b).toBe(true);
expect(msg.blocks.length).toBe(1);
const r1 = ensureExtensionRunBlock(msg);
const r2 = ensureExtensionRunBlock(msg);
expect(r1 === r2).toBe(true);
expect(msg.blocks.length).toBe(2);
});
});
describe('sendExtension 扩展引导流', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('发送 → 用户消息落库 + 扩展骨架(upload 块)+ SSE 帧分派 + 终态收口', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'extension_created', data: { ext_id: 'ext-1', display_name: 'X', status: 'draft' } },
{ event: 'message_done', data: { kind: 'extension' } },
]));
await chat.sendExtension('创建一个扩展:统计 fastq 质量');
expect(chat.messages.length).toBe(2);
const aMsg = chat.messages[1];
expect(aMsg.kind).toBe('bioflow');
expect(aMsg.status).toBe('done');
const up = aMsg.blocks.find((b) => b.type === 'extension_upload');
expect(up).toBeTruthy();
expect(up.extId).toBe('ext-1');
// 终态收口:ctx 已清理、busy 释放
expect(chat.busy).toBe(false);
expect(chat.lastResult.ok).toBe(true);
});
it('error 帧 → 消息标注可重试 + 收口', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: '服务不可用' } },
]));
await chat.sendExtension('创建一个扩展');
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.errText).toBe('服务不可用');
expect(chat.lastResult.ok).toBe(false);
expect(chat.lastResult.error.retryable).toBe(true);
expect(chat.busy).toBe(false);
});
it('发流失败(ApiError 信封)→ 标注可重试', async () => {
const { chat } = freshStores();
const err = new Error('upstream');
err.code = 'UPSTREAM_DOWN';
apiMock.bioflowChatStream.mockRejectedValue(err);
await chat.sendExtension('创建一个扩展');
const aMsg = chat.messages[1];
expect(aMsg.status).toBe('error');
expect(aMsg.errText).toBe('upstream');
expect(chat.lastResult.ok).toBe(false);
});
it('retry 对扩展消息走 sendExtension(meta.extension 标记)', async () => {
const { chat } = freshStores();
apiMock.bioflowChatStream.mockResolvedValue(makeSseResponse([
{ event: 'extension_upload', data: { session_ext_id: 'sess-1' } },
{ event: 'message_done', data: {} },
]));
await chat.sendExtension('创建一个扩展');
// 手工构造失败态以触发 retry 判定
const aMsg = chat.messages[1];
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
await chat.retry();
expect(apiMock.bioflowChatStream).toHaveBeenCalledTimes(2);
expect(chat.messages.length).toBe(2); // 同位置替换不新增
expect(chat.messages[1].status).toBe('done');
});
});
describe('扩展 store 动作(upload/trial/confirm)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uploadExtensionFile → FormData 上传 + 块 patch(extId/status/files 进度)', async () => {
const { chat } = freshStores();
apiMock.createExtension.mockResolvedValue({ ok: true, data: { ext_id: 'ext-9', display_name: 'X' } });
const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null };
chat.messages = [aMsg];
const blk = aMsg.blocks[0];
const zip = new File(['zip'], 'x.zip', { type: 'application/zip' });
const res = await chat.uploadExtensionFile(blk, { zip, displayName: 'X', command: 'python x.py', runtimeImage: 'bioflow-gf', golden: null });
expect(res.ext_id).toBe('ext-9');
expect(blk.extId).toBe('ext-9');
expect(blk.status).toBe('created');
expect(blk.files[0].status).toBe('done');
// FormData 字段断言
const form = apiMock.createExtension.mock.calls[0][0];
expect(form instanceof FormData).toBe(true);
expect(form.get('display_name')).toBe('X');
expect(form.get('command')).toBe('python x.py');
expect(form.get('runtime_image')).toBe('bioflow-gf');
});
it('uploadExtensionFile 失败 → 文件记录 error 并抛出', async () => {
const { chat } = freshStores();
apiMock.createExtension.mockRejectedValue(new Error('boom'));
const aMsg = { id: 'am', role: 'assistant', status: 'streaming', blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }], raw: null };
chat.messages = [aMsg];
const blk = aMsg.blocks[0];
await expect(chat.uploadExtensionFile(blk, {
zip: new File(['z'], 'x.zip'),
displayName: 'X',
command: 'cmd',
runtimeImage: 'bioflow-gf',
golden: null,
})).rejects.toThrow('boom');
expect(blk.files[0].status).toBe('error');
expect(blk.extId).toBeNull();
});
it('trialRunExtension → FormData 携带 test_inputs;返回登记结果', async () => {
const { chat } = freshStores();
apiMock.trialRunExtension.mockResolvedValue({ ok: true, data: { accepted: true } });
const f = new File(['t'], 'sample.fq');
const res = await chat.trialRunExtension('ext-1', [f]);
expect(res.accepted).toBe(true);
const form = apiMock.trialRunExtension.mock.calls[0][1];
expect(form instanceof FormData).toBe(true);
expect(form.has('test_inputs')).toBe(true);
});
it('confirmExtensionRun → runExtension + 挂运行卡(run_started 形态)', async () => {
const { chat } = freshStores();
apiMock.runExtension.mockResolvedValue({ ok: true, data: { run_id: 'run-7' } });
const aMsg = {
id: 'am', role: 'assistant', status: 'streaming',
blocks: [
{ type: 'extension_confirm', extId: 'ext-1', paramsSchema: [], extractedParams: {}, filesRequired: [] },
],
raw: null,
};
chat.messages = [aMsg];
const confirmBlk = aMsg.blocks[0];
const res = await chat.confirmExtensionRun(confirmBlk, { params: { fastq: '/a.fq' }, files: [], aMsg });
expect(res.run_id).toBe('run-7');
expect(confirmBlk.submitted).toBe(true);
const runBlk = aMsg.blocks.find((b) => b.type === 'extension_run');
expect(runBlk).toBeTruthy();
expect(runBlk.runId).toBe('run-7');
expect(runBlk.status).toBe('running');
// JSON body 参数透传(无文件时走 JSON 分支)
const body = apiMock.runExtension.mock.calls[0][1];
expect(body.params.fastq).toBe('/a.fq');
});
});
describe('extensions store(侧栏入口数据面)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('load → items 归一化;remove → 删除后出列', async () => {
apiMock.listExtensions.mockResolvedValue([
{ id: 'e1', display_name: 'A', status: 'published' },
{ id: 'e2', display_name: 'B', status: 'draft' },
]);
const pinia = createPinia();
setActivePinia(pinia);
const ext = useExtensionsStore();
await ext.load();
expect(ext.items.length).toBe(2);
expect(ext.published.length).toBe(1);
apiMock.deleteExtension.mockResolvedValue({ ok: true });
await ext.remove('e1');
expect(ext.items.length).toBe(1);
expect(apiMock.deleteExtension).toHaveBeenCalledWith('e1');
});
it('loadRuns → runs 记录(列表/error 两态)', async () => {
apiMock.listExtensionRuns.mockResolvedValue({ runs: [{ run_id: 'r1', status: 'done' }] });
const pinia = createPinia();
setActivePinia(pinia);
const ext = useExtensionsStore();
const list = await ext.loadRuns('e1');
expect(list.length).toBe(1);
expect(ext.runs.e1.error).toBe(false);
apiMock.listExtensionRuns.mockRejectedValue(new Error('x'));
await expect(ext.loadRuns('e2')).rejects.toThrow('x');
expect(ext.runs.e2.error).toBe(true);
});
});
describe('intent.js 创建意图词典(§5 A 层)', () => {
it('动作词 + 目标词同句命中', () => {
expect(isExtensionCreateIntent('帮我创建一个扩展:统计 fastq 质量')).toBe(true);
expect(isExtensionCreateIntent('做一个拓展分析 统计 fastq 质量')).toBe(true);
expect(isExtensionCreateIntent('新建一个分析模板')).toBe(true);
expect(isExtensionCreateIntent('上传我的分析脚本')).toBe(true);
});
it('漏判安全:无动作词 / 无目标词不命中(歧义消解由服务端判定)', () => {
expect(isExtensionCreateIntent('帮我跑水稻 SPL 家族鉴定')).toBe(false);
expect(isExtensionCreateIntent('创建一个文档')).toBe(false);
expect(isExtensionCreateIntent('做一下质量统计')).toBe(false);
expect(isExtensionCreateIntent('扩展分析')).toBe(false);
// 契约 §5:目标词命中但无动作词(「做拓展分析 xxx」)不属 A 层——
// 已发布扩展命中走运行、无命中回「是否创建?」引导卡,均由服务端 intent.py 判定
expect(isExtensionCreateIntent('做拓展分析 统计 fastq 质量')).toBe(false);
});
});
describe('卡片组件 mount 冒烟', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
});
afterEach(() => {
setActivePinia(createPinia());
});
const mountCard = (Comp, block) => mount(Comp, {
props: { block, message: { id: 'am', blocks: [block] } },
});
it('ExtensionUploadCard 渲染:镜像下拉 8 项 + 提交按钮禁用态(缺 zip)', async () => {
const w = mountCard(ExtensionUploadCard, { type: 'extension_upload', status: 'idle', extId: null, files: [], runtimeImage: 'bioflow-gf' });
expect(w.text()).toContain('扩展上传引导');
const options = w.findAll('option');
expect(options.length).toBe(8);
expect(options[0].text()).toContain('bioflow-gf');
const btn = w.find('button.ext-primary');
expect(btn.attributes('disabled')).toBeDefined(); // 无 zip 提交禁用
});
it('ExtensionBuildCard 渲染:模板/依赖/触发词/参数 + 提交修正', async () => {
const w = mountCard(ExtensionBuildCard, {
type: 'extension_build',
extId: 'ext-1',
status: 'built',
commandTemplate: 'python {code_dir}/q.py --out {output_dir}',
requirements: { pip: ['pandas'], apt: [] },
runtimeImage: 'bioflow-gfpy',
nlTriggers: ['质量统计'],
analysisType: 'table_filter',
parameters: [{ name: 'fastq', type: 'file' }],
});
expect(w.text()).toContain('命令模板');
expect(w.text()).toContain('pandas');
expect(w.text()).toContain('fastq');
const inputs = w.findAll('input.ext-input');
expect(inputs[0].element.value).toContain('{output_dir}');
// 触发词是 input 值(编辑态),不在文本节点中——断言输入值(顺序:模板/镜像/触发词/类型)
expect(inputs[2].element.value).toBe('质量统计');
expect(inputs[1].element.value).toBe('bioflow-gfpy');
});
it('ExtensionTrialCard 渲染:日志尾 + 失败建议 + AI 修复按钮', async () => {
const w = mountCard(ExtensionTrialCard, {
type: 'extension_trial',
extId: 'ext-1',
status: 'failed',
logTail: 'traceback...',
exitCode: 1,
error: 'ModuleNotFoundError',
suggestion: '加 pandas',
});
expect(w.text()).toContain('试运行失败');
expect(w.text()).toContain('traceback...');
expect(w.text()).toContain('加 pandas');
expect(w.text()).toContain('AI 修复');
});
it('ExtensionValidateCard 渲染:金标对比 + 参数确认', async () => {
const w = mountCard(ExtensionValidateCard, {
type: 'extension_validate',
extId: 'ext-1',
golden: { ok: true },
formDefaults: [{ name: 'fastq', default: '', description: '输入' }],
});
expect(w.text()).toContain('金标');
expect(w.text()).toContain('fastq');
});
it('ExtensionPublishCard 渲染:触发词编辑 + 发布确认', async () => {
const w = mountCard(ExtensionPublishCard, {
type: 'extension_publish',
extId: 'ext-1',
displayName: 'X',
nlTriggers: ['a', 'b'],
published: false,
});
expect(w.text()).toContain('发布扩展');
const input = w.find('input.ext-input');
expect(input.element.value).toBe('a, b');
});
it('ExtensionCandidatesCard 渲染:候选列表 + 点击选择 emit', async () => {
const w = mountCard(ExtensionCandidatesCard, {
type: 'extension_candidates',
candidates: [{ ext_id: 'e1', display_name: 'A', purpose: 'p1' }, { ext_id: 'e2', display_name: 'B', purpose: 'p2' }],
selected: null,
});
expect(w.text()).toContain('匹配到多个扩展');
expect(w.text()).toContain('A');
await w.findAll('.ext-row')[1].trigger('click');
expect(w.emitted('pick')).toBeTruthy();
expect(w.emitted('pick')[0][0].ext_id).toBe('e2');
});
it('ExtensionConfirmCard 渲染:参数提取值展示 + 确认运行按钮', async () => {
const w = mountCard(ExtensionConfirmCard, {
type: 'extension_confirm',
extId: 'ext-1',
paramsSchema: [{ name: 'fastq', type: 'file', default: '' }],
extractedParams: { fastq: '/a.fq' },
filesRequired: ['a.fq'],
});
expect(w.text()).toContain('运行参数确认');
const input = w.find('input.ext-input');
expect(input.element.value).toBe('/a.fq');
});
it('ExtensionRunCard 渲染:badge 四态 + 日志 + 输出', async () => {
const w = mountCard(ExtensionRunCard, {
type: 'extension_run',
runId: 'r1',
extId: 'ext-1',
status: 'done',
log: 'a\nb\n',
outputs: ['out/x'],
});
expect(w.text()).toContain('已完成');
expect(w.text()).toContain('out/x');
});
});