release / govulncheck (push) Successful in 34s
release / web (push) Failing after 1m20s
release / go (push) Successful in 1m37s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist, History) now draws its rows with TrackListRow inside a TrackList box, in place of four row components that had drifted apart: - TrackListRow owns the look: lead column, title over "artist · album", like, radio, add to queue, menu, duration, then a trailing slot. It also owns the stripe, hover, the now-playing band and the unavailable state. The stripe is dropped on now-playing and selected rows, where Tailwind would otherwise let `odd:` win. - TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only their behaviour: track number and multi-select; grip, remove and dead-entry states; cover and timestamp. Playlist and History rows gain radio, add and the menu, so every list offers the same actions. - The radio control is Lucide's Radio icon, not the 📻 emoji. - Enter on a control inside a row now belongs to that control. The old TrackRow played the track when Enter was pressed on "add to queue". - TrackList has no fill, so the stripe shows on playlists too. Drag reorder (#5393): neodrag kept the drop offset, so after the list re-rendered the dragged row sat between two slots. reorderDrag, shared by the playlist and queue rows: - snaps the row to whole rows while dragging; - resets it to its slot on drop; - on playlists, gives the touch gesture to the grip only, so the list still scrolls by touch. The playlist page reorders optimistically and rolls back on error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.2 KiB
TypeScript
120 lines
5.2 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
import { mockInfiniteQuery } from '../../../test-utils/query';
|
|
import { apiClientMock } from '../../../test-utils/mocks/client';
|
|
import type { ArtistRef, AlbumRef, TrackRef, Page } from '#lib/api/types.js';
|
|
import { readable } from 'svelte/store';
|
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
|
|
|
vi.mock('#lib/api/likes.js', () => ({
|
|
createLikedIdsQuery: () => readable({
|
|
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
|
isPending: false,
|
|
isError: false
|
|
}),
|
|
createLikedTracksInfiniteQuery: vi.fn(),
|
|
createLikedAlbumsInfiniteQuery: vi.fn(),
|
|
createLikedArtistsInfiniteQuery: vi.fn(),
|
|
likeEntity: vi.fn(),
|
|
unlikeEntity: vi.fn()
|
|
}));
|
|
|
|
vi.mock('#lib/api/client.js', () => apiClientMock());
|
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
|
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(), playRadio: vi.fn(),
|
|
player: { current: undefined }
|
|
}));
|
|
|
|
import LikedPage from './+page.svelte';
|
|
import {
|
|
createLikedTracksInfiniteQuery,
|
|
createLikedAlbumsInfiniteQuery,
|
|
createLikedArtistsInfiniteQuery
|
|
} from '#lib/api/likes.js';
|
|
|
|
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
|
|
return { items, total, offset, limit };
|
|
}
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe('liked library page', () => {
|
|
test('renders three sections when each has items', () => {
|
|
const ar: ArtistRef = { id: 'a1', name: 'X', sort_name: 'X', album_count: 1, cover_url: '' };
|
|
const al: AlbumRef = {
|
|
id: 'al1', title: 'Y', sort_title: 'Y', artist_id: 'a1', artist_name: 'X',
|
|
year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null
|
|
};
|
|
const tr: TrackRef = makeTrack({
|
|
title: 'Z', album_id: 'al1', album_title: 'Y',
|
|
artist_id: 'a1', artist_name: 'X', duration_sec: 100
|
|
});
|
|
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page([ar], 1)] })
|
|
);
|
|
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page([al], 1)] })
|
|
);
|
|
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page([tr], 1)] })
|
|
);
|
|
render(LikedPage);
|
|
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('all three sections empty renders the whole-tab onboarding card', () => {
|
|
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
|
);
|
|
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
|
);
|
|
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
|
);
|
|
render(LikedPage);
|
|
// The whole-tab EmptyState replaces the three per-section hints
|
|
// when every section is empty — single heading + action affordances.
|
|
expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('one populated section + two empty subsections renders inline hints', () => {
|
|
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] })
|
|
);
|
|
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
|
);
|
|
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
|
);
|
|
render(LikedPage);
|
|
expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument();
|
|
// Inline variant uses link affordances, not the big card heading.
|
|
expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('Load more calls fetchNextPage on each section that has more', async () => {
|
|
const fetchTracks = vi.fn();
|
|
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<TrackRef>([], 100)], hasNextPage: true, fetchNextPage: fetchTracks })
|
|
);
|
|
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
|
);
|
|
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
|
);
|
|
render(LikedPage);
|
|
const buttons = screen.getAllByRole('button', { name: /load more/i });
|
|
expect(buttons).toHaveLength(1);
|
|
await fireEvent.click(buttons[0]);
|
|
expect(fetchTracks).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|