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
196 changes: 195 additions & 1 deletion __tests__/socket.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { socketService } from '../services/socket';
import { useDeviceStore } from '../stores/useDeviceStore';
import { useSessionStore } from '../stores/useSessionStore';
import type { Shot } from '../types';
import { useProfileStore } from '../stores/useProfileStore';
import type { Profile, ProfilesSnapshot, Shot } from '../types';

// Fake Socket.IO socket. The fake is built *inside* the mock factory (not
// captured from an outer const) so it exists by the time `services/socket`
Expand Down Expand Up @@ -111,6 +112,9 @@ beforeEach(() => {
club: null,
});
useDeviceStore.getState().reset();
// The profile store is a module singleton too; without this a roster can
// survive into the next test and let an assertion pass for the wrong reason.
useProfileStore.getState().reset();
for (const key of Object.keys(mockHandlers)) delete mockHandlers[key];
mockStatus.connected = false;
mockIo.mockClear();
Expand Down Expand Up @@ -695,3 +699,193 @@ describe('the selected club', () => {
expect(useSessionStore.getState().club).toBeNull();
});
});

function makeProfile(overrides: Partial<Profile> = {}): Profile {
return {
id: 'p1',
name: 'Alex',
created_at: '2026-09-14T10:00:00Z',
settings: {},
...overrides,
};
}

function makeSnapshot(overrides: Partial<ProfilesSnapshot> = {}): ProfilesSnapshot {
return { profiles: [makeProfile()], active_profile_id: 'p1', ...overrides };
}

describe('the profile roster', () => {
it('asks for the roster once connected', () => {
// It does not ride along on session_state, so it has to be asked for.
socketService.connect('http://host:8080');
trigger('connect');

expect(mockEmit).toHaveBeenCalledWith('get_profiles');
});

it('asks again after reconnecting', () => {
// Profiles can be added or renamed on the kiosk while the phone is away.
socketService.connect('http://host:8080');
trigger('connect');
trigger('disconnect');
mockEmit.mockClear();

trigger('connect');

expect(mockEmit).toHaveBeenCalledWith('get_profiles');
});

it('mirrors the roster the server broadcast', () => {
socketService.connect('http://host:8080');
trigger('connect');

trigger('profiles', {
profiles: [makeProfile({ id: 'p1', name: 'Alex' }), makeProfile({ id: 'p2', name: 'Sam' })],
active_profile_id: 'p2',
});

const state = useProfileStore.getState();
expect(state.profiles.map((profile) => profile.name)).toEqual(['Alex', 'Sam']);
expect(state.activeProfileId).toBe('p2');
expect(state.loaded).toBe(true);
});

it('keeps the roster through a transient drop', () => {
// Socket.IO reconnects on its own; blanking the picker on every wifi
// hiccup would be worse than showing one the next snapshot replaces.
socketService.connect('http://host:8080');
trigger('connect');
trigger('profiles', makeSnapshot());

trigger('disconnect');

expect(useProfileStore.getState().profiles).toHaveLength(1);
});

it('forgets the roster when the user disconnects deliberately', () => {
// A roster from the previous server must not linger as though current.
socketService.connect('http://host:8080');
trigger('connect');
trigger('profiles', makeSnapshot());

socketService.disconnect();

const state = useProfileStore.getState();
expect(state.profiles).toEqual([]);
expect(state.loaded).toBe(false);
});

it('forgets the roster when switching to a different server', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('profiles', makeSnapshot());

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

const state = useProfileStore.getState();
expect(state.profiles).toEqual([]);
expect(state.loaded).toBe(false);
});

it('keeps the roster when retrying the same server', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('profiles', makeSnapshot());
trigger('connect_error');

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

expect(useProfileStore.getState().profiles).toHaveLength(1);
});

it('keeps the last good roster when a malformed snapshot arrives', () => {
socketService.connect('http://host:8080');
trigger('connect');
trigger('profiles', makeSnapshot());

trigger('profiles', { profiles: undefined });

expect(useProfileStore.getState().profiles).toHaveLength(1);
});

it('applies a repeated snapshot without accumulating the roster', () => {
// The server rebroadcasts after every mutation, so the same roster arrives
// repeatedly; each one replaces rather than appends.
socketService.connect('http://host:8080');
trigger('connect');

trigger('profiles', makeSnapshot());
trigger('profiles', makeSnapshot());

expect(useProfileStore.getState().profiles).toHaveLength(1);
});
});

describe('changing the roster', () => {
beforeEach(() => {
socketService.connect('http://host:8080');
trigger('connect');
mockEmit.mockClear();
});

it('selects a profile by id', () => {
socketService.setActiveProfile('p2');
expect(mockEmit).toHaveBeenCalledWith('set_active_profile', { profile_id: 'p2' });
});

it('adds a profile by name', () => {
socketService.addProfile('Sam');
expect(mockEmit).toHaveBeenCalledWith('add_profile', { name: 'Sam' });
});

it('renames a profile', () => {
socketService.renameProfile('p1', 'Alexandra');
expect(mockEmit).toHaveBeenCalledWith('rename_profile', {
profile_id: 'p1',
name: 'Alexandra',
});
});

it('removes a profile', () => {
socketService.removeProfile('p2');
expect(mockEmit).toHaveBeenCalledWith('remove_profile', { profile_id: 'p2' });
});

it('sends nothing when there is no connection', () => {
// A screen can still be mounted after a disconnect; emitting into a closed
// socket would be silently lost, so nothing is sent at all.
socketService.disconnect();
mockEmit.mockClear();

socketService.setActiveProfile('p2');

expect(mockEmit).not.toHaveBeenCalled();
});

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, landing a stale selection that decides who the
// next shots are filed under.
trigger('disconnect');

socketService.setActiveProfile('p2');
socketService.addProfile('Sam');
socketService.renameProfile('p1', 'Alexandra');
socketService.removeProfile('p2');
trigger('connect');

expect(mockEmit).not.toHaveBeenCalledWith('set_active_profile', expect.anything());
expect(mockEmit).not.toHaveBeenCalledWith('add_profile', expect.anything());
expect(mockEmit).not.toHaveBeenCalledWith('rename_profile', expect.anything());
expect(mockEmit).not.toHaveBeenCalledWith('remove_profile', expect.anything());
});

it('sends again once the connection is back', () => {
trigger('disconnect');
trigger('connect');

socketService.setActiveProfile('p2');

expect(mockEmit).toHaveBeenCalledWith('set_active_profile', { profile_id: 'p2' });
});
});
122 changes: 122 additions & 0 deletions __tests__/useProfileStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useProfileStore } from '../stores/useProfileStore';
import type { Profile, ProfilesSnapshot } from '../types';

function makeProfile(overrides: Partial<Profile> = {}): Profile {
return {
id: 'p1',
name: 'Alex',
created_at: '2026-09-14T10:00:00Z',
settings: {},
...overrides,
};
}

function makeSnapshot(overrides: Partial<ProfilesSnapshot> = {}): ProfilesSnapshot {
return {
profiles: [makeProfile()],
active_profile_id: 'p1',
...overrides,
};
}

// The store is a module singleton; reset it so one test's roster cannot leak
// into the next.
beforeEach(() => {
useProfileStore.getState().reset();
});

describe('useProfileStore', () => {
it('starts empty and not yet loaded', () => {
const state = useProfileStore.getState();
expect(state.profiles).toEqual([]);
expect(state.activeProfileId).toBe('');
// A screen needs to tell "not asked yet" apart from "no profiles exist".
expect(state.loaded).toBe(false);
});

it('mirrors the roster and selection the server sent', () => {
useProfileStore.getState().applySnapshot(
makeSnapshot({
profiles: [makeProfile({ id: 'p1', name: 'Alex' }), makeProfile({ id: 'p2', name: 'Sam' })],
active_profile_id: 'p2',
}),
);

const state = useProfileStore.getState();
expect(state.profiles.map((profile) => profile.name)).toEqual(['Alex', 'Sam']);
expect(state.activeProfileId).toBe('p2');
expect(state.loaded).toBe(true);
});

it('replaces the roster wholesale rather than merging into it', () => {
// The server sends its complete roster every time, so a profile removed on
// the kiosk has to disappear here too.
useProfileStore
.getState()
.applySnapshot(
makeSnapshot({ profiles: [makeProfile({ id: 'p1' }), makeProfile({ id: 'p2' })] }),
);

useProfileStore
.getState()
.applySnapshot(
makeSnapshot({ profiles: [makeProfile({ id: 'p1' })], active_profile_id: 'p1' }),
);

expect(useProfileStore.getState().profiles.map((profile) => profile.id)).toEqual(['p1']);
});

it('keeps the last good roster when a malformed snapshot arrives', () => {
// Blanking the picker mid-session would be worse than showing a stale
// roster that the next valid snapshot corrects.
useProfileStore.getState().applySnapshot(makeSnapshot());

useProfileStore
.getState()
.applySnapshot({ profiles: undefined } as unknown as ProfilesSnapshot);

const state = useProfileStore.getState();
expect(state.profiles).toHaveLength(1);
expect(state.loaded).toBe(true);
});

it('treats a missing active_profile_id as no selection', () => {
useProfileStore
.getState()
.applySnapshot({ profiles: [makeProfile()] } as unknown as ProfilesSnapshot);

expect(useProfileStore.getState().activeProfileId).toBe('');
expect(useProfileStore.getState().loaded).toBe(true);
});

it('accepts an empty roster as a real answer, not a failure', () => {
useProfileStore.getState().applySnapshot(makeSnapshot({ profiles: [], active_profile_id: '' }));

const state = useProfileStore.getState();
expect(state.profiles).toEqual([]);
expect(state.loaded).toBe(true);
});

it('round-trips the open settings dict untouched', () => {
// The server persists `settings` without interpreting it, and later
// features claim keys there; the client must not reshape it.
const settings = { bag: ['driver', '7-iron'], nested: { anything: 1 } };

useProfileStore
.getState()
.applySnapshot(makeSnapshot({ profiles: [makeProfile({ settings })] }));

expect(useProfileStore.getState().profiles[0].settings).toEqual(settings);
});

it('forgets the roster on reset so a stale one cannot look current', () => {
useProfileStore.getState().applySnapshot(makeSnapshot());

useProfileStore.getState().reset();

const state = useProfileStore.getState();
expect(state.profiles).toEqual([]);
expect(state.activeProfileId).toBe('');
expect(state.loaded).toBe(false);
});
});
Loading
Loading