/** * `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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(usePlayerStore.getState().repeatMode).toBe('off'); fireEvent.click(getByLabelText('Repeat')); expect(usePlayerStore.getState().repeatMode).toBe('all'); rerender(); 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(); 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(); 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(); expect(getByLabelText('Music Player')).toBeInTheDocument(); }); });