import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { readable } from 'svelte/store'; import type { TrackRef } from '$lib/api/types'; // Mutable mock-user handle so individual tests can flip is_admin / null // without re-importing the module under test. const userState = vi.hoisted(() => ({ current: { id: 'u1', username: 'admin', is_admin: true } as | { id: string; username: string; is_admin: boolean } | null })); vi.mock('$lib/auth/store.svelte', () => ({ user: { get value() { return userState.current; } } })); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$lib/api/likes', () => ({ createLikedIdsQuery: () => readable({ data: { track_ids: [], album_ids: [], artist_ids: [] }, isPending: false, isError: false }), likeEntity: vi.fn().mockResolvedValue(undefined), unlikeEntity: vi.fn().mockResolvedValue(undefined) })); vi.mock('$lib/api/quarantine', () => ({ flagTrack: vi.fn().mockResolvedValue({}), unflagTrack: vi.fn().mockResolvedValue(undefined), listMyQuarantine: vi.fn().mockResolvedValue([]), createMyQuarantineQuery: () => readable({ data: [], isPending: false, isError: false }) })); vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) })); vi.mock('$lib/api/playlists', () => ({ createPlaylistsQuery: () => readable({ data: { owned: [], public: [] }, isPending: false, isError: false }), appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }), createPlaylist: vi.fn().mockResolvedValue({ id: 'p1', name: 'New' }) })); vi.mock('$lib/player/store.svelte', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn() })); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; }); import TrackMenu from './TrackMenu.svelte'; import { playNext, enqueueTrack } from '$lib/player/store.svelte'; 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(); userState.current = { id: 'u1', username: 'admin', is_admin: true }; }); describe('TrackMenu', () => { test('kebab toggles aria-expanded', async () => { render(TrackMenu, { props: { track } }); const kebab = screen.getByRole('button', { name: /track actions for roygbiv/i }); expect(kebab.getAttribute('aria-expanded')).toBe('false'); await fireEvent.click(kebab); expect(kebab.getAttribute('aria-expanded')).toBe('true'); }); test('opening menu shows all 9 entries for an admin user', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); expect(screen.getByRole('menuitem', { name: /play next/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument(); expect(screen.getAllByRole('menuitem')).toHaveLength(9); }); test('"Remove from library" hidden for non-admin', async () => { userState.current = { id: 'u2', username: 'normal', is_admin: false }; render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); expect(screen.getAllByRole('menuitem')).toHaveLength(8); }); test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); const item = screen.getByRole('menuitem', { name: /add to playlist/i }); expect(item.getAttribute('aria-disabled')).toBe('false'); await fireEvent.click(item); await waitFor(() => expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument() ); }); test('"Play next" dispatches playNext(track)', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); await fireEvent.click(screen.getByRole('menuitem', { name: /play next/i })); expect(playNext).toHaveBeenCalledWith(track); }); test('"Add to queue" dispatches enqueueTrack(track)', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); await fireEvent.click(screen.getByRole('menuitem', { name: /add to queue/i })); expect(enqueueTrack).toHaveBeenCalledWith(track); }); test('Escape closes the 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(); }); });