wip: 2026-08-11 工作树快照(docs/memory/h5.py/scripts 等 447 项未评审改动,安全提交到 feat 分支)

This commit is contained in:
Simon
2026-08-11 09:59:44 +08:00
parent 6be361fb63
commit f2fd4fa012
447 changed files with 273482 additions and 1386 deletions
@@ -0,0 +1,721 @@
/**
* 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-toolbarv1.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<string> = [
'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<string, boolean> {
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<string, boolean> {
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<string, boolean> {
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 玻璃拟态 classv1.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 图片头像 classv1.9 默认 44pxgem 修饰符覆盖为 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 class5 色状态徽标契约保持)', () => {
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<AgentState, string> = {
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<void> => { calls.push('shakeAgent') },
cancelQueue: async (): Promise<void> => { calls.push('cancelQueue') },
closeCurrentConversation: async (): Promise<void> => { calls.push('closeCurrentConversation') },
reopenCurrentConversation: async (): Promise<void> => { calls.push('reopenCurrentConversation') },
}
const route = async (state: AgentState): Promise<void> => {
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 识别完成后立即为 recognized1500ms 后回默认', () => {
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)
})
})