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>
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
|
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
|
import type { PlaylistTrack } from '#lib/api/types.js';
|
|
|
|
// LikeButton + TrackMenu transitively pull in TanStack Query, the auth
|
|
// store, likes/quarantine APIs, the player store, and SvelteKit
|
|
// navigation. Mock all of them at module level so the component can be
|
|
// rendered in isolation.
|
|
|
|
vi.mock('#lib/auth/store.svelte.js', () => ({
|
|
user: { value: { id: 'u1', username: 'me', is_admin: false } }
|
|
}));
|
|
|
|
vi.mock('$app/navigation', () => ({
|
|
goto: vi.fn()
|
|
}));
|
|
|
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
|
|
|
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
|
|
|
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
|
playNext: vi.fn(),
|
|
enqueueTrack: vi.fn(),
|
|
playQueue: vi.fn(),
|
|
playRadio: vi.fn(),
|
|
player: { current: undefined }
|
|
}));
|
|
|
|
import PlaylistTrackRow from './PlaylistTrackRow.svelte';
|
|
|
|
const live: PlaylistTrack = {
|
|
position: 2,
|
|
track_id: 't-1',
|
|
album_id: 'a-1',
|
|
artist_id: 'ar-1',
|
|
title: 'Roygbiv',
|
|
artist_name: 'Boards of Canada',
|
|
album_title: 'MHTRTC',
|
|
duration_sec: 137,
|
|
stream_url: '/api/tracks/t-1/stream',
|
|
added_at: '',
|
|
unavailable: false
|
|
};
|
|
|
|
const removed: PlaylistTrack = {
|
|
...live,
|
|
track_id: null,
|
|
album_id: null,
|
|
artist_id: null,
|
|
stream_url: null
|
|
};
|
|
|
|
// Missing file (#2527): the track row still exists — ids intact, history
|
|
// intact — but the server withholds the stream URL because the bytes are
|
|
// not on disk. Deliberately distinct from `removed` above.
|
|
const missingFile: PlaylistTrack = {
|
|
...live,
|
|
stream_url: null,
|
|
unavailable: true
|
|
};
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe('PlaylistTrackRow', () => {
|
|
test('renders title, artist, mm:ss duration', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
|
|
expect(screen.getByText('2:17')).toBeInTheDocument();
|
|
});
|
|
|
|
test('shows reorder handle and remove button for owner', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
expect(screen.getByLabelText(/reorder track/i)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByLabelText(/Remove Roygbiv from playlist/i)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
test('hides reorder handle and remove button for non-owner', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
expect(screen.queryByLabelText(/reorder track/i)).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByLabelText(/Remove Roygbiv from playlist/i)
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('ArrowDown on the reorder handle calls onMove(pos, pos+1)', async () => {
|
|
const onMove = vi.fn();
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove }
|
|
});
|
|
const handle = screen.getByLabelText(/reorder track/i);
|
|
await fireEvent.keyDown(handle, { key: 'ArrowDown' });
|
|
expect(onMove).toHaveBeenCalledWith(2, 3);
|
|
});
|
|
|
|
test('ArrowUp on the reorder handle calls onMove(pos, pos-1)', async () => {
|
|
const onMove = vi.fn();
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn(), onMove }
|
|
});
|
|
const handle = screen.getByLabelText(/reorder track/i);
|
|
await fireEvent.keyDown(handle, { key: 'ArrowUp' });
|
|
expect(onMove).toHaveBeenCalledWith(2, 1);
|
|
});
|
|
|
|
test('greyed-out + strikethrough when track_id is null', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
const titleEl = screen.getByText('Roygbiv');
|
|
expect(titleEl.className).toContain('line-through');
|
|
});
|
|
|
|
test('clicking the title fires onPlay with the position', async () => {
|
|
const onPlay = vi.fn();
|
|
render(PlaylistTrackRow, {
|
|
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay }
|
|
});
|
|
await fireEvent.click(screen.getByText('Roygbiv'));
|
|
expect(onPlay).toHaveBeenCalledWith(2);
|
|
});
|
|
|
|
// A missing file is NOT a removed track: the row says so, and says it
|
|
// without the strikethrough that would imply the track is gone for good.
|
|
test('missing file is labelled and not struck through', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
expect(screen.getByText(/file missing/i)).toBeTruthy();
|
|
expect(screen.getByText('Roygbiv').className).not.toContain('line-through');
|
|
});
|
|
|
|
test('clicking a missing-file row does not play it', async () => {
|
|
const onPlay = vi.fn();
|
|
render(PlaylistTrackRow, {
|
|
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay }
|
|
});
|
|
await fireEvent.click(screen.getByText('Roygbiv'));
|
|
expect(onPlay).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// The owner must still be able to take the dead entry out of their list.
|
|
test('remove stays available on a missing-file row', () => {
|
|
render(PlaylistTrackRow, {
|
|
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
|
});
|
|
expect(screen.getByLabelText(/remove roygbiv from playlist/i)).toBeTruthy();
|
|
});
|
|
});
|