This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

View File

@@ -0,0 +1,373 @@
// src/shortcuts.js 测试
//
// 覆盖:
// - 8 个全局快捷键派发到对应 handler
// - 编辑器内(.cm-editor 内部)让位给 CM6Ctrl+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-H3README 提到 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+FmacOS → onFocusSearchmetaKey 视为 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 在编辑器内 → 不触发 onSaveCM6 已 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 不触发 onToggleEditorModeCJK 拼音中途保护)', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: true });
expect(handlers.onToggleEditorMode).not.toHaveBeenCalled();
});
it('keyCode=229Firefox / 旧 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 disposePhase 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();
});
});