Files
yellowjacket/frontend/test/stores/keyboard-shortcuts.test.ts
T
logan 1aa1598ecb
Build & publish Arch package / arch-package (push) Successful in 2m1s
CI / check (push) Successful in 2m24s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Canceled after 8s
feat(shortcuts): tell the key story once, and give the arrows back
Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).

The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.

A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
2026-08-12 12:33:50 -04:00

510 lines
14 KiB
TypeScript

/**
* 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<string, string>): 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<T extends HTMLElement>(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 <div> 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);
});
});
// ===================================================================
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<void>((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<void>((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');
});
});