71dbaaede5
The "Add to playlist…" entry that #372 reserved as a disabled slot is now active. Submenu lists the operator's own playlists alphabetically; "New playlist…" toggles an inline create form that makes the playlist + appends the track in two API calls. TrackMenu's existing test asserts the entry is enabled and opens the submenu instead of the previous "is disabled with tooltip" check.
153 lines
6.2 KiB
TypeScript
153 lines
6.2 KiB
TypeScript
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<string, unknown>;
|
|
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();
|
|
});
|
|
});
|