/** * AssistantText 路由标签悬停显示单元测试 (U5)。 * * 验证场景: * - 默认状态路由 tag 不可见 * - 鼠标悬停助手消息时 tag 淡入显示 * - 鼠标移开时 tag 淡出 * - 无 matched_skill 的消息悬停时也不显示 tag * - 淡入淡出过渡平滑无闪烁(v-show 保留 DOM,无重挂载) * * 说明:happy-dom 不计算 CSS transition,因此"淡入/淡出"通过 * `assistant-text__routing--visible` class 的增删来断言;"平滑无闪烁" * 通过断言 DOM 节点恒定(v-show 而非 v-if,hover 前后同一节点)验证。 */ import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick, type App } from 'vue' import AssistantText from '@/components/chat/messages/AssistantText.vue' import type { IChatMessage } from '@/api/types' // ── Mocks ──────────────────────────────────────────────────────────── // 路由标签可见性逻辑不依赖 markdown 渲染,mock 掉重量级依赖以隔离逻辑、 // 加快测试。ant-design-vue 的 Tag/Spin 与图标保留真实加载(happy-dom 可渲染)。 vi.mock('markdown-it', () => { const md = { render: (s: string) => s, utils: { escapeHtml: (s: string) => s }, renderer: { rules: {} as Record }, } return { default: vi.fn(() => md) } }) vi.mock('dompurify', () => ({ default: { sanitize: (html: string) => html }, })) vi.mock('highlight.js/lib/core', () => ({ default: { registerLanguage: vi.fn(), getLanguage: vi.fn(() => null), highlight: vi.fn(() => ({ value: '' })), }, })) // ── Fixtures & helpers ─────────────────────────────────────────────── function makeMessage(overrides: Partial = {}): IChatMessage { return { id: 'msg-1', role: 'assistant', content: '你好', timestamp: '2026-07-01T00:00:00.000Z', status: 'completed', matched_skill: 'react', routing_method: 'semantic', confidence: 0.9, ...overrides, } } interface Mounted { container: HTMLElement root: HTMLElement app: App unmount: () => void } function mountAssistantText(message: IChatMessage): Mounted { const container = document.createElement('div') document.body.appendChild(container) const app = createApp({ render: () => h(AssistantText, { message }), }) app.mount(container) const root = container.querySelector('.assistant-text') as HTMLElement return { container, root, app, unmount: () => { app.unmount(); container.remove() } } } function hover(el: HTMLElement): void { el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })) } function leave(el: HTMLElement): void { el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })) } function getRouting(container: HTMLElement): HTMLElement { return container.querySelector('.assistant-text__routing') as HTMLElement } // ── Tests ──────────────────────────────────────────────────────────── describe('AssistantText — 路由标签悬停显示 (U5)', () => { let mounted: Mounted | null = null afterEach(() => { mounted?.unmount() mounted = null }) it('默认状态路由 tag 不可见', () => { mounted = mountAssistantText(makeMessage()) const routing = getRouting(mounted.container) // v-show 保留 DOM(非 v-if),但默认 opacity:0 → 无 --visible class expect(routing).not.toBeNull() expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false) }) it('鼠标悬停助手消息时 tag 淡入显示', async () => { mounted = mountAssistantText(makeMessage()) const routing = getRouting(mounted.container) expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false) hover(mounted.root) await nextTick() expect(routing.classList.contains('assistant-text__routing--visible')).toBe(true) }) it('鼠标移开时 tag 淡出', async () => { mounted = mountAssistantText(makeMessage()) hover(mounted.root) await nextTick() const routing = getRouting(mounted.container) expect(routing.classList.contains('assistant-text__routing--visible')).toBe(true) leave(mounted.root) await nextTick() expect(routing.classList.contains('assistant-text__routing--visible')).toBe(false) }) it('无 matched_skill 的消息悬停时也不显示 tag', async () => { mounted = mountAssistantText(makeMessage({ matched_skill: undefined })) const routing = getRouting(mounted.container) // v-show=false → display:none expect(routing.style.display).toBe('none') hover(mounted.root) await nextTick() // 即使悬停,因无路由信息仍隐藏 expect(routing.style.display).toBe('none') }) it('淡入淡出过渡平滑无闪烁(v-show 保留 DOM,无重挂载)', async () => { mounted = mountAssistantText(makeMessage()) const routingBefore = getRouting(mounted.container) hover(mounted.root) await nextTick() const routingHovered = getRouting(mounted.container) // hover 前后同一节点 → 未重挂载 expect(routingHovered).toBe(routingBefore) leave(mounted.root) await nextTick() const routingLeft = getRouting(mounted.container) expect(routingLeft).toBe(routingBefore) }) })