Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
150 changes: 150 additions & 0 deletions __tests__/ArchivedShowView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
act,
renderAsync,
screen,
userEvent,
} from '@testing-library/react-native';
import TrackPlayer, { State } 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';
import { ArchiveService } from '@services/ArchiveService';

const archiveService = ArchiveService.getInstance();
const testArchive = mockShow.archives[0];

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 () => {
/**
* Drive the ArchiveService into a playing state using its public API.
*
* This is necessary so that `isArchivePlaying` is true in the component,
* causing the skip buttons to appear.
*
* Technically this only needs to be done once for the whole test suite, but
* I'm putting it in each test so that each test is more self-contained.
*
*/
await archiveService.playArchive(testArchive, mockShow);

await renderAsync(<ArchivedShowView />, { wrapper: TestWrapper });

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 { setPlaybackState, setDuration, setPosition } =
getTrackPlayerTestApi();

await archiveService.playArchive(testArchive, mockShow);

await act(async () => {
setPlaybackState(State.Playing);
setDuration(120); // 2 minutes
setPosition(40); // start at 40s
});

await renderAsync(<ArchivedShowView />, { wrapper: TestWrapper });

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 { setPlaybackState, setDuration, setPosition } =
getTrackPlayerTestApi();

await archiveService.playArchive(testArchive, mockShow);

await act(async () => {
setPlaybackState(State.Playing);
setDuration(120); // 2 minutes
setPosition(40); // start at 40s
});

await renderAsync(<ArchivedShowView />, { wrapper: TestWrapper });

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 { setPlaybackState, setDuration, setPosition } =
getTrackPlayerTestApi();

await archiveService.playArchive(testArchive, mockShow);

await act(async () => {
setPlaybackState(State.Playing);
setDuration(120); // 2 minutes
setPosition(110); // start at 110s
});

await renderAsync(<ArchivedShowView />, { wrapper: TestWrapper });

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 { setPlaybackState, setDuration, setPosition } =
getTrackPlayerTestApi();

await archiveService.playArchive(testArchive, mockShow);

await act(async () => {
setPlaybackState(State.Playing);
setDuration(120); // 2 minutes
setPosition(10); // start at 10s
});

await renderAsync(<ArchivedShowView />, { wrapper: TestWrapper });

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,
};
});
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