This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
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)
})
})
})