Files
MyGoNavi/frontend/src/utils/shortcuts.test.ts
Syngnat b85e7491a9 feat(shortcuts): 新增标签页切换快捷键
- 新增切换到下一个标签页动作,默认 Ctrl+Tab
- 新增切换到上一个标签页动作,默认 Ctrl+Shift+Tab
- 接入全局快捷键处理,按当前标签顺序首尾循环切换
- 补充快捷键默认值与全局执行链路测试

Refs #399
2026-06-01 12:05:25 +08:00

380 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
DEFAULT_SHORTCUT_OPTIONS,
findReservedConflict,
findReservedConflicts,
describeConflictContext,
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
getPrimaryShortcutDisplayLabel,
getShortcutDisplayLabel,
getShortcutPrimaryModifierDisplayLabel,
resolveShortcutBinding,
resolveShortcutDisplay,
sanitizeShortcutOptions,
SHORTCUT_ACTION_META,
} from './shortcuts';
import type { ConflictInfo } from './shortcuts';
// ─── findReservedConflict ────────────────────────────────────────────
describe('findReservedConflict', () => {
it('finds Ctrl+F conflict (Monaco Find)', () => {
const result = findReservedConflict('Ctrl+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
expect(result!.monacoCommandId).toBe('actions.find');
});
it('finds Ctrl+S conflict (browser save)', () => {
const result = findReservedConflict('Ctrl+S');
expect(result).not.toBeNull();
expect(result!.label).toBe('浏览器保存');
expect(result!.context).toBe('global');
});
it('returns null for non-reserved combo', () => {
expect(findReservedConflict('Ctrl+Shift+R')).toBeNull();
});
it('returns null for empty string', () => {
expect(findReservedConflict('')).toBeNull();
});
it('finds Meta+F (macOS variant)', () => {
const result = findReservedConflict('Meta+F');
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
expect(result!.context).toBe('monaco');
});
it('matches after normalization (ctrl+f → Ctrl+F)', () => {
const result = findReservedConflict(normalizeShortcutCombo('ctrl+f'));
expect(result).not.toBeNull();
expect(result!.label).toBe('编辑器查找');
});
it('finds F2 conflict', () => {
const result = findReservedConflict('F2');
expect(result).not.toBeNull();
expect(result!.context).toBe('monaco');
});
});
// ─── findReservedConflicts ───────────────────────────────────────────
describe('findReservedConflicts', () => {
it('returns multiple conflicts for Ctrl+Enter', () => {
const results = findReservedConflicts('Ctrl+Enter');
expect(results.length).toBeGreaterThanOrEqual(1);
const labels = results.map(r => r.label);
expect(labels).toContain('编辑器在下方插入行');
});
it('returns empty array for non-reserved combo', () => {
expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]);
});
it('preserves monacoCommandId in results', () => {
const results = findReservedConflicts('Ctrl+F');
expect(results[0].monacoCommandId).toBe('actions.find');
});
it('uses Command instead of Control for macOS find shortcut conflicts', () => {
expect(findReservedConflicts('Ctrl+F', 'mac')).toEqual([]);
expect(findReservedConflicts('Meta+F', 'mac')[0]).toMatchObject({
label: '编辑器查找',
monacoCommandId: 'actions.find',
});
expect(findReservedConflicts('Ctrl+F', 'windows')[0]).toMatchObject({
label: '编辑器查找',
monacoCommandId: 'actions.find',
});
});
});
// ─── describeConflictContext ─────────────────────────────────────────
describe('describeConflictContext', () => {
it('describes global context', () => {
expect(describeConflictContext('global')).toBe('浏览器');
});
it('describes monaco context', () => {
expect(describeConflictContext('monaco')).toBe('编辑器');
});
it('describes datagrid context', () => {
expect(describeConflictContext('datagrid')).toBe('数据表格');
});
});
// ─── RESERVED_SHORTCUTS sanity ───────────────────────────────────────
describe('RESERVED_SHORTCUTS', () => {
it('all combos are already normalized', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.combo).toBe(normalizeShortcutCombo(entry.combo));
}
});
it('has at least 10 entries', () => {
expect(RESERVED_SHORTCUTS.length).toBeGreaterThanOrEqual(10);
});
it('every entry has a label and context', () => {
for (const entry of RESERVED_SHORTCUTS) {
expect(entry.label).toBeTruthy();
expect(['global', 'monaco', 'datagrid']).toContain(entry.context);
}
});
});
// ─── shortcut defaults ───────────────────────────────────────────────
describe('shortcut defaults', () => {
it('registers select current statement as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.selectCurrentStatement).toEqual({
mac: { combo: 'Meta+E', enabled: true },
windows: { combo: 'Ctrl+E', enabled: true },
});
expect(SHORTCUT_ACTION_META.selectCurrentStatement).toMatchObject({
label: '选择当前语句',
scope: 'queryEditor',
});
});
it('registers save query as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.saveQuery).toEqual({
mac: { combo: 'Meta+S', enabled: true },
windows: { combo: 'Ctrl+S', enabled: true },
});
expect(SHORTCUT_ACTION_META.saveQuery).toMatchObject({
label: '保存查询',
scope: 'queryEditor',
allowInEditable: true,
});
});
// Windows 任务栏恢复后字体异常变大的兜底入口(方案 3
// 自动 fix 路径9848b8b2刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。
it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.resetWindowZoom).toEqual({
mac: { combo: '', enabled: false },
windows: { combo: 'Ctrl+Shift+0', enabled: true },
});
expect(SHORTCUT_ACTION_META.resetWindowZoom).toMatchObject({
label: '重置窗口缩放',
allowInEditable: true,
});
});
it('keeps configurable shortcut descriptions free of hardcoded shortcut labels', () => {
Object.values(SHORTCUT_ACTION_META).forEach((meta) => {
expect(meta.description).not.toMatch(/⌘|⌃|Ctrl|Meta|Cmd|Command|Alt\+/);
});
});
it('keeps enabled default shortcuts unique per platform', () => {
for (const platform of ['mac', 'windows'] as const) {
const seen = new Map<string, string>();
Object.entries(DEFAULT_SHORTCUT_OPTIONS).forEach(([action, bindings]) => {
const binding = bindings[platform];
if (!binding.enabled || !binding.combo) return;
const existingAction = seen.get(binding.combo);
expect(existingAction, `${platform} ${binding.combo} is shared by ${existingAction} and ${action}`).toBeUndefined();
seen.set(binding.combo, action);
});
}
});
it('uses Navicat-inspired defaults separately for macOS and Windows/Linux', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.runQuery).toEqual({
mac: { combo: 'Meta+R', enabled: true },
windows: { combo: 'Ctrl+R', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.newQueryTab).toEqual({
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.switchToNextTab).toEqual({
mac: { combo: 'Ctrl+Tab', enabled: true },
windows: { combo: 'Ctrl+Tab', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.switchToPreviousTab).toEqual({
mac: { combo: 'Ctrl+Shift+Tab', enabled: true },
windows: { combo: 'Ctrl+Shift+Tab', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.toggleLogPanel).toEqual({
mac: { combo: 'Meta+Shift+H', enabled: true },
windows: { combo: 'Ctrl+H', enabled: true },
});
});
it('registers connection and AI panel actions as real shortcuts', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.newConnection).toEqual({
mac: { combo: 'Meta+Shift+N', enabled: true },
windows: { combo: 'Ctrl+Shift+N', enabled: true },
});
expect(DEFAULT_SHORTCUT_OPTIONS.toggleAIPanel).toEqual({
mac: { combo: 'Meta+J', enabled: true },
windows: { combo: 'Ctrl+J', enabled: true },
});
expect(SHORTCUT_ACTION_META.newConnection.label).toBe('新建数据源');
expect(SHORTCUT_ACTION_META.toggleAIPanel.label).toBe('打开 AI 数据洞察');
});
it('migrates legacy single-platform shortcut bindings into both platform slots', () => {
const options = sanitizeShortcutOptions({
runQuery: { combo: 'Ctrl+Shift+R', enabled: false },
});
expect(options.runQuery).toEqual({
mac: { combo: 'Ctrl+Shift+R', enabled: false },
windows: { combo: 'Ctrl+Shift+R', enabled: false },
});
expect(options.newQueryTab.windows.combo).toBe('Ctrl+N');
});
it('sanitizes partial platform shortcut bindings without losing defaults', () => {
const options = sanitizeShortcutOptions({
newQueryTab: {
mac: { combo: 'Meta+N', enabled: false },
},
sendAIChatMessage: {
windows: { combo: 'A', enabled: true },
},
});
expect(options.newQueryTab.mac).toEqual({ combo: 'Meta+N', enabled: false });
expect(options.newQueryTab.windows).toEqual({ combo: 'Ctrl+N', enabled: true });
expect(options.saveQuery.windows).toEqual({ combo: 'Ctrl+S', enabled: true });
expect(options.sendAIChatMessage.windows).toEqual({ combo: 'Enter', enabled: true });
});
it('resolves and displays platform-specific bindings', () => {
const options = sanitizeShortcutOptions({
newQueryTab: {
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
},
});
expect(resolveShortcutBinding(options, 'newQueryTab', 'mac')).toEqual({
combo: 'Meta+N',
enabled: true,
});
expect(getShortcutDisplayLabel('Meta+N', 'mac')).toBe('⌘N');
expect(getShortcutDisplayLabel('Meta+Shift+H', 'mac')).toBe('⌘⇧H');
expect(getShortcutDisplayLabel('Ctrl+Meta+F', 'mac')).toBe('⌃⌘F');
expect(getShortcutDisplayLabel('Meta+S', 'mac')).toBe('⌘S');
expect(getShortcutDisplayLabel('Ctrl+S', 'windows')).toBe('Ctrl+S');
expect(getShortcutPrimaryModifierDisplayLabel('mac')).toBe('⌘');
expect(getShortcutPrimaryModifierDisplayLabel('windows')).toBe('Ctrl');
expect(getPrimaryShortcutDisplayLabel('C', 'mac')).toBe('⌘C');
expect(getPrimaryShortcutDisplayLabel('C', 'windows')).toBe('Ctrl+C');
expect(getPrimaryShortcutDisplayLabel('Enter', 'mac')).toBe('⌘↵');
expect(getPrimaryShortcutDisplayLabel('Enter', 'windows')).toBe('Ctrl+Enter');
expect(resolveShortcutDisplay(options, 'newQueryTab', 'windows')).toBe('Ctrl+N');
});
});
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────
describe('comboToMonacoKeyBinding', () => {
const mockKeyMod = {
CtrlCmd: 2048,
WinCtrl: 256,
Alt: 512,
Shift: 1024,
};
const mockKeyCode = {
Enter: 3,
Tab: 2,
Escape: 9,
Space: 10,
Backspace: 1,
Delete: 20,
Home: 14,
End: 13,
PageUp: 11,
PageDown: 12,
UpArrow: 16,
DownArrow: 17,
LeftArrow: 15,
RightArrow: 18,
Insert: 19,
KeyA: 31, KeyB: 32, KeyC: 33, KeyD: 34, KeyE: 35,
KeyF: 41,
KeyG: 42, KeyH: 43, KeyK: 47, KeyN: 50, KeyP: 52, KeyR: 54, KeyS: 55,
Digit0: 21, Digit1: 22, Digit2: 23, Digit3: 24, Digit4: 25,
Digit5: 26, Digit6: 27, Digit7: 28, Digit8: 29, Digit9: 30,
F1: 61, F2: 62, F3: 63, F4: 64, F5: 65, F6: 66,
F7: 67, F8: 68, F9: 69, F10: 70, F11: 71, F12: 72,
Oem1: 80, Oem2: 81, Oem3: 82, Oem4: 83, Oem5: 84,
Oem6: 85, Oem7: 86, OemComma: 87, OemMinus: 88,
OemPlus: 89, OemPeriod: 90,
};
it('maps Ctrl+Enter correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Enter,
});
});
it('maps Ctrl+Shift+R correctly', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift,
keyCode: mockKeyCode.KeyR,
});
});
it('maps Meta+Enter (macOS variant)', () => {
expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.WinCtrl,
keyCode: mockKeyCode.Enter,
});
});
it('maps F2 key', () => {
expect(comboToMonacoKeyBinding('F2', mockKeyMod, mockKeyCode)).toEqual({
keyMod: 0,
keyCode: mockKeyCode.F2,
});
});
it('maps Ctrl+, (comma)', () => {
expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.OemComma,
});
});
it('returns null for empty combo', () => {
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
});
it('returns null for combo with only modifiers', () => {
expect(comboToMonacoKeyBinding('Ctrl+Shift', mockKeyMod, mockKeyCode)).toBeNull();
});
it('maps Ctrl+Digit1', () => {
expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd,
keyCode: mockKeyCode.Digit1,
});
});
it('maps Ctrl+Alt+Delete', () => {
expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({
keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt,
keyCode: mockKeyCode.Delete,
});
});
});