373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
// 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 = '<input id="search-input" />';
|
||
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 = `
|
||
<div class="cm-editor">
|
||
<div class="cm-content" contenteditable="true">hello world</div>
|
||
</div>
|
||
`;
|
||
handlers = {
|
||
onFocusSearch: vi.fn(),
|
||
onSave: vi.fn(),
|
||
onToggleAi: vi.fn(),
|
||
onSettings: vi.fn(),
|
||
onToggleEditorMode: vi.fn(),
|
||
};
|
||
mountKeyboardShortcuts(handlers);
|
||
});
|
||
|
||
it('Ctrl+F 在编辑器内 → 不抢焦点(让位 CM6 搜索面板)', () => {
|
||
const cm = document.querySelector('.cm-content');
|
||
fireKey({ key: 'f', ctrlKey: true, target: cm });
|
||
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Ctrl+S 在编辑器内 → 不触发 onSave(CM6 已 save)', () => {
|
||
const cm = document.querySelector('.cm-content');
|
||
fireKey({ key: 's', ctrlKey: true, target: cm });
|
||
expect(handlers.onSave).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Ctrl+Shift+A 在编辑器内 → 不抢(Shift+A 在编辑器里有内置语义)', () => {
|
||
const cm = document.querySelector('.cm-content');
|
||
fireKey({ key: 'A', ctrlKey: true, shiftKey: true, target: cm });
|
||
expect(handlers.onToggleAi).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('Ctrl+E 在编辑器内 → 仍然生效(CM6 未占用)', () => {
|
||
const cm = document.querySelector('.cm-content');
|
||
fireKey({ key: 'e', ctrlKey: true, target: cm });
|
||
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('shortcuts 模态打开时吞键', () => {
|
||
let handlers;
|
||
|
||
beforeEach(() => {
|
||
document.body.innerHTML = `
|
||
<div id="modal-root"></div>
|
||
<input id="search-input" />
|
||
`;
|
||
handlers = {
|
||
onFocusSearch: vi.fn(),
|
||
onSettings: vi.fn(),
|
||
onToggleTheme: vi.fn(),
|
||
onToggleEditorMode: vi.fn(),
|
||
onNewFile: vi.fn(),
|
||
onToggleFocusMode: vi.fn(),
|
||
onToggleAi: vi.fn(),
|
||
onSave: vi.fn(),
|
||
};
|
||
mountKeyboardShortcuts(handlers);
|
||
});
|
||
|
||
afterEach(() => {
|
||
// 关闭所有可能残留的 modal
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
it('模态打开时 Ctrl+, → 不打开设置(避免顶替当前 dialog)', () => {
|
||
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
|
||
expect(modalIsOpen()).toBe(true);
|
||
|
||
fireKey({ key: ',', ctrlKey: true });
|
||
expect(handlers.onSettings).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('模态打开时 Ctrl+F → 不抢焦点', () => {
|
||
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
|
||
fireKey({ key: 'f', ctrlKey: true });
|
||
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('模态打开时 Ctrl+Shift+T → 不切主题', () => {
|
||
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
|
||
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
|
||
expect(handlers.onToggleTheme).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('模态打开时 Ctrl+Shift+A → 不切 AI dock', () => {
|
||
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
|
||
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
|
||
expect(handlers.onToggleAi).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('模态打开时 Ctrl+S 仍允许(与编辑器保存一致)', () => {
|
||
// 验证「Ctrl+S 在模态内不禁用」这条契约;具体走哪个分支不重要,
|
||
// 关键是 onSave 仍会被调用(设置对话框 / 文件新建 dialog 关闭时不丢改动)。
|
||
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
|
||
fireKey({ key: 's', ctrlKey: true });
|
||
expect(handlers.onSave).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('shortcuts Esc 清空搜索', () => {
|
||
let handlers;
|
||
|
||
beforeEach(() => {
|
||
document.body.innerHTML = '<input id="search-input" />';
|
||
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 = '<div id="modal-root"></div>';
|
||
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 = '<input id="search-input" />';
|
||
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();
|
||
});
|
||
}); |