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>
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import { mockQuery } from '../../test-utils/query';
|
|
import type { Playlist } from '$lib/api/types';
|
|
|
|
vi.mock('$lib/api/playlists', () => ({
|
|
createPlaylistsQuery: vi.fn(),
|
|
createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
|
|
}));
|
|
|
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|
|
|
import PlaylistsPage from './+page.svelte';
|
|
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
|
|
|
|
const mockedCreatePlaylistsQuery = createPlaylistsQuery as ReturnType<typeof vi.fn>;
|
|
const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
|
|
|
|
function p(over: Partial<Playlist>): Playlist {
|
|
return {
|
|
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
|
|
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: '', ...over
|
|
};
|
|
}
|
|
|
|
describe('Playlists index page', () => {
|
|
test('renders own + public sections', () => {
|
|
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
|
data: {
|
|
owned: [p({ id: 'p1', name: 'Mine' })],
|
|
public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
|
|
}
|
|
}));
|
|
render(PlaylistsPage);
|
|
expect(screen.getByText('Mine')).toBeInTheDocument();
|
|
expect(screen.getByText('Theirs')).toBeInTheDocument();
|
|
expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/from other users/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test('empty-state copy when operator has no playlists', () => {
|
|
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
|
data: { owned: [], public: [] }
|
|
}));
|
|
render(PlaylistsPage);
|
|
expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test('hides "From other users" section when empty', () => {
|
|
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({
|
|
data: { owned: [p({ id: 'p1', name: 'Mine' })], public: [] }
|
|
}));
|
|
render(PlaylistsPage);
|
|
expect(screen.queryByText(/from other users/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('Create button reveals inline form; submit creates and navigates', async () => {
|
|
mockedCreatePlaylistsQuery.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
|
|
render(PlaylistsPage);
|
|
await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
|
|
const input = screen.getByPlaceholderText(/saturday morning/i);
|
|
await fireEvent.input(input, { target: { value: 'Test' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
|
|
await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
|
|
});
|
|
});
|