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>
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import { readable } from 'svelte/store';
|
|
import { makeTrack } from '$test-utils/fixtures/track';
|
|
|
|
const playlistsData = vi.hoisted(() => ({
|
|
owned: [
|
|
{
|
|
id: 'p1',
|
|
user_id: 'u-self',
|
|
owner_username: 'me',
|
|
name: 'B-list',
|
|
description: '',
|
|
is_public: false,
|
|
kind: 'user',
|
|
system_variant: null,
|
|
refreshable: false,
|
|
seed_artist_id: null,
|
|
cover_url: '',
|
|
track_count: 3,
|
|
duration_sec: 0,
|
|
created_at: '',
|
|
updated_at: ''
|
|
},
|
|
{
|
|
id: 'p2',
|
|
user_id: 'u-self',
|
|
owner_username: 'me',
|
|
name: 'A-list',
|
|
description: '',
|
|
is_public: false,
|
|
kind: 'user',
|
|
system_variant: null,
|
|
refreshable: false,
|
|
seed_artist_id: null,
|
|
cover_url: '',
|
|
track_count: 5,
|
|
duration_sec: 0,
|
|
created_at: '',
|
|
updated_at: ''
|
|
}
|
|
],
|
|
public: [] as unknown[]
|
|
}));
|
|
|
|
vi.mock('$lib/auth/store.svelte', () => ({
|
|
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
|
}));
|
|
|
|
vi.mock('$lib/api/playlists', () => ({
|
|
createPlaylistsQuery: () =>
|
|
readable({ data: playlistsData, isPending: false, isError: false }),
|
|
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
|
createPlaylist: vi.fn().mockResolvedValue({
|
|
id: 'p3',
|
|
user_id: 'u-self',
|
|
owner_username: 'me',
|
|
name: 'New',
|
|
description: '',
|
|
is_public: false,
|
|
kind: 'user',
|
|
system_variant: null,
|
|
refreshable: false,
|
|
seed_artist_id: null,
|
|
cover_url: '',
|
|
track_count: 0,
|
|
duration_sec: 0,
|
|
created_at: '',
|
|
updated_at: ''
|
|
})
|
|
}));
|
|
|
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
|
|
|
const track = makeTrack();
|
|
|
|
describe('AddToPlaylistMenu', () => {
|
|
test('lists own playlists alphabetically followed by "New playlist…"', () => {
|
|
render(AddToPlaylistMenu, { props: { tracks: [track], onClose: vi.fn() } });
|
|
const items = screen.getAllByRole('menuitem');
|
|
// First two should be the playlists in alpha order, then "New playlist…".
|
|
expect(items[0].textContent).toContain('A-list');
|
|
expect(items[1].textContent).toContain('B-list');
|
|
expect(items[2].textContent).toContain('New playlist');
|
|
});
|
|
|
|
test('clicking a playlist appends and closes', async () => {
|
|
const onClose = vi.fn();
|
|
const { appendTracks } = await import('$lib/api/playlists');
|
|
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
|
|
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']);
|
|
});
|
|
|
|
test('"New playlist…" toggles inline create form', async () => {
|
|
render(AddToPlaylistMenu, { props: { tracks: [track], onClose: vi.fn() } });
|
|
await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ }));
|
|
expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/Create & add/i)).toBeInTheDocument();
|
|
});
|
|
});
|