Skip to content

Commit 12e9e50

Browse files
Add Component Test Coverage (#1109)
* feat(testing): add component tests for key UI components Adds component tests for several key UI components using React Testing Library, as requested in issue #106. This change includes: - Unit tests for TimerDisplay, HrmTiles, and SpotifyDisplay components. - An integration test for the main Control Panel page. - Fixes to the Jest configuration to handle ES modules and provide necessary context providers. These tests improve coverage and help prevent future UI regressions. * chore(lint): fix linting errors Fixes Prettier formatting issues in the newly added test files. * chore(lint): fix linting errors Fixes Prettier formatting issues in the newly added test files. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 6ea8b16 commit 12e9e50

6 files changed

Lines changed: 433 additions & 1 deletion

File tree

jest.config.cjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,23 @@ const config = {
2727
'!**/node_modules/**',
2828
],
2929
transform: {
30+
'^.+\\.mjs$': 'babel-jest', // Added to handle .mjs files if any
3031
'^.+\\.(ts|tsx)$': [
3132
'ts-jest',
3233
{
3334
useESM: true,
3435
tsconfig: {
3536
module: 'ES2022',
36-
moduleResolution: 'node',
37+
moduleResolution: 'bundler', // bundler is a better choice for modern apps
3738
esModuleInterop: true,
3839
allowSyntheticDefaultImports: true,
3940
},
4041
},
4142
],
4243
},
44+
transformIgnorePatterns: [
45+
'/node_modules/(?!uuid)', // Ensure uuid is transformed
46+
],
4347
extensionsToTreatAsEsm: ['.ts', '.tsx'],
4448
moduleNameMapper: {
4549
'^(\\.{1,2}/.*)\\.js$': '$1',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/** @jest-environment jsdom */
2+
3+
import ControlPage from '@/app/client/control/page'
4+
import { WebSocketProvider } from '@/context/WebSocketContext'
5+
import { SessionProvider } from 'next-auth/react'
6+
import '@testing-library/jest-dom'
7+
import { render, screen, waitFor } from '@testing-library/react'
8+
import React from 'react'
9+
10+
// Mock child components that have complex internal logic
11+
jest.mock('@/app/client/control/components/TimerControls', () => ({
12+
__esModule: true,
13+
default: () => <div data-testid="mock-timer-controls">Timer Controls</div>,
14+
}))
15+
jest.mock('@/app/client/control/components/SpotifyControls', () => ({
16+
__esModule: true,
17+
default: () => (
18+
<div data-testid="mock-spotify-controls">Spotify Controls</div>
19+
),
20+
}))
21+
22+
describe('ControlPage Integration', () => {
23+
it('should render all child components within the providers', async () => {
24+
render(
25+
<SessionProvider session={null}>
26+
<WebSocketProvider>
27+
<ControlPage />
28+
</WebSocketProvider>
29+
</SessionProvider>
30+
)
31+
32+
// Wait for all components to be rendered, including dynamic ones
33+
await waitFor(() => {
34+
expect(screen.getByTestId('mock-timer-controls')).toBeInTheDocument()
35+
expect(screen.getByTestId('mock-spotify-controls')).toBeInTheDocument()
36+
})
37+
})
38+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/** @jest-environment jsdom */
2+
3+
import HrmTiles from '@/components/HrmTiles'
4+
import { useWebSocket } from '@/context/WebSocketContext'
5+
import '@testing-library/jest-dom'
6+
import { render, screen, within } from '@testing-library/react'
7+
8+
// Mock the context and child component for isolation
9+
jest.mock('@/context/WebSocketContext')
10+
jest.mock('@/components/HrTile', () => ({
11+
__esModule: true,
12+
default: ({ name, bpm }: { name: string; bpm: number | null }) => (
13+
<div data-testid="mock-hr-tile">
14+
<p>{name}</p>
15+
<p>{bpm === null ? 'Signal Drop' : bpm}</p>
16+
</div>
17+
),
18+
}))
19+
20+
const mockedUseWebSocket = useWebSocket as jest.Mock
21+
22+
describe('HrmTiles', () => {
23+
beforeEach(() => {
24+
jest.resetAllMocks()
25+
})
26+
27+
it('should render HRM data correctly for a user', () => {
28+
mockedUseWebSocket.mockReturnValue({
29+
hrmData: [{ clientId: 'user1', name: 'Ariel', value: 150 }],
30+
connectionStatus: 'Connected',
31+
activeAlerts: [],
32+
})
33+
34+
render(<HrmTiles />)
35+
36+
const tile = screen.getByTestId('mock-hr-tile')
37+
expect(within(tile).getByText('Ariel')).toBeInTheDocument()
38+
expect(within(tile).getByText('150')).toBeInTheDocument()
39+
})
40+
41+
it('should render "Signal Drop" when value is null', () => {
42+
mockedUseWebSocket.mockReturnValue({
43+
hrmData: [{ clientId: 'user1', name: 'Ariel', value: null }],
44+
connectionStatus: 'Connected',
45+
activeAlerts: [],
46+
})
47+
48+
render(<HrmTiles />)
49+
50+
const tile = screen.getByTestId('mock-hr-tile')
51+
expect(within(tile).getByText('Ariel')).toBeInTheDocument()
52+
expect(within(tile).getByText('Signal Drop')).toBeInTheDocument()
53+
})
54+
55+
it('should render skeleton containers when hrmData is empty', () => {
56+
mockedUseWebSocket.mockReturnValue({
57+
hrmData: [],
58+
connectionStatus: 'Connected',
59+
activeAlerts: [],
60+
})
61+
62+
render(<HrmTiles />)
63+
64+
// The component renders skeleton containers when there's no data
65+
expect(screen.getAllByTestId('hr-tile-grid-item')).toHaveLength(2)
66+
// And no actual HrTile components are rendered
67+
expect(screen.queryByTestId('mock-hr-tile')).not.toBeInTheDocument()
68+
})
69+
70+
it('should render skeleton containers when connection status is not "Connected"', () => {
71+
mockedUseWebSocket.mockReturnValue({
72+
hrmData: [{ clientId: 'user1', name: 'Ariel', value: 150 }],
73+
connectionStatus: 'Connecting...',
74+
activeAlerts: [],
75+
})
76+
77+
render(<HrmTiles />)
78+
79+
expect(screen.getAllByTestId('hr-tile-grid-item')).toHaveLength(2)
80+
expect(screen.queryByTestId('mock-hr-tile')).not.toBeInTheDocument()
81+
})
82+
83+
it('should filter out users with placeholder names or a value of 0', () => {
84+
mockedUseWebSocket.mockReturnValue({
85+
hrmData: [
86+
{ clientId: 'user1', name: 'new user (1)', value: 120 },
87+
{ clientId: 'user2', name: 'Ariel', value: 0 },
88+
{ clientId: 'user3', name: 'Valid User', value: 130 },
89+
],
90+
connectionStatus: 'Connected',
91+
activeAlerts: [],
92+
})
93+
94+
render(<HrmTiles />)
95+
96+
// Only the 'Valid User' tile should be rendered
97+
const tiles = screen.getAllByTestId('mock-hr-tile')
98+
expect(tiles).toHaveLength(1)
99+
expect(within(tiles[0]).getByText('Valid User')).toBeInTheDocument()
100+
expect(within(tiles[0]).getByText('130')).toBeInTheDocument()
101+
})
102+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** @jest-environment jsdom */
2+
3+
import PlaylistSelector from '@/components/Spotify/PlaylistSelector'
4+
import '@testing-library/jest-dom'
5+
import { render, screen, waitFor, within } from '@testing-library/react'
6+
import userEvent from '@testing-library/user-event'
7+
8+
const mockPlaylists = {
9+
presetPlaylists: [
10+
{ id: '1', name: 'Chill Hits', uri: 'spotify:playlist:1' },
11+
{ id: '2', name: 'Rock Classics', uri: 'spotify:playlist:2' },
12+
],
13+
userPlaylists: [{ id: '3', name: 'Focus Flow', uri: 'spotify:playlist:3' }],
14+
}
15+
16+
describe('PlaylistSelector', () => {
17+
beforeEach(() => {
18+
global.fetch = jest.fn(() =>
19+
Promise.resolve({
20+
ok: true,
21+
json: () => Promise.resolve(mockPlaylists),
22+
})
23+
) as jest.Mock
24+
})
25+
26+
it('should fetch and display playlists on render', async () => {
27+
render(
28+
<PlaylistSelector
29+
onPlaylistSelected={jest.fn()}
30+
onPlaylistPlay={jest.fn()}
31+
/>
32+
)
33+
34+
// Wait for the playlists to be fetched and rendered
35+
await waitFor(() => {
36+
expect(screen.getByText('Chill Hits')).toBeInTheDocument()
37+
expect(screen.getByText('Rock Classics')).toBeInTheDocument()
38+
expect(screen.getByText('Focus Flow')).toBeInTheDocument()
39+
})
40+
})
41+
42+
it('should call onPlaylistSelected with the correct URI when a playlist is selected from the list', async () => {
43+
const onPlaylistSelected = jest.fn()
44+
render(
45+
<PlaylistSelector
46+
onPlaylistSelected={onPlaylistSelected}
47+
onPlaylistPlay={jest.fn()}
48+
/>
49+
)
50+
const user = userEvent.setup()
51+
52+
const rockClassicsItem = await screen.findByText('Rock Classics')
53+
await user.click(rockClassicsItem)
54+
55+
// Verify the callback was called with the correct URI
56+
expect(onPlaylistSelected).toHaveBeenCalledWith('spotify:playlist:2')
57+
})
58+
59+
it('should call onPlaylistPlay with the correct URI when the play button is clicked', async () => {
60+
const onPlaylistPlay = jest.fn()
61+
render(
62+
<PlaylistSelector
63+
onPlaylistSelected={jest.fn()}
64+
onPlaylistPlay={onPlaylistPlay}
65+
/>
66+
)
67+
const user = userEvent.setup()
68+
69+
const focusFlowItem = await screen.findByText('Focus Flow')
70+
const listItem = focusFlowItem.closest('li')
71+
if (!listItem) throw new Error('Playlist item not found')
72+
73+
const playButton = within(listItem).getByRole('button', { name: /play/i })
74+
75+
await user.click(playButton)
76+
77+
expect(onPlaylistPlay).toHaveBeenCalledWith('spotify:playlist:3')
78+
})
79+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/** @jest-environment jsdom */
2+
3+
// Mock the uuid module at the top level BEFORE any other imports
4+
jest.mock('uuid', () => ({
5+
v4: () => 'mock-uuid-1234',
6+
}))
7+
8+
import SpotifyDisplay from '@/components/SpotifyDisplay'
9+
import { ErrorProvider } from '@/context/ErrorContext'
10+
import { useWebSocket } from '@/context/WebSocketContext'
11+
import useSpotifyWebPlayback from '@/hooks/useSpotifyWebPlayback'
12+
import '@testing-library/jest-dom'
13+
import { render, screen, waitFor } from '@testing-library/react'
14+
import { useSession } from 'next-auth/react'
15+
import React from 'react'
16+
17+
// Mock child components and dependencies
18+
jest.mock('@/components/SpotifyLoginButton', () => ({
19+
__esModule: true,
20+
default: () => <button>Login with Spotify</button>,
21+
}))
22+
jest.mock('@/context/WebSocketContext')
23+
jest.mock('next-auth/react')
24+
jest.mock('@/hooks/useSpotifyWebPlayback', () => ({
25+
__esModule: true,
26+
default: jest.fn(),
27+
}))
28+
29+
const mockedUseWebSocket = useWebSocket as jest.Mock
30+
const mockedUseSession = useSession as jest.Mock
31+
const mockedUseSpotifyWebPlayback = useSpotifyWebPlayback as jest.Mock
32+
33+
// Custom renderer to wrap component with required providers
34+
const renderWithProviders = (ui: React.ReactElement) => {
35+
return render(<ErrorProvider>{ui}</ErrorProvider>)
36+
}
37+
38+
describe('SpotifyDisplay', () => {
39+
beforeEach(() => {
40+
jest.resetAllMocks()
41+
mockedUseSpotifyWebPlayback.mockReturnValue({
42+
isReady: true,
43+
deviceId: 'mock-device-id',
44+
player: null,
45+
isAuthenticated: true,
46+
})
47+
global.fetch = jest.fn(() =>
48+
Promise.resolve({
49+
ok: true,
50+
json: () => Promise.resolve([]),
51+
})
52+
) as jest.Mock
53+
})
54+
55+
it('should render the login button when not logged in', async () => {
56+
mockedUseSession.mockReturnValue({ data: null, status: 'unauthenticated' })
57+
mockedUseWebSocket.mockReturnValue({
58+
spotifyData: { trackName: '', artist: '', isPlaying: false },
59+
spotifyServiceInitialized: true,
60+
})
61+
mockedUseSpotifyWebPlayback.mockReturnValue({
62+
isAuthenticated: false,
63+
})
64+
65+
renderWithProviders(<SpotifyDisplay />)
66+
67+
await waitFor(() => {
68+
expect(
69+
screen.getByRole('button', { name: /login with spotify/i })
70+
).toBeInTheDocument()
71+
})
72+
})
73+
74+
it('should render "No Active Playback" when logged in but trackName is "Awaiting Login..."', async () => {
75+
mockedUseSession.mockReturnValue({
76+
data: { accessToken: 'fake-token' },
77+
status: 'authenticated',
78+
})
79+
mockedUseWebSocket.mockReturnValue({
80+
spotifyData: {
81+
trackName: 'Awaiting Login...',
82+
artist: '',
83+
isPlaying: false,
84+
},
85+
spotifyServiceInitialized: true,
86+
})
87+
88+
renderWithProviders(<SpotifyDisplay />)
89+
90+
await waitFor(() => {
91+
expect(screen.getByText('No Active Playback')).toBeInTheDocument()
92+
})
93+
})
94+
95+
it('should render the track name and artist when a track is playing', async () => {
96+
mockedUseSession.mockReturnValue({
97+
data: { accessToken: 'fake-token' },
98+
status: 'authenticated',
99+
})
100+
mockedUseWebSocket.mockReturnValue({
101+
spotifyData: {
102+
trackName: 'Test Track',
103+
artist: 'Test Artist',
104+
isPlaying: true,
105+
},
106+
spotifyServiceInitialized: true,
107+
})
108+
109+
renderWithProviders(<SpotifyDisplay />)
110+
111+
await waitFor(() => {
112+
expect(screen.getByText(/Test Track Test Artist/i)).toBeInTheDocument()
113+
})
114+
})
115+
})

0 commit comments

Comments
 (0)