d67c0de596
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
TypeScript
308 lines
10 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';
|
|
|
|
vi.mock('$lib/auth/store.svelte', () => ({
|
|
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
|
}));
|
|
|
|
vi.mock('$lib/api/playlists', () => ({
|
|
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'
|
|
}
|
|
]
|
|
} 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'
|
|
}
|
|
]
|
|
} as PlaylistDetail),
|
|
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, track_ids: ['t-1'] })
|
|
}));
|
|
|
|
vi.mock('$lib/player/store.svelte', () => ({
|
|
playQueue: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$lib/api/queries', () => ({
|
|
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');
|
|
const { playQueue } = await import('$lib/player/store.svelte');
|
|
|
|
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');
|
|
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');
|
|
const { playQueue } = await import('$lib/player/store.svelte');
|
|
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');
|
|
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');
|
|
const { playQueue } = await import('$lib/player/store.svelte');
|
|
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');
|
|
const { playQueue } = await import('$lib/player/store.svelte');
|
|
|
|
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();
|
|
});
|
|
});
|