RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
539 lines
15 KiB
TypeScript
539 lines
15 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);
|
|
});
|
|
|
|
/**
|
|
* `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<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');
|
|
});
|
|
});
|