mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-22 05:11:44 +08:00
- 新增独立内联补全模型配置与服务端透传 - 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全 - 引入基于已存查询和执行日志的 SQL 记忆补全 - 补充前后端本地化、快捷键与补全回归测试
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
setCurrentLanguage,
|
||
} from '../i18n';
|
||
import {
|
||
DEFAULT_SHORTCUT_OPTIONS,
|
||
findReservedConflict,
|
||
findReservedConflicts,
|
||
findReservedConflictsForAction,
|
||
describeConflictContext,
|
||
normalizeShortcutCombo,
|
||
RESERVED_SHORTCUTS,
|
||
comboToMonacoKeyBinding,
|
||
eventToShortcut,
|
||
getPrimaryShortcutDisplayLabel,
|
||
getShortcutDisplayLabel,
|
||
getShortcutPrimaryModifierDisplayLabel,
|
||
installGlobalImeCompositionTracking,
|
||
isGlobalImeCompositionActive,
|
||
isImeComposingKeyEvent,
|
||
isShortcutMatch,
|
||
resolveShortcutBinding,
|
||
resolveShortcutDisplay,
|
||
setGlobalImeCompositionActive,
|
||
sanitizeShortcutOptions,
|
||
SHORTCUT_ACTION_META,
|
||
} from './shortcuts';
|
||
import type { ConflictInfo } from './shortcuts';
|
||
|
||
beforeEach(() => {
|
||
setCurrentLanguage('zh-CN');
|
||
setGlobalImeCompositionActive(false);
|
||
});
|
||
|
||
// ─── 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',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('findReservedConflictsForAction', () => {
|
||
it('allows duplicate current line to reuse Monaco add-selection shortcut on Windows', () => {
|
||
expect(findReservedConflicts('Ctrl+D', 'windows')).toEqual([
|
||
expect.objectContaining({
|
||
monacoCommandId: 'editor.action.addSelectionToNextFindMatch',
|
||
}),
|
||
]);
|
||
expect(findReservedConflictsForAction('duplicateCurrentLine', 'Ctrl+D', 'windows')).toEqual([]);
|
||
});
|
||
|
||
it('allows duplicate current line to reuse Monaco add-selection shortcut on macOS', () => {
|
||
expect(findReservedConflicts('Meta+D', 'mac')).toEqual([
|
||
expect.objectContaining({
|
||
monacoCommandId: 'editor.action.addSelectionToNextFindMatch',
|
||
}),
|
||
]);
|
||
expect(findReservedConflictsForAction('duplicateCurrentLine', 'Meta+D', 'mac')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ─── 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('数据表格');
|
||
});
|
||
});
|
||
|
||
describe('shortcut localization', () => {
|
||
it('localizes action meta and reserved conflict copy for the active language while preserving raw combos', () => {
|
||
setCurrentLanguage('en-US');
|
||
try {
|
||
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('Run SQL');
|
||
expect(SHORTCUT_ACTION_META.saveQuery.description).toBe('Save the current query tab; unnamed queries open the save dialog');
|
||
expect(SHORTCUT_ACTION_META.formatSql.label).toBe('Format SQL');
|
||
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion.label).toBe('Trigger SQL AI Completion');
|
||
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.label).toBe('Toggle Results Panel');
|
||
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.description).toBe('Show or hide the results area below the query editor');
|
||
expect(SHORTCUT_ACTION_META.sendAIChatMessage.description).toContain('Shift+Enter');
|
||
expect(describeConflictContext('global')).toBe('Browser');
|
||
|
||
const browserSave = findReservedConflict('Ctrl+S');
|
||
expect(browserSave).toMatchObject({
|
||
label: 'Browser Save',
|
||
context: 'global',
|
||
});
|
||
|
||
setCurrentLanguage('zh-CN');
|
||
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('执行 SQL');
|
||
expect(SHORTCUT_ACTION_META.formatSql.label).toBe('美化 SQL');
|
||
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion.label).toBe('触发 SQL AI 自动补全');
|
||
expect(findReservedConflict('Ctrl+S')?.label).toBe('浏览器保存');
|
||
} finally {
|
||
setCurrentLanguage('zh-CN');
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── 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);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('IME shortcut guards', () => {
|
||
it('tracks composition state through global listeners', () => {
|
||
const windowListeners = new Map<string, EventListener[]>();
|
||
const documentListeners = new Map<string, EventListener[]>();
|
||
const target = {
|
||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||
windowListeners.set(type, [...(windowListeners.get(type) || []), listener]);
|
||
}),
|
||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||
windowListeners.set(type, (windowListeners.get(type) || []).filter(item => item !== listener));
|
||
}),
|
||
};
|
||
const documentTarget = {
|
||
visibilityState: 'visible' as DocumentVisibilityState,
|
||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
|
||
}),
|
||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||
documentListeners.set(type, (documentListeners.get(type) || []).filter(item => item !== listener));
|
||
}),
|
||
};
|
||
|
||
const dispose = installGlobalImeCompositionTracking(
|
||
target as unknown as Window,
|
||
documentTarget as unknown as Document,
|
||
);
|
||
|
||
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
|
||
expect(isGlobalImeCompositionActive()).toBe(true);
|
||
|
||
windowListeners.get('compositionend')?.forEach(listener => listener(new Event('compositionend')));
|
||
expect(isGlobalImeCompositionActive()).toBe(false);
|
||
|
||
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
|
||
windowListeners.get('blur')?.forEach(listener => listener(new Event('blur')));
|
||
expect(isGlobalImeCompositionActive()).toBe(false);
|
||
|
||
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
|
||
documentTarget.visibilityState = 'hidden';
|
||
documentListeners.get('visibilitychange')?.forEach(listener => listener(new Event('visibilitychange')));
|
||
expect(isGlobalImeCompositionActive()).toBe(false);
|
||
|
||
dispose();
|
||
expect(target.removeEventListener).toHaveBeenCalledWith('compositionstart', expect.any(Function), true);
|
||
expect(documentTarget.removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function), true);
|
||
});
|
||
|
||
it('treats composing key events as non-shortcuts', () => {
|
||
const event = {
|
||
key: 'Process',
|
||
keyCode: 229,
|
||
which: 229,
|
||
isComposing: true,
|
||
ctrlKey: true,
|
||
metaKey: false,
|
||
altKey: false,
|
||
shiftKey: false,
|
||
nativeEvent: {
|
||
isComposing: true,
|
||
keyCode: 229,
|
||
which: 229,
|
||
},
|
||
} as unknown as KeyboardEvent;
|
||
|
||
expect(isImeComposingKeyEvent(event)).toBe(true);
|
||
expect(eventToShortcut(event)).toBe('');
|
||
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
|
||
});
|
||
|
||
it('matches modifier shortcuts from KeyboardEvent.code when WebView reports Process', () => {
|
||
const event = {
|
||
key: 'Process',
|
||
code: 'Backslash',
|
||
keyCode: 220,
|
||
which: 220,
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
altKey: true,
|
||
shiftKey: false,
|
||
isComposing: false,
|
||
nativeEvent: {
|
||
code: 'Backslash',
|
||
keyCode: 220,
|
||
which: 220,
|
||
isComposing: false,
|
||
},
|
||
} as unknown as KeyboardEvent;
|
||
|
||
expect(isImeComposingKeyEvent(event)).toBe(false);
|
||
expect(eventToShortcut(event)).toBe('Alt+\\');
|
||
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
|
||
});
|
||
|
||
it('matches modifier shortcuts from IntlBackslash layout events when WebView reports Process', () => {
|
||
const event = {
|
||
key: 'Process',
|
||
code: 'IntlBackslash',
|
||
keyCode: 226,
|
||
which: 226,
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
altKey: true,
|
||
shiftKey: false,
|
||
isComposing: false,
|
||
nativeEvent: {
|
||
code: 'IntlBackslash',
|
||
keyCode: 226,
|
||
which: 226,
|
||
isComposing: false,
|
||
},
|
||
} as unknown as KeyboardEvent;
|
||
|
||
expect(isImeComposingKeyEvent(event)).toBe(false);
|
||
expect(eventToShortcut(event)).toBe('Alt+\\');
|
||
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
|
||
});
|
||
|
||
it('treats number keys as non-shortcuts while a composition session is active', () => {
|
||
setGlobalImeCompositionActive(true);
|
||
const event = {
|
||
key: '1',
|
||
keyCode: 49,
|
||
which: 49,
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
altKey: false,
|
||
shiftKey: false,
|
||
isComposing: false,
|
||
nativeEvent: {
|
||
isComposing: false,
|
||
},
|
||
} as unknown as KeyboardEvent;
|
||
|
||
expect(isImeComposingKeyEvent(event)).toBe(true);
|
||
expect(eventToShortcut(event)).toBe('');
|
||
expect(isShortcutMatch(event, '1')).toBe(false);
|
||
});
|
||
|
||
it('treats Monaco visible IME textarea events as composing even without native flags', () => {
|
||
const target = {
|
||
className: 'inputarea monaco-mouse-cursor-text ime-input',
|
||
classList: {
|
||
contains: (name: string) => name === 'ime-input',
|
||
},
|
||
closest: vi.fn(),
|
||
} as unknown as EventTarget;
|
||
const event = {
|
||
key: '1',
|
||
keyCode: 49,
|
||
which: 49,
|
||
ctrlKey: false,
|
||
metaKey: false,
|
||
altKey: false,
|
||
shiftKey: false,
|
||
isComposing: false,
|
||
nativeEvent: {
|
||
isComposing: false,
|
||
},
|
||
target,
|
||
} as unknown as KeyboardEvent;
|
||
|
||
expect(isImeComposingKeyEvent(event)).toBe(true);
|
||
expect(eventToShortcut(event)).toBe('');
|
||
expect(isShortcutMatch(event, '1')).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── 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 duplicate current line as a query editor shortcut', () => {
|
||
expect(DEFAULT_SHORTCUT_OPTIONS.duplicateCurrentLine).toEqual({
|
||
mac: { combo: 'Meta+D', enabled: true },
|
||
windows: { combo: 'Ctrl+D', enabled: true },
|
||
});
|
||
expect(SHORTCUT_ACTION_META.duplicateCurrentLine).toMatchObject({
|
||
label: '复制当前行到下一行',
|
||
scope: 'queryEditor',
|
||
allowInEditable: true,
|
||
allowedReservedMonacoCommandIds: ['editor.action.addSelectionToNextFindMatch'],
|
||
});
|
||
});
|
||
|
||
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,
|
||
});
|
||
});
|
||
|
||
it('registers format SQL as a query editor shortcut', () => {
|
||
expect(DEFAULT_SHORTCUT_OPTIONS.formatSql).toEqual({
|
||
mac: { combo: 'Alt+Shift+F', enabled: true },
|
||
windows: { combo: 'Alt+Shift+F', enabled: true },
|
||
});
|
||
expect(SHORTCUT_ACTION_META.formatSql).toMatchObject({
|
||
label: '美化 SQL',
|
||
scope: 'queryEditor',
|
||
allowInEditable: true,
|
||
});
|
||
});
|
||
|
||
it('registers manual SQL AI completion as a query editor shortcut', () => {
|
||
expect(DEFAULT_SHORTCUT_OPTIONS.triggerSqlAiCompletion).toEqual({
|
||
mac: { combo: 'Alt+\\', enabled: true },
|
||
windows: { combo: 'Alt+\\', enabled: true },
|
||
});
|
||
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion).toMatchObject({
|
||
label: '触发 SQL AI 自动补全',
|
||
scope: 'queryEditor',
|
||
allowInEditable: true,
|
||
});
|
||
});
|
||
|
||
it('registers query results panel toggle as a query editor shortcut', () => {
|
||
expect(DEFAULT_SHORTCUT_OPTIONS.toggleQueryResultsPanel).toEqual({
|
||
mac: { combo: 'Meta+Shift+M', enabled: true },
|
||
windows: { combo: 'Ctrl+Shift+M', enabled: true },
|
||
});
|
||
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel).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.WinCtrl,
|
||
keyCode: mockKeyCode.Enter,
|
||
});
|
||
});
|
||
|
||
it('maps Ctrl+Shift+R correctly', () => {
|
||
expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({
|
||
keyMod: mockKeyMod.WinCtrl | mockKeyMod.Shift,
|
||
keyCode: mockKeyCode.KeyR,
|
||
});
|
||
});
|
||
|
||
it('maps Alt+Shift+F correctly', () => {
|
||
expect(comboToMonacoKeyBinding('Alt+Shift+F', mockKeyMod, mockKeyCode)).toEqual({
|
||
keyMod: mockKeyMod.Alt | mockKeyMod.Shift,
|
||
keyCode: mockKeyCode.KeyF,
|
||
});
|
||
});
|
||
|
||
it('maps Meta+Enter (macOS variant)', () => {
|
||
expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({
|
||
keyMod: mockKeyMod.CtrlCmd,
|
||
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.WinCtrl,
|
||
keyCode: mockKeyCode.OemComma,
|
||
});
|
||
});
|
||
|
||
it('maps Alt+\\ (manual AI completion)', () => {
|
||
expect(comboToMonacoKeyBinding('Alt+\\', mockKeyMod, mockKeyCode)).toEqual({
|
||
keyMod: mockKeyMod.Alt,
|
||
keyCode: mockKeyCode.Oem5,
|
||
});
|
||
});
|
||
|
||
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.WinCtrl,
|
||
keyCode: mockKeyCode.Digit1,
|
||
});
|
||
});
|
||
|
||
it('maps Ctrl+Alt+Delete', () => {
|
||
expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({
|
||
keyMod: mockKeyMod.WinCtrl | mockKeyMod.Alt,
|
||
keyCode: mockKeyCode.Delete,
|
||
});
|
||
});
|
||
});
|