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(