Files
Psychotoxical-psysonic/src/components/PlayerBar.test.tsx
T
Psychotoxical 44d373d7bb fix(player): player bar context menu acts on the current song, not its album (#1117)
* fix(player): player bar context menu acts on the current song, not its album

Right-clicking the current track in the player bar built an album object
from the playing track and opened the album context menu, so "Add to
playlist" added the whole album instead of the song. It now opens a
song-scoped menu for the current track. Left-click on the title still
navigates to the album.

* docs(changelog): note player bar add-to-playlist fix (#1117)
2026-06-17 17:07:47 +02:00

173 lines
6.8 KiB
TypeScript

/**
* `PlayerBar` characterization (Phase F5b).
*
* Asserts on the public control wiring (play/pause/next/prev/repeat/stop)
* and the no-track empty state. Visual / hover / overflow logic lives in
* the manual smoke list — jsdom doesn't compute layout.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
pingWithCredentials: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
import PlayerBar from './PlayerBar';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
vi.useFakeTimers();
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
describe('PlayerBar — render', () => {
it('renders the player region with the labelled landmark', () => {
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Music Player')).toBeInTheDocument();
});
it('exposes Previous Track / Play / Next Track / Repeat / Stop controls when a track is loaded', () => {
const track = makeTrack();
usePlayerStore.setState({ currentTrack: track, isPlaying: false });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Previous Track')).toBeInTheDocument();
expect(getByLabelText('Play')).toBeInTheDocument();
expect(getByLabelText('Next Track')).toBeInTheDocument();
expect(getByLabelText('Repeat')).toBeInTheDocument();
expect(getByLabelText('Stop')).toBeInTheDocument();
});
it('the middle control reads "Pause" while playback is active', () => {
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Pause')).toBeInTheDocument();
});
});
describe('PlayerBar — control wiring', () => {
it('clicking the Play/Pause button calls togglePlay', () => {
const track = makeTrack();
usePlayerStore.setState({ currentTrack: track, isPlaying: false });
const toggleSpy = vi.spyOn(usePlayerStore.getState(), 'togglePlay');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Play'));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 10 }); // > 3 s → restart current
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
const t1 = makeTrack({ id: 'a' });
const t2 = makeTrack({ id: 'b' });
seedQueue([t1, t2], { index: 0, currentTrack: t1 });
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles through off → all → one', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText, rerender } = renderWithProviders(<PlayerBar />);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
rerender(<PlayerBar />);
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('one');
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(<PlayerBar />);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
});
describe('PlayerBar — current track context menu', () => {
it('right-clicking the track name opens a song-scoped menu for the current track (#1116)', () => {
const track = makeTrack({ id: 'cur', albumId: 'alb-1' });
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
const spy = vi.spyOn(usePlayerStore.getState(), 'openContextMenu');
const { container } = renderWithProviders(<PlayerBar />);
const trackName = container.querySelector('.player-track-name');
expect(trackName).not.toBeNull();
fireEvent.contextMenu(trackName!);
expect(spy).toHaveBeenCalledTimes(1);
const [, , item, type, , , , , pin] = spy.mock.calls[0];
expect(item).toBe(track);
expect(type).toBe('song');
expect(pin).toBe(true);
});
});
describe('PlayerBar — empty state (no current track)', () => {
it('still renders the region landmark when no track is loaded', () => {
usePlayerStore.setState({ currentTrack: null, isPlaying: false });
const { getByLabelText } = renderWithProviders(<PlayerBar />);
expect(getByLabelText('Music Player')).toBeInTheDocument();
});
});