Files
minstrel/web/src/lib/components/PlaylistTrackRow.test.ts
T
bvandeusen 9fc21d217a
test-web / test (push) Successful in 31s
feat(web): keyboard reorder on playlist tracks (Tier C10)
Drag was already wired via @neodrag/svelte; this adds the missing
keyboard path so reorder is accessible without a pointer device.

- The drag handle becomes a real <button> (focusable). Disabled
  when canDrag is false so non-owners + unavailable tracks can't
  attempt a move.
- ArrowUp / ArrowDown on the focused handle call
  onMove(row.position, row.position +/- 1). Bounds clamping
  already lives in the page-level handler (playlists/[id]/+page.svelte:39),
  so out-of-range targets resolve as no-ops.
- Matches QueueTrackRow's pattern: aria-label "Reorder track …",
  aria-keyshortcuts, swallow Space/Enter to avoid scrolling.

Test updates: drag-handle assertions retargeted to /reorder track/
+ two new ArrowUp/ArrowDown handler tests.
2026-06-01 14:16:27 -04:00

127 lines
4.0 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';
// 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', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
}));
vi.mock('$lib/player/store.svelte', () => ({
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: ''
};
const removed: PlaylistTrack = {
...live,
track_id: null,
album_id: null,
artist_id: null,
stream_url: null
};
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);
});
});