From 8b91d32d25bf762a71878203ba46161b2982f0ec Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sat, 22 Nov 2025 00:17:23 -0500 Subject: [PATCH 1/8] test: start adding tests for archive show page --- __mocks__/MockShows.ts | 31 ++++++++ __mocks__/react-native-track-player.ts | 5 ++ __tests__/ArchivedShowView.test.tsx | 98 ++++++++++++++++++++++++++ jest.setup.ts | 10 +++ 4 files changed, 144 insertions(+) create mode 100644 __mocks__/MockShows.ts create mode 100644 __tests__/ArchivedShowView.test.tsx 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..7d6811b 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -80,6 +80,11 @@ 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()), diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx new file mode 100644 index 0000000..2f2d865 --- /dev/null +++ b/__tests__/ArchivedShowView.test.tsx @@ -0,0 +1,98 @@ +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 { 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], + }, + }), + useNavigation: () => ({ + goBack: jest.fn(), + navigate: jest.fn(), + setOptions: jest.fn(), + }), + }; +}); + +describe('ArchivedShowView', () => { + // afterEach(async () => { + // // Reset TrackPlayer mock and ArchiveService to a neutral state between tests + // resetAll(); + // await act(async () => { + // await archiveService.switchToLive(); + // }); + // }); + + test('renders ArchivedShowView', async () => { + await renderAsync(, { 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 + await archiveService.playArchive(testArchive, mockShow); + + await renderAsync(, { wrapper: TestWrapper }); + + // Wait for the skip buttons to appear after service subscription updates + expect( + await screen.findByLabelText('Skip backward 30 seconds'), + ).toBeTruthy(); + expect( + await screen.findByLabelText('Skip forward 30 seconds'), + ).toBeTruthy(); + }); + + test('skip buttons modify TrackPlayer position', async () => { + const user = userEvent.setup(); + + // Arrange: make archive playing and set duration/position + await archiveService.playArchive(testArchive, mockShow); + const { setPlaybackState, setDuration, setPosition } = + getTrackPlayerTestApi(); + await act(async () => { + setPlaybackState(State.Playing); + setDuration(120); // 2 minutes + setPosition(40); // start at 40s + }); + + await renderAsync(, { wrapper: TestWrapper }); + + // Act: skip forward by SKIP_INTERVAL (30) -> expect 70 + await user.press(await screen.findByLabelText('Skip forward 30 seconds')); + expect(await TrackPlayer.getPosition()).toBe(70); + + // Act: skip backward by SKIP_INTERVAL (30) -> expect 40 (clamped) + await user.press(await screen.findByLabelText('Skip backward 30 seconds')); + expect(await TrackPlayer.getPosition()).toBe(40); + + // Edge cases: skip forward near end should clamp to duration + await act(async () => setPosition(110)); // 110 + 30 -> clamp to 120 + await user.press(await screen.findByLabelText('Skip forward 30 seconds')); + expect(await TrackPlayer.getPosition()).toBe(120); + + // Edge case: skip backward near start should clamp to 0 (or min allowed) + await act(async () => setPosition(10)); + await user.press(await screen.findByLabelText('Skip backward 30 seconds')); + expect(await TrackPlayer.getPosition()).toBe(0); + }); +}); diff --git a/jest.setup.ts b/jest.setup.ts index 6d4143a..ca17655 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -28,3 +28,13 @@ 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'); + return { + ...actual, + useHeaderHeight: () => 0, + }; +}); From a70fd8c72b7872f7b0af2d2a51bee31c6b79b89d Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sat, 22 Nov 2025 12:51:15 -0500 Subject: [PATCH 2/8] test: fix ArchiveShowView tests --- __mocks__/react-native-track-player.ts | 144 +++++++++++++++++-------- __tests__/ArchivedShowView.test.tsx | 12 ++- __tests__/PlayButton.test.tsx | 9 +- jest.setup.ts | 6 +- src/utils/TestUtils.tsx | 2 +- 5 files changed, 117 insertions(+), 56 deletions(-) diff --git a/__mocks__/react-native-track-player.ts b/__mocks__/react-native-track-player.ts index 7d6811b..a8293cf 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -1,71 +1,130 @@ -export const Event = { - PlaybackState: 'playback-state', - PlaybackProgressUpdated: 'playback-progress', - PlaybackQueueEnded: 'playback-queue-ended', -} as const; - -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 const State = { - Playing: 'PLAYING', - Stopped: 'STOPPED', - Paused: 'PAUSED', -} as const; +import { useEffect, useState } from 'react'; + +export enum Event { + PlaybackState = 'playback-state', + PlaybackProgressUpdated = 'playback-progress', + PlaybackQueueEnded = 'playback-queue-ended', +} + +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 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 = { +// listeners for the hook-based mocks +const progressListeners = new Set< + (progress: { position: number; duration: number }) => void +>(); + +const playbackStateListeners = new Set<(state: State) => void>(); + +function notifyProgress() { + const progress = { position, duration }; + progressListeners.forEach(fn => fn(progress)); +} + +function notifyPlaybackState() { + playbackStateListeners.forEach(fn => fn(playbackState)); +} + +export const testApi = { resetAll: () => { playbackState = State.Stopped; position = 0; duration = 0; initialized = false; queue = []; + // notify subscribers of reset + notifyProgress(); + notifyPlaybackState(); }, - setPlaybackState: (s: string) => { - playbackState = s; + setPlaybackState: (state: State) => { + playbackState = state; + notifyPlaybackState(); }, - setPosition: (sec: number) => { - position = sec; + setPosition: (seconds: number) => { + position = seconds; + notifyProgress(); }, - setDuration: (sec: number) => { - duration = sec; + setDuration: (seconds: number) => { + duration = seconds; + notifyProgress(); }, advance: (ms: number) => { position = Math.min(duration, position + ms / 1000); + notifyProgress(); }, }; +export const useProgress = () => { + const [progress, setProgress] = useState({ position, duration }); + + useEffect(() => { + const fn = (newProgress: { position: number; duration: number }) => + setProgress(newProgress); + + progressListeners.add(fn); + + return () => { + progressListeners.delete(fn); + }; + }, []); + + return progress; +}; + +export const usePlaybackState = () => { + const [state, setState] = useState(playbackState); + + useEffect(() => { + const fn = (newState: State) => setState(newState); + + playbackStateListeners.add(fn); + + return () => { + playbackStateListeners.delete(fn); + }; + }, []); + + return { state }; +}; + const TrackPlayer = { Event, State, Capability, - useProgress: jest.fn(() => ({ position, duration })), - usePlaybackState: jest.fn(() => ({ state: playbackState })), + useProgress, + usePlaybackState, setupPlayer: jest.fn(() => { // mark the mock as initialized so getPlaybackState will resolve initialized = true; + notifyPlaybackState(); return Promise.resolve(); }), updateOptions: jest.fn(() => Promise.resolve()), @@ -83,6 +142,8 @@ const TrackPlayer = { seekTo: jest.fn(async (sec: number) => { // clamp into [0, duration] and update internal position position = Math.max(0, Math.min(duration, sec)); + // notify listeners that position changed as a result of seeking + notifyProgress(); return Promise.resolve(); }), play: jest.fn(async () => Promise.resolve()), @@ -91,11 +152,6 @@ const TrackPlayer = { 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; -export const usePlaybackState = TrackPlayer.usePlaybackState; - export default TrackPlayer; diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx index 2f2d865..c2f9e15 100644 --- a/__tests__/ArchivedShowView.test.tsx +++ b/__tests__/ArchivedShowView.test.tsx @@ -67,8 +67,10 @@ describe('ArchivedShowView', () => { // Arrange: make archive playing and set duration/position await archiveService.playArchive(testArchive, mockShow); + const { setPlaybackState, setDuration, setPosition } = getTrackPlayerTestApi(); + await act(async () => { setPlaybackState(State.Playing); setDuration(120); // 2 minutes @@ -79,20 +81,20 @@ describe('ArchivedShowView', () => { // Act: skip forward by SKIP_INTERVAL (30) -> expect 70 await user.press(await screen.findByLabelText('Skip forward 30 seconds')); - expect(await TrackPlayer.getPosition()).toBe(70); + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(70); - // Act: skip backward by SKIP_INTERVAL (30) -> expect 40 (clamped) + // Act: skip backward by SKIP_INTERVAL (30) -> expect 40 (from 70 - 30) await user.press(await screen.findByLabelText('Skip backward 30 seconds')); - expect(await TrackPlayer.getPosition()).toBe(40); + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(40); // Edge cases: skip forward near end should clamp to duration await act(async () => setPosition(110)); // 110 + 30 -> clamp to 120 await user.press(await screen.findByLabelText('Skip forward 30 seconds')); - expect(await TrackPlayer.getPosition()).toBe(120); + expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(120); // Edge case: skip backward near start should clamp to 0 (or min allowed) await act(async () => setPosition(10)); await user.press(await screen.findByLabelText('Skip backward 30 seconds')); - expect(await TrackPlayer.getPosition()).toBe(0); + 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 ca17655..c13a523 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -29,10 +29,12 @@ jest.mock('./src/utils/Debug.ts', () => ({ // 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'); + const actual = jest.requireActual( + '@react-navigation/elements', + ) as typeof import('@react-navigation/elements'); + return { ...actual, useHeaderHeight: () => 0, diff --git a/src/utils/TestUtils.tsx b/src/utils/TestUtils.tsx index 4c98f75..d80dc0a 100644 --- a/src/utils/TestUtils.tsx +++ b/src/utils/TestUtils.tsx @@ -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( From fad66df2c12062799502ff694e8c94b33bfbdb70 Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sat, 22 Nov 2025 13:40:45 -0500 Subject: [PATCH 3/8] test: clean up ArchivedShowView test --- __tests__/ArchivedShowView.test.tsx | 34 +++++++++++++---------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx index c2f9e15..78d422d 100644 --- a/__tests__/ArchivedShowView.test.tsx +++ b/__tests__/ArchivedShowView.test.tsx @@ -9,6 +9,7 @@ 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(); @@ -24,23 +25,10 @@ jest.mock('@react-navigation/native', () => { archive: mockShow.archives[0], }, }), - useNavigation: () => ({ - goBack: jest.fn(), - navigate: jest.fn(), - setOptions: jest.fn(), - }), }; }); describe('ArchivedShowView', () => { - // afterEach(async () => { - // // Reset TrackPlayer mock and ArchiveService to a neutral state between tests - // resetAll(); - // await act(async () => { - // await archiveService.switchToLive(); - // }); - // }); - test('renders ArchivedShowView', async () => { await renderAsync(, { wrapper: TestWrapper }); @@ -55,10 +43,10 @@ describe('ArchivedShowView', () => { // Wait for the skip buttons to appear after service subscription updates expect( - await screen.findByLabelText('Skip backward 30 seconds'), + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), ).toBeTruthy(); expect( - await screen.findByLabelText('Skip forward 30 seconds'), + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), ).toBeTruthy(); }); @@ -80,21 +68,29 @@ describe('ArchivedShowView', () => { await renderAsync(, { wrapper: TestWrapper }); // Act: skip forward by SKIP_INTERVAL (30) -> expect 70 - await user.press(await screen.findByLabelText('Skip forward 30 seconds')); + await user.press( + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), + ); expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(70); // Act: skip backward by SKIP_INTERVAL (30) -> expect 40 (from 70 - 30) - await user.press(await screen.findByLabelText('Skip backward 30 seconds')); + await user.press( + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), + ); expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(40); // Edge cases: skip forward near end should clamp to duration await act(async () => setPosition(110)); // 110 + 30 -> clamp to 120 - await user.press(await screen.findByLabelText('Skip forward 30 seconds')); + await user.press( + await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), + ); expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(120); // Edge case: skip backward near start should clamp to 0 (or min allowed) await act(async () => setPosition(10)); - await user.press(await screen.findByLabelText('Skip backward 30 seconds')); + await user.press( + await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), + ); expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(0); }); }); From bd0db89c4327f687af2955fdfc02e42e537bec38 Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sun, 23 Nov 2025 10:20:47 -0500 Subject: [PATCH 4/8] test: revert track player mock complexity --- __mocks__/react-native-track-player.ts | 42 ++------------ __tests__/ArchivedShowView.test.tsx | 80 +++++++++++++++++++++----- 2 files changed, 72 insertions(+), 50 deletions(-) diff --git a/__mocks__/react-native-track-player.ts b/__mocks__/react-native-track-player.ts index a8293cf..bc7bdce 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -1,5 +1,3 @@ -import { useEffect, useState } from 'react'; - export enum Event { PlaybackState = 'playback-state', PlaybackProgressUpdated = 'playback-progress', @@ -82,45 +80,12 @@ export const testApi = { }, }; -export const useProgress = () => { - const [progress, setProgress] = useState({ position, duration }); - - useEffect(() => { - const fn = (newProgress: { position: number; duration: number }) => - setProgress(newProgress); - - progressListeners.add(fn); - - return () => { - progressListeners.delete(fn); - }; - }, []); - - return progress; -}; - -export const usePlaybackState = () => { - const [state, setState] = useState(playbackState); - - useEffect(() => { - const fn = (newState: State) => setState(newState); - - playbackStateListeners.add(fn); - - return () => { - playbackStateListeners.delete(fn); - }; - }, []); - - return { state }; -}; - const TrackPlayer = { Event, State, Capability, - useProgress, - usePlaybackState, + useProgress: jest.fn(() => ({ position, duration })), + usePlaybackState: jest.fn(() => ({ state: playbackState })), setupPlayer: jest.fn(() => { // mark the mock as initialized so getPlaybackState will resolve initialized = true; @@ -154,4 +119,7 @@ const TrackPlayer = { addEventListener: jest.fn(() => Promise.resolve()), }; +export const useProgress = TrackPlayer.useProgress; +export const usePlaybackState = TrackPlayer.usePlaybackState; + export default TrackPlayer; diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx index 78d422d..009b806 100644 --- a/__tests__/ArchivedShowView.test.tsx +++ b/__tests__/ArchivedShowView.test.tsx @@ -36,12 +36,20 @@ describe('ArchivedShowView', () => { }); test('renders skip forward and back', async () => { - // Drive the ArchiveService into a playing state using its public API + /** + * 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(, { wrapper: TestWrapper }); - // Wait for the skip buttons to appear after service subscription updates expect( await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), ).toBeTruthy(); @@ -49,16 +57,17 @@ describe('ArchivedShowView', () => { await screen.findByLabelText(`Skip forward ${SKIP_INTERVAL} seconds`), ).toBeTruthy(); }); +}); - test('skip buttons modify TrackPlayer position', async () => { +describe('ArchivedShowView skip buttons', () => { + test('skip forward works', async () => { const user = userEvent.setup(); - // Arrange: make archive playing and set duration/position - await archiveService.playArchive(testArchive, mockShow); - const { setPlaybackState, setDuration, setPosition } = getTrackPlayerTestApi(); + await archiveService.playArchive(testArchive, mockShow); + await act(async () => { setPlaybackState(State.Playing); setDuration(120); // 2 minutes @@ -67,27 +76,72 @@ describe('ArchivedShowView', () => { await renderAsync(, { wrapper: TestWrapper }); - // Act: skip forward by SKIP_INTERVAL (30) -> expect 70 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(, { wrapper: TestWrapper }); - // Act: skip backward by SKIP_INTERVAL (30) -> expect 40 (from 70 - 30) await user.press( await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), ); - expect(TrackPlayer.seekTo).toHaveBeenLastCalledWith(40); + 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(, { wrapper: TestWrapper }); - // Edge cases: skip forward near end should clamp to duration - await act(async () => setPosition(110)); // 110 + 30 -> clamp to 120 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(, { wrapper: TestWrapper }); - // Edge case: skip backward near start should clamp to 0 (or min allowed) - await act(async () => setPosition(10)); await user.press( await screen.findByLabelText(`Skip backward ${SKIP_INTERVAL} seconds`), ); From 6f967aaef820c758c093a4ee2d29454ef869c967 Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sun, 23 Nov 2025 10:21:53 -0500 Subject: [PATCH 5/8] fix: remove notify listeners --- __mocks__/react-native-track-player.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/__mocks__/react-native-track-player.ts b/__mocks__/react-native-track-player.ts index bc7bdce..032797d 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -35,22 +35,6 @@ let duration = 0; // seconds let initialized = false; let queue: any[] = []; -// listeners for the hook-based mocks -const progressListeners = new Set< - (progress: { position: number; duration: number }) => void ->(); - -const playbackStateListeners = new Set<(state: State) => void>(); - -function notifyProgress() { - const progress = { position, duration }; - progressListeners.forEach(fn => fn(progress)); -} - -function notifyPlaybackState() { - playbackStateListeners.forEach(fn => fn(playbackState)); -} - export const testApi = { resetAll: () => { playbackState = State.Stopped; @@ -58,25 +42,18 @@ export const testApi = { duration = 0; initialized = false; queue = []; - // notify subscribers of reset - notifyProgress(); - notifyPlaybackState(); }, setPlaybackState: (state: State) => { playbackState = state; - notifyPlaybackState(); }, setPosition: (seconds: number) => { position = seconds; - notifyProgress(); }, setDuration: (seconds: number) => { duration = seconds; - notifyProgress(); }, advance: (ms: number) => { position = Math.min(duration, position + ms / 1000); - notifyProgress(); }, }; @@ -89,7 +66,6 @@ const TrackPlayer = { setupPlayer: jest.fn(() => { // mark the mock as initialized so getPlaybackState will resolve initialized = true; - notifyPlaybackState(); return Promise.resolve(); }), updateOptions: jest.fn(() => Promise.resolve()), @@ -107,8 +83,6 @@ const TrackPlayer = { seekTo: jest.fn(async (sec: number) => { // clamp into [0, duration] and update internal position position = Math.max(0, Math.min(duration, sec)); - // notify listeners that position changed as a result of seeking - notifyProgress(); return Promise.resolve(); }), play: jest.fn(async () => Promise.resolve()), From d2230532fe9b2a3bdbf56dd01d033eb85d2dff17 Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sun, 23 Nov 2025 10:23:09 -0500 Subject: [PATCH 6/8] fix: revert var rename --- __mocks__/react-native-track-player.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__mocks__/react-native-track-player.ts b/__mocks__/react-native-track-player.ts index 032797d..8ad5dfe 100644 --- a/__mocks__/react-native-track-player.ts +++ b/__mocks__/react-native-track-player.ts @@ -46,11 +46,11 @@ export const testApi = { setPlaybackState: (state: State) => { playbackState = state; }, - setPosition: (seconds: number) => { - position = seconds; + setPosition: (sec: number) => { + position = sec; }, - setDuration: (seconds: number) => { - duration = seconds; + setDuration: (sec: number) => { + duration = sec; }, advance: (ms: number) => { position = Math.min(duration, position + ms / 1000); From 9e6bfd02f7a35e508971282e1e372e4b8c46f1f9 Mon Sep 17 00:00:00 2001 From: Jay Sitter Date: Sun, 23 Nov 2025 10:35:04 -0500 Subject: [PATCH 7/8] fix: add labels to archive playback button --- src/app/Schedule/ArchivedShowView.tsx | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) 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 && ( Date: Sun, 23 Nov 2025 10:35:09 -0500 Subject: [PATCH 8/8] test: simplify tests --- __tests__/ArchivedShowView.test.tsx | 55 ++++++++++------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/__tests__/ArchivedShowView.test.tsx b/__tests__/ArchivedShowView.test.tsx index 009b806..e84c92b 100644 --- a/__tests__/ArchivedShowView.test.tsx +++ b/__tests__/ArchivedShowView.test.tsx @@ -4,16 +4,12 @@ import { screen, userEvent, } from '@testing-library/react-native'; -import TrackPlayer, { State } from 'react-native-track-player'; +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'; -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'); @@ -36,23 +32,16 @@ describe('ArchivedShowView', () => { }); 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); + 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(); @@ -63,63 +52,59 @@ describe('ArchivedShowView skip buttons', () => { test('skip forward works', async () => { const user = userEvent.setup(); - const { setPlaybackState, setDuration, setPosition } = - getTrackPlayerTestApi(); - - await archiveService.playArchive(testArchive, mockShow); + const { setDuration, setPosition } = getTrackPlayerTestApi(); await act(async () => { - setPlaybackState(State.Playing); 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 { setPlaybackState, setDuration, setPosition } = - getTrackPlayerTestApi(); - - await archiveService.playArchive(testArchive, mockShow); + const { setDuration, setPosition } = getTrackPlayerTestApi(); await act(async () => { - setPlaybackState(State.Playing); 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 { setPlaybackState, setDuration, setPosition } = - getTrackPlayerTestApi(); - - await archiveService.playArchive(testArchive, mockShow); + const { setDuration, setPosition } = getTrackPlayerTestApi(); await act(async () => { - setPlaybackState(State.Playing); 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`), ); @@ -129,19 +114,17 @@ describe('ArchivedShowView skip buttons', () => { test('skip backward is clamped to 0', async () => { const user = userEvent.setup(); - const { setPlaybackState, setDuration, setPosition } = - getTrackPlayerTestApi(); - - await archiveService.playArchive(testArchive, mockShow); + const { setDuration, setPosition } = getTrackPlayerTestApi(); await act(async () => { - setPlaybackState(State.Playing); 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`), );