diff --git a/web/src/lib/components/FlagPopover.svelte b/web/src/lib/components/FlagPopover.svelte new file mode 100644 index 00000000..25dbcf75 --- /dev/null +++ b/web/src/lib/components/FlagPopover.svelte @@ -0,0 +1,97 @@ + + + diff --git a/web/src/lib/components/FlagPopover.test.ts b/web/src/lib/components/FlagPopover.test.ts new file mode 100644 index 00000000..0bd19363 --- /dev/null +++ b/web/src/lib/components/FlagPopover.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import FlagPopover from './FlagPopover.svelte'; +import type { TrackRef } from '$lib/api/types'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: invalidateMock }) + }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({ track_id: 't1', reason: 'bad_rip' }) +})); + +import { flagTrack } from '$lib/api/quarantine'; + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'Geogaddi', + artist_id: 'ar1', + artist_name: 'Boards of Canada', + duration_sec: 240, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('FlagPopover', () => { + test('default reason is bad_rip; button reads "Flag" when not initialReason', () => { + render(FlagPopover, { props: { track, onClose: vi.fn() } }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('bad_rip'); + expect(screen.getByRole('button', { name: /^flag$/i })).toBeInTheDocument(); + }); + + test('pre-fills reason and notes when initial values are provided; button reads "Update flag"', () => { + render(FlagPopover, { + props: { + track, + onClose: vi.fn(), + initialReason: 'wrong_tags', + initialNotes: 'wrong artist' + } + }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('wrong_tags'); + const textarea = screen.getByPlaceholderText(/what's wrong/i) as HTMLTextAreaElement; + expect(textarea.value).toBe('wrong artist'); + expect(screen.getByRole('button', { name: /update flag/i })).toBeInTheDocument(); + }); + + test('submits with reason and notes; calls invalidateQueries on success', async () => { + const onClose = vi.fn(); + render(FlagPopover, { props: { track, onClose } }); + const select = screen.getByRole('combobox'); + await fireEvent.change(select, { target: { value: 'duplicate' } }); + const textarea = screen.getByPlaceholderText(/what's wrong/i); + await fireEvent.input(textarea, { target: { value: 'same recording' } }); + await fireEvent.click(screen.getByRole('button', { name: /^flag$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(flagTrack).toHaveBeenCalledWith({ + track_id: 't1', + reason: 'duplicate', + notes: 'same recording' + }); + expect(invalidateMock).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + test('empty notes are not sent', async () => { + render(FlagPopover, { props: { track, onClose: vi.fn() } }); + await fireEvent.click(screen.getByRole('button', { name: /^flag$/i })); + await Promise.resolve(); + await Promise.resolve(); + expect(flagTrack).toHaveBeenCalledWith({ + track_id: 't1', + reason: 'bad_rip', + notes: undefined + }); + }); + + test('cancel button calls onClose without firing flagTrack', async () => { + const onClose = vi.fn(); + render(FlagPopover, { props: { track, onClose } }); + await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(flagTrack).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte new file mode 100644 index 00000000..a57629bf --- /dev/null +++ b/web/src/lib/components/TrackMenu.svelte @@ -0,0 +1,65 @@ + + + (menuOpen = false)} + onkeydown={(e) => e.key === 'Escape' && closeAll()} +/> + +
+ + + {#if menuOpen} + + {/if} + + {#if popoverOpen} + + {/if} +
diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts new file mode 100644 index 00000000..267908e9 --- /dev/null +++ b/web/src/lib/components/TrackMenu.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import TrackMenu from './TrackMenu.svelte'; +import type { TrackRef } from '$lib/api/types'; + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + flagTrack: vi.fn().mockResolvedValue({}) +})); + +const track: TrackRef = { + id: 't1', + title: 'Roygbiv', + album_id: 'a1', + album_title: 'Geogaddi', + artist_id: 'ar1', + artist_name: 'Boards of Canada', + duration_sec: 240, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('TrackMenu', () => { + test('opens menu on kebab click', async () => { + render(TrackMenu, { props: { track } }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /track actions for roygbiv/i })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); + }); + + test('Escape key closes menu', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + expect(screen.getByRole('menu')).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => { + render(TrackMenu, { props: { track } }); + await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i })); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: /flag this track as broken/i })).toBeInTheDocument(); + }); +});