Files
minstrel/web/src/lib/components/PlaylistCard.test.ts
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00

310 lines
11 KiB
TypeScript

import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import PlaylistCard from './PlaylistCard.svelte';
import type { Playlist, PlaylistDetail } from '#lib/api/types.js';
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('#lib/api/playlists.js', () => ({
getPlaylist: vi.fn().mockResolvedValue({
id: 'p-1',
user_id: 'u-self',
owner_username: 'me',
name: 'Saturday morning',
description: '',
is_public: false,
kind: 'user',
system_variant: null,
refreshable: false,
seed_artist_id: null,
cover_url: '',
track_count: 1,
duration_sec: 60,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
tracks: [
{
position: 0,
track_id: 't-1',
album_id: 'al-1',
artist_id: 'ar-1',
title: 'Test Track',
artist_name: 'Test Artist',
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-1',
added_at: '2026-01-01T00:00:00Z',
unavailable: false
}
]
} as PlaylistDetail),
systemShuffle: vi.fn().mockResolvedValue({
id: 'p-sys',
user_id: 'u-self',
owner_username: 'me',
name: 'For You',
description: '',
is_public: false,
kind: 'system',
system_variant: 'for_you',
refreshable: true,
seed_artist_id: null,
cover_url: '',
track_count: 1,
duration_sec: 60,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
tracks: [
{
position: 0,
track_id: 't-9',
album_id: 'al-1',
artist_id: 'ar-1',
title: 'Rotation Track',
artist_name: 'Test Artist',
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-9',
added_at: '2026-01-01T00:00:00Z',
unavailable: false
}
]
} as PlaylistDetail),
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, track_ids: ['t-1'] })
}));
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn()
}));
vi.mock('#lib/api/queries.js', () => ({
qk: {
playlist: (id: string) => ['playlist', id],
playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }]
}
}));
const base: Playlist = {
id: 'p-1',
user_id: 'u-self',
owner_username: 'me',
name: 'Saturday morning',
description: '',
is_public: false,
kind: 'user',
system_variant: null,
refreshable: false,
seed_artist_id: null,
cover_url: '',
track_count: 12,
duration_sec: 0,
created_at: '',
updated_at: ''
};
describe('PlaylistCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders own playlist without owner attribution', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.getByText('Saturday morning')).toBeInTheDocument();
expect(screen.getByText(/12\s+tracks/i)).toBeInTheDocument();
expect(screen.queryByText(/by /)).not.toBeInTheDocument();
});
test('navigates to playlist when clicking the card', () => {
render(PlaylistCard, { props: { playlist: base } });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/playlists/p-1');
});
test('renders cover image when cover_url is set', () => {
// The <img> has alt="" (decorative — the playlist name is in the
// sibling text below, so duplicating it for screen readers would be
// noise). Empty alt makes Testing Library expose role="presentation"
// instead of role="img"; query the DOM directly so the assertion
// doesn't fight the (correct) a11y choice.
const { container } = render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } });
const img = container.querySelector('img');
expect(img).not.toBeNull();
expect(img!.getAttribute('src')).toBe('/api/playlists/p-1/cover');
});
test('renders glyph fallback when cover_url is empty', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument();
});
test("attributes other users' playlists to the owner", () => {
render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } });
expect(screen.getByText(/by bob/i)).toBeInTheDocument();
});
test('singular "track" when count is 1', () => {
render(PlaylistCard, { props: { playlist: { ...base, track_count: 1 } } });
expect(screen.getByText(/1\s+track\b/i)).toBeInTheDocument();
});
test('play button exists with correct aria label', () => {
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
expect(playButton).toBeInTheDocument();
});
test('play button is disabled when track_count is 0', () => {
render(PlaylistCard, { props: { playlist: { ...base, track_count: 0 } } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
expect(playButton).toBeDisabled();
});
test('play button calls playQueue with tracks when clicked', async () => {
const { getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
// Allow promises to resolve
await new Promise(resolve => setTimeout(resolve, 10));
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalled();
});
test('shows kebab on refreshable (singleton system) playlists', () => {
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'discover',
refreshable: true,
name: 'Discover'
};
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument();
});
test('does not show kebab on user playlists', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
});
test('does not show kebab on a non-refreshable system playlist', () => {
// e.g. songs_like_artist — system but multi-per-user, server
// reports refreshable:false.
const songsLike: Playlist = {
...base,
kind: 'system',
system_variant: 'songs_like_artist',
refreshable: false,
name: 'Songs like X'
};
render(PlaylistCard, { props: { playlist: songsLike } });
expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument();
});
test('Refresh item appears in the kebab, labelled by name', async () => {
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'discover',
refreshable: true,
name: 'Discover'
};
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
await fireEvent.click(screen.getByLabelText(/playlist actions/i));
expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument();
});
test('clicking Refresh calls refreshSystem with the raw variant', async () => {
const { refreshSystem } = await import('#lib/api/playlists.js');
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'discover',
refreshable: true,
name: 'Discover'
};
render(PlaylistCard, { props: { playlist: discoverPlaylist } });
await fireEvent.click(screen.getByLabelText(/playlist actions/i));
await fireEvent.click(screen.getByRole('menuitem', { name: /refresh discover/i }));
await waitFor(() => expect(refreshSystem).toHaveBeenCalledWith('discover'));
});
test('For-You play calls systemShuffle, not getPlaylist', async () => {
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'for_you',
refreshable: true,
name: 'For You',
track_count: 25
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
const playButton = screen.getByLabelText(/Play For You/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
// #415/#411: system play hits the rotation-aware shuffle endpoint
// (raw variant), not the cached detail GET, and never refreshes.
expect(systemShuffle).toHaveBeenCalledWith('for_you');
expect(getPlaylist).not.toHaveBeenCalled();
expect(playQueue).toHaveBeenCalled();
});
test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => {
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
system_variant: 'for_you',
name: 'For You'
};
render(PlaylistCard, { props: { playlist: forYouPlaylist } });
const playButton = screen.getByLabelText(/Play For You/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(playQueue).toHaveBeenCalledWith(
expect.any(Array),
0,
expect.objectContaining({ source: 'for_you' })
);
});
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(systemShuffle).not.toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalledWith(expect.any(Array), 0);
});
test('user-playlist play does not refresh anything', async () => {
const { refreshSystem, getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
await new Promise(resolve => setTimeout(resolve, 10));
expect(refreshSystem).not.toHaveBeenCalled();
expect(getPlaylist).toHaveBeenCalledWith('p-1');
expect(playQueue).toHaveBeenCalled();
});
});