/** * The keyboard shortcut service and the store behind it. This is the * test that most justifies running in a real browser: the service walks * shadow roots to find the deepest focused element, and no jsdom * approximation of that is worth trusting. */ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { buildKeyString } from '../../src/services/keyboard-shortcut-service'; import '../../src/services/keyboard-shortcut-service'; import { shortcutsStore } from '@store/shortcuts-store'; import { Events } from '../../src/events'; import { emit, calls, lastArgs, stub } from '@test/support/harness'; /** Install a binding table, as the backend's config push does. */ function bindings(table: Record): void { emit(Events.ShortcutsConfigChanged, table); } /** Send a keydown through the document, where the service listens. */ function press( key: string, modifiers: Partial< Record<'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey', boolean> > = {}, ): KeyboardEvent { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...modifiers, }); document.dispatchEvent(event); return event; } const mounted: HTMLElement[] = []; /** Append an element to the body and remember to remove it. */ function mount(el: T): T { document.body.append(el); mounted.push(el); return el; } afterEach(() => { while (mounted.length > 0) mounted.pop()?.remove(); bindings({}); }); // =================================================================== describe('buildKeyString', () => { it('uppercases a bare printable key', () => { expect(buildKeyString(new KeyboardEvent('keydown', { key: 'n' }))).toBe( 'N', ); }); it('orders modifiers Ctrl, Alt, Shift regardless of press order', () => { const e = new KeyboardEvent('keydown', { key: 'f', shiftKey: true, altKey: true, ctrlKey: true, }); expect(buildKeyString(e)).toBe('Ctrl+Alt+Shift+F'); }); it('folds Meta into Ctrl so macOS and Linux share one binding table', () => { const meta = new KeyboardEvent('keydown', { key: 'f', metaKey: true }); const ctrl = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }); expect(buildKeyString(meta)).toBe(buildKeyString(ctrl)); }); it('aliases arrows and space to their canonical names', () => { const names = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].map( (key) => buildKeyString(new KeyboardEvent('keydown', { key })), ); expect(names).toEqual(['Up', 'Down', 'Left', 'Right', 'Space']); }); it('does not report Shift for a character Shift produced', () => { // `?` is Shift+/ on a US layout and something else elsewhere, so // "Shift+?" is a binding nobody would write down. Letters keep it: // `Shift+A` and `A` are the same character. const question = new KeyboardEvent('keydown', { key: '?', shiftKey: true, }); const letter = new KeyboardEvent('keydown', { key: 'A', shiftKey: true }); expect(buildKeyString(question)).toBe('?'); expect(buildKeyString(letter)).toBe('Shift+A'); }); it('leaves multi-character named keys alone', () => { expect( buildKeyString(new KeyboardEvent('keydown', { key: 'Escape' })), ).toBe('Escape'); }); it('returns nothing for a bare modifier press', () => { const bare = ['Control', 'Alt', 'Shift', 'Meta'].map((key) => buildKeyString(new KeyboardEvent('keydown', { key })), ); expect(bare).toEqual(['', '', '', '']); }); }); // =================================================================== describe('shortcuts store: lookup', () => { beforeEach(() => { bindings({ 'player.playPause': 'Space', 'player.next': 'Ctrl+Right', 'tracklist.play': 'Enter', 'tracklist.delete': 'Delete', }); }); it('resolves an action to its key', () => { expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right'); }); it('reverse-resolves a key to its global action', () => { expect(shortcutsStore.getActionForKey('Space')).toBe('player.playPause'); }); it('does not resolve a panel binding from global scope', () => { // `tracklist.` is a panel prefix: Enter must do nothing unless the // track list has focus. expect(shortcutsStore.getActionForKey('Enter')).toBeUndefined(); }); it('resolves a panel binding when the matching scope is supplied', () => { expect(shortcutsStore.getActionForKey('Enter', 'panel:tracklist')).toBe( 'tracklist.play', ); }); it('falls back to global inside a panel scope', () => { expect(shortcutsStore.getActionForKey('Space', 'panel:tracklist')).toBe( 'player.playPause', ); }); it('reports a conflict, excluding the action being rebound', () => { expect( shortcutsStore.findConflict('Space', 'global', 'player.next'), ).toEqual({ action: 'player.playPause', key: 'Space' }); }); it('does not report an action conflicting with itself', () => { expect( shortcutsStore.findConflict('Space', 'global', 'player.playPause'), ).toBeNull(); }); }); // =================================================================== describe('shortcut dispatch: scope', () => { beforeEach(() => { bindings({ 'player.playPause': 'Space', 'tracklist.play': 'Enter', }); }); it('dispatches from global scope', () => { press(' '); expect(calls('queue.Queue.Play')).toHaveLength(1); }); it('preventDefaults a key it handled', () => { expect(press(' ').defaultPrevented).toBe(true); }); it('leaves an unbound key alone', () => { expect(press('q').defaultPrevented).toBe(false); }); it('suppresses shortcuts while a text input has focus', () => { const input = mount(document.createElement('input')); input.type = 'text'; input.focus(); press(' '); expect(calls('queue.Queue.Play')).toHaveLength(0); }); it('suppresses shortcuts inside a contenteditable', () => { const div = mount(document.createElement('div')); div.contentEditable = 'true'; div.tabIndex = 0; div.focus(); press(' '); expect(calls('queue.Queue.Play')).toHaveLength(0); }); it('leaves Space to a focused checkbox, which owns it', () => { // The global bindings are unmodified single keys, so the service has // to yield to a control that means something by the key itself — // otherwise the checkbox you tabbed to cannot be ticked (H-6). const input = mount(document.createElement('input')); input.type = 'checkbox'; input.focus(); expect(press(' ').defaultPrevented).toBe(false); expect(calls('queue.Queue.Play')).toHaveLength(0); }); it('still fires a key the focused control does not own', () => { bindings({ 'player.playPause': 'Space', 'player.next': 'N' }); const button = mount(document.createElement('button')); button.focus(); press('n'); expect(calls('queue.Queue.Next')).toHaveLength(1); }); it('leaves the arrow keys to a focused slider', () => { bindings({ 'player.volumeUp': 'Up' }); const input = mount(document.createElement('input')); input.type = 'range'; input.focus(); press('ArrowUp'); expect(calls('player.Player.ChangeVolume')).toHaveLength(0); }); it('leaves Up and Down to a focused row, which moves on them', () => { bindings({ 'player.volumeUp': 'Up' }); const row = mount(document.createElement('div')); row.setAttribute('role', 'row'); row.tabIndex = 0; row.focus(); press('ArrowUp'); expect(calls('player.Player.ChangeVolume')).toHaveLength(0); }); it('still seeks on Left/Right from a focused row', () => { // Phase 1 gave the arrows to the grid, correctly — but all six of // them, and no list in this app moves horizontally, so seeking // stopped working from a focused row and nothing gained the keys. // Reproduced in the app: two ArrowRights on a focused track row, // zero Player.Seek calls, against one per press from the body. bindings({ 'player.seekForward': 'Right' }); const row = mount(document.createElement('div')); row.setAttribute('role', 'row'); row.tabIndex = 0; row.focus(); press('ArrowRight'); expect(calls('player.Player.CurrentPositionSeconds')).toHaveLength(1); }); it('leaves every unmodified key to an open dialog', () => { const dialog = mount(document.createElement('div')); const button = document.createElement('button'); dialog.setAttribute('role', 'dialog'); dialog.append(button); button.focus(); press(' '); expect(calls('queue.Queue.Play')).toHaveLength(0); }); it('keeps a modified binding even inside a dialog', () => { bindings({ 'app.selectAll': 'Ctrl+A' }); const dialog = mount(document.createElement('div')); const button = document.createElement('button'); dialog.setAttribute('role', 'dialog'); dialog.append(button); button.focus(); let fired = 0; const listener = (): void => { fired += 1; }; document.addEventListener('shortcut:select-all', listener); press('a', { ctrlKey: true }); document.removeEventListener('shortcut:select-all', listener); expect(fired).toBe(1); }); it('blurs the input on Escape, and only on Escape', () => { const input = mount(document.createElement('input')); input.type = 'search'; input.focus(); press('Escape'); expect(document.activeElement).not.toBe(input); }); it('finds a text input nested in a shadow root', () => { // document.activeElement stops at the shadow host, so a service // that did not walk the chain would see
and fire the shortcut. const host = mount(document.createElement('div')); const root = host.attachShadow({ mode: 'open' }); const input = document.createElement('input'); input.type = 'text'; root.append(input); input.focus(); press(' '); expect(calls('queue.Queue.Play')).toHaveLength(0); }); it('resolves a panel scope from a data-shortcut-scope ancestor', () => { const panel = mount(document.createElement('div')); const button = document.createElement('div'); // Focused on a plain focusable, not a button: a button owns Enter // itself and is meant to keep it. button.tabIndex = 0; panel.dataset['shortcutScope'] = 'tracklist'; panel.append(button); button.focus(); let fired = 0; const listener = (): void => { fired += 1; }; document.addEventListener('shortcut:tracklist-play', listener); press('Enter'); document.removeEventListener('shortcut:tracklist-play', listener); expect(fired).toBe(1); }); it('crosses a shadow boundary looking for the panel scope', () => { const panel = mount(document.createElement('div')); const inner = document.createElement('div'); const root = inner.attachShadow({ mode: 'open' }); const button = document.createElement('div'); button.tabIndex = 0; panel.dataset['shortcutScope'] = 'tracklist'; panel.append(inner); root.append(button); button.focus(); let fired = 0; const listener = (): void => { fired += 1; }; document.addEventListener('shortcut:tracklist-play', listener); press('Enter'); document.removeEventListener('shortcut:tracklist-play', listener); expect(fired).toBe(1); }); /** * `tracklist.delete` was advertised in Settings for six phases with * nothing dispatching for it. What it dispatches now only *opens* a * confirmation, which is what makes a destructive action defensible * on an unmodified key one row from the user's music. */ it('dispatches for tracklist.delete, which had nothing on the other end', () => { bindings({ 'tracklist.delete': 'Delete' }); const panel = mount(document.createElement('div')); const row = document.createElement('div'); row.tabIndex = 0; panel.dataset['shortcutScope'] = 'tracklist'; panel.append(row); row.focus(); let fired = 0; const listener = (): void => { fired += 1; }; document.addEventListener('shortcut:tracklist-delete', listener); press('Delete'); document.removeEventListener('shortcut:tracklist-delete', listener); expect(fired).toBe(1); }); }); // =================================================================== describe('shortcut dispatch: actions', () => { it('toggles between pause and play based on cached player state', () => { bindings({ 'player.playPause': 'Space' }); emit(Events.PlaybackStateChanged, { state: 'playing' }); press(' '); emit(Events.PlaybackStateChanged, { state: 'paused' }); press(' '); expect(calls().map((c) => c.path)).toEqual([ 'player.Player.Pause', 'queue.Queue.Play', ]); }); it('steps the volume by a fixed amount in each direction', () => { bindings({ 'player.volumeUp': 'Up', 'player.volumeDown': 'Down' }); press('ArrowUp'); press('ArrowDown'); expect(calls('player.Player.ChangeVolume').map((c) => c.args)).toEqual([ [5], [-5], ]); }); it('clamps a forward seek to the track length', async () => { bindings({ 'player.seekForward': 'Right' }); stub('player.Player.CurrentPositionSeconds', 98); stub('player.Player.TrackLengthInSeconds', 100); press('ArrowRight'); await new Promise((r) => { setTimeout(r, 0); }); expect(lastArgs('player.Player.Seek')).toEqual([100]); }); it('clamps a backward seek at zero', async () => { bindings({ 'player.seekBack': 'Left' }); stub('player.Player.CurrentPositionSeconds', 2); press('ArrowLeft'); await new Promise((r) => { setTimeout(r, 0); }); expect(lastArgs('player.Player.Seek')).toEqual([0]); }); it('toggles the queue panel open and closed', () => { bindings({ 'nav.queue': 'Q' }); const panel = mount(document.createElement('div')); panel.id = 'queue-panel'; press('q'); const opened = panel.hasAttribute('open'); press('q'); expect([opened, panel.hasAttribute('open')]).toEqual([true, false]); }); it('broadcasts select-all as a document event', () => { bindings({ 'app.selectAll': 'Ctrl+A' }); let fired = 0; const listener = (): void => { fired += 1; }; document.addEventListener('shortcut:select-all', listener); press('a', { ctrlKey: true }); document.removeEventListener('shortcut:select-all', listener); expect(fired).toBe(1); }); it('ignores an action name the dispatcher does not know', () => { bindings({ 'player.teleport': 'T' }); press('t'); expect(calls()).toEqual([]); }); }); // =================================================================== describe('shortcuts store: writes', () => { it('sends a single rebind to the backend', async () => { await shortcutsStore.updateBinding('player.next', 'Ctrl+N'); expect(lastArgs('config.Config.SetShortcut')).toEqual([ 'player.next', 'Ctrl+N', ]); }); it('sends a whole table at once', async () => { await shortcutsStore.setAll({ 'player.next': 'N' }); expect(lastArgs('config.Config.SetShortcuts')).toEqual([ { 'player.next': 'N' }, ]); }); it('does not update its cache optimistically', async () => { bindings({ 'player.next': 'Ctrl+Right' }); await shortcutsStore.updateBinding('player.next', 'Ctrl+N'); // The backend is the only writer; the cache waits for the // ShortcutsConfigChanged push. expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right'); }); });