Files

634 lines
27 KiB
JavaScript
Raw Permalink 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.
// 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');
});
});