// src/shortcuts.js 测试 // // 覆盖: // - 8 个全局快捷键派发到对应 handler // - 编辑器内(.cm-editor 内部)让位给 CM6(Ctrl+F / Ctrl+S / Ctrl+Shift+A) // - 模态打开时吞掉特定快捷键(避免抢焦点 / 顶替 dialog) // - Esc 在搜索框内 → 清空搜索 // - non-modifier 按键不触发 // // 用 jsdom 模拟 document.addEventListener('keydown')。 // modal-stack 用真实模块(之前 modal.test.js 已验证行为稳定)。 /* @vitest-environment jsdom */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mountKeyboardShortcuts } from '../../src/shortcuts.js'; import { mountModal } from '../../src/modal.js'; import { isOpen as modalIsOpen } from '../../src/modal-stack.js'; function fireKey(opts) { // 默认值:Ctrl 修饰,target = document.body const init = { key: 'f', ctrlKey: true, bubbles: true, cancelable: true, ...opts, }; const target = opts.target || document.body; const ev = new KeyboardEvent('keydown', init); target.dispatchEvent(ev); return ev; } describe('shortcuts 基本派发', () => { let handlers; let detach; beforeEach(() => { document.body.innerHTML = ''; handlers = { onFocusSearch: vi.fn(), onClearSearch: vi.fn(), onSettings: vi.fn(), onToggleTheme: vi.fn(), onSave: vi.fn(), onToggleEditorMode: vi.fn(), onNewFile: vi.fn(), onToggleFocusMode: vi.fn(), onToggleAi: vi.fn(), onReload: vi.fn(), }; detach = mountKeyboardShortcuts(handlers); }); afterEach(() => { if (typeof detach === 'function') detach(); }); it('Ctrl+F → onFocusSearch(不在编辑器内)', () => { fireKey({ key: 'f', ctrlKey: true }); expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1); expect(handlers.onFocusSearch.mock.invocationCallOrder[0]) .toBeLessThan(Number.MAX_SAFE_INTEGER); }); it('Ctrl+, → onSettings', () => { fireKey({ key: ',', ctrlKey: true }); expect(handlers.onSettings).toHaveBeenCalledTimes(1); }); it('Ctrl+Shift+T → onToggleTheme', () => { fireKey({ key: 'T', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleTheme).toHaveBeenCalledTimes(1); fireKey({ key: 't', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleTheme).toHaveBeenCalledTimes(2); }); it('Ctrl+S → onSave(非编辑器内)', () => { fireKey({ key: 's', ctrlKey: true }); expect(handlers.onSave).toHaveBeenCalledTimes(1); }); it('Ctrl+E → onToggleEditorMode', () => { fireKey({ key: 'e', ctrlKey: true }); expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1); }); it('Ctrl+N → onNewFile', () => { fireKey({ key: 'n', ctrlKey: true }); expect(handlers.onNewFile).toHaveBeenCalledTimes(1); }); it('Ctrl+Shift+F → onToggleFocusMode', () => { fireKey({ key: 'F', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleFocusMode).toHaveBeenCalledTimes(1); }); it('Ctrl+Shift+A → onToggleAi', () => { fireKey({ key: 'A', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleAi).toHaveBeenCalledTimes(1); }); // audit K1-H3:README 提到 Ctrl+R 重新加载界面但之前未注册。 // 补:注册到 onReload handler(与主菜单 reload 同源)。 it('Ctrl+R → onReload', () => { fireKey({ key: 'r', ctrlKey: true }); expect(handlers.onReload).toHaveBeenCalledTimes(1); // 大写 R 也应命中(Shift 不会被按,但 key.toLowerCase() 仍可工作) fireKey({ key: 'R', ctrlKey: true }); expect(handlers.onReload).toHaveBeenCalledTimes(2); }); it('非修饰键 → 不触发任何 handler', () => { fireKey({ key: 'a', ctrlKey: false, metaKey: false }); for (const [name, fn] of Object.entries(handlers)) { expect(fn, name).not.toHaveBeenCalled(); } }); it('Cmd+F(macOS) → onFocusSearch(metaKey 视为 ctrl)', () => { fireKey({ key: 'f', ctrlKey: false, metaKey: true }); expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1); }); }); describe('shortcuts 编辑器内让位', () => { let handlers; beforeEach(() => { // 构造编辑器内部 DOM:.cm-editor 容器包裹 .cm-content document.body.innerHTML = `
x
' }); expect(modalIsOpen()).toBe(true); fireKey({ key: ',', ctrlKey: true }); expect(handlers.onSettings).not.toHaveBeenCalled(); }); it('模态打开时 Ctrl+F → 不抢焦点', () => { mountModal({ title: 'mock-modal', body: 'x
' }); fireKey({ key: 'f', ctrlKey: true }); expect(handlers.onFocusSearch).not.toHaveBeenCalled(); }); it('模态打开时 Ctrl+Shift+T → 不切主题', () => { mountModal({ title: 'mock-modal', body: 'x
' }); fireKey({ key: 'T', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleTheme).not.toHaveBeenCalled(); }); it('模态打开时 Ctrl+Shift+A → 不切 AI dock', () => { mountModal({ title: 'mock-modal', body: 'x
' }); fireKey({ key: 'A', ctrlKey: true, shiftKey: true }); expect(handlers.onToggleAi).not.toHaveBeenCalled(); }); it('模态打开时 Ctrl+S 仍允许(与编辑器保存一致)', () => { // 验证「Ctrl+S 在模态内不禁用」这条契约;具体走哪个分支不重要, // 关键是 onSave 仍会被调用(设置对话框 / 文件新建 dialog 关闭时不丢改动)。 mountModal({ title: 'mock-modal', body: 'x
' }); fireKey({ key: 's', ctrlKey: true }); expect(handlers.onSave).toHaveBeenCalledTimes(1); }); }); describe('shortcuts Esc 清空搜索', () => { let handlers; beforeEach(() => { document.body.innerHTML = ''; handlers = { onClearSearch: vi.fn(), onFocusSearch: vi.fn(), }; mountKeyboardShortcuts(handlers); }); it('焦点在 search-input 时按 Esc → onClearSearch', () => { const input = document.getElementById('search-input'); input.focus(); document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(handlers.onClearSearch).toHaveBeenCalledTimes(1); }); it('焦点不在搜索框时按 Esc → 不触发 onClearSearch', () => { document.body.focus(); document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(handlers.onClearSearch).not.toHaveBeenCalled(); }); }); describe('shortcuts handler 缺失时静默', () => { it('不传 onSettings 也不应抛错', () => { document.body.innerHTML = ''; mountKeyboardShortcuts({}); // 全空 expect(() => { fireKey({ key: ',', ctrlKey: true }); fireKey({ key: 'f', ctrlKey: true }); fireKey({ key: 'T', ctrlKey: true, shiftKey: true }); fireKey({ key: 'F', ctrlKey: true, shiftKey: true }); fireKey({ key: 's', ctrlKey: true }); fireKey({ key: 'e', ctrlKey: true }); fireKey({ key: 'n', ctrlKey: true }); fireKey({ key: 'O', ctrlKey: true, shiftKey: true }); fireKey({ key: 'A', ctrlKey: true, shiftKey: true }); }).not.toThrow(); }); }); describe('shortcuts IME 合成期间(C3 audit)', () => { let handlers; let detach; beforeEach(() => { document.body.innerHTML = ''; handlers = { onToggleEditorMode: vi.fn(), onNewFile: vi.fn(), onFocusSearch: vi.fn(), onSave: vi.fn(), }; detach = mountKeyboardShortcuts(handlers); }); afterEach(() => { if (detach && typeof detach.dispose === 'function') detach.dispose(); }); it('isComposing=true 时 Ctrl+E 不触发 onToggleEditorMode(CJK 拼音中途保护)', () => { fireKey({ key: 'e', ctrlKey: true, isComposing: true }); expect(handlers.onToggleEditorMode).not.toHaveBeenCalled(); }); it('keyCode=229(Firefox / 旧 Chromium)时 Ctrl+N 不触发 onNewFile', () => { fireKey({ key: 'n', ctrlKey: true, keyCode: 229 }); expect(handlers.onNewFile).not.toHaveBeenCalled(); }); it('isComposing=true 时 Ctrl+F 也不抢焦点', () => { fireKey({ key: 'f', ctrlKey: true, isComposing: true }); expect(handlers.onFocusSearch).not.toHaveBeenCalled(); }); it('isComposing=false 时(正常组合键)行为不变', () => { fireKey({ key: 'e', ctrlKey: true, isComposing: false }); expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1); }); }); describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => { let handlers; let detach; let modalApi; beforeEach(() => { document.body.innerHTML = ''; handlers = { onSettings: vi.fn() }; detach = mountKeyboardShortcuts(handlers); // 模拟有模态打开 modalApi = mountModal({ title: 't', body: '', footer: '', onBackdropClose: 'cancel' }); }); afterEach(() => { if (detach && typeof detach.dispose === 'function') detach.dispose(); if (modalApi) modalApi.close('test-cleanup'); }); it('模态打开时 Ctrl+W 不触发默认行为(preventDefault)', () => { const ev = fireKey({ key: 'w', ctrlKey: true }); expect(ev.defaultPrevented).toBe(true); }); it('模态未开时 Ctrl+W 不在抑制列表里(normal 行为:不被 preventDefault)', () => { if (modalApi) modalApi.close('test-no-modal'); modalApi = null; const ev = fireKey({ key: 'w', ctrlKey: true }); // 不被任何 handler 拦截 → defaultPrevented 应该是 false(由浏览器处理关窗口) expect(ev.defaultPrevented).toBe(false); }); }); describe('shortcuts dispose(Phase O-M2 audit)', () => { it('mountKeyboardShortcuts 返回的 dispose 函数能 removeEventListener', () => { document.body.innerHTML = ''; const handler1 = vi.fn(); const detach1 = mountKeyboardShortcuts({ onSettings: handler1, }); expect(typeof detach1).toBe('function'); // 销毁第一个 mount detach1(); // 再 mount 第二个 const handler2 = vi.fn(); const detach2 = mountKeyboardShortcuts({ onSettings: handler2, }); // 触发 Ctrl+, fireKey({ key: ',', ctrlKey: true }); // 旧 handler 已经被 dispose → 不应被调用 expect(handler1).not.toHaveBeenCalled(); // 新 handler 应当被调用 expect(handler2).toHaveBeenCalledTimes(1); detach2(); }); });