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) }) }) })