u
This commit is contained in:
1 parent
b4498e5350
commit
bf9dca19de
47 files changed
+8592
-313
No files matched your search
@@ -0,0 +1,333 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user