diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx
new file mode 100644
index 00000000..3fed2a3c
--- /dev/null
+++ b/src/components/ContextMenu.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * `ContextMenu` characterization (Phase F5a).
+ *
+ * Drives the menu via `usePlayerStore.openContextMenu(...)`, asserts on
+ * the rendered items + their click → store-action wiring. Avoids deep
+ * snapshots — tests survive a refactor that re-orders or re-styles the
+ * markup as long as the menu items + their handlers stay observable.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/api/subsonic', () => ({
+ savePlayQueue: vi.fn(async () => undefined),
+ getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
+ buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
+ buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
+ buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
+ coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
+ getSong: vi.fn(async () => null),
+ getRandomSongs: vi.fn(async () => []),
+ getSimilarSongs2: vi.fn(async () => []),
+ getTopSongs: vi.fn(async () => []),
+ getAlbumInfo2: vi.fn(async () => null),
+ getAlbum: vi.fn(async () => ({ album: { id: 'a1', songs: [] }, songs: [] })),
+ reportNowPlaying: vi.fn(async () => undefined),
+ scrobbleSong: vi.fn(async () => undefined),
+ setRating: vi.fn(async () => undefined),
+ star: vi.fn(async () => undefined),
+ unstar: vi.fn(async () => undefined),
+}));
+
+vi.mock('@/api/lastfm', () => ({
+ lastfmScrobble: vi.fn(async () => undefined),
+ lastfmUpdateNowPlaying: vi.fn(async () => undefined),
+ lastfmLoveTrack: vi.fn(async () => undefined),
+ lastfmUnloveTrack: vi.fn(async () => undefined),
+ lastfmGetTrackLoved: vi.fn(async () => false),
+ lastfmGetAllLovedTracks: vi.fn(async () => []),
+}));
+
+vi.mock('@/utils/orbitBulkGuard', () => ({
+ orbitBulkGuard: vi.fn(async () => true),
+}));
+
+import ContextMenu from './ContextMenu';
+import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+import { usePlayerStore, type Track } from '@/store/playerStore';
+import { useAuthStore, type ServerProfile } from '@/store/authStore';
+import { resetAllStores } from '@/test/helpers/storeReset';
+import { makeTrack, makeServer } from '@/test/helpers/factories';
+import { onInvoke } from '@/test/mocks/tauri';
+import { fireEvent } from '@testing-library/react';
+
+function setUpActiveServer(): ServerProfile {
+ const server = makeServer();
+ const id = useAuthStore.getState().addServer({
+ name: server.name, url: server.url, username: server.username, password: server.password,
+ });
+ useAuthStore.getState().setActiveServer(id);
+ useAuthStore.getState().setLoggedIn(true);
+ return { ...server, id };
+}
+
+function openMenuFor(type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', item: unknown, queueIndex?: number): void {
+ usePlayerStore.getState().openContextMenu(100, 100, item as never, type, queueIndex);
+}
+
+beforeEach(() => {
+ resetAllStores();
+ setUpActiveServer();
+ // Several menu actions invoke playback / engine commands — stub the common ones.
+ onInvoke('audio_play', () => undefined);
+ onInvoke('audio_pause', () => undefined);
+ onInvoke('audio_stop', () => undefined);
+ onInvoke('audio_seek', () => undefined);
+ onInvoke('audio_get_state', () => ({ playing: false }));
+ onInvoke('audio_update_replay_gain', () => undefined);
+ onInvoke('audio_set_normalization', () => undefined);
+ onInvoke('discord_update_presence', () => undefined);
+ onInvoke('frontend_debug_log', () => undefined);
+});
+
+afterEach(() => {
+ // Close any open menu so the next test starts clean.
+ usePlayerStore.getState().closeContextMenu();
+});
+
+describe('ContextMenu — visibility', () => {
+ it('renders nothing when the menu is closed', () => {
+ const { container } = renderWithProviders();
+ // No items, no portal.
+ expect(container.querySelector('.context-menu')).toBeNull();
+ });
+
+ it('renders the menu when openContextMenu has run', () => {
+ openMenuFor('song', makeTrack());
+ const { container } = renderWithProviders();
+ expect(container.querySelector('.context-menu')).not.toBeNull();
+ });
+
+ it('closeContextMenu hides the rendered menu on the next render', () => {
+ openMenuFor('song', makeTrack());
+ const { container, rerender } = renderWithProviders();
+ expect(container.querySelector('.context-menu')).not.toBeNull();
+
+ usePlayerStore.getState().closeContextMenu();
+ rerender();
+ expect(container.querySelector('.context-menu')).toBeNull();
+ });
+});
+
+describe('ContextMenu — type=song', () => {
+ it('shows Play Now / Play Next / Add to Queue items', () => {
+ openMenuFor('song', makeTrack({ id: 'tr-1' }));
+ const { getByText } = renderWithProviders();
+ expect(getByText('Play Now')).toBeInTheDocument();
+ expect(getByText('Play Next')).toBeInTheDocument();
+ expect(getByText('Add to Queue')).toBeInTheDocument();
+ });
+
+ it('"Play Next" click calls playerStore.playNext with the song', () => {
+ const track = makeTrack({ id: 'tr-pn' });
+ const playNextSpy = vi.spyOn(usePlayerStore.getState(), 'playNext');
+ openMenuFor('song', track);
+ const { getByText } = renderWithProviders();
+
+ fireEvent.click(getByText('Play Next'));
+
+ expect(playNextSpy).toHaveBeenCalledTimes(1);
+ expect(playNextSpy.mock.calls[0]?.[0]).toHaveLength(1);
+ expect(playNextSpy.mock.calls[0]?.[0][0].id).toBe('tr-pn');
+ });
+
+ it('"Add to Queue" click calls playerStore.enqueue', () => {
+ const track = makeTrack({ id: 'tr-eq' });
+ const enqueueSpy = vi.spyOn(usePlayerStore.getState(), 'enqueue');
+ openMenuFor('song', track);
+ const { getByText } = renderWithProviders();
+
+ fireEvent.click(getByText('Add to Queue'));
+
+ expect(enqueueSpy).toHaveBeenCalled();
+ expect(enqueueSpy.mock.calls[0]?.[0]?.[0]?.id).toBe('tr-eq');
+ });
+
+ it('selecting any action closes the menu', () => {
+ const track = makeTrack();
+ openMenuFor('song', track);
+ const { getByText } = renderWithProviders();
+
+ fireEvent.click(getByText('Play Next'));
+ expect(usePlayerStore.getState().contextMenu.isOpen).toBe(false);
+ });
+});
+
+describe('ContextMenu — type=album', () => {
+ it('shows the album surface (Open Album / Play Next / Enqueue Album / Go to Artist)', () => {
+ openMenuFor('album', {
+ id: 'al-1', name: 'Album', artist: 'Artist', artistId: 'ar-1',
+ songCount: 5, duration: 1200, year: 2024,
+ });
+ const { getByText } = renderWithProviders();
+ expect(getByText('Open Album')).toBeInTheDocument();
+ expect(getByText('Play Next')).toBeInTheDocument();
+ expect(getByText('Enqueue Album')).toBeInTheDocument();
+ expect(getByText('Go to Artist')).toBeInTheDocument();
+ });
+});
+
+describe('ContextMenu — type=artist', () => {
+ it('shows the artist menu surface (Start Radio + share-link affordances)', () => {
+ openMenuFor('artist', {
+ id: 'ar-1', name: 'Artist', albumCount: 3,
+ });
+ const { container } = renderWithProviders();
+ expect(container.querySelector('.context-menu')).not.toBeNull();
+ expect(container.textContent).toMatch(/Start Radio/i);
+ expect(container.textContent).toMatch(/share/i);
+ });
+});
+
+describe('ContextMenu — type=queue-item', () => {
+ it('shows a Remove from Queue affordance the song menu does not have', () => {
+ const track = makeTrack({ id: 'q-1' });
+ usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track });
+ openMenuFor('queue-item', track, 0);
+ const { container } = renderWithProviders();
+ expect(container.querySelector('.context-menu')).not.toBeNull();
+ // The Remove option's i18n key (queue.removeFromQueue) ends up rendered;
+ // assert *something* queue-flavoured appears (we don't pin the exact
+ // wording so a translation tweak doesn't flip the test).
+ expect(container.textContent).toMatch(/remove/i);
+ });
+});
+
+describe('ContextMenu — Escape closes', () => {
+ it('Escape on the menu closes it', () => {
+ openMenuFor('song', makeTrack());
+ const { container } = renderWithProviders();
+ const menu = container.querySelector('.context-menu') as HTMLElement;
+ expect(menu).not.toBeNull();
+
+ fireEvent.keyDown(menu, { key: 'Escape' });
+ expect(usePlayerStore.getState().contextMenu.isOpen).toBe(false);
+ });
+});
diff --git a/src/components/WaveformSeek.test.tsx b/src/components/WaveformSeek.test.tsx
new file mode 100644
index 00000000..12e8e050
--- /dev/null
+++ b/src/components/WaveformSeek.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * `WaveformSeek` characterization (Phase F5a).
+ *
+ * jsdom does not run canvas rendering, so this file tests the input
+ * surface (cursor state, no-track guard, wheel-debounce → seek wiring)
+ * rather than the visual output. The actual canvas drawing path is
+ * covered by manual smoke per the v2 plan.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/api/subsonic', () => ({
+ savePlayQueue: vi.fn(async () => undefined),
+ getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
+ buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
+ buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
+ buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
+ coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
+ getSong: vi.fn(async () => null),
+ getRandomSongs: vi.fn(async () => []),
+ getSimilarSongs2: vi.fn(async () => []),
+ getTopSongs: vi.fn(async () => []),
+ getAlbumInfo2: vi.fn(async () => null),
+ reportNowPlaying: vi.fn(async () => undefined),
+ scrobbleSong: vi.fn(async () => undefined),
+}));
+
+vi.mock('@/api/lastfm', () => ({
+ lastfmScrobble: vi.fn(async () => undefined),
+ lastfmUpdateNowPlaying: vi.fn(async () => undefined),
+ lastfmGetTrackLoved: vi.fn(async () => false),
+ lastfmGetAllLovedTracks: vi.fn(async () => []),
+}));
+
+import WaveformSeek from './WaveformSeek';
+import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+import { usePlayerStore } from '@/store/playerStore';
+import { useAuthStore } from '@/store/authStore';
+import { resetAllStores } from '@/test/helpers/storeReset';
+import { makeTrack } from '@/test/helpers/factories';
+import { onInvoke } from '@/test/mocks/tauri';
+import { fireEvent } from '@testing-library/react';
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ resetAllStores();
+ // Seed an active server so any downstream invokes are valid.
+ const id = useAuthStore.getState().addServer({
+ name: 'T', url: 'https://x.test', username: 'u', password: 'p',
+ });
+ useAuthStore.getState().setActiveServer(id);
+ onInvoke('audio_play', () => undefined);
+ onInvoke('audio_pause', () => undefined);
+ onInvoke('audio_seek', () => undefined);
+ onInvoke('audio_get_state', () => ({ playing: false }));
+ onInvoke('audio_update_replay_gain', () => undefined);
+});
+
+afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+});
+
+describe('WaveformSeek — render surface', () => {
+ it('renders a canvas element', () => {
+ const { container } = renderWithProviders();
+ expect(container.querySelector('canvas')).not.toBeNull();
+ });
+
+ it('canvas cursor is "default" when trackId is undefined (no track loaded)', () => {
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+ expect(canvas.style.cursor).toBe('default');
+ });
+
+ it('canvas cursor is "pointer" when a trackId is present (seekable)', () => {
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+ expect(canvas.style.cursor).toBe('pointer');
+ });
+});
+
+describe('WaveformSeek — guards before seek', () => {
+ it('does not call seek when wheeled without a trackId', () => {
+ const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+
+ fireEvent.wheel(canvas, { deltaY: -100 });
+ vi.advanceTimersByTime(1000);
+
+ expect(seekSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not call seek when wheeled without a current track in the store (duration = 0)', () => {
+ const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
+ // trackId is set but the store currentTrack is null → duration = 0 → guard fires.
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+
+ fireEvent.wheel(canvas, { deltaY: -100 });
+ vi.advanceTimersByTime(1000);
+
+ expect(seekSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe('WaveformSeek — wheel-to-seek wiring', () => {
+ it('commits the seek through a 350 ms trailing debounce', () => {
+ const track = makeTrack({ id: 't1', duration: 200 });
+ usePlayerStore.setState({ currentTrack: track, isPlaying: true });
+ const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
+
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+
+ fireEvent.wheel(canvas, { deltaY: -120 });
+ // Seek not yet committed — still inside the 350 ms debounce.
+ expect(seekSpy).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(400);
+ expect(seekSpy).toHaveBeenCalledTimes(1);
+ const fraction = seekSpy.mock.calls[0]?.[0] as number;
+ expect(fraction).toBeGreaterThanOrEqual(0);
+ expect(fraction).toBeLessThanOrEqual(1);
+ });
+
+ it('coalesces rapid wheel events when each fires within the debounce window', () => {
+ const track = makeTrack({ id: 't1', duration: 200 });
+ usePlayerStore.setState({ currentTrack: track });
+ const seekSpy = vi.spyOn(usePlayerStore.getState(), 'seek');
+
+ const { container } = renderWithProviders();
+ const canvas = container.querySelector('canvas') as HTMLCanvasElement;
+
+ // Three wheels in quick succession (all within 350 ms of each other).
+ fireEvent.wheel(canvas, { deltaY: -100 });
+ fireEvent.wheel(canvas, { deltaY: -100 });
+ fireEvent.wheel(canvas, { deltaY: -100 });
+ vi.advanceTimersByTime(400);
+
+ // Far fewer commits than wheel events — coalescing reduces engine load.
+ expect(seekSpy.mock.calls.length).toBeLessThan(3);
+ expect(seekSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
+ });
+});
+
+describe('WaveformSeek — listener lifecycle', () => {
+ it('mount + unmount completes without throwing', () => {
+ const { unmount } = renderWithProviders();
+ expect(() => unmount()).not.toThrow();
+ });
+});