- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
258 lines
9.9 KiB
JavaScript
258 lines
9.9 KiB
JavaScript
// @vitest-environment jsdom
|
||
// components.test.js —— Composer 快捷键与禁用逻辑 + AssistantMessage 块联合渲染
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import { setActivePinia, createPinia } from 'pinia';
|
||
import Composer from '../../../../frontend/src/components/chat/Composer.vue';
|
||
import AssistantMessage from '../../../../frontend/src/components/chat/AssistantMessage.vue';
|
||
import SessionItem from '../../../../frontend/src/components/session/SessionItem.vue';
|
||
|
||
describe('Composer(FR-CHAT-005/007/011)', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
const mountComposer = (props = {}) =>
|
||
mount(Composer, {
|
||
props: { mode: 'ask', busy: false, disabled: false, ...props },
|
||
});
|
||
|
||
it('空输入禁用发送;⌘/Ctrl+Enter 发送;Enter 换行', async () => {
|
||
const w = mountComposer();
|
||
const btn = w.find('.send-btn');
|
||
expect(btn.attributes('disabled')).toBeDefined();
|
||
|
||
const ta = w.find('textarea');
|
||
await ta.setValue('水稻SPL基因家族?');
|
||
|
||
// Enter 单独按下:不触发发送(换行)
|
||
await ta.trigger('keydown', { key: 'Enter' });
|
||
expect(w.emitted('send')).toBeUndefined();
|
||
|
||
// Ctrl+Enter:发送
|
||
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true });
|
||
expect(w.emitted('send')).toBeTruthy();
|
||
expect(w.emitted('send')[0][0]).toBe('水稻SPL基因家族?');
|
||
// 发送后清空输入
|
||
expect(ta.element.value).toBe('');
|
||
});
|
||
|
||
it('⌘+Enter 同样发送(mac)', async () => {
|
||
const w = mountComposer();
|
||
const ta = w.find('textarea');
|
||
await ta.setValue('问题');
|
||
await ta.trigger('keydown', { key: 'Enter', metaKey: true });
|
||
expect(w.emitted('send')).toBeTruthy();
|
||
});
|
||
|
||
it('busy 时按钮变「停止」;点击 stop 触发事件(FR-CHAT-011)', async () => {
|
||
const w = mountComposer({ busy: true });
|
||
const btn = w.find('.send-btn');
|
||
expect(btn.text()).toContain('停止');
|
||
await btn.trigger('click');
|
||
expect(w.emitted('stop')).toBeTruthy();
|
||
});
|
||
|
||
it('disabled(健康降级)时输入与发送均不可用', async () => {
|
||
const w = mountComposer({ disabled: true });
|
||
expect(w.find('textarea').attributes('disabled')).toBeDefined();
|
||
const ta = w.find('textarea');
|
||
await ta.setValue('问题');
|
||
expect(w.find('.send-btn').attributes('disabled')).toBeDefined();
|
||
});
|
||
|
||
it('模式 pill 三态切换(labels.MODES)', async () => {
|
||
const w = mountComposer({ mode: 'ask' });
|
||
const pills = w.findAll('.mode-pill');
|
||
expect(pills).toHaveLength(3);
|
||
expect(pills[0].classes()).toContain('on');
|
||
await pills[1].trigger('click');
|
||
expect(w.emitted('change-mode')[0][0]).toBe('plan');
|
||
});
|
||
|
||
it('输入法组合键 Enter 不触发发送', async () => {
|
||
const w = mountComposer();
|
||
const ta = w.find('textarea');
|
||
await ta.setValue('问题');
|
||
await ta.trigger('keydown', { key: 'Enter', ctrlKey: true, isComposing: true });
|
||
expect(w.emitted('send')).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('AssistantMessage 块联合渲染', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
const mountMsg = (blocks, extra = {}) =>
|
||
mount(AssistantMessage, {
|
||
props: { message: { role: 'assistant', status: 'done', blocks, timeLabel: '10:00' }, ...extra },
|
||
});
|
||
|
||
it('text 块:markdown 渲染 + 脚本注入被消毒(FR-CHAT-004)', () => {
|
||
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><img src=x onerror="alert(1)"><script>alert(1)</script>' }]);
|
||
const html = w.find('.msg-body').html();
|
||
expect(html).toContain('<strong>SPL</strong>');
|
||
expect(html).not.toContain('onerror');
|
||
expect(html).not.toContain('<script');
|
||
});
|
||
|
||
it('table 块:md-table + 表头「说明」列非 mono,其余 mono(UI_SPEC §4.3)', () => {
|
||
const w = mountMsg([{
|
||
type: 'table',
|
||
headers: ['成员', '说明'],
|
||
rows: [['OsSPL14', '穗分枝']],
|
||
}]);
|
||
const tds = w.findAll('tbody td');
|
||
expect(tds).toHaveLength(2);
|
||
expect(tds[0].classes()).toContain('mono');
|
||
expect(tds[1].classes()).not.toContain('mono');
|
||
});
|
||
|
||
it('code 块:code-block 类', () => {
|
||
const w = mountMsg([{ type: 'code', text: 'ATGC' }]);
|
||
expect(w.find('.code-block').text()).toBe('ATGC');
|
||
});
|
||
|
||
it('stream 块:stream-cursor + shimmer-line + 跳过按钮', () => {
|
||
const w = mountMsg([{ type: 'stream', text: '部分文字' }], { reveal: { text: '全文' } });
|
||
expect(w.find('.stream-cursor').exists()).toBe(true);
|
||
expect(w.find('.shimmer-line').exists()).toBe(true);
|
||
expect(w.find('.skip-btn').exists()).toBe(true);
|
||
awaitFlush(w);
|
||
w.find('.skip-btn').trigger('click');
|
||
expect(w.emitted('skip-reveal')).toBeTruthy();
|
||
});
|
||
|
||
it('pipeline 块:五相位步骤 + 状态点字形(✓/●/◌)', () => {
|
||
const steps = [
|
||
{ name: '规划', state: 'done', desc: '意图理解', phase: 'planning' },
|
||
{ name: '检索', state: 'running', desc: '多路检索', phase: 'retrieval' },
|
||
{ name: '证据', state: 'waiting', desc: '证据筛选', phase: 'evidence' },
|
||
{ name: '生成', state: 'waiting', desc: '答案生成', phase: 'generation' },
|
||
{ name: '完成', state: 'waiting', desc: '检索完成', phase: 'done' },
|
||
];
|
||
const w = mountMsg([{ type: 'pipeline', status: 'running', steps }]);
|
||
const dots = w.findAll('.p-dot');
|
||
expect(dots).toHaveLength(5);
|
||
expect(dots[0].text()).toBe('✓');
|
||
expect(dots[1].text()).toBe('●');
|
||
expect(dots[2].text()).toBe('◌');
|
||
expect(w.find('.pipeline-card .pc-title').text()).toContain('检索流水线');
|
||
// 整体语义徽章
|
||
expect(w.find('.pipeline-card .badge.running').exists()).toBe(true);
|
||
});
|
||
|
||
it('tool 块:折叠头 + 展开体 + 日志类', () => {
|
||
const block = {
|
||
type: 'tool', icon: '🔬', name: '证据筛选', status: 'done', dur: '3.2s', open: true, phase: 'evidence',
|
||
params: 'accepted_count: 4', result: '4 条证据通过审核',
|
||
logs: [{ text: '> ok', cls: 'log-ok' }],
|
||
};
|
||
const w = mountMsg([block]);
|
||
expect(w.find('.tool-card.open').exists()).toBe(true);
|
||
expect(w.find('.t-name').text()).toBe('证据筛选');
|
||
expect(w.find('.t-dur').text()).toBe('3.2s');
|
||
expect(w.find('.tb-params').text()).toContain('accepted_count: 4');
|
||
expect(w.find('.log-ok').exists()).toBe(true);
|
||
});
|
||
|
||
it('sources 块:内联引用 chip 编号从 1 起(FR-CHAT-015)', () => {
|
||
const w = mountMsg([{
|
||
type: 'sources',
|
||
items: [
|
||
{ id: 's1', title: 'A' },
|
||
{ id: 's2', title: 'B' },
|
||
],
|
||
}]);
|
||
const chips = w.findAll('.src-chip');
|
||
expect(chips).toHaveLength(2);
|
||
expect(chips[0].text()).toBe('[1]');
|
||
expect(chips[1].text()).toBe('[2]');
|
||
chips[0].trigger('click');
|
||
expect(w.emitted('jump-source')[0][0]).toMatchObject({ index: 1 });
|
||
});
|
||
|
||
it('graph 块:轻量摘要(节点/边数),完整图在 bench(kg 跳转事件)', () => {
|
||
const w = mountMsg([{
|
||
type: 'graph',
|
||
nodes: [{ id: 'n1' }, { id: 'n2' }, { id: 'n3' }],
|
||
edges: [{ source: 'n1', target: 'n2' }],
|
||
chains: [],
|
||
}]);
|
||
expect(w.find('.graph-summary').exists()).toBe(true);
|
||
expect(w.find('.graph-summary').text()).toContain('3');
|
||
expect(w.find('.graph-summary').text()).toContain('1');
|
||
w.find('.gs-link').trigger('click');
|
||
expect(w.emitted('jump-graph')).toBeTruthy();
|
||
});
|
||
|
||
it('error 标注 + 重试按钮(FR-CHAT-012)', () => {
|
||
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
|
||
message: {
|
||
role: 'assistant', status: 'error', streamErr: 'timeout',
|
||
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
|
||
},
|
||
onRetry: () => {},
|
||
});
|
||
expect(w.find('.err-note').text()).toContain('检索超时');
|
||
expect(w.find('.retry-btn').exists()).toBe(true);
|
||
});
|
||
|
||
it('BUG-R1-05:errText(后端 message)优先于 streamErr 标注展示', () => {
|
||
const w = mountMsg([{ type: 'pipeline', status: 'error', steps: [] }], {
|
||
message: {
|
||
role: 'assistant', status: 'error', streamErr: 'message',
|
||
errText: '上游问答服务返回错误',
|
||
blocks: [{ type: 'pipeline', status: 'error', steps: [] }], timeLabel: '10:00',
|
||
},
|
||
onRetry: () => {},
|
||
});
|
||
expect(w.find('.err-note .err-text').text()).toBe('上游问答服务返回错误');
|
||
expect(w.find('.retry-btn').exists()).toBe(true);
|
||
});
|
||
|
||
it('BUG-R1-06:text 块 HTML 直接 v-html 输出(渲染路径不再 sanitize 一次即吞掉格式)', () => {
|
||
const w = mountMsg([{ type: 'text', html: '<p>加粗 <strong>SPL</strong></p><ul><li>成员</li></ul>' }]);
|
||
const html = w.find('.md-text').html();
|
||
expect(html).toContain('<strong>SPL</strong>');
|
||
expect(html).toContain('<li>成员</li>');
|
||
});
|
||
});
|
||
|
||
describe('SessionItem 删除入口(BUG-R1-03)', () => {
|
||
beforeEach(() => {
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
const mountItem = () =>
|
||
mount(SessionItem, {
|
||
props: { session: { id: 's1', title: '测试会话', status: 'done', meta: {} }, active: false, metaText: '2 · 10:00' },
|
||
});
|
||
|
||
it('右键唤出删除事件(不触发选择)', async () => {
|
||
const w = mountItem();
|
||
await w.find('.session-item').trigger('contextmenu');
|
||
expect(w.emitted('delete')).toBeTruthy();
|
||
expect(w.emitted('delete')[0][0]).toMatchObject({ id: 's1' });
|
||
expect(w.emitted('select')).toBeUndefined();
|
||
});
|
||
|
||
it('删除按钮点击触发 delete 且不冒泡选择', async () => {
|
||
const w = mountItem();
|
||
await w.find('.item-del').trigger('click');
|
||
expect(w.emitted('delete')).toBeTruthy();
|
||
expect(w.emitted('select')).toBeUndefined();
|
||
});
|
||
|
||
it('双击仍进入重命名(delete 入口不破坏既有交互)', async () => {
|
||
const w = mountItem();
|
||
await w.find('.session-item').trigger('dblclick');
|
||
expect(w.find('.rename-input').exists()).toBe(true);
|
||
});
|
||
});
|
||
|
||
function awaitFlush(w) {
|
||
return w.vm.$nextTick();
|
||
}
|