Skip to content
Merged
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
110 changes: 110 additions & 0 deletions __tests__/ClubPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react-native';
import { ClubPicker } from '../components/ClubPicker';
import { socketService } from '../services/socket';
import { useSessionStore } from '../stores/useSessionStore';
import type { ConnectionState } from '../types';

jest.mock(
'react-native-safe-area-context',
() => require('react-native-safe-area-context/jest/mock').default,
);

// The socket service is exercised directly in socket.test.ts; here we only care
// that the picker wires the user's choice to it.
jest.mock('../services/socket', () => ({
socketService: { setClub: jest.fn() },
}));

const mockedSocket = socketService as jest.Mocked<typeof socketService>;

// render() and fireEvent are asynchronous in React Native Testing Library 14;
// every call is awaited so state is committed before the next assertion.
async function renderPicker(connectionState: ConnectionState, club: string | null) {
useSessionStore.setState({ connectionState, club });
await render(<ClubPicker />);
}

beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
cleanup();
});

describe('ClubPicker', () => {
it('shows the club the server is filing shots under', async () => {
await renderPicker('connected', '7-iron');

expect(screen.getByLabelText('Club: 7 Iron. Change club')).toBeTruthy();
});

it('says so when the server has not reported a club', async () => {
await renderPicker('connected', null);

expect(screen.getByLabelText('Club: not set. Change club')).toBeTruthy();
});

it('lists every club by type and marks the current one', async () => {
await renderPicker('connected', '7-iron');

await fireEvent.press(screen.getByLabelText('Club: 7 Iron. Change club'));

expect(screen.getByText('Irons')).toBeTruthy();
expect(screen.getByText('Hybrids')).toBeTruthy();
expect(screen.getByText('Woods')).toBeTruthy();
expect(screen.getByRole('button', { name: '7 Iron', selected: true })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Driver', selected: false })).toBeTruthy();
});

it('asks the server for the picked club and closes', async () => {
await renderPicker('connected', 'driver');
await fireEvent.press(screen.getByLabelText('Club: Driver. Change club'));

await fireEvent.press(screen.getByRole('button', { name: 'Pitching Wedge' }));

expect(mockedSocket.setClub).toHaveBeenCalledWith('pw');
expect(screen.queryByText('Irons')).toBeNull();
});

it('keeps showing the current club until the server confirms the change', async () => {
await renderPicker('connected', 'driver');
await fireEvent.press(screen.getByLabelText('Club: Driver. Change club'));

await fireEvent.press(screen.getByRole('button', { name: 'Pitching Wedge' }));

expect(screen.getByLabelText('Club: Driver. Change club')).toBeTruthy();
});

it('closes without a change', async () => {
await renderPicker('connected', 'driver');
await fireEvent.press(screen.getByLabelText('Club: Driver. Change club'));

await fireEvent.press(screen.getByRole('button', { name: 'Close club list' }));

expect(mockedSocket.setClub).not.toHaveBeenCalled();
expect(screen.queryByText('Irons')).toBeNull();
});

it.each<ConnectionState>(['disconnected', 'connecting', 'error'])(
'cannot be changed while %s',
async (connectionState) => {
// A pick that cannot reach the server must not look as though it did.
await renderPicker(connectionState, 'driver');
const trigger = screen.getByLabelText('Club: Driver. Change club');

expect(trigger).toBeDisabled();
await fireEvent.press(trigger);
expect(screen.queryByText('Irons')).toBeNull();
},
);

it('closes the list if the connection drops while it is open', async () => {
await renderPicker('connected', 'driver');
await fireEvent.press(screen.getByLabelText('Club: Driver. Change club'));

await act(() => useSessionStore.setState({ connectionState: 'disconnected' }));

expect(screen.queryByText('Irons')).toBeNull();
});
});
3 changes: 2 additions & 1 deletion __tests__/LiveScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ describe.each(['dark', 'light'])('Live screen in %s mode', (scheme) => {
});

it('shows the latest shot once connected', async () => {
useSessionStore.setState({ connectionState: 'connected', shots: [shot] });
useSessionStore.setState({ connectionState: 'connected', shots: [shot], club: '7-iron' });

await render(<LiveScreen />);

expect(screen.getByText('Simulate Shot')).toBeTruthy();
expect(screen.getByLabelText('Club: 7 Iron. Change club')).toBeTruthy();
expect(screen.getByText('152.4')).toBeTruthy();
expect(screen.getByText('241')).toBeTruthy();
expect(screen.getByText('2,650')).toBeTruthy();
Expand Down
141 changes: 138 additions & 3 deletions __tests__/socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ jest.mock('socket.io-client', () => {
const handlers: Record<string, (...args: unknown[]) => void> = {};
const emit = jest.fn();
const close = jest.fn();
// Mirrors Socket.IO's `socket.connected`; `trigger` flips it alongside the
// connect/disconnect events it fires.
const status = { connected: false };
const io = jest.fn((_url: string, _opts?: unknown) => ({
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = cb;
},
emit,
close,
get connected() {
return status.connected;
},
}));
return { io, __mock: { handlers, emit, close } };
return { io, __mock: { handlers, emit, close, status } };
});

jest.mock('@react-native-async-storage/async-storage', () =>
Expand Down Expand Up @@ -52,12 +58,20 @@ const socketMock = jest.requireMock('socket.io-client') as {
handlers: Record<string, (...args: unknown[]) => void>;
emit: jest.Mock;
close: jest.Mock;
status: { connected: boolean };
};
};
const { io: mockIo } = socketMock;
const { emit: mockEmit, close: mockClose, handlers: mockHandlers } = socketMock.__mock;
const {
emit: mockEmit,
close: mockClose,
handlers: mockHandlers,
status: mockStatus,
} = socketMock.__mock;

function trigger(event: string, ...args: unknown[]) {
if (event === 'connect') mockStatus.connected = true;
if (event === 'disconnect' || event === 'connect_error') mockStatus.connected = false;
mockHandlers[event]?.(...args);
}

Expand Down Expand Up @@ -89,8 +103,14 @@ function makeShot(timestamp: string, overrides: Partial<Shot> = {}): Shot {
}

beforeEach(() => {
useSessionStore.setState({ connectionState: 'disconnected', sessionId: null, shots: [] });
useSessionStore.setState({
connectionState: 'disconnected',
sessionId: null,
shots: [],
club: null,
});
for (const key of Object.keys(mockHandlers)) delete mockHandlers[key];
mockStatus.connected = false;
mockIo.mockClear();
mockEmit.mockClear();
mockClose.mockClear();
Expand Down Expand Up @@ -336,3 +356,118 @@ describe('a shot the server enriches after publishing it', () => {
expect(useSessionStore.getState().shots[0].spin_rpm).toBe(2680);
});
});

describe('the selected club', () => {
it('takes the club the server restores on connect', () => {
socketService.connect('http://host:8080');
trigger('connect');

trigger('session_state', { shots: [], club: '7-iron' });

expect(useSessionStore.getState().club).toBe('7-iron');
});

it('keeps the club when a session snapshot does not carry one', () => {
// An older server's session_state has no club key; that is not a reset.
socketService.connect('http://host:8080');
trigger('connect');
trigger('session_state', { shots: [], club: '7-iron' });

trigger('session_state', { shots: [] });

expect(useSessionStore.getState().club).toBe('7-iron');
});

it('follows a change made on another client', () => {
socketService.connect('http://host:8080');
trigger('connect');

trigger('club_changed', { club: 'pw' });

expect(useSessionStore.getState().club).toBe('pw');
});

it('ignores a malformed club change', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('club_changed', { club: 'pw' });

trigger('club_changed', {});
trigger('club_changed', { club: 7 });
trigger('club_changed', null);

expect(useSessionStore.getState().club).toBe('pw');
});

it('asks the server to change club while connected', () => {
socketService.connect('http://host:8080');
trigger('connect');

socketService.setClub('5-wood');

expect(mockEmit).toHaveBeenCalledWith('set_club', { club: '5-wood' });
});

it('waits for the server to confirm before showing the new club', () => {
// The server ignores a club it does not recognise without replying, so a
// local change would show a club that shots are not filed under.
socketService.connect('http://host:8080');
trigger('connect');
trigger('session_state', { shots: [], club: 'driver' });

socketService.setClub('5-wood');

expect(useSessionStore.getState().club).toBe('driver');
});

it('sends nothing before a connection is established', () => {
socketService.connect('http://host:8080');

socketService.setClub('5-wood');

expect(mockEmit).not.toHaveBeenCalledWith('set_club', expect.anything());
});

it('sends nothing during a transient drop, even once reconnected', () => {
// Socket.IO keeps the socket through a wifi drop and would buffer the emit
// for replay on reconnect, filing later shots under a club picked earlier.
socketService.connect('http://host:8080');
trigger('connect');
trigger('disconnect');

socketService.setClub('5-wood');
trigger('connect');

expect(mockEmit).not.toHaveBeenCalledWith('set_club', expect.anything());
});

it('keeps the club through a transient drop', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('club_changed', { club: 'pw' });

trigger('disconnect');

expect(useSessionStore.getState().club).toBe('pw');
});

it('forgets the club when the user disconnects deliberately', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('club_changed', { club: 'pw' });

socketService.disconnect();

expect(useSessionStore.getState().club).toBeNull();
});

it('forgets the club when switching to a different server', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('club_changed', { club: 'pw' });

socketService.connect('http://other:8080');

expect(useSessionStore.getState().club).toBeNull();
});
});
2 changes: 2 additions & 0 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Keyboard, StyleSheet, TouchableWithoutFeedback, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionStore } from '../../stores/useSessionStore';
import { ClubPicker } from '../../components/ClubPicker';
import { ConnectionBar } from '../../components/ConnectionBar';
import { CurrentShotView } from '../../components/CurrentShotView';
import { spacing, type Palette } from '../../components/theme/tokens';
Expand All @@ -20,6 +21,7 @@ export default function LiveScreen() {
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inner}>
<ConnectionBar />
<ClubPicker />
<CurrentShotView shot={latestShot} />
</View>
</TouchableWithoutFeedback>
Expand Down
Loading
Loading