// @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: '

加粗 SPL

' }]); const html = w.find('.msg-body').html(); expect(html).toContain('SPL'); expect(html).not.toContain('onerror'); expect(html).not.toContain(' { 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: '

加粗 SPL

' }]); const html = w.find('.md-text').html(); expect(html).toContain('SPL'); expect(html).toContain('
  • 成员
  • '); }); }); 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(); }