Files
workspace/code/g3soft-erp/web/tests/composables/useHotkeys.spec.ts
T
2026-10-15 17:31:35 +08:00

334 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi, afterEach } from 'vitest'
import { defineComponent, h, KeepAlive, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { useHotkeys, type Hotkey } from '@/composables/useHotkeys'
/**
* useHotkeys 回归用例。
*
* 监听挂在 window 的捕获阶段,所以这里一律往 window 上派发 KeyboardEvent
* (而非某个元素),才能覆盖真实路径;输入态用例则把事件派发到输入框上,
* 靠 target 判断触发。
*/
/** 往 window 派发一次 keydown(bubbles 让捕获阶段能从 target 一路收到) */
function press(init: KeyboardEventInit, target?: HTMLElement): void {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
;(target ?? window).dispatchEvent(event)
}
/**
* 挂一个「最小宿主组件」调用 useHotkeys —— 组合式函数依赖 onMounted 等生命周期,
* 必须在组件上下文里跑。用渲染函数直接写,省去为测试造 .vue 文件。
*/
function mountWithHotkeys(shortcuts: Hotkey[]) {
const Comp = defineComponent({
setup() {
useHotkeys(shortcuts)
return () => h('div')
},
})
return mount(Comp)
}
describe('useHotkeys', () => {
afterEach(() => {
document.body.innerHTML = ''
})
describe('修饰符精确匹配', () => {
it('mod 匹配 Ctrl 与 Meta(跨平台主修饰键)', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
press({ key: 's', ctrlKey: true })
press({ key: 's', metaKey: true })
expect(handler).toHaveBeenCalledTimes(2)
})
it('未声明的修饰符按下则不匹配', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
press({ key: 's' }) // 无 mod
press({ key: 's', ctrlKey: true, shiftKey: true }) // 多按了 shift
expect(handler).not.toHaveBeenCalled()
})
it('无修饰符的单独键要求修饰键未按下', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: '/', handler }])
await nextTick()
press({ key: '/' })
expect(handler).toHaveBeenCalledTimes(1)
press({ key: '/', ctrlKey: true })
expect(handler).toHaveBeenCalledTimes(1)
})
it('alt / shift 精确匹配', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 'ArrowRight', shift: true, handler }])
await nextTick()
press({ key: 'ArrowRight', shiftKey: true })
expect(handler).toHaveBeenCalledTimes(1)
press({ key: 'ArrowRight' })
press({ key: 'ArrowRight', shiftKey: true, altKey: true })
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('中文输入法兜底(e.key 不可用时的 e.code 回退)', () => {
it('e.key 为 Process / Unidentified 时按物理键位匹配', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
press({ key: 'Process', code: 'KeyS', ctrlKey: true })
press({ key: 'Unidentified', code: 'KeyS', ctrlKey: true })
expect(handler).toHaveBeenCalledTimes(2)
})
it('e.code 不是 Key* 时不误匹配', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
press({ key: 'Process', code: 'Digit1', ctrlKey: true })
expect(handler).not.toHaveBeenCalled()
})
})
describe('输入态判断', () => {
it('默认在输入框内忽略', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: '/', handler }])
await nextTick()
const input = document.createElement('input')
document.body.appendChild(input)
press({ key: '/' }, input)
expect(handler).not.toHaveBeenCalled()
})
it('ignoreWhenTyping: false 时输入框内也触发(保存类)', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, ignoreWhenTyping: false, handler }])
await nextTick()
const input = document.createElement('input')
document.body.appendChild(input)
press({ key: 's', ctrlKey: true }, input)
expect(handler).toHaveBeenCalledTimes(1)
})
it('select 也算输入态(原版 fms-vue 漏了这一档)', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: '/', handler }])
await nextTick()
const select = document.createElement('select')
document.body.appendChild(select)
press({ key: '/' }, select)
expect(handler).not.toHaveBeenCalled()
})
it('焦点在输入框内层元素上也判为输入态(closest 口径)', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: '/', handler }])
await nextTick()
const input = document.createElement('input')
const inner = document.createElement('span')
input.appendChild(inner)
document.body.appendChild(input)
press({ key: '/' }, inner)
expect(handler).not.toHaveBeenCalled()
})
it('contenteditable 区域同样忽略', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: '/', handler }])
await nextTick()
const box = document.createElement('div')
box.setAttribute('contenteditable', 'true')
document.body.appendChild(box)
press({ key: '/' }, box)
expect(handler).not.toHaveBeenCalled()
})
})
describe('when 前置条件', () => {
it('条件不满足时不触发、也不 preventDefault', async () => {
const handler = vi.fn()
let enabled = false
mountWithHotkeys([{ key: 'Escape', when: () => enabled, handler }])
await nextTick()
const blocked = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
window.dispatchEvent(blocked)
expect(handler).not.toHaveBeenCalled()
// 关键:未接管就必须让行,否则弹窗的 Esc 关闭会失效
expect(blocked.defaultPrevented).toBe(false)
enabled = true
const allowed = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
window.dispatchEvent(allowed)
expect(handler).toHaveBeenCalledTimes(1)
expect(allowed.defaultPrevented).toBe(true)
})
it('前一条条件不满足时,继续尝试后面的条目', async () => {
const blocked = vi.fn()
const fallback = vi.fn()
mountWithHotkeys([
{ key: 'Escape', when: () => false, handler: blocked },
{ key: 'Escape', handler: fallback },
])
await nextTick()
press({ key: 'Escape' })
expect(blocked).not.toHaveBeenCalled()
expect(fallback).toHaveBeenCalledTimes(1)
})
})
it('命中即 preventDefault,未命中不拦截', async () => {
const handler = vi.fn()
mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
const hit = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true })
window.dispatchEvent(hit)
expect(hit.defaultPrevented).toBe(true)
const miss = new KeyboardEvent('keydown', { key: 'q', cancelable: true })
window.dispatchEvent(miss)
expect(miss.defaultPrevented).toBe(false)
})
it('一次按键只触发命中的第一条(break 语义)', async () => {
const first = vi.fn()
const second = vi.fn()
mountWithHotkeys([
{ key: 's', mod: true, handler: first },
{ key: 's', mod: true, handler: second },
])
await nextTick()
press({ key: 's', ctrlKey: true })
expect(first).toHaveBeenCalledTimes(1)
expect(second).not.toHaveBeenCalled()
})
it('捕获阶段仍能收到:后代在冒泡阶段 stopPropagation 拦不住', async () => {
const handler = vi.fn()
// ignoreWhenTyping 关掉,专测「捕获阶段能收到」这一点(否则输入框会把事件滤掉)
mountWithHotkeys([{ key: 's', mod: true, ignoreWhenTyping: false, handler }])
await nextTick()
// 模拟「结构树重命名输入框」那种在冒泡阶段 stop 掉的控件
const box = document.createElement('div')
const inner = document.createElement('input')
box.appendChild(inner)
document.body.appendChild(box)
inner.addEventListener('keydown', (e) => e.stopPropagation())
inner.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true }),
)
expect(handler).toHaveBeenCalledTimes(1)
})
describe('生命周期', () => {
it('卸载后解绑,不再响应', async () => {
const handler = vi.fn()
const wrapper = mountWithHotkeys([{ key: 's', mod: true, handler }])
await nextTick()
press({ key: 's', ctrlKey: true })
expect(handler).toHaveBeenCalledTimes(1)
wrapper.unmount()
press({ key: 's', ctrlKey: true })
expect(handler).toHaveBeenCalledTimes(1)
})
/**
* keep-alive 页签互抢的回归:切走的页签必须解绑。
* 这里用 KeepAlive + 动态组件还原真实形态(DefaultLayout 的 include 白名单同源问题)。
*/
it('keep-alive 切走的页签解绑,切回后重新绑定', async () => {
const handlerA = vi.fn()
const handlerB = vi.fn()
const PageA = defineComponent({
name: 'PageA',
setup() {
useHotkeys([{ key: 's', mod: true, handler: handlerA }])
return () => h('div', 'A')
},
})
const PageB = defineComponent({
name: 'PageB',
setup() {
useHotkeys([{ key: 's', mod: true, handler: handlerB }])
return () => h('div', 'B')
},
})
// 用 KeepAlive 包动态组件:切走触发 onDeactivated、切回触发 onActivated
const Host = defineComponent({
props: { current: { type: String, default: 'A' } },
setup(props) {
return () =>
h(KeepAlive, null, {
default: () => h(props.current === 'A' ? PageA : PageB),
})
},
})
const wrapper = mount(Host, { props: { current: 'A' } })
await nextTick()
press({ key: 's', ctrlKey: true })
expect(handlerA).toHaveBeenCalledTimes(1)
expect(handlerB).not.toHaveBeenCalled()
// 切到 B:A 应已解绑,只有 B 响应
await wrapper.setProps({ current: 'B' })
await nextTick()
press({ key: 's', ctrlKey: true })
expect(handlerA).toHaveBeenCalledTimes(1)
expect(handlerB).toHaveBeenCalledTimes(1)
// 切回 A:A 重新绑定
await wrapper.setProps({ current: 'A' })
await nextTick()
press({ key: 's', ctrlKey: true })
expect(handlerA).toHaveBeenCalledTimes(2)
expect(handlerB).toHaveBeenCalledTimes(1)
})
})
})