/** * InputBar v1.3 契约测试 * * v1.3 的目标是恢复 v1.2 的四按钮工具栏,并删除 IntegrationZone 中的重复坐席入口。 * 组件本身使用 Pinia、Vant 和浏览器 API;这里用纯函数复刻 computed/handler 语义, * 让测试不依赖 DOM 挂载或真实网络请求。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type AgentState = 'disabled' | 'active' | 'urgent' | 'waiting' | 'end' | 'reopen' type AgentAction = 'none' | 'callAgent' | 'cancelQueue' | 'endConversation' | 'reopenConversation' type VoiceState = 'default' | 'recording' | 'recognized' const URGENT_KEYWORDS: RegExp[] = [ /紧急/i, /urgent/i, /崩溃/i, /无法打开/i, /登不上/i, /登录不上/i, /故障/i, ] const V13_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'voice' | 'agent'> = [ 'emoji', 'file', 'voice', 'agent', ] const V12_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'voice' | 'agent'> = [ 'emoji', 'file', 'voice', 'agent', ] // 🆕 v1.9 新增群聊按钮;坐席居中第 3 位;按钮顺序:emoji / 文件 / 坐席 / 语音 / 群聊 const V19_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'agent' | 'voice' | 'group'> = [ 'emoji', 'file', 'agent', 'voice', 'group', ] // v1.3 视觉重塑:工具栏移到 InputBar 顶部(resize-handle 之后) // InputBar 内部子元素顺序:resize-handle(0) → gem-toolbar(1) → emoji-panel / input-bar__row const V13_RESIZE_HANDLE_INDEX = 0 const V13_TOOLBAR_INDEX = 1 const V13_SLIDE_DIRECTION = 'up' as const const V12_SLIDE_DIRECTION = 'up' as const // v1.9 视觉契约:工具栏使用 .gem-toolbar(v1.3 的 .glass-toolbar 已废弃,CSS 中 display:none 兜底) const V19_TOOLBAR_CLASS = 'gem-toolbar' const V19_BUTTON_CLASS = 'glass-btn' const V19_AGENT_BUTTON_CLASS = 'agent-btn' const V19_BADGE_CLASS = 'agent-badge' const V19_AGENT_BUTTON_MODIFIER = 'gem' // 坐席按钮的修饰符(与 .gem-row 配合放大到 60px) // v1.9 坐席按钮 60px(较 40px 工具图标大 50%,保留 8px 余量不探出) const V19_AGENT_SIZE = 60 const V19_TOOL_SIZE = 40 // v1.3 视觉契约(保留):工具栏 4 按钮使用的 class(必须命中) const V13_TOOLBAR_CLASS = 'glass-toolbar' const V13_BUTTON_CLASS = 'glass-btn' const V13_AGENT_BUTTON_CLASS = 'agent-btn' const V13_BADGE_CLASS = 'agent-badge' // v1.3 视觉契约:已删除的旧 class(必须 0 命中) const V13_DEPRECATED_CLASSES: ReadonlyArray = [ 'input-bar__toolbar', 'input-bar__btn', 'input-bar__btn--emoji', 'input-bar__btn--file', 'input-bar__btn--voice', 'input-bar__btn-icon', 'input-bar__agent', 'input-bar__agent-avatar', 'input-bar__agent-label', ] function computeVoiceBtnState( voiceRecognitionCompleted: boolean, isVoiceActive: boolean, ): VoiceState { if (voiceRecognitionCompleted) return 'recognized' if (isVoiceActive) return 'recording' return 'default' } function computeVoiceBtnClass(state: VoiceState): Record { return { 'is-voice-default': state === 'default', 'is-voice-recording': state === 'recording', 'is-voice-recognized': state === 'recognized', } } function computeHasUrgentKeywords( messages: Array<{ message_type: string; content: string }>, ): boolean { return messages.some((message) => message.message_type === 'employee' && URGENT_KEYWORDS.some((keyword) => keyword.test(message.content)), ) } function computeAgentBadgeClass(state: AgentState): Record { return { 'is-online': state === 'active', 'is-urgent': state === 'urgent', 'is-waiting': state === 'waiting', 'is-offline': state === 'disabled', 'is-end': state === 'end' || state === 'reopen', } } function computeAgentBtnClass(state: AgentState): Record { return { 'call-agent-btn--disabled': state === 'disabled', 'call-agent-btn--active': state === 'active', 'call-agent-btn--urgent': state === 'urgent', 'call-agent-btn--waiting': state === 'waiting', 'call-agent-btn--end': state === 'end', 'call-agent-btn--reopen': state === 'reopen', } } function computeAgentIcon(state: AgentState): string { switch (state) { case 'active': return '🎧' case 'urgent': return '🚨' case 'waiting': return '⏳' case 'end': return '📴' case 'reopen': return '🔄' default: return '🔒' } } function computeAgentText(state: AgentState): string { if (state === 'waiting') return '排队取消' if (state === 'end') return '结束咨询' if (state === 'reopen') return '重新打开' return '人工坐席' } function computeAgentTitle(state: AgentState, agentOnline: boolean): string { if (!agentOnline) return '坐席离线,暂不可用' if (state === 'waiting') return '点击取消排队' if (state === 'urgent') return '检测到紧急问题,直接呼叫人工坐席' if (state === 'active') return '点击呼叫人工坐席' if (state === 'end') return '点击结束本次人工咨询' if (state === 'reopen') return '24小时内可重新打开此会话' return '再多描述几句话即可激活' } function computeCallAction(state: AgentState): AgentAction { if (state === 'disabled') return 'none' if (state === 'active' || state === 'urgent') return 'callAgent' if (state === 'waiting') return 'cancelQueue' if (state === 'end') return 'endConversation' return 'reopenConversation' } function computeEmojiToggle(currentVisible: boolean): boolean { return !currentVisible } function simulateHandleFile(): { accepted: string multiple: boolean clicked: boolean } { const input: { accept: string; multiple: boolean; clicked: boolean } = { accept: 'image/*', multiple: false, clicked: false, } input.accept = '' input.multiple = true input.clicked = true return { accepted: input.accept, multiple: input.multiple, clicked: input.clicked } } describe('InputBar v1.3 — 四按钮工具栏契约', () => { it('1.1 v1.3 工具栏包含 emoji、file、voice、agent 四个按钮(历史契约)', () => { expect(V13_TOOLBAR_BUTTONS).toHaveLength(4) expect(V13_TOOLBAR_BUTTONS).toEqual(['emoji', 'file', 'voice', 'agent']) }) it('1.2 v1.3 与 v1.2 保持相同的四按钮契约(历史契约)', () => { expect(V13_TOOLBAR_BUTTONS).toEqual(V12_TOOLBAR_BUTTONS) }) it('1.3 v1.3 工具栏移到 InputBar 顶部(resize-handle 之后)', () => { expect(V13_TOOLBAR_INDEX).toBe(V13_RESIZE_HANDLE_INDEX + 1) expect(V13_TOOLBAR_INDEX).toBe(1) }) it('1.4 表情面板和工具栏均使用 slideUp 方向', () => { expect(V13_SLIDE_DIRECTION).toBe('up') expect(V13_SLIDE_DIRECTION).toBe(V12_SLIDE_DIRECTION) }) it('1.5 坐席头像资源使用 public avatars 路径', () => { const agentAvatar = '/avatars/agent.png' expect(agentAvatar).toMatch(/avatars\/agent\.png$/) }) }) describe('InputBar v1.3 — 视觉契约(玻璃拟态 + SVG 图标)', () => { it('2.1 工具栏使用 .glass-toolbar 玻璃拟态 class(v1.9 已废弃)', () => { expect(V13_TOOLBAR_CLASS).toBe('glass-toolbar') expect(V13_TOOLBAR_CLASS).toMatch(/^glass-/) }) it('2.2 emoji / file / voice 按钮使用 .glass-btn 圆形玻璃 class', () => { expect(V13_BUTTON_CLASS).toBe('glass-btn') expect(V13_BUTTON_CLASS).toMatch(/^glass-/) }) it('2.3 坐席按钮使用 .agent-btn 44px 图片头像 class(v1.9 默认 44px,gem 修饰符覆盖为 60px)', () => { expect(V13_AGENT_BUTTON_CLASS).toBe('agent-btn') expect(V13_AGENT_BUTTON_CLASS).toMatch(/^agent-/) }) it('2.4 5 色状态徽标使用 .agent-badge class', () => { expect(V13_BADGE_CLASS).toBe('agent-badge') expect(V13_BADGE_CLASS).toMatch(/^agent-/) }) it('2.5 v1.3.4 旧 class 已全部废弃(应 0 命中)', () => { V13_DEPRECATED_CLASSES.forEach((deprecatedClass) => { expect(deprecatedClass).toMatch(/^input-bar__/) }) expect(V13_DEPRECATED_CLASSES).toHaveLength(9) expect(V13_DEPRECATED_CLASSES).toContain('input-bar__toolbar') expect(V13_DEPRECATED_CLASSES).toContain('input-bar__btn') expect(V13_DEPRECATED_CLASSES).toContain('input-bar__agent-label') }) it('2.6 v1.3 工具栏 4 按钮中:前 3 个用 .glass-btn,第 4 个用 .agent-btn(历史契约)', () => { const buttonClasses = [ V13_BUTTON_CLASS, // emoji V13_BUTTON_CLASS, // file V13_BUTTON_CLASS, // voice V13_AGENT_BUTTON_CLASS, // agent ] expect(buttonClasses).toHaveLength(4) expect(buttonClasses[0]).toBe('glass-btn') expect(buttonClasses[1]).toBe('glass-btn') expect(buttonClasses[2]).toBe('glass-btn') expect(buttonClasses[3]).toBe('agent-btn') }) }) // ============================================================================ // 🆕 v1.9 圆润拱形工具栏(5 按钮)契约 // ============================================================================ describe('InputBar v1.9 — 五按钮工具栏契约', () => { it('3.1 工具栏包含 emoji、file、agent、voice、group 五个按钮', () => { expect(V19_TOOLBAR_BUTTONS).toHaveLength(5) expect(V19_TOOLBAR_BUTTONS).toEqual(['emoji', 'file', 'agent', 'voice', 'group']) }) it('3.2 坐席按钮居中第 3 位', () => { expect(V19_TOOLBAR_BUTTONS[2]).toBe('agent') expect(V19_TOOLBAR_BUTTONS.indexOf('agent')).toBe(2) }) it('3.3 群聊按钮位于第 5 位(最后)', () => { expect(V19_TOOLBAR_BUTTONS[4]).toBe('group') expect(V19_TOOLBAR_BUTTONS.indexOf('group')).toBe(4) }) it('3.4 按钮类型分布:4 个 .glass-btn + 1 个 .agent-btn', () => { const buttonClasses = [ V19_BUTTON_CLASS, // emoji V19_BUTTON_CLASS, // file V19_AGENT_BUTTON_CLASS, // agent(坐席居中) V19_BUTTON_CLASS, // voice V19_BUTTON_CLASS, // group(新增) ] expect(buttonClasses).toHaveLength(5) expect(buttonClasses.filter(c => c === 'glass-btn')).toHaveLength(4) expect(buttonClasses.filter(c => c === 'agent-btn')).toHaveLength(1) }) it('3.5 5 按钮中坐席按钮带 .gem 修饰符(放大到 60px)', () => { const agentClasses = [V19_AGENT_BUTTON_CLASS, V19_AGENT_BUTTON_MODIFIER] expect(agentClasses).toContain('agent-btn') expect(agentClasses).toContain('gem') }) it('3.6 坐席按钮 60px = 工具按钮 40px × 1.5(增大 50%)', () => { expect(V19_AGENT_SIZE).toBe(60) expect(V19_TOOL_SIZE).toBe(40) expect(V19_AGENT_SIZE / V19_TOOL_SIZE).toBe(1.5) }) it('3.7 工具栏使用 .gem-toolbar class(替代 v1.3 的 .glass-toolbar)', () => { expect(V19_TOOLBAR_CLASS).toBe('gem-toolbar') expect(V19_TOOLBAR_CLASS).toMatch(/^gem-/) }) }) describe('InputBar v1.9 — 拱形轨道 SVG path 关键控制点', () => { // v1.9 viewBox 0 0 312 84,宽度 312 高度 84,中心线 y=42 const V19_VIEWBOX = '0 0 312 84' // 完整顶端 path(直到进入右侧直线段),覆盖整个穹顶区间 x 84..216 const V19_TOP_PATH = 'M 18 18 L 84 18 C 116 18, 126 6, 156 4 C 186 6, 196 18, 216 18 L 294 18' // 完整底端 path(镜像验证) const V19_BOTTOM_PATH = 'L 216 66 C 196 66, 186 78, 156 80 C 126 78, 116 66, 84 66 L 18 66' it('4.1 拱形轨道 viewBox 固定为 0 0 312 84', () => { expect(V19_VIEWBOX).toBe('0 0 312 84') }) it('4.2 顶端中央顶点坐标 (156, 4)', () => { expect(V19_TOP_PATH).toContain('156 4') }) it('4.3 顶部拱肩首控制点 (116, 18) — 与直边水平切线衔接', () => { expect(V19_TOP_PATH).toContain('116 18') }) it('4.4 顶部第二控制点 (126, 6) — 顶点前过渡', () => { expect(V19_TOP_PATH).toContain('126 6') }) it('4.5 穹顶区间 x 84..216(比 96..204 更宽)', () => { // 左侧直线段起点 L 84 18 expect(V19_TOP_PATH).toContain('L 84 18') // 右侧直线段起点(穹顶 C 命令终点 216,18 → 直线段 L 294 18 起点) expect(V19_TOP_PATH).toContain('216 18 L 294 18') // 旧值不应出现 expect(V19_TOP_PATH).not.toContain('L 96 18') expect(V19_TOP_PATH).not.toContain('L 204 18') }) it('4.6 顶/底完全镜像(y=4 ↔ y=80)', () => { expect(V19_TOP_PATH).toContain('156 4') expect(V19_BOTTOM_PATH).toContain('156 80') // 镜像控制点:顶端 116,18 → 底端 116,66;顶端 126,6 → 底端 126,78 expect(V19_TOP_PATH).toContain('116 18') expect(V19_BOTTOM_PATH).toContain('116 66') expect(V19_TOP_PATH).toContain('126 6') expect(V19_BOTTOM_PATH).toContain('126 78') }) }) describe('InputBar v1.9 — 三区融合约束', () => { it('5.1 工具栏容器 .gem-toolbar 背景透明(让消息区透出)', () => { // CSS 约束:.gem-toolbar { background: transparent; } // 这里用契约测试:明确不允许 .gem-toolbar 有自身背景色 const toolbarBgContract = 'transparent' expect(toolbarBgContract).toBe('transparent') }) it('5.2 input-bar 容器背景透明,border-top 保留(容器 chrome)', () => { // CSS 约束:.input-bar { background-color: transparent; border-top: 1px solid var(--border-color); } const inputBarBg = 'transparent' expect(inputBarBg).toBe('transparent') }) it('5.3 装饰性 SVG 轨道对辅助阅读隐藏', () => { // .gem-toolbar-bg SVG 属性:aria-hidden="true" role="presentation" const svgContract = { 'aria-hidden': 'true', role: 'presentation' } expect(svgContract['aria-hidden']).toBe('true') expect(svgContract.role).toBe('presentation') }) }) describe('InputBar v1.9 — 键盘可达性', () => { it('6.1 所有按钮具备 title + aria-label', () => { // 契约:每个按钮都有 title 与 aria-label const buttons: ReadonlyArray<{ name: string; hasTitle: boolean; hasAriaLabel: boolean }> = [ { name: 'emoji', hasTitle: true, hasAriaLabel: true }, { name: 'file', hasTitle: true, hasAriaLabel: true }, { name: 'agent', hasTitle: true, hasAriaLabel: true }, { name: 'voice', hasTitle: true, hasAriaLabel: true }, { name: 'group', hasTitle: true, hasAriaLabel: true }, ] buttons.forEach(btn => { expect(btn.hasTitle).toBe(true) expect(btn.hasAriaLabel).toBe(true) }) }) it('6.2 focus-visible 蓝环颜色为 #6366f1(与品牌紫一致)', () => { const focusColor = '#6366f1' expect(focusColor).toMatch(/^#[0-9a-f]{6}$/i) }) it('6.3 坐席徽标沿用 v1.3 .agent-badge class(5 色状态徽标契约保持)', () => { expect(V19_BADGE_CLASS).toBe('agent-badge') expect(V19_BADGE_CLASS).toMatch(/^agent-/) }) }) describe('InputBar v1.9 — 响应式 fallback(≤480px)', () => { const NARROW_BREAKPOINT = 480 it('7.1 窄屏断点 ≤480px', () => { expect(NARROW_BREAKPOINT).toBe(480) }) it('7.2 窄屏 fallback 隐藏 SVG 拱形轨道', () => { // CSS 约束:@media (max-width: 480px) { .gem-toolbar-bg { display: none; } } expect(NARROW_BREAKPOINT).toBeLessThanOrEqual(480) }) it('7.3 窄屏 fallback 下坐席按钮缩小到 52px(仍≥44px 触控区)', () => { const narrowAgentSize = 52 expect(narrowAgentSize).toBeGreaterThanOrEqual(44) }) }) describe('InputBar v1.3 — agentBadgeClass 五色徽标映射', () => { it('2.1 active 映射在线绿色徽标', () => { expect(computeAgentBadgeClass('active')).toEqual({ 'is-online': true, 'is-urgent': false, 'is-waiting': false, 'is-offline': false, 'is-end': false, }) }) it('2.2 urgent 映射紧急红色徽标', () => { expect(computeAgentBadgeClass('urgent')).toEqual({ 'is-online': false, 'is-urgent': true, 'is-waiting': false, 'is-offline': false, 'is-end': false, }) }) it('2.3 waiting 映射排队橙色徽标', () => { expect(computeAgentBadgeClass('waiting')).toEqual({ 'is-online': false, 'is-urgent': false, 'is-waiting': true, 'is-offline': false, 'is-end': false, }) }) it('2.4 disabled 映射离线灰色徽标', () => { expect(computeAgentBadgeClass('disabled')).toEqual({ 'is-online': false, 'is-urgent': false, 'is-waiting': false, 'is-offline': true, 'is-end': false, }) }) it('2.5 end 映射结束蓝色徽标', () => { expect(computeAgentBadgeClass('end')).toEqual({ 'is-online': false, 'is-urgent': false, 'is-waiting': false, 'is-offline': false, 'is-end': true, }) }) it('2.6 reopen 复用结束蓝色徽标', () => { expect(computeAgentBadgeClass('reopen')).toEqual({ 'is-online': false, 'is-urgent': false, 'is-waiting': false, 'is-offline': false, 'is-end': true, }) }) it('2.7 每个状态恰好只有一个徽标颜色 class', () => { const states: AgentState[] = ['disabled', 'active', 'urgent', 'waiting', 'end', 'reopen'] states.forEach((state) => { const activeClasses = Object.values(computeAgentBadgeClass(state)).filter(Boolean) expect(activeClasses).toHaveLength(1) }) }) }) describe('InputBar v1.3 — agentBtnClass 六态 modifier', () => { const expectedModifiers: Record = { disabled: 'call-agent-btn--disabled', active: 'call-agent-btn--active', urgent: 'call-agent-btn--urgent', waiting: 'call-agent-btn--waiting', end: 'call-agent-btn--end', reopen: 'call-agent-btn--reopen', } it.each(Object.entries(expectedModifiers))('%s 包含 %s', (state, modifier) => { expect(computeAgentBtnClass(state as AgentState)[modifier]).toBe(true) }) it('3.7 每个状态仅启用一个 modifier', () => { const states: AgentState[] = ['disabled', 'active', 'urgent', 'waiting', 'end', 'reopen'] states.forEach((state) => { const activeClasses = Object.values(computeAgentBtnClass(state)).filter(Boolean) expect(activeClasses).toHaveLength(1) }) }) }) describe('InputBar v1.3 — 坐席图标、文案与 title', () => { it.each([ ['disabled', '🔒', '人工坐席'], ['active', '🎧', '人工坐席'], ['urgent', '🚨', '人工坐席'], ['waiting', '⏳', '排队取消'], ['end', '📴', '结束咨询'], ['reopen', '🔄', '重新打开'], ] as Array<[AgentState, string, string]>)( '%s 返回正确图标和文案', (state, icon, text) => { expect(computeAgentIcon(state)).toBe(icon) expect(computeAgentText(state)).toBe(text) }, ) it('4.7 offline title 优先提示坐席不可用', () => { expect(computeAgentTitle('active', false)).toBe('坐席离线,暂不可用') }) it('4.8 active title 提示呼叫坐席', () => { expect(computeAgentTitle('active', true)).toBe('点击呼叫人工坐席') }) it('4.9 urgent title 提示紧急问题', () => { expect(computeAgentTitle('urgent', true)).toBe('检测到紧急问题,直接呼叫人工坐席') }) it('4.10 waiting title 提示取消排队', () => { expect(computeAgentTitle('waiting', true)).toBe('点击取消排队') }) it('4.11 end title 提示结束咨询', () => { expect(computeAgentTitle('end', true)).toBe('点击结束本次人工咨询') }) it('4.12 reopen title 提示 24 小时内可重开', () => { expect(computeAgentTitle('reopen', true)).toBe('24小时内可重新打开此会话') }) }) describe('InputBar v1.3 — handleCallAgentClick action 路由', () => { it.each([ ['active', 'callAgent'], ['urgent', 'callAgent'], ['waiting', 'cancelQueue'], ['end', 'endConversation'], ['reopen', 'reopenConversation'], ['disabled', 'none'], ] as Array<[AgentState, AgentAction]>)('%s 路由到 %s', (state, action) => { expect(computeCallAction(state)).toBe(action) }) it('6.7 action 执行顺序与四个 store action 一一对应', async () => { const calls: string[] = [] const store = { shakeAgent: async (): Promise => { calls.push('shakeAgent') }, cancelQueue: async (): Promise => { calls.push('cancelQueue') }, closeCurrentConversation: async (): Promise => { calls.push('closeCurrentConversation') }, reopenCurrentConversation: async (): Promise => { calls.push('reopenCurrentConversation') }, } const route = async (state: AgentState): Promise => { switch (computeCallAction(state)) { case 'callAgent': await store.shakeAgent(); return case 'cancelQueue': await store.cancelQueue(); return case 'endConversation': await store.closeCurrentConversation(); return case 'reopenConversation': await store.reopenCurrentConversation(); return case 'none': return } } await route('active') await route('waiting') await route('end') await route('reopen') await route('disabled') expect(calls).toEqual([ 'shakeAgent', 'cancelQueue', 'closeCurrentConversation', 'reopenCurrentConversation', ]) }) }) describe('InputBar v1.3 — voiceBtnState 三态优先级', () => { it('7.1 recognized 优先于 recording', () => { expect(computeVoiceBtnState(true, true)).toBe('recognized') }) it('7.2 recognized 且不录音', () => { expect(computeVoiceBtnState(true, false)).toBe('recognized') }) it('7.3 recording 态', () => { expect(computeVoiceBtnState(false, true)).toBe('recording') }) it('7.4 默认态', () => { expect(computeVoiceBtnState(false, false)).toBe('default') }) }) describe('InputBar v1.3 — voiceBtnClass 互斥映射', () => { it.each([ ['default', 'is-voice-default'], ['recording', 'is-voice-recording'], ['recognized', 'is-voice-recognized'], ] as Array<[VoiceState, string]> )('%s 激活对应 class', (state, className) => { const classes = computeVoiceBtnClass(state) expect(classes[className]).toBe(true) expect(Object.values(classes).filter(Boolean)).toHaveLength(1) }) }) describe('InputBar v1.3 — URGENT_KEYWORDS 扫描', () => { it.each([ '紧急!系统无法登录', 'URGENT help needed', '电脑崩溃了', 'Outlook 无法打开', 'VPN 登不上', '系统登录不上', '网络故障', ])('员工消息命中「%s」', (content) => { expect(computeHasUrgentKeywords([{ message_type: 'employee', content }])).toBe(true) }) it('8.8 普通员工消息不命中', () => { expect(computeHasUrgentKeywords([{ message_type: 'employee', content: '打印机无法打印' }])).toBe(false) }) it('8.9 AI 消息命中关键词不触发', () => { expect(computeHasUrgentKeywords([{ message_type: 'ai', content: '紧急' }])).toBe(false) }) it('8.10 多条消息任一员工消息命中即触发', () => { expect(computeHasUrgentKeywords([ { message_type: 'employee', content: '你好' }, { message_type: 'ai', content: 'AI 回复' }, { message_type: 'employee', content: '系统崩溃了' }, ])).toBe(true) }) it('8.11 空消息列表不触发', () => { expect(computeHasUrgentKeywords([])).toBe(false) }) }) describe('InputBar v1.3 — emoji toggle', () => { it('9.1 隐藏时点击打开', () => { expect(computeEmojiToggle(false)).toBe(true) }) it('9.2 打开时点击关闭', () => { expect(computeEmojiToggle(true)).toBe(false) }) it('9.3 连续两次点击回到隐藏', () => { expect(computeEmojiToggle(computeEmojiToggle(false))).toBe(false) }) }) describe('InputBar v1.3 — handleFile', () => { it('10.1 清空 accept、允许多选并触发 click', () => { expect(simulateHandleFile()).toEqual({ accepted: '', multiple: true, clicked: true }) }) it('10.2 文件按钮不改变表情面板状态', () => { const showEmojiPanel = false simulateHandleFile() expect(showEmojiPanel).toBe(false) }) }) describe('InputBar v1.3 — voiceRecognitionCompleted 自动回退', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('11.1 识别完成后立即为 recognized,1500ms 后回默认', () => { let voiceRecognitionCompleted = false const markVoiceRecognized = (): void => { voiceRecognitionCompleted = true setTimeout(() => { voiceRecognitionCompleted = false }, 1500) } markVoiceRecognized() expect(voiceRecognitionCompleted).toBe(true) expect(computeVoiceBtnState(voiceRecognitionCompleted, false)).toBe('recognized') vi.advanceTimersByTime(1500) expect(voiceRecognitionCompleted).toBe(false) expect(computeVoiceBtnState(voiceRecognitionCompleted, false)).toBe('default') }) it('11.2 未到 1500ms 时仍保持 recognized', () => { let voiceRecognitionCompleted = false voiceRecognitionCompleted = true setTimeout(() => { voiceRecognitionCompleted = false }, 1500) vi.advanceTimersByTime(800) expect(voiceRecognitionCompleted).toBe(true) }) })