feat(web): TrackMenu rewrite — 9 entries in 4 groups (M7 #372)
Replaces the M5b-era FlagPopover-only menu with the full track-actions surface: queue (Play next, Add to queue), collection (Like/Unlike, Add to playlist… reserved for #352), navigation (Go to album/artist), lifecycle (Flag, Hide/Unhide, Remove from library — admin-only). Remove from library opens a new RemoveTrackPopover with a single "Also stop Lidarr from finding a replacement" checkbox. The destructive flow always deletes file + DB through Minstrel; the checkbox controls whether Lidarr is also told to unmonitor. Lidarr unmonitor failure flows back as lidarr_unmonitor_failed in the response — destructive part already succeeded. The component's prop API (track, direction) is unchanged so TrackRow and PlayerBar pick up the new entries with no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,54 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import TrackMenu from './TrackMenu.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/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() }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => ({
|
||||
flagTrack: vi.fn().mockResolvedValue({})
|
||||
}));
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
||||
|
||||
const track: TrackRef = {
|
||||
id: 't1',
|
||||
@@ -23,18 +61,68 @@ const track: TrackRef = {
|
||||
stream_url: '/api/tracks/t1/stream'
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userState.current = { id: 'u1', username: 'admin', is_admin: true };
|
||||
});
|
||||
|
||||
describe('TrackMenu', () => {
|
||||
test('opens menu on kebab click', async () => {
|
||||
test('kebab toggles aria-expanded', 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();
|
||||
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('Escape key closes menu', async () => {
|
||||
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…" rendered as aria-disabled with tooltip', 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('true');
|
||||
expect(item.getAttribute('title')).toBe('Coming with playlists');
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -47,6 +135,8 @@ describe('TrackMenu', () => {
|
||||
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();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user