import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; import { makeTrack } from '$test-utils/fixtures/track'; // 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', () => emptyLikesMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) })); vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/player/store.svelte', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn() })); import TrackMenu from './TrackMenu.svelte'; import { playNext, enqueueTrack } from '$lib/player/store.svelte'; const track = makeTrack({ title: 'Roygbiv' }); 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(); }); });