import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { flushSync } from 'svelte'; import type { TrackRef } from '#lib/api/types.js'; import { makeTrack } from '#test-utils/fixtures/track.js'; function track(id: string): TrackRef { return makeTrack({ id, title: `T ${id}`, duration_sec: 200, stream_url: `/api/tracks/${id}/stream` }); } function press(key: string, target?: EventTarget): void { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); (target ?? window).dispatchEvent(event); } describe('useGlobalShortcuts', () => { let cleanup: () => void; beforeEach(async () => { const store = await import('./store.svelte'); store.playQueue([track('1'), track('2'), track('3')]); store.reportDuration(200); flushSync(); const { useGlobalShortcuts } = await import('./shortcuts.svelte'); cleanup = $effect.root(() => useGlobalShortcuts()); }); afterEach(() => { cleanup?.(); }); test('Space toggles play/pause', async () => { const store = await import('./store.svelte'); // playQueue leaves the store at 'loading' (intent-to-play; jsdom // never moves it to 'playing' because there's no real audio). The // togglePlay contract is: loading|playing -> paused, anything else // -> loading. So Space round-trips loading <-> paused. expect(store.player.state).toBe('loading'); press(' '); flushSync(); expect(store.player.state).toBe('paused'); press(' '); flushSync(); expect(store.player.state).toBe('loading'); }); test('K is an alias for Space (YouTube convention)', async () => { const store = await import('./store.svelte'); expect(store.player.state).toBe('loading'); press('k'); flushSync(); expect(store.player.state).toBe('paused'); }); test('ArrowRight advances to the next track', async () => { const store = await import('./store.svelte'); expect(store.player.index).toBe(0); press('ArrowRight'); flushSync(); expect(store.player.index).toBe(1); }); test('ArrowLeft goes to the previous track', async () => { const store = await import('./store.svelte'); store.skipNext(); flushSync(); expect(store.player.index).toBe(1); press('ArrowLeft'); flushSync(); expect(store.player.index).toBe(0); }); test('M toggles mute, restoring the previous volume', async () => { const store = await import('./store.svelte'); store.setVolume(0.42); flushSync(); expect(store.player.volume).toBe(0.42); press('m'); flushSync(); expect(store.player.volume).toBe(0); press('m'); flushSync(); expect(store.player.volume).toBeCloseTo(0.42, 5); }); test('ArrowUp / ArrowDown adjust volume by 5%', async () => { const store = await import('./store.svelte'); store.setVolume(0.5); flushSync(); press('ArrowUp'); flushSync(); expect(store.player.volume).toBeCloseTo(0.55, 5); press('ArrowDown'); flushSync(); expect(store.player.volume).toBeCloseTo(0.5, 5); }); test('shortcuts are ignored when focus is in an input', async () => { const store = await import('./store.svelte'); const before = store.player.state; const input = document.createElement('input'); document.body.appendChild(input); input.focus(); press(' ', input); flushSync(); // No state change — Space should pass through to the input. expect(store.player.state).toBe(before); input.remove(); }); test('modifier keys disable the shortcut (browser hotkey passthrough)', async () => { const store = await import('./store.svelte'); const before = store.player.state; const event = new KeyboardEvent('keydown', { key: ' ', ctrlKey: true, bubbles: true, cancelable: true }); window.dispatchEvent(event); flushSync(); expect(store.player.state).toBe(before); }); });