@flighthq/shortcut 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,831 @@
1
+ import { clearSignal, connectSignal } from '@flighthq/signals';
2
+ import type {
3
+ AcceleratorParseError,
4
+ ParsedAccelerator,
5
+ ShortcutBackend,
6
+ ShortcutEvent,
7
+ ShortcutModifier,
8
+ } from '@flighthq/types';
9
+
10
+ import {
11
+ areAcceleratorsEqual,
12
+ createParsedAccelerator,
13
+ createWebShortcutBackend,
14
+ disableGlobalShortcut,
15
+ enableGlobalShortcut,
16
+ enableGlobalShortcutSignals,
17
+ formatAcceleratorForDisplay,
18
+ getAcceleratorKey,
19
+ getAcceleratorKeyLabel,
20
+ getAcceleratorModifierLabel,
21
+ getAcceleratorModifiers,
22
+ getRegisteredGlobalShortcuts,
23
+ getShortcutBackend,
24
+ hasGlobalShortcutConflict,
25
+ isAcceleratorValid,
26
+ isGlobalShortcutRegistered,
27
+ normalizeAccelerator,
28
+ parseAccelerator,
29
+ parseAcceleratorDetailed,
30
+ registerGlobalShortcut,
31
+ resolveCommandOrControlModifier,
32
+ resumeAllGlobalShortcuts,
33
+ setShortcutBackend,
34
+ suspendAllGlobalShortcuts,
35
+ unregisterAllGlobalShortcuts,
36
+ unregisterGlobalShortcut,
37
+ } from './shortcut';
38
+
39
+ // A full-featured fake backend for testing.
40
+ interface FakeBackend extends ShortcutBackend {
41
+ entries: Map<string, { handler: (event: Readonly<ShortcutEvent>) => void; enabled: boolean }>;
42
+ allEnabled: boolean;
43
+ }
44
+
45
+ function fakeBackend(): FakeBackend {
46
+ const entries = new Map<string, { handler: (event: Readonly<ShortcutEvent>) => void; enabled: boolean }>();
47
+ return {
48
+ entries,
49
+ allEnabled: true,
50
+ getRegistered() {
51
+ return [...entries.keys()];
52
+ },
53
+ isRegistered(accelerator) {
54
+ return entries.has(accelerator);
55
+ },
56
+ register(accelerator, handler) {
57
+ entries.set(accelerator, { handler, enabled: true });
58
+ return true;
59
+ },
60
+ setAllEnabled(enabled) {
61
+ this.allEnabled = enabled;
62
+ for (const entry of entries.values()) entry.enabled = enabled;
63
+ },
64
+ setEnabled(accelerator, enabled) {
65
+ const entry = entries.get(accelerator);
66
+ if (!entry) return false;
67
+ entry.enabled = enabled;
68
+ return true;
69
+ },
70
+ unregister(accelerator) {
71
+ return entries.delete(accelerator);
72
+ },
73
+ unregisterAll() {
74
+ entries.clear();
75
+ },
76
+ };
77
+ }
78
+
79
+ afterEach(() => {
80
+ setShortcutBackend(null);
81
+ // Disconnect any signal listeners registered in this test to avoid cross-test bleed.
82
+ const signals = enableGlobalShortcutSignals();
83
+ clearSignal(signals.onTrigger);
84
+ });
85
+
86
+ describe('areAcceleratorsEqual', () => {
87
+ it('returns true for same chord in different spellings', () => {
88
+ expect(areAcceleratorsEqual('Ctrl+K', 'Control+K')).toBe(true);
89
+ expect(areAcceleratorsEqual('Cmd+Shift+S', 'Meta+Shift+S')).toBe(true);
90
+ expect(areAcceleratorsEqual('ctrl+shift+k', 'Control+Shift+K')).toBe(true);
91
+ });
92
+
93
+ it('returns false for different chords', () => {
94
+ expect(areAcceleratorsEqual('Ctrl+K', 'Ctrl+S')).toBe(false);
95
+ expect(areAcceleratorsEqual('Ctrl+K', 'Alt+K')).toBe(false);
96
+ });
97
+
98
+ it('returns false when either accelerator is unparseable', () => {
99
+ expect(areAcceleratorsEqual('', 'Ctrl+K')).toBe(false);
100
+ expect(areAcceleratorsEqual('Ctrl+K', 'bad###key')).toBe(false);
101
+ expect(areAcceleratorsEqual('', '')).toBe(false);
102
+ });
103
+
104
+ it('is order-insensitive for modifiers', () => {
105
+ expect(areAcceleratorsEqual('Shift+Ctrl+K', 'Control+Shift+K')).toBe(true);
106
+ expect(areAcceleratorsEqual('Alt+Shift+Control+K', 'Ctrl+Shift+Alt+K')).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe('createParsedAccelerator', () => {
111
+ it('returns a zeroed ParsedAccelerator', () => {
112
+ const out = createParsedAccelerator();
113
+ expect(out.key).toBe('');
114
+ expect(out.modifiers).toEqual([]);
115
+ });
116
+ });
117
+
118
+ describe('createWebShortcutBackend', () => {
119
+ it('returns sentinels without throwing (web has no global hotkeys)', () => {
120
+ const backend = createWebShortcutBackend();
121
+ expect(backend.register('Control+K', () => {})).toBe(false);
122
+ expect(backend.unregister('Control+K')).toBe(false);
123
+ expect(backend.isRegistered('Control+K')).toBe(false);
124
+ expect(backend.setEnabled('Control+K', false)).toBe(false);
125
+ expect(backend.getRegistered()).toEqual([]);
126
+ expect(() => backend.unregisterAll()).not.toThrow();
127
+ expect(() => backend.setAllEnabled(false)).not.toThrow();
128
+ });
129
+ });
130
+
131
+ describe('disableGlobalShortcut', () => {
132
+ it('disables a registered shortcut without unregistering it', () => {
133
+ const backend = fakeBackend();
134
+ setShortcutBackend(backend);
135
+ registerGlobalShortcut('Control+K', () => {});
136
+ expect(disableGlobalShortcut('Control+K')).toBe(true);
137
+ expect(backend.entries.get('Control+K')?.enabled).toBe(false);
138
+ expect(isGlobalShortcutRegistered('Control+K')).toBe(true);
139
+ });
140
+
141
+ it('returns false on web backend', () => {
142
+ expect(disableGlobalShortcut('Control+K')).toBe(false);
143
+ });
144
+
145
+ it('accepts alias spellings', () => {
146
+ const backend = fakeBackend();
147
+ setShortcutBackend(backend);
148
+ registerGlobalShortcut('Ctrl+K', () => {});
149
+ expect(disableGlobalShortcut('control+k')).toBe(true);
150
+ });
151
+
152
+ it('returns false for unparseable accelerator', () => {
153
+ const backend = fakeBackend();
154
+ setShortcutBackend(backend);
155
+ expect(disableGlobalShortcut('')).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe('enableGlobalShortcut', () => {
160
+ it('re-enables a disabled shortcut', () => {
161
+ const backend = fakeBackend();
162
+ setShortcutBackend(backend);
163
+ registerGlobalShortcut('Control+K', () => {});
164
+ disableGlobalShortcut('Control+K');
165
+ expect(enableGlobalShortcut('Control+K')).toBe(true);
166
+ expect(backend.entries.get('Control+K')?.enabled).toBe(true);
167
+ });
168
+
169
+ it('returns false on web backend', () => {
170
+ expect(enableGlobalShortcut('Control+K')).toBe(false);
171
+ });
172
+
173
+ it('returns false for unparseable accelerator', () => {
174
+ const backend = fakeBackend();
175
+ setShortcutBackend(backend);
176
+ expect(enableGlobalShortcut('')).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe('enableGlobalShortcutSignals', () => {
181
+ it('returns a ShortcutSignals object with an onTrigger signal', () => {
182
+ const signals = enableGlobalShortcutSignals();
183
+ expect(signals).not.toBeNull();
184
+ expect(signals.onTrigger).toBeDefined();
185
+ });
186
+
187
+ it('returns the same object on repeated calls (stable identity)', () => {
188
+ const a = enableGlobalShortcutSignals();
189
+ const b = enableGlobalShortcutSignals();
190
+ expect(a).toBe(b);
191
+ });
192
+
193
+ it('fires onTrigger when a registered shortcut is triggered', () => {
194
+ const backend = fakeBackend();
195
+ setShortcutBackend(backend);
196
+ const signals = enableGlobalShortcutSignals();
197
+ const received: string[] = [];
198
+ connectSignal(signals.onTrigger, (event) => received.push(event.accelerator));
199
+
200
+ registerGlobalShortcut('Control+K', () => {});
201
+ // Simulate OS triggering the shortcut via the backend's internal handler
202
+ const entry = backend.entries.get('Control+K');
203
+ entry?.handler({ accelerator: 'Control+K' });
204
+
205
+ expect(received).toEqual(['Control+K']);
206
+ });
207
+
208
+ it('fires onTrigger after the direct handler has run', () => {
209
+ const backend = fakeBackend();
210
+ setShortcutBackend(backend);
211
+ const signals = enableGlobalShortcutSignals();
212
+ const order: string[] = [];
213
+
214
+ connectSignal(signals.onTrigger, () => order.push('signal'));
215
+ registerGlobalShortcut('Control+K', () => order.push('handler'));
216
+
217
+ const entry = backend.entries.get('Control+K');
218
+ entry?.handler({ accelerator: 'Control+K' });
219
+
220
+ expect(order).toEqual(['handler', 'signal']);
221
+ });
222
+
223
+ it('does not fire for unregistered or unparseable accelerators', () => {
224
+ const backend = fakeBackend();
225
+ setShortcutBackend(backend);
226
+ const signals = enableGlobalShortcutSignals();
227
+ const received: string[] = [];
228
+ connectSignal(signals.onTrigger, (event) => received.push(event.accelerator));
229
+
230
+ // Unparseable: no registration call; no trigger
231
+ registerGlobalShortcut('', () => {});
232
+
233
+ expect(received).toHaveLength(0);
234
+ expect(backend.entries.size).toBe(0);
235
+ });
236
+ });
237
+
238
+ describe('formatAcceleratorForDisplay', () => {
239
+ // Tests are environment-neutral: we just check the output is non-empty and contains
240
+ // both the expected modifier component and key. Platform-specific symbol vs text is tested
241
+ // via resolveCommandOrControlModifier golden tables in that function's own block.
242
+ it('returns non-empty string for valid accelerator', () => {
243
+ const result = formatAcceleratorForDisplay('Control+Shift+K');
244
+ expect(typeof result).toBe('string');
245
+ expect(result.length).toBeGreaterThan(0);
246
+ });
247
+
248
+ it('returns empty string for unparseable accelerator', () => {
249
+ expect(formatAcceleratorForDisplay('')).toBe('');
250
+ expect(formatAcceleratorForDisplay('bad###key')).toBe('');
251
+ });
252
+
253
+ it('formats single key with no modifiers', () => {
254
+ const result = formatAcceleratorForDisplay('F5');
255
+ expect(result).toBe('F5');
256
+ });
257
+
258
+ it('includes the key label in the output', () => {
259
+ const result = formatAcceleratorForDisplay('Control+K');
260
+ expect(result).toContain('K');
261
+ });
262
+
263
+ it('uses symbols with no separator on macOS (platform override)', () => {
264
+ // macOS: ⌃⇧K (no '+' separator)
265
+ const result = formatAcceleratorForDisplay('Control+Shift+K', 'macos');
266
+ expect(result).toBe('⌃⇧K');
267
+ });
268
+
269
+ it('uses text labels with + separator on windows (platform override)', () => {
270
+ const result = formatAcceleratorForDisplay('Control+Shift+K', 'windows');
271
+ expect(result).toBe('Ctrl+Shift+K');
272
+ });
273
+
274
+ it('uses text labels with + separator on linux (platform override)', () => {
275
+ const result = formatAcceleratorForDisplay('Control+Shift+K', 'linux');
276
+ expect(result).toBe('Ctrl+Shift+K');
277
+ });
278
+
279
+ it('resolves CommandOrControl to Meta (⌘) on macOS', () => {
280
+ const result = formatAcceleratorForDisplay('CommandOrControl+K', 'macos');
281
+ expect(result).toBe('⌘K');
282
+ });
283
+
284
+ it('resolves CommandOrControl to Ctrl on windows', () => {
285
+ const result = formatAcceleratorForDisplay('CommandOrControl+K', 'windows');
286
+ expect(result).toBe('Ctrl+K');
287
+ });
288
+ });
289
+
290
+ describe('getAcceleratorKey', () => {
291
+ it('returns the canonical key for valid accelerators', () => {
292
+ expect(getAcceleratorKey('Control+K')).toBe('K');
293
+ expect(getAcceleratorKey('Shift+F1')).toBe('F1');
294
+ expect(getAcceleratorKey('Ctrl+shift+arrowup')).toBe('ArrowUp');
295
+ expect(getAcceleratorKey('Escape')).toBe('Escape');
296
+ });
297
+
298
+ it('returns null for unparseable input', () => {
299
+ expect(getAcceleratorKey('')).toBeNull();
300
+ expect(getAcceleratorKey('Control+')).toBeNull();
301
+ expect(getAcceleratorKey('Control+InvalidKey123')).toBeNull();
302
+ });
303
+
304
+ it('handles aliases', () => {
305
+ expect(getAcceleratorKey('Ctrl+Esc')).toBe('Escape');
306
+ expect(getAcceleratorKey('Cmd+Del')).toBe('Delete');
307
+ expect(getAcceleratorKey('Alt+Enter')).toBe('Return');
308
+ });
309
+ });
310
+
311
+ describe('getAcceleratorKeyLabel', () => {
312
+ it('returns symbol labels for special keys', () => {
313
+ expect(getAcceleratorKeyLabel('ArrowUp')).toBe('↑');
314
+ expect(getAcceleratorKeyLabel('ArrowDown')).toBe('↓');
315
+ expect(getAcceleratorKeyLabel('Return')).toBe('↵');
316
+ expect(getAcceleratorKeyLabel('Escape')).toBe('Esc');
317
+ expect(getAcceleratorKeyLabel('Tab')).toBe('⇥');
318
+ expect(getAcceleratorKeyLabel('Backspace')).toBe('⌫');
319
+ });
320
+
321
+ it('returns key name as-is for ordinary keys', () => {
322
+ expect(getAcceleratorKeyLabel('K')).toBe('K');
323
+ expect(getAcceleratorKeyLabel('F1')).toBe('F1');
324
+ expect(getAcceleratorKeyLabel('Space')).toBe('Space');
325
+ });
326
+ });
327
+
328
+ describe('getAcceleratorModifierLabel', () => {
329
+ it('returns non-empty labels for all modifiers', () => {
330
+ const modifiers: ShortcutModifier[] = ['Alt', 'Control', 'Meta', 'Shift', 'Super', 'CommandOrControl'];
331
+ for (const m of modifiers) {
332
+ expect(getAcceleratorModifierLabel(m).length).toBeGreaterThan(0);
333
+ }
334
+ });
335
+
336
+ it('resolves CommandOrControl without returning empty string', () => {
337
+ const label = getAcceleratorModifierLabel('CommandOrControl');
338
+ expect(label).not.toBe('');
339
+ });
340
+
341
+ it('returns macOS symbols with platform override', () => {
342
+ expect(getAcceleratorModifierLabel('Control', 'macos')).toBe('⌃');
343
+ expect(getAcceleratorModifierLabel('Alt', 'macos')).toBe('⌥');
344
+ expect(getAcceleratorModifierLabel('Shift', 'macos')).toBe('⇧');
345
+ expect(getAcceleratorModifierLabel('Meta', 'macos')).toBe('⌘');
346
+ });
347
+
348
+ it('returns text labels on non-macOS with platform override', () => {
349
+ expect(getAcceleratorModifierLabel('Control', 'windows')).toBe('Ctrl');
350
+ expect(getAcceleratorModifierLabel('Alt', 'linux')).toBe('Alt');
351
+ expect(getAcceleratorModifierLabel('Shift', 'windows')).toBe('Shift');
352
+ expect(getAcceleratorModifierLabel('Meta', 'linux')).toBe('Win');
353
+ });
354
+
355
+ it('resolves CommandOrControl to ⌘ on macOS via platform override', () => {
356
+ expect(getAcceleratorModifierLabel('CommandOrControl', 'macos')).toBe('⌘');
357
+ });
358
+
359
+ it('resolves CommandOrControl to Ctrl on windows via platform override', () => {
360
+ expect(getAcceleratorModifierLabel('CommandOrControl', 'windows')).toBe('Ctrl');
361
+ });
362
+ });
363
+
364
+ describe('getAcceleratorModifiers', () => {
365
+ it('returns modifiers in canonical order', () => {
366
+ const out: ShortcutModifier[] = [];
367
+ const result = getAcceleratorModifiers('Shift+Control+K', out);
368
+ expect(result).toBe(out);
369
+ expect(out).toEqual(['Control', 'Shift']);
370
+ });
371
+
372
+ it('clears the out array and fills it', () => {
373
+ const out: ShortcutModifier[] = ['Meta'];
374
+ const result = getAcceleratorModifiers('Alt+K', out);
375
+ expect(result).toBe(out);
376
+ expect(out).toEqual(['Alt']);
377
+ });
378
+
379
+ it('returns null for unparseable input', () => {
380
+ const out: ShortcutModifier[] = [];
381
+ expect(getAcceleratorModifiers('', out)).toBeNull();
382
+ expect(out).toHaveLength(0);
383
+ });
384
+
385
+ it('returns empty array for modifier-free accelerator', () => {
386
+ const out: ShortcutModifier[] = [];
387
+ const result = getAcceleratorModifiers('F5', out);
388
+ expect(result).toBe(out);
389
+ expect(out).toEqual([]);
390
+ });
391
+ });
392
+
393
+ describe('getRegisteredGlobalShortcuts', () => {
394
+ it('returns empty array on web backend', () => {
395
+ expect(getRegisteredGlobalShortcuts()).toEqual([]);
396
+ });
397
+
398
+ it('returns all registered normalized accelerators', () => {
399
+ const backend = fakeBackend();
400
+ setShortcutBackend(backend);
401
+ registerGlobalShortcut('Control+K', () => {});
402
+ // Canonical modifier order: Control < Alt < Shift < Meta < Super → 'Shift+Meta+S'
403
+ registerGlobalShortcut('Meta+Shift+S', () => {});
404
+ const registered = getRegisteredGlobalShortcuts();
405
+ expect(registered).toContain('Control+K');
406
+ expect(registered).toContain('Shift+Meta+S');
407
+ expect(registered).toHaveLength(2);
408
+ });
409
+
410
+ it('re-normalizes raw backend entries and drops unparseable ones', () => {
411
+ // A native backend may populate the registry with non-normalized or invalid strings; the getter
412
+ // normalizes them rather than trusting the cast, so the Accelerator type is earned.
413
+ const backend = fakeBackend();
414
+ backend.getRegistered = () => ['ctrl+shift+k', 'Meta+Alt+S', 'bad###key'];
415
+ setShortcutBackend(backend);
416
+ const registered = getRegisteredGlobalShortcuts();
417
+ expect(registered).toEqual(['Control+Shift+K', 'Alt+Meta+S']);
418
+ });
419
+ });
420
+
421
+ describe('getShortcutBackend', () => {
422
+ it('falls back to a web backend', () => {
423
+ expect(getShortcutBackend()).not.toBeNull();
424
+ });
425
+
426
+ it('returns the registered backend', () => {
427
+ const backend = fakeBackend();
428
+ setShortcutBackend(backend);
429
+ expect(getShortcutBackend()).toBe(backend);
430
+ });
431
+ });
432
+
433
+ describe('hasGlobalShortcutConflict', () => {
434
+ it('returns true when the chord is already registered', () => {
435
+ const backend = fakeBackend();
436
+ setShortcutBackend(backend);
437
+ registerGlobalShortcut('Control+K', () => {});
438
+ expect(hasGlobalShortcutConflict('Control+K')).toBe(true);
439
+ expect(hasGlobalShortcutConflict('ctrl+k')).toBe(true);
440
+ });
441
+
442
+ it('returns false when not registered', () => {
443
+ const backend = fakeBackend();
444
+ setShortcutBackend(backend);
445
+ expect(hasGlobalShortcutConflict('Control+K')).toBe(false);
446
+ });
447
+
448
+ it('returns false for unparseable accelerator', () => {
449
+ expect(hasGlobalShortcutConflict('')).toBe(false);
450
+ expect(hasGlobalShortcutConflict('bad###key')).toBe(false);
451
+ });
452
+ });
453
+
454
+ describe('isAcceleratorValid', () => {
455
+ it('returns true for well-formed accelerators', () => {
456
+ expect(isAcceleratorValid('Control+K')).toBe(true);
457
+ expect(isAcceleratorValid('Meta+Shift+S')).toBe(true);
458
+ expect(isAcceleratorValid('F5')).toBe(true);
459
+ expect(isAcceleratorValid('Escape')).toBe(true);
460
+ expect(isAcceleratorValid('ctrl+shift+k')).toBe(true);
461
+ expect(isAcceleratorValid('CommandOrControl+Q')).toBe(true);
462
+ });
463
+
464
+ it('returns false for malformed accelerators', () => {
465
+ expect(isAcceleratorValid('')).toBe(false);
466
+ expect(isAcceleratorValid('Control+')).toBe(false);
467
+ expect(isAcceleratorValid('UnknownMod+K')).toBe(false);
468
+ expect(isAcceleratorValid('Control+InvalidKey999')).toBe(false);
469
+ });
470
+
471
+ it('accepts all ShortcutKeyName values', () => {
472
+ // A representative sample across categories
473
+ for (const key of [
474
+ 'A',
475
+ 'Z',
476
+ '0',
477
+ '9',
478
+ 'F1',
479
+ 'F12',
480
+ 'F24',
481
+ 'Space',
482
+ 'Tab',
483
+ 'Return',
484
+ 'ArrowUp',
485
+ 'Home',
486
+ 'End',
487
+ 'PageDown',
488
+ 'Numpad0',
489
+ 'MediaPlayPause',
490
+ 'CapsLock',
491
+ ]) {
492
+ expect(isAcceleratorValid(key)).toBe(true);
493
+ }
494
+ });
495
+ });
496
+
497
+ describe('isGlobalShortcutRegistered', () => {
498
+ it('reflects backend state', () => {
499
+ const backend = fakeBackend();
500
+ setShortcutBackend(backend);
501
+ expect(isGlobalShortcutRegistered('Control+S')).toBe(false);
502
+ registerGlobalShortcut('Control+S', () => {});
503
+ expect(isGlobalShortcutRegistered('Control+S')).toBe(true);
504
+ });
505
+
506
+ it('returns false on the web backend', () => {
507
+ expect(isGlobalShortcutRegistered('Control+S')).toBe(false);
508
+ });
509
+
510
+ it('normalizes before querying — alias spellings match', () => {
511
+ const backend = fakeBackend();
512
+ setShortcutBackend(backend);
513
+ registerGlobalShortcut('Ctrl+S', () => {});
514
+ expect(isGlobalShortcutRegistered('Control+S')).toBe(true);
515
+ expect(isGlobalShortcutRegistered('ctrl+s')).toBe(true);
516
+ });
517
+
518
+ it('returns false for unparseable accelerator', () => {
519
+ const backend = fakeBackend();
520
+ setShortcutBackend(backend);
521
+ expect(isGlobalShortcutRegistered('')).toBe(false);
522
+ });
523
+ });
524
+
525
+ describe('normalizeAccelerator', () => {
526
+ it('returns canonical form for standard spellings', () => {
527
+ expect(normalizeAccelerator('Control+K')).toBe('Control+K');
528
+ // Canonical modifier order: Control < Alt < Shift < Meta < Super
529
+ expect(normalizeAccelerator('Meta+Shift+S')).toBe('Shift+Meta+S');
530
+ expect(normalizeAccelerator('F5')).toBe('F5');
531
+ });
532
+
533
+ it('normalizes modifier aliases', () => {
534
+ expect(normalizeAccelerator('Ctrl+K')).toBe('Control+K');
535
+ expect(normalizeAccelerator('Cmd+K')).toBe('Meta+K');
536
+ expect(normalizeAccelerator('Command+K')).toBe('Meta+K');
537
+ expect(normalizeAccelerator('Option+K')).toBe('Alt+K');
538
+ expect(normalizeAccelerator('Win+K')).toBe('Super+K');
539
+ });
540
+
541
+ it('normalizes case', () => {
542
+ expect(normalizeAccelerator('ctrl+shift+k')).toBe('Control+Shift+K');
543
+ expect(normalizeAccelerator('CTRL+SHIFT+K')).toBe('Control+Shift+K');
544
+ });
545
+
546
+ it('normalizes modifier order (Control < Alt < Shift < Meta < Super)', () => {
547
+ expect(normalizeAccelerator('Shift+Control+K')).toBe('Control+Shift+K');
548
+ expect(normalizeAccelerator('Alt+Shift+Control+K')).toBe('Control+Alt+Shift+K');
549
+ expect(normalizeAccelerator('Meta+Alt+Shift+Control+K')).toBe('Control+Alt+Shift+Meta+K');
550
+ expect(normalizeAccelerator('Meta+Shift+K')).toBe('Shift+Meta+K');
551
+ });
552
+
553
+ it('normalizes key name aliases', () => {
554
+ expect(normalizeAccelerator('Ctrl+Esc')).toBe('Control+Escape');
555
+ expect(normalizeAccelerator('Ctrl+Del')).toBe('Control+Delete');
556
+ expect(normalizeAccelerator('Ctrl+Enter')).toBe('Control+Return');
557
+ expect(normalizeAccelerator('Ctrl+Up')).toBe('Control+ArrowUp');
558
+ expect(normalizeAccelerator('Ctrl+Down')).toBe('Control+ArrowDown');
559
+ });
560
+
561
+ it('returns null for empty input', () => {
562
+ expect(normalizeAccelerator('')).toBeNull();
563
+ expect(normalizeAccelerator(' ')).toBeNull();
564
+ });
565
+
566
+ it('returns null for missing key', () => {
567
+ expect(normalizeAccelerator('Control+')).toBeNull();
568
+ expect(normalizeAccelerator('Control+Shift+')).toBeNull();
569
+ });
570
+
571
+ it('returns null for unknown modifier', () => {
572
+ expect(normalizeAccelerator('UnknownMod+K')).toBeNull();
573
+ });
574
+
575
+ it('returns null for unknown key', () => {
576
+ expect(normalizeAccelerator('Control+InvalidKey999')).toBeNull();
577
+ });
578
+
579
+ it('accepts dash separator', () => {
580
+ expect(normalizeAccelerator('Ctrl-K')).toBe('Control+K');
581
+ expect(normalizeAccelerator('Ctrl-Shift-K')).toBe('Control+Shift+K');
582
+ });
583
+
584
+ it('produces stable output (idempotent)', () => {
585
+ const once = normalizeAccelerator('ctrl+shift+k');
586
+ const twice = normalizeAccelerator(once!);
587
+ expect(once).toBe(twice);
588
+ });
589
+
590
+ it('breaks the Control / CommandOrControl tie deterministically regardless of input order', () => {
591
+ // CommandOrControl has its own ordinal (after Super), so the two orderings collapse to one form.
592
+ expect(normalizeAccelerator('CommandOrControl+Control+K')).toBe('Control+CommandOrControl+K');
593
+ expect(normalizeAccelerator('Control+CommandOrControl+K')).toBe('Control+CommandOrControl+K');
594
+ });
595
+ });
596
+
597
+ describe('parseAccelerator', () => {
598
+ it('parses a simple accelerator into modifiers and key', () => {
599
+ const out = createParsedAccelerator();
600
+ const result = parseAccelerator('Control+Shift+K', out);
601
+ expect(result).toBe(out);
602
+ expect(out.key).toBe('K');
603
+ expect(out.modifiers).toEqual(['Control', 'Shift']);
604
+ });
605
+
606
+ it('resolves modifier aliases (canonical order: Alt before Meta)', () => {
607
+ const out = createParsedAccelerator();
608
+ parseAccelerator('Cmd+Option+S', out);
609
+ expect(out.key).toBe('S');
610
+ // Canonical order: Control < Alt < Shift < Meta < Super
611
+ expect(out.modifiers).toEqual(['Alt', 'Meta']);
612
+ });
613
+
614
+ it('returns null on failure', () => {
615
+ const out = createParsedAccelerator();
616
+ expect(parseAccelerator('', out)).toBeNull();
617
+ expect(parseAccelerator('Control+', out)).toBeNull();
618
+ expect(parseAccelerator('Control+BadKey999', out)).toBeNull();
619
+ });
620
+
621
+ it('does not mutate out on failure', () => {
622
+ const out = createParsedAccelerator();
623
+ void out.key; // just read to confirm it exists before parsing
624
+ parseAccelerator('', out);
625
+ expect(out.key).toBe('');
626
+ expect(out.modifiers).toEqual([]);
627
+ });
628
+
629
+ it('aliased out — same object as a previously-filled value', () => {
630
+ const out = createParsedAccelerator();
631
+ parseAccelerator('Ctrl+K', out);
632
+ // Re-use out as input source (simulate aliased call)
633
+ const result2 = parseAccelerator('Alt+F', out);
634
+ expect(result2).toBe(out);
635
+ expect(out.key).toBe('F');
636
+ expect(out.modifiers).toEqual(['Alt']);
637
+ });
638
+
639
+ it('parses all modifier aliases correctly', () => {
640
+ const cases: [string, ShortcutModifier][] = [
641
+ ['Ctrl', 'Control'],
642
+ ['Control', 'Control'],
643
+ ['Cmd', 'Meta'],
644
+ ['Command', 'Meta'],
645
+ ['Meta', 'Meta'],
646
+ ['Option', 'Alt'],
647
+ ['Alt', 'Alt'],
648
+ ['Shift', 'Shift'],
649
+ ['Win', 'Super'],
650
+ ['Super', 'Super'],
651
+ ];
652
+ for (const [alias, expected] of cases) {
653
+ const out = createParsedAccelerator();
654
+ const result = parseAccelerator(`${alias}+K`, out);
655
+ expect(result).not.toBeNull();
656
+ expect(out.modifiers).toContain(expected);
657
+ }
658
+ });
659
+ });
660
+
661
+ describe('parseAcceleratorDetailed', () => {
662
+ it('returns the filled out on success', () => {
663
+ const out = createParsedAccelerator();
664
+ const result = parseAcceleratorDetailed('Control+K', out);
665
+ expect(result).toBe(out);
666
+ expect((result as ParsedAccelerator).key).toBe('K');
667
+ });
668
+
669
+ it('returns AcceleratorParseError with reason empty for empty input', () => {
670
+ const out = createParsedAccelerator();
671
+ const result = parseAcceleratorDetailed('', out);
672
+ expect((result as AcceleratorParseError).reason).toBe('empty');
673
+ });
674
+
675
+ it('returns AcceleratorParseError with reason missing-key when only modifiers', () => {
676
+ const out = createParsedAccelerator();
677
+ const result = parseAcceleratorDetailed('Control+Shift', out);
678
+ expect((result as AcceleratorParseError).reason).toBe('missing-key');
679
+ });
680
+
681
+ it('returns AcceleratorParseError with reason unknown-key for bad key', () => {
682
+ const out = createParsedAccelerator();
683
+ const result = parseAcceleratorDetailed('Control+InvalidKey999', out);
684
+ const err = result as AcceleratorParseError;
685
+ expect(err.reason).toBe('unknown-key');
686
+ expect(err.token).toBe('InvalidKey999');
687
+ });
688
+
689
+ it('returns AcceleratorParseError with reason duplicate-modifier', () => {
690
+ const out = createParsedAccelerator();
691
+ const result = parseAcceleratorDetailed('Ctrl+Control+K', out);
692
+ expect((result as AcceleratorParseError).reason).toBe('duplicate-modifier');
693
+ });
694
+ });
695
+
696
+ describe('registerGlobalShortcut', () => {
697
+ it('registers via the active backend with a normalized key', () => {
698
+ const backend = fakeBackend();
699
+ setShortcutBackend(backend);
700
+ expect(registerGlobalShortcut('Ctrl+Q', () => {})).toBe(true);
701
+ // Stored normalized
702
+ expect(backend.entries.has('Control+Q')).toBe(true);
703
+ });
704
+
705
+ it('fires the handler with a ShortcutEvent containing the accelerator', () => {
706
+ const backend = fakeBackend();
707
+ setShortcutBackend(backend);
708
+ const received: string[] = [];
709
+ registerGlobalShortcut('Control+K', (event) => received.push(event.accelerator));
710
+ // Simulate trigger
711
+ const entry = backend.entries.get('Control+K');
712
+ entry?.handler({ accelerator: 'Control+K' });
713
+ expect(received).toEqual(['Control+K']);
714
+ });
715
+
716
+ it('returns false on the web backend', () => {
717
+ expect(registerGlobalShortcut('Control+Q', () => {})).toBe(false);
718
+ });
719
+
720
+ it('returns false for unparseable accelerator', () => {
721
+ const backend = fakeBackend();
722
+ setShortcutBackend(backend);
723
+ expect(registerGlobalShortcut('', () => {})).toBe(false);
724
+ expect(registerGlobalShortcut('Bad###Key', () => {})).toBe(false);
725
+ });
726
+ });
727
+
728
+ describe('resolveCommandOrControlModifier', () => {
729
+ it('returns Control or Meta (never CommandOrControl)', () => {
730
+ const result = resolveCommandOrControlModifier();
731
+ expect(['Control', 'Meta']).toContain(result);
732
+ });
733
+
734
+ it('returns Meta on macOS via platform override', () => {
735
+ expect(resolveCommandOrControlModifier('macos')).toBe('Meta');
736
+ expect(resolveCommandOrControlModifier('MacOS')).toBe('Meta');
737
+ expect(resolveCommandOrControlModifier('macintosh')).toBe('Meta');
738
+ });
739
+
740
+ it('returns Control on non-macOS via platform override', () => {
741
+ expect(resolveCommandOrControlModifier('windows')).toBe('Control');
742
+ expect(resolveCommandOrControlModifier('linux')).toBe('Control');
743
+ expect(resolveCommandOrControlModifier('Windows NT')).toBe('Control');
744
+ });
745
+ });
746
+
747
+ describe('resumeAllGlobalShortcuts', () => {
748
+ it('re-enables all shortcuts after suspend', () => {
749
+ const backend = fakeBackend();
750
+ setShortcutBackend(backend);
751
+ registerGlobalShortcut('Control+K', () => {});
752
+ suspendAllGlobalShortcuts();
753
+ resumeAllGlobalShortcuts();
754
+ expect(backend.allEnabled).toBe(true);
755
+ });
756
+
757
+ it('is a no-op on the web backend', () => {
758
+ expect(() => resumeAllGlobalShortcuts()).not.toThrow();
759
+ });
760
+ });
761
+
762
+ describe('setShortcutBackend', () => {
763
+ it('clears back to the web fallback when passed null', () => {
764
+ setShortcutBackend(fakeBackend());
765
+ setShortcutBackend(null);
766
+ expect(getShortcutBackend()).not.toBeNull();
767
+ // Web backend sentinel
768
+ expect(getRegisteredGlobalShortcuts()).toEqual([]);
769
+ });
770
+ });
771
+
772
+ describe('suspendAllGlobalShortcuts', () => {
773
+ it('disables all registered shortcuts', () => {
774
+ const backend = fakeBackend();
775
+ setShortcutBackend(backend);
776
+ registerGlobalShortcut('Control+K', () => {});
777
+ registerGlobalShortcut('Meta+S', () => {});
778
+ suspendAllGlobalShortcuts();
779
+ expect(backend.allEnabled).toBe(false);
780
+ for (const entry of backend.entries.values()) {
781
+ expect(entry.enabled).toBe(false);
782
+ }
783
+ });
784
+
785
+ it('is a no-op on the web backend', () => {
786
+ expect(() => suspendAllGlobalShortcuts()).not.toThrow();
787
+ });
788
+ });
789
+
790
+ describe('unregisterAllGlobalShortcuts', () => {
791
+ it('clears every shortcut via the active backend', () => {
792
+ const backend = fakeBackend();
793
+ setShortcutBackend(backend);
794
+ registerGlobalShortcut('Control+A', () => {});
795
+ registerGlobalShortcut('Control+B', () => {});
796
+ unregisterAllGlobalShortcuts();
797
+ expect(backend.entries.size).toBe(0);
798
+ });
799
+
800
+ it('is a no-op on the web backend', () => {
801
+ expect(() => unregisterAllGlobalShortcuts()).not.toThrow();
802
+ });
803
+ });
804
+
805
+ describe('unregisterGlobalShortcut', () => {
806
+ it('unregisters via the active backend', () => {
807
+ const backend = fakeBackend();
808
+ setShortcutBackend(backend);
809
+ registerGlobalShortcut('Control+W', () => {});
810
+ expect(unregisterGlobalShortcut('Control+W')).toBe(true);
811
+ expect(backend.entries.has('Control+W')).toBe(false);
812
+ });
813
+
814
+ it('normalizes before unregistering — alias spellings work', () => {
815
+ const backend = fakeBackend();
816
+ setShortcutBackend(backend);
817
+ registerGlobalShortcut('Ctrl+W', () => {});
818
+ expect(unregisterGlobalShortcut('control+w')).toBe(true);
819
+ expect(backend.entries.size).toBe(0);
820
+ });
821
+
822
+ it('returns false on the web backend', () => {
823
+ expect(unregisterGlobalShortcut('Control+W')).toBe(false);
824
+ });
825
+
826
+ it('returns false for unparseable accelerator', () => {
827
+ const backend = fakeBackend();
828
+ setShortcutBackend(backend);
829
+ expect(unregisterGlobalShortcut('')).toBe(false);
830
+ });
831
+ });