diff --git a/__mocks__/MockShows.ts b/__mocks__/MockShows.ts new file mode 100644 index 0000000..a4bc404 --- /dev/null +++ b/__mocks__/MockShows.ts @@ -0,0 +1,31 @@ +import { Show } from '@customTypes/RecentlyPlayed'; + +// Realistic mock show based on __mocks__/MockNetworkResponses.ts (archivesXml) +export const mockShow: Show = { + id: '8982', + name: 'Africa Kabisa', + day: 0, + day_str: 'Sunday', + time: 960, + time_str: '4:00p', + length: 120, + hosts: 'Brutus leaderson', + alternates: 0, + archives: [ + { + url: 'https://wmbr.org/archive/Africa_Kabisa_%28rebroadcast%29____11_12_25_1%3A58_AM.mp3', + date: 'Wed, 12 Nov 2025 07:00:00 GMT', + size: '119046897', + }, + { + url: 'https://wmbr.org/archive/Africa_Kabisa____11_9_25_3%3A58_PM.mp3', + date: 'Sun, 09 Nov 2025 21:00:00 GMT', + size: '119033104', + }, + { + url: 'https://wmbr.org/archive/Africa_Kabisa____11_2_25_3%3A58_PM.mp3', + date: 'Sun, 02 Nov 2025 21:00:00 GMT', + size: '119066540', + }, + ], +}; diff --git a/__mocks__/react-native-track-player.ts b/__mocks__/react-native-track-player.ts index 630f568..8ad5dfe 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -1,41 +1,41 @@ -export const Event = { - PlaybackState: 'playback-state', - PlaybackProgressUpdated: 'playback-progress', - PlaybackQueueEnded: 'playback-queue-ended', -} as const; +export enum Event { + PlaybackState = 'playback-state', + PlaybackProgressUpdated = 'playback-progress', + PlaybackQueueEnded = 'playback-queue-ended', +} -export const Capability = { - Play: 'play', - PlayFromId: 'play-from-id', - PlayFromSearch: 'play-from-search', - Pause: 'pause', - Stop: 'stop', - SeekTo: 'seek-to', - Skip: 'skip', - SkipToNext: 'skip-to-next', - SkipToPrevious: 'skip-to-previous', - JumpForward: 'jump-forward', - JumpBackward: 'jump-backward', - SetRating: 'set-rating', - Like: 'like', - Dislike: 'dislike', - Bookmark: 'bookmark', -} as const; +export enum Capability { + Play = 'play', + PlayFromId = 'play-from-id', + PlayFromSearch = 'play-from-search', + Pause = 'pause', + Stop = 'stop', + SeekTo = 'seek-to', + Skip = 'skip', + SkipToNext = 'skip-to-next', + SkipToPrevious = 'skip-to-previous', + JumpForward = 'jump-forward', + JumpBackward = 'jump-backward', + SetRating = 'set-rating', + Like = 'like', + Dislike = 'dislike', + Bookmark = 'bookmark', +} -export const State = { - Playing: 'PLAYING', - Stopped: 'STOPPED', - Paused: 'PAUSED', -} as const; +export enum State { + Playing = 'PLAYING', + Stopped = 'STOPPED', + Paused = 'PAUSED', +} // internal mock state -let playbackState: string = State.Stopped; +let playbackState: State = State.Stopped; let position = 0; // seconds let duration = 0; // seconds let initialized = false; let queue: any[] = []; -const testApi = { +export const testApi = { resetAll: () => { playbackState = State.Stopped; position = 0; @@ -43,8 +43,8 @@ const testApi = { initialized = false; queue = []; }, - setPlaybackState: (s: string) => { - playbackState = s; + setPlaybackState: (state: State) => { + playbackState = state; }, setPosition: (sec: number) => { position = sec; @@ -80,14 +80,17 @@ const TrackPlayer = { }), getPosition: jest.fn(async () => Promise.resolve(position)), getDuration: jest.fn(async () => Promise.resolve(duration)), + seekTo: jest.fn(async (sec: number) => { + // clamp into [0, duration] and update internal position + position = Math.max(0, Math.min(duration, sec)); + return Promise.resolve(); + }), play: jest.fn(async () => Promise.resolve()), pause: jest.fn(async () => Promise.resolve()), stop: jest.fn(async () => Promise.resolve()), reset: jest.fn(async () => Promise.resolve()), updateMetadataForTrack: jest.fn(() => Promise.resolve()), addEventListener: jest.fn(() => Promise.resolve()), - // test-only API available to TestUtils via require('react-native-track-player').__testApi - __testApi: testApi, }; export const useProgress = TrackPlayer.useProgress; diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx new file mode 100644 index 0000000..e84c92b --- /dev/null +++ b/__tests__/ArchivedShowView.test.tsx @@ -0,0 +1,133 @@ +import { + act, + renderAsync, + screen, + userEvent, +} from '@testing-library/react-native'; +import TrackPlayer from 'react-native-track-player'; + +import ArchivedShowView from '@app/Schedule/ArchivedShowView'; +import { mockShow } from '../__mocks__/MockShows'; +import { getTrackPlayerTestApi, TestWrapper } from '@utils/TestUtils'; +import { SKIP_INTERVAL } from '@utils/TrackPlayerUtils'; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: () => ({ + params: { + show: mockShow, + archive: mockShow.archives[0], + }, + }), + }; +}); + +describe('ArchivedShowView', () => { + test('renders ArchivedShowView', async () => { + await renderAsync(, { wrapper: TestWrapper }); + + expect(screen.getByText(mockShow.name)).toBeTruthy(); + }); + + test('renders skip forward and back', async () => { + const user = userEvent.setup(); + + await renderAsync(, { wrapper: TestWrapper }); + + await user.press(await screen.findByLabelText('Play')); + + expect( + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), + ).toBeTruthy(); + + expect( + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), + ).toBeTruthy(); + }); +}); + +describe('ArchivedShowView skip buttons', () => { + test('skip forward works', async () => { + const user = userEvent.setup(); + + const { setDuration, setPosition } = getTrackPlayerTestApi(); + + await act(async () => { + setDuration(120); // 2 minutes + setPosition(40); // start at 40s + }); + + await renderAsync(, { wrapper: TestWrapper }); + + await user.press(await screen.findByLabelText('Play')); + + await user.press( + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), + ); + + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(70); + }); + + test('skip backward works', async () => { + const user = userEvent.setup(); + + const { setDuration, setPosition } = getTrackPlayerTestApi(); + + await act(async () => { + setDuration(120); // 2 minutes + setPosition(40); // start at 40s + }); + + await renderAsync(, { wrapper: TestWrapper }); + + await user.press(await screen.findByLabelText('Play')); + + await user.press( + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), + ); + + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(10); + }); + + test('skip forward is clamped to duration', async () => { + const user = userEvent.setup(); + + const { setDuration, setPosition } = getTrackPlayerTestApi(); + + await act(async () => { + setDuration(120); // 2 minutes + setPosition(110); // start at 110s + }); + + await renderAsync(, { wrapper: TestWrapper }); + + await user.press(await screen.findByLabelText('Play')); + + await user.press( + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), + ); + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(120); + }); + + test('skip backward is clamped to 0', async () => { + const user = userEvent.setup(); + + const { setDuration, setPosition } = getTrackPlayerTestApi(); + + await act(async () => { + setDuration(120); // 2 minutes + setPosition(10); // start at 10s + }); + + await renderAsync(, { wrapper: TestWrapper }); + + await user.press(await screen.findByLabelText('Play')); + + await user.press( + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), + ); + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(0); + }); +}); diff --git a/__tests__/PlayButton.test.tsx b/__tests__/PlayButton.test.tsx index 7c8ea7a..99c3ee8 100644 --- a/__tests__/PlayButton.test.tsx +++ b/__tests__/PlayButton.test.tsx @@ -1,5 +1,9 @@ import { render, screen, userEvent } from '@testing-library/react-native'; import PlayButton from '@app/Home/PlayButton'; +import { getTrackPlayerTestApi } from '@utils/TestUtils'; +import { State } from 'react-native-track-player'; + +const { setPlaybackState } = getTrackPlayerTestApi(); const mockOnPress = jest.fn(); @@ -38,10 +42,7 @@ describe('PlayButton', () => { }); test('shows stop icon when playing', () => { - // TODO: Test actual interaction, not mockReturnValue - const { usePlaybackState } = require('react-native-track-player'); - const { State } = require('react-native-track-player'); - usePlaybackState.mockReturnValue({ state: State.Playing }); + setPlaybackState(State.Playing); render(); diff --git a/jest.setup.ts b/jest.setup.ts index 6d4143a..c13a523 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -28,3 +28,15 @@ jest.mock('./src/utils/Debug.ts', () => ({ // Mock fetch at the network boundary instead of mocking services // This allows real service code to run in tests jest.spyOn(global, 'fetch').mockImplementation(createMockFetch()); + +// Mock useHeaderHeight from @react-navigation/elements used by header components +jest.mock('@react-navigation/elements', () => { + const actual = jest.requireActual( + '@react-navigation/elements', + ) as typeof import('@react-navigation/elements'); + + return { + ...actual, + useHeaderHeight: () => 0, + }; +}); diff --git a/src/app/Schedule/ArchivedShowView.tsx b/src/app/Schedule/ArchivedShowView.tsx index c711455..c6d0a13 100644 --- a/src/app/Schedule/ArchivedShowView.tsx +++ b/src/app/Schedule/ArchivedShowView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, @@ -219,6 +219,24 @@ export default function ArchivedShowView() { await TrackPlayer.seekTo(newPosition); }; + const playbackButtonLabel = useMemo( + () => + isArchivePlaying && playbackState?.state === State.Playing + ? 'Pause' + : 'Play', + [isArchivePlaying, playbackState?.state], + ); + + const playbackButtonIcon = useMemo( + () => + isArchivePlaying && playbackState?.state === State.Playing ? ( + + ) : ( + + ), + [isArchivePlaying, playbackState?.state], + ); + return ( <> @@ -259,12 +277,9 @@ export default function ArchivedShowView() { style={styles.playButton} onPress={handlePlayPause} activeOpacity={0.8} + accessibilityLabel={playbackButtonLabel} > - {isArchivePlaying && playbackState?.state === State.Playing ? ( - - ) : ( - - )} + {playbackButtonIcon} {isArchivePlaying && ( { - const api = require('react-native-track-player')?.default?.__testApi; + const api = require('react-native-track-player').testApi; if (!api) { throw new Error(