334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|