Merge remote-tracking branch 'origin/main' into main
合入远端 ad8fd18d(feat(chat): 工具栏统一设计 v1.9 — 圆润拱形 + 5 按钮 + 三区无边框融合,新增 src/frontend-h5/src/components/chat/InputBar.vue 与 InputBar.test.ts)。 与本地提交(docs 结构整改 + compose 双目录对齐)无文件重叠,无冲突。 本合并提交由对象层 merge-tree/commit-tree 生成,未经工作树 checkout, 以规避本仓库不全克隆导致的 auto-stash 失败问题。
This commit is contained in:
@@ -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-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<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 玻璃拟态 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<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 识别完成后立即为 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user