import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; import type { PlaylistTrack } from '#lib/api/types.js'; // LikeButton + TrackMenu transitively pull in TanStack Query, the auth // store, likes/quarantine APIs, the player store, and SvelteKit // navigation. Mock all of them at module level so the component can be // rendered in isolation. vi.mock('#lib/auth/store.svelte.js', () => ({ user: { value: { id: 'u1', username: 'me', is_admin: false } } })); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('#lib/api/likes.js', () => emptyLikesMock()); vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); vi.mock('#lib/player/store.svelte.js', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn(), playQueue: vi.fn(), playRadio: vi.fn(), player: { current: undefined } })); import PlaylistTrackRow from './PlaylistTrackRow.svelte'; const live: PlaylistTrack = { position: 2, track_id: 't-1', album_id: 'a-1', artist_id: 'ar-1', title: 'Roygbiv', artist_name: 'Boards of Canada', album_title: 'MHTRTC', duration_sec: 137, stream_url: '/api/tracks/t-1/stream', added_at: '', unavailable: false }; const removed: PlaylistTrack = { ...live, track_id: null, album_id: null, artist_id: null, stream_url: null }; // Missing file (#2527): the track row still exists — ids intact, history // intact — but the server withholds the stream URL because the bytes are // not on disk. Deliberately distinct from `removed` above. const missingFile: PlaylistTrack = { ...live, stream_url: null, unavailable: true }; afterEach(() => vi.clearAllMocks()); describe('PlaylistTrackRow', () => { test('renders title, artist, mm:ss duration', () => { render(PlaylistTrackRow, { props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } }); expect(screen.getByText('Roygbiv')).toBeInTheDocument(); expect(screen.getByText('2:17')).toBeInTheDocument(); }); test('shows reorder handle and remove button for owner', () => { render(PlaylistTrackRow, { props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } }); expect(screen.getByLabelText(/reorder track/i)).toBeInTheDocument(); expect( screen.getByLabelText(/Remove Roygbiv from playlist/i) ).toBeInTheDocument(); }); test('hides reorder handle and remove button for non-owner', () => { render(PlaylistTrackRow, { props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() } }); expect(screen.queryByLabelText(/reorder track/i)).not.toBeInTheDocument(); expect( screen.queryByLabelText(/Remove Roygbiv from playlist/i) ).not.toBeInTheDocument(); }); test('ArrowDown on the reorder handle calls onMove(pos, pos+1)', async () => { const onMove = vi.fn(); render(PlaylistTrackRow, { props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove } }); const handle = screen.getByLabelText(/reorder track/i); await fireEvent.keyDown(handle, { key: 'ArrowDown' }); expect(onMove).toHaveBeenCalledWith(2, 3); }); test('ArrowUp on the reorder handle calls onMove(pos, pos-1)', async () => { const onMove = vi.fn(); render(PlaylistTrackRow, { props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove } }); const handle = screen.getByLabelText(/reorder track/i); await fireEvent.keyDown(handle, { key: 'ArrowUp' }); expect(onMove).toHaveBeenCalledWith(2, 1); }); test('greyed-out + strikethrough when track_id is null', () => { render(PlaylistTrackRow, { props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } }); const titleEl = screen.getByText('Roygbiv'); expect(titleEl.className).toContain('line-through'); }); test('clicking the title fires onPlay with the position', async () => { const onPlay = vi.fn(); render(PlaylistTrackRow, { props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay } }); await fireEvent.click(screen.getByText('Roygbiv')); expect(onPlay).toHaveBeenCalledWith(2); }); // A missing file is NOT a removed track: the row says so, and says it // without the strikethrough that would imply the track is gone for good. test('missing file is labelled and not struck through', () => { render(PlaylistTrackRow, { props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } }); expect(screen.getByText(/file missing/i)).toBeTruthy(); expect(screen.getByText('Roygbiv').className).not.toContain('line-through'); }); test('clicking a missing-file row does not play it', async () => { const onPlay = vi.fn(); render(PlaylistTrackRow, { props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay } }); await fireEvent.click(screen.getByText('Roygbiv')); expect(onPlay).not.toHaveBeenCalled(); }); // The owner must still be able to take the dead entry out of their list. test('remove stays available on a missing-file row', () => { render(PlaylistTrackRow, { props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } }); expect(screen.getByLabelText(/remove roygbiv from playlist/i)).toBeTruthy(); }); });