feat: add keyboard shortcut tests and fix browser conflicts
Testing: - Add vitest with jsdom environment for unit testing - Create 61 comprehensive tests for keyboard shortcuts - Add test helpers for platform switching and key simulation - Mock SvelteKit $app modules for testing Fix: - Change Windows/Linux shortcuts from Ctrl to Alt to avoid browser shortcut conflicts (Ctrl+N opens new browser window) - Mac shortcuts remain Cmd+N/K/B (unaffected) New shortcuts on Windows/Linux: - Alt+N: New chat - Alt+K: Search conversations - Alt+B: Toggle sidebar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1095
frontend/package-lock.json
generated
1095
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,21 +8,28 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/node": "^22.10.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.16.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@skeletonlabs/skeleton": "^2.10.0",
|
||||
|
||||
807
frontend/src/lib/utils/keyboard.test.ts
Normal file
807
frontend/src/lib/utils/keyboard.test.ts
Normal file
@@ -0,0 +1,807 @@
|
||||
/**
|
||||
* Keyboard shortcuts tests
|
||||
*
|
||||
* Tests the keyboard shortcuts management system including:
|
||||
* - Platform detection
|
||||
* - Modifier key handling
|
||||
* - Shortcut registration and triggering
|
||||
* - Input field detection
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
keyboardShortcuts,
|
||||
isPrimaryModifier,
|
||||
getPrimaryModifierDisplay,
|
||||
formatShortcut,
|
||||
getShortcuts,
|
||||
_resetPlatformCache,
|
||||
type Shortcut
|
||||
} from './keyboard';
|
||||
import { setPlatform, pressShortcut, pressKeyOn } from '../../tests/setup';
|
||||
|
||||
// Helper to properly switch platforms (resets cache + sets navigator.platform)
|
||||
function switchPlatform(platform: 'mac' | 'windows' | 'linux'): void {
|
||||
_resetPlatformCache();
|
||||
setPlatform(platform);
|
||||
}
|
||||
|
||||
describe('keyboard.ts', () => {
|
||||
describe('Platform Detection', () => {
|
||||
it('detects Mac platform correctly', () => {
|
||||
switchPlatform('mac');
|
||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Windows platform correctly (uses Alt)', () => {
|
||||
switchPlatform('windows');
|
||||
const event = new KeyboardEvent('keydown', { altKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Linux platform correctly (uses Alt)', () => {
|
||||
switchPlatform('linux');
|
||||
const event = new KeyboardEvent('keydown', { altKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrimaryModifier', () => {
|
||||
beforeEach(() => {
|
||||
switchPlatform('mac');
|
||||
});
|
||||
|
||||
it('returns true for metaKey on Mac', () => {
|
||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for ctrlKey on Mac', () => {
|
||||
const event = new KeyboardEvent('keydown', { ctrlKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for altKey on Windows', () => {
|
||||
switchPlatform('windows');
|
||||
const event = new KeyboardEvent('keydown', { altKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for metaKey on Windows', () => {
|
||||
switchPlatform('windows');
|
||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for ctrlKey on Windows (browser shortcut conflict)', () => {
|
||||
switchPlatform('windows');
|
||||
const event = new KeyboardEvent('keydown', { ctrlKey: true });
|
||||
expect(isPrimaryModifier(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrimaryModifierDisplay', () => {
|
||||
it('returns ⌘ on Mac', () => {
|
||||
switchPlatform('mac');
|
||||
expect(getPrimaryModifierDisplay()).toBe('⌘');
|
||||
});
|
||||
|
||||
it('returns Alt on Windows', () => {
|
||||
switchPlatform('windows');
|
||||
expect(getPrimaryModifierDisplay()).toBe('Alt');
|
||||
});
|
||||
|
||||
it('returns Alt on Linux', () => {
|
||||
switchPlatform('linux');
|
||||
expect(getPrimaryModifierDisplay()).toBe('Alt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortcut', () => {
|
||||
beforeEach(() => {
|
||||
switchPlatform('mac');
|
||||
});
|
||||
|
||||
it('formats single key without modifiers', () => {
|
||||
expect(formatShortcut('Escape')).toBe('Escape');
|
||||
});
|
||||
|
||||
it('formats key with meta modifier on Mac', () => {
|
||||
expect(formatShortcut('k', { meta: true })).toBe('⌘K');
|
||||
});
|
||||
|
||||
it('formats key with ctrl modifier', () => {
|
||||
expect(formatShortcut('s', { ctrl: true })).toBe('CtrlS');
|
||||
});
|
||||
|
||||
it('formats key with shift modifier on Mac', () => {
|
||||
expect(formatShortcut('n', { shift: true })).toBe('⇧N');
|
||||
});
|
||||
|
||||
it('formats key with alt modifier on Mac', () => {
|
||||
expect(formatShortcut('p', { alt: true })).toBe('⌥P');
|
||||
});
|
||||
|
||||
it('formats multiple modifiers', () => {
|
||||
expect(formatShortcut('z', { ctrl: true, shift: true })).toBe('Ctrl⇧Z');
|
||||
});
|
||||
|
||||
it('formats with Windows-style on non-Mac', () => {
|
||||
switchPlatform('windows');
|
||||
expect(formatShortcut('k', { meta: true })).toBe('Win+K');
|
||||
expect(formatShortcut('s', { ctrl: true })).toBe('Ctrl+S');
|
||||
expect(formatShortcut('n', { shift: true })).toBe('Shift+N');
|
||||
});
|
||||
|
||||
it('uppercases single character keys', () => {
|
||||
expect(formatShortcut('a')).toBe('A');
|
||||
expect(formatShortcut('z')).toBe('Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
switchPlatform('mac');
|
||||
});
|
||||
|
||||
it('returns all predefined shortcuts', () => {
|
||||
const shortcuts = getShortcuts();
|
||||
expect(shortcuts).toHaveProperty('NEW_CHAT');
|
||||
expect(shortcuts).toHaveProperty('SEARCH');
|
||||
expect(shortcuts).toHaveProperty('TOGGLE_SIDENAV');
|
||||
expect(shortcuts).toHaveProperty('CLOSE_MODAL');
|
||||
expect(shortcuts).toHaveProperty('SEND_MESSAGE');
|
||||
expect(shortcuts).toHaveProperty('STOP_GENERATION');
|
||||
});
|
||||
|
||||
it('NEW_CHAT has correct configuration', () => {
|
||||
const shortcuts = getShortcuts();
|
||||
expect(shortcuts.NEW_CHAT.id).toBe('new-chat');
|
||||
expect(shortcuts.NEW_CHAT.key).toBe('n');
|
||||
expect(shortcuts.NEW_CHAT.description).toBe('New chat');
|
||||
});
|
||||
|
||||
it('uses meta modifier on Mac', () => {
|
||||
const shortcuts = getShortcuts();
|
||||
expect(shortcuts.NEW_CHAT.modifiers).toEqual({ meta: true });
|
||||
expect(shortcuts.SEARCH.modifiers).toEqual({ meta: true });
|
||||
});
|
||||
|
||||
it('uses alt modifier on Windows (avoids browser shortcut conflicts)', () => {
|
||||
switchPlatform('windows');
|
||||
const shortcuts = getShortcuts();
|
||||
expect(shortcuts.NEW_CHAT.modifiers).toEqual({ alt: true });
|
||||
expect(shortcuts.SEARCH.modifiers).toEqual({ alt: true });
|
||||
});
|
||||
|
||||
it('CLOSE_MODAL has no modifiers', () => {
|
||||
const shortcuts = getShortcuts();
|
||||
expect('modifiers' in shortcuts.CLOSE_MODAL).toBe(false);
|
||||
expect(shortcuts.CLOSE_MODAL.key).toBe('Escape');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyboardShortcutsManager', () => {
|
||||
beforeEach(() => {
|
||||
switchPlatform('mac');
|
||||
keyboardShortcuts.destroy(); // Clean state
|
||||
keyboardShortcuts.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
keyboardShortcuts.destroy();
|
||||
});
|
||||
|
||||
describe('initialize and destroy', () => {
|
||||
it('initializes and attaches event listener', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-init',
|
||||
key: 'a',
|
||||
description: 'Test init',
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('a');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destroy removes event listener', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-destroy',
|
||||
key: 'b',
|
||||
description: 'Test destroy',
|
||||
handler
|
||||
});
|
||||
|
||||
keyboardShortcuts.destroy();
|
||||
pressShortcut('b');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears shortcuts on destroy', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-clear',
|
||||
key: 'c',
|
||||
description: 'Test clear',
|
||||
handler: vi.fn()
|
||||
});
|
||||
|
||||
expect(keyboardShortcuts.getShortcuts()).toHaveLength(1);
|
||||
keyboardShortcuts.destroy();
|
||||
expect(keyboardShortcuts.getShortcuts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register and unregister', () => {
|
||||
it('registers a shortcut', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-register',
|
||||
key: 'r',
|
||||
description: 'Test register',
|
||||
handler
|
||||
});
|
||||
|
||||
const shortcuts = keyboardShortcuts.getShortcuts();
|
||||
expect(shortcuts).toHaveLength(1);
|
||||
expect(shortcuts[0].id).toBe('test-register');
|
||||
});
|
||||
|
||||
it('unregisters a shortcut', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-unregister',
|
||||
key: 'u',
|
||||
description: 'Test unregister',
|
||||
handler: vi.fn()
|
||||
});
|
||||
|
||||
expect(keyboardShortcuts.getShortcuts()).toHaveLength(1);
|
||||
keyboardShortcuts.unregister('test-unregister');
|
||||
expect(keyboardShortcuts.getShortcuts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shortcut is enabled by default', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-enabled-default',
|
||||
key: 'e',
|
||||
description: 'Test enabled',
|
||||
handler: vi.fn()
|
||||
});
|
||||
|
||||
const shortcut = keyboardShortcuts.getShortcuts()[0];
|
||||
expect(shortcut.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('respects explicit enabled: false', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-disabled',
|
||||
key: 'd',
|
||||
description: 'Test disabled',
|
||||
handler: vi.fn(),
|
||||
enabled: false
|
||||
});
|
||||
|
||||
const shortcut = keyboardShortcuts.getShortcuts()[0];
|
||||
expect(shortcut.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcut triggering', () => {
|
||||
it('triggers handler on key press', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-trigger',
|
||||
key: 't',
|
||||
description: 'Test trigger',
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('t');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes event to handler', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-event',
|
||||
key: 'e',
|
||||
description: 'Test event',
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('e');
|
||||
expect(handler).toHaveBeenCalledWith(expect.any(KeyboardEvent));
|
||||
});
|
||||
|
||||
it('triggers with correct modifier on Mac', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-mac-mod',
|
||||
key: 'm',
|
||||
modifiers: { meta: true },
|
||||
description: 'Test Mac modifier',
|
||||
handler
|
||||
});
|
||||
|
||||
// Without modifier - should NOT trigger
|
||||
pressShortcut('m');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// With meta (Cmd) - should trigger
|
||||
pressShortcut('m', { meta: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('triggers with correct modifier on Windows (Alt)', () => {
|
||||
switchPlatform('windows');
|
||||
keyboardShortcuts.destroy();
|
||||
keyboardShortcuts.initialize();
|
||||
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-win-mod',
|
||||
key: 'w',
|
||||
modifiers: { alt: true },
|
||||
description: 'Test Windows modifier',
|
||||
handler
|
||||
});
|
||||
|
||||
// Without modifier - should NOT trigger
|
||||
pressShortcut('w');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// With alt - should trigger
|
||||
pressShortcut('w', { alt: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is case insensitive for keys', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-case',
|
||||
key: 'K',
|
||||
description: 'Test case',
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('k');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not trigger for wrong key', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-wrong-key',
|
||||
key: 'x',
|
||||
description: 'Test wrong key',
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('y');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not trigger for wrong modifiers', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-wrong-mod',
|
||||
key: 'z',
|
||||
modifiers: { meta: true, shift: true },
|
||||
description: 'Test wrong modifiers',
|
||||
handler
|
||||
});
|
||||
|
||||
// Only meta, missing shift
|
||||
pressShortcut('z', { meta: true });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
// Correct modifiers
|
||||
pressShortcut('z', { meta: true, shift: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preventDefault behavior', () => {
|
||||
it('prevents default by default', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-prevent-default',
|
||||
key: 'p',
|
||||
description: 'Test prevent default',
|
||||
handler: vi.fn()
|
||||
});
|
||||
|
||||
const event = pressShortcut('p');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does not prevent default when preventDefault: false', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-no-prevent',
|
||||
key: 'n',
|
||||
description: 'Test no prevent',
|
||||
handler: vi.fn(),
|
||||
preventDefault: false
|
||||
});
|
||||
|
||||
const event = pressShortcut('n');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('disables a specific shortcut', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-disable',
|
||||
key: 'd',
|
||||
description: 'Test disable',
|
||||
handler
|
||||
});
|
||||
|
||||
keyboardShortcuts.setEnabled('test-disable', false);
|
||||
|
||||
pressShortcut('d');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-enables a disabled shortcut', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-reenable',
|
||||
key: 'r',
|
||||
description: 'Test reenable',
|
||||
handler
|
||||
});
|
||||
|
||||
keyboardShortcuts.setEnabled('test-reenable', false);
|
||||
keyboardShortcuts.setEnabled('test-reenable', true);
|
||||
|
||||
pressShortcut('r');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles non-existent shortcut gracefully', () => {
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
keyboardShortcuts.setEnabled('non-existent', false);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGlobalEnabled', () => {
|
||||
it('disables all shortcuts when global disabled', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-global-1',
|
||||
key: 'a',
|
||||
description: 'Test global 1',
|
||||
handler: handler1
|
||||
});
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-global-2',
|
||||
key: 'b',
|
||||
description: 'Test global 2',
|
||||
handler: handler2
|
||||
});
|
||||
|
||||
keyboardShortcuts.setGlobalEnabled(false);
|
||||
|
||||
pressShortcut('a');
|
||||
pressShortcut('b');
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled();
|
||||
expect(handler2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-enables all shortcuts when global enabled', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-global-reenable',
|
||||
key: 'g',
|
||||
description: 'Test global reenable',
|
||||
handler
|
||||
});
|
||||
|
||||
keyboardShortcuts.setGlobalEnabled(false);
|
||||
keyboardShortcuts.setGlobalEnabled(true);
|
||||
|
||||
pressShortcut('g');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input field detection', () => {
|
||||
it('does not trigger shortcuts when focused on input', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-input',
|
||||
key: 'i',
|
||||
description: 'Test input',
|
||||
handler
|
||||
});
|
||||
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
pressKeyOn(input, 'i');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('does not trigger shortcuts when focused on textarea', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-textarea',
|
||||
key: 't',
|
||||
description: 'Test textarea',
|
||||
handler
|
||||
});
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
|
||||
pressKeyOn(textarea, 't');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
it('does not trigger shortcuts when focused on contenteditable', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-contenteditable',
|
||||
key: 'c',
|
||||
description: 'Test contenteditable',
|
||||
handler
|
||||
});
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.contentEditable = 'true';
|
||||
// jsdom doesn't implement isContentEditable, so we need to mock it
|
||||
Object.defineProperty(div, 'isContentEditable', { value: true });
|
||||
document.body.appendChild(div);
|
||||
div.focus();
|
||||
|
||||
pressKeyOn(div, 'c');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
it('DOES trigger Escape even when focused on input', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-escape-input',
|
||||
key: 'Escape',
|
||||
description: 'Test Escape in input',
|
||||
handler
|
||||
});
|
||||
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
pressKeyOn(input, 'Escape');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('DOES trigger Escape even when focused on textarea', () => {
|
||||
const handler = vi.fn();
|
||||
keyboardShortcuts.register({
|
||||
id: 'test-escape-textarea',
|
||||
key: 'Escape',
|
||||
description: 'Test Escape in textarea',
|
||||
handler
|
||||
});
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
|
||||
pressKeyOn(textarea, 'Escape');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple shortcuts', () => {
|
||||
it('only triggers matching shortcut', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
const handler3 = vi.fn();
|
||||
|
||||
keyboardShortcuts.register({
|
||||
id: 'multi-1',
|
||||
key: 'a',
|
||||
description: 'Multi 1',
|
||||
handler: handler1
|
||||
});
|
||||
keyboardShortcuts.register({
|
||||
id: 'multi-2',
|
||||
key: 'b',
|
||||
description: 'Multi 2',
|
||||
handler: handler2
|
||||
});
|
||||
keyboardShortcuts.register({
|
||||
id: 'multi-3',
|
||||
key: 'c',
|
||||
description: 'Multi 3',
|
||||
handler: handler3
|
||||
});
|
||||
|
||||
pressShortcut('b');
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled();
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
expect(handler3).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('first registered shortcut wins on conflict', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
keyboardShortcuts.register({
|
||||
id: 'conflict-1',
|
||||
key: 'x',
|
||||
description: 'Conflict 1',
|
||||
handler: handler1
|
||||
});
|
||||
keyboardShortcuts.register({
|
||||
id: 'conflict-2',
|
||||
key: 'x',
|
||||
description: 'Conflict 2',
|
||||
handler: handler2
|
||||
});
|
||||
|
||||
pressShortcut('x');
|
||||
|
||||
// First registered should trigger (Map iteration order)
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortcuts', () => {
|
||||
it('returns all registered shortcuts', () => {
|
||||
keyboardShortcuts.register({
|
||||
id: 'get-1',
|
||||
key: 'a',
|
||||
description: 'Get 1',
|
||||
handler: vi.fn()
|
||||
});
|
||||
keyboardShortcuts.register({
|
||||
id: 'get-2',
|
||||
key: 'b',
|
||||
description: 'Get 2',
|
||||
handler: vi.fn()
|
||||
});
|
||||
|
||||
const shortcuts = keyboardShortcuts.getShortcuts();
|
||||
expect(shortcuts).toHaveLength(2);
|
||||
expect(shortcuts.map(s => s.id)).toContain('get-1');
|
||||
expect(shortcuts.map(s => s.id)).toContain('get-2');
|
||||
});
|
||||
|
||||
it('returns empty array when no shortcuts registered', () => {
|
||||
expect(keyboardShortcuts.getShortcuts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world shortcut scenarios', () => {
|
||||
beforeEach(() => {
|
||||
switchPlatform('mac');
|
||||
keyboardShortcuts.destroy();
|
||||
keyboardShortcuts.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
keyboardShortcuts.destroy();
|
||||
});
|
||||
|
||||
it('Cmd+N creates new chat', () => {
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.NEW_CHAT,
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('n', { meta: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Cmd+K opens search', () => {
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.SEARCH,
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('k', { meta: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Cmd+B toggles sidenav', () => {
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.TOGGLE_SIDENAV,
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('b', { meta: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Escape closes modal', () => {
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.CLOSE_MODAL,
|
||||
handler
|
||||
});
|
||||
|
||||
pressShortcut('Escape');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Alt+N works on Windows (avoids browser Ctrl+N conflict)', () => {
|
||||
switchPlatform('windows');
|
||||
keyboardShortcuts.destroy();
|
||||
keyboardShortcuts.initialize();
|
||||
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.NEW_CHAT,
|
||||
handler
|
||||
});
|
||||
|
||||
// Alt+N should trigger (our shortcut)
|
||||
pressShortcut('n', { alt: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Ctrl+N does NOT trigger on Windows (reserved by browser)', () => {
|
||||
switchPlatform('windows');
|
||||
keyboardShortcuts.destroy();
|
||||
keyboardShortcuts.initialize();
|
||||
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.NEW_CHAT,
|
||||
handler
|
||||
});
|
||||
|
||||
// Ctrl+N should NOT trigger (browser opens new window)
|
||||
pressShortcut('n', { ctrl: true });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents browser default for Cmd+K (spotlight search)', () => {
|
||||
const handler = vi.fn();
|
||||
const shortcuts = getShortcuts();
|
||||
keyboardShortcuts.register({
|
||||
...shortcuts.SEARCH,
|
||||
handler
|
||||
});
|
||||
|
||||
const event = pressShortcut('k', { meta: true });
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -46,17 +46,25 @@ const isMac = (): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the primary modifier is pressed (Cmd on Mac, Ctrl on others)
|
||||
* Reset platform cache (for testing only)
|
||||
* @internal
|
||||
*/
|
||||
export function _resetPlatformCache(): void {
|
||||
_isMac = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the primary modifier is pressed (Cmd on Mac, Alt on others)
|
||||
*/
|
||||
export function isPrimaryModifier(event: KeyboardEvent): boolean {
|
||||
return isMac() ? event.metaKey : event.ctrlKey;
|
||||
return isMac() ? event.metaKey : event.altKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display string for the primary modifier
|
||||
*/
|
||||
export function getPrimaryModifierDisplay(): string {
|
||||
return isMac() ? '⌘' : 'Ctrl';
|
||||
return isMac() ? '⌘' : 'Alt';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,10 +206,12 @@ class KeyboardShortcutsManager {
|
||||
export const keyboardShortcuts = new KeyboardShortcutsManager();
|
||||
|
||||
/**
|
||||
* Get platform-aware primary modifier (Cmd on Mac, Ctrl on others)
|
||||
* Get platform-aware primary modifier
|
||||
* - Mac: Cmd (meta)
|
||||
* - Windows/Linux: Alt (because Ctrl+N/K/etc are browser shortcuts that can't be overridden)
|
||||
*/
|
||||
function getPrimaryModifiers(): Modifiers {
|
||||
return isMac() ? { meta: true } : { ctrl: true };
|
||||
return isMac() ? { meta: true } : { alt: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
14
frontend/src/tests/mocks/app/navigation.ts
Normal file
14
frontend/src/tests/mocks/app/navigation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Mock for $app/navigation
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const goto = vi.fn().mockResolvedValue(undefined);
|
||||
export const invalidate = vi.fn().mockResolvedValue(undefined);
|
||||
export const invalidateAll = vi.fn().mockResolvedValue(undefined);
|
||||
export const preloadData = vi.fn().mockResolvedValue(undefined);
|
||||
export const preloadCode = vi.fn().mockResolvedValue(undefined);
|
||||
export const beforeNavigate = vi.fn();
|
||||
export const afterNavigate = vi.fn();
|
||||
export const onNavigate = vi.fn();
|
||||
18
frontend/src/tests/mocks/app/stores.ts
Normal file
18
frontend/src/tests/mocks/app/stores.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Mock for $app/stores
|
||||
*/
|
||||
|
||||
import { readable, writable } from 'svelte/store';
|
||||
|
||||
export const page = readable({
|
||||
url: new URL('http://localhost'),
|
||||
params: {},
|
||||
route: { id: '/' },
|
||||
status: 200,
|
||||
error: null,
|
||||
data: {},
|
||||
form: null
|
||||
});
|
||||
|
||||
export const navigating = readable(null);
|
||||
export const updated = { check: async () => false, subscribe: readable(false).subscribe };
|
||||
133
frontend/src/tests/setup.ts
Normal file
133
frontend/src/tests/setup.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Vitest test setup
|
||||
* Configures the testing environment with necessary mocks and utilities
|
||||
*/
|
||||
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock navigator for platform detection
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: {
|
||||
platform: 'MacIntel', // Default to Mac for consistent tests
|
||||
userAgent: 'Mozilla/5.0 (Macintosh)'
|
||||
},
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Mock window if not present
|
||||
if (typeof window === 'undefined') {
|
||||
// @ts-expect-error - Minimal window mock for Node environment
|
||||
globalThis.window = globalThis;
|
||||
}
|
||||
|
||||
// Track event listeners for cleanup
|
||||
const eventListeners: Map<string, Set<EventListenerOrEventListenerObject>> = new Map();
|
||||
|
||||
// Store original methods
|
||||
const originalAddEventListener = window.addEventListener.bind(window);
|
||||
const originalRemoveEventListener = window.removeEventListener.bind(window);
|
||||
|
||||
// Override addEventListener to track listeners (use type assertion for simplified signature)
|
||||
(window as Window).addEventListener = function(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
): void {
|
||||
if (listener) {
|
||||
if (!eventListeners.has(type)) {
|
||||
eventListeners.set(type, new Set());
|
||||
}
|
||||
eventListeners.get(type)!.add(listener);
|
||||
}
|
||||
originalAddEventListener(type, listener as EventListener, options);
|
||||
};
|
||||
|
||||
(window as Window).removeEventListener = function(
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: boolean | EventListenerOptions
|
||||
): void {
|
||||
if (listener) {
|
||||
eventListeners.get(type)?.delete(listener);
|
||||
}
|
||||
originalRemoveEventListener(type, listener as EventListener, options);
|
||||
};
|
||||
|
||||
// Helper to dispatch keyboard events
|
||||
export function dispatchKeyboardEvent(
|
||||
key: string,
|
||||
options: Partial<KeyboardEventInit> = {}
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...options
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
// Helper to simulate keyboard shortcut
|
||||
export function pressShortcut(
|
||||
key: string,
|
||||
modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } = {}
|
||||
): KeyboardEvent {
|
||||
return dispatchKeyboardEvent(key, {
|
||||
ctrlKey: modifiers.ctrl ?? false,
|
||||
altKey: modifiers.alt ?? false,
|
||||
shiftKey: modifiers.shift ?? false,
|
||||
metaKey: modifiers.meta ?? false
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to simulate keyboard event on specific element
|
||||
export function pressKeyOn(
|
||||
element: HTMLElement,
|
||||
key: string,
|
||||
modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } = {}
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: modifiers.ctrl ?? false,
|
||||
altKey: modifiers.alt ?? false,
|
||||
shiftKey: modifiers.shift ?? false,
|
||||
metaKey: modifiers.meta ?? false
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
// Helper to set platform
|
||||
export function setPlatform(platform: 'mac' | 'windows' | 'linux'): void {
|
||||
const platforms: Record<string, string> = {
|
||||
mac: 'MacIntel',
|
||||
windows: 'Win32',
|
||||
linux: 'Linux x86_64'
|
||||
};
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
value: platforms[platform],
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
// Reset keyboard manager state between tests
|
||||
beforeEach(() => {
|
||||
// Reset platform to Mac by default
|
||||
setPlatform('mac');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear all event listeners
|
||||
eventListeners.forEach((listeners, type) => {
|
||||
listeners.forEach(listener => {
|
||||
originalRemoveEventListener(type, listener);
|
||||
});
|
||||
listeners.clear();
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
25
frontend/vitest.config.ts
Normal file
25
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/tests/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/lib/**/*.ts'],
|
||||
exclude: ['src/lib/**/*.svelte', 'src/tests/**']
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: resolve('./src/lib'),
|
||||
$app: resolve('./src/tests/mocks/app')
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user