Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions __mocks__/MockShows.ts
Original file line number Diff line number Diff line change
@@ -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',
},
],
};
69 changes: 36 additions & 33 deletions __mocks__/react-native-track-player.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,50 @@
export const Event = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These all make more sense as enums (that's what they are in React Native Track Player itself) and allows us to type things better in tests.

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;
duration = 0;
initialized = false;
queue = [];
},
setPlaybackState: (s: string) => {
playbackState = s;
setPlaybackState: (state: State) => {
playbackState = state;
},
setPosition: (sec: number) => {
position = sec;
Expand Down Expand Up @@ -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;
Expand Down
133 changes: 133 additions & 0 deletions __tests__/ArchivedShowView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ArchivedShowView />, { wrapper: TestWrapper });

expect(screen.getByText(mockShow.name)).toBeTruthy();
});

test('renders skip forward and back', async () => {
const user = userEvent.setup();

await renderAsync(<ArchivedShowView />, { 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();
});
Comment on lines +27 to +48
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests should verify that the button toggles between "Play" and "Pause" labels based on playback state. The component has playbackButtonLabel logic that changes the accessibility label between "Play" and "Pause", but there's no test coverage for this toggle behavior. Consider adding a test that:

  1. Sets the playback state to State.Playing using setPlaybackState
  2. Renders the component
  3. Verifies the button shows "Pause" label
  4. Presses the button to pause
  5. Verifies the button shows "Play" label again

Copilot uses AI. Check for mistakes.
});

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(<ArchivedShowView />, { 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(<ArchivedShowView />, { 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(<ArchivedShowView />, { 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(<ArchivedShowView />, { 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);
});
});
9 changes: 5 additions & 4 deletions __tests__/PlayButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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(<TestComponent />);

Expand Down
12 changes: 12 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
27 changes: 21 additions & 6 deletions src/app/Schedule/ArchivedShowView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
Text,
Expand Down Expand Up @@ -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 ? (
<Icon name="pause-circle" size={64} color="#FFFFFF" />
) : (
<Icon name="play-circle" size={64} color="#FFFFFF" />
),
[isArchivePlaying, playbackState?.state],
);

return (
<>
<StatusBar barStyle="light-content" backgroundColor={gradientStart} />
Expand Down Expand Up @@ -259,12 +277,9 @@ export default function ArchivedShowView() {
style={styles.playButton}
onPress={handlePlayPause}
activeOpacity={0.8}
accessibilityLabel={playbackButtonLabel}
>
{isArchivePlaying && playbackState?.state === State.Playing ? (
<Icon name="pause-circle" size={64} color="#FFFFFF" />
) : (
<Icon name="play-circle" size={64} color="#FFFFFF" />
)}
{playbackButtonIcon}
</TouchableOpacity>
{isArchivePlaying && (
<TouchableOpacity
Expand Down
2 changes: 1 addition & 1 deletion src/utils/TestUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function generateScheduleXml(options?: {
// Test helpers for driving the react-native-track-player mock from tests.
// These access the __testApi exported by the mock in __mocks__/react-native-track-player.ts.
export const getTrackPlayerTestApi = () => {
const api = require('react-native-track-player')?.default?.__testApi;
const api = require('react-native-track-player').testApi;

if (!api) {
throw new Error(
Expand Down