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
83 changes: 83 additions & 0 deletions __tests__/clubs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ALL_CLUBS, CLUBS_BY_TYPE, getClubName } from '../data/clubs';

// Every id below is a literal value of `ClubType` in the server's
// `launch_monitor.py`. The server answers an unrecognized id with silence, so
// drift from the enum is invisible at runtime — these assertions are the only
// place it gets caught. Update them only alongside a real wire-contract change.
const EXPECTED_IDS = [
'2-iron',
'3-iron',
'4-iron',
'5-iron',
'6-iron',
'7-iron',
'8-iron',
'9-iron',
'pw',
'gw',
'sw',
'lw',
'3-hybrid',
'5-hybrid',
'7-hybrid',
'9-hybrid',
'driver',
'3-wood',
'5-wood',
'7-wood',
];

describe('ALL_CLUBS', () => {
it('holds exactly the server club ids, in picker order', () => {
expect(ALL_CLUBS.map((club) => club.id)).toEqual(EXPECTED_IDS);
});

it('omits the server\'s "unknown" fallback, which is not a selectable club', () => {
expect(ALL_CLUBS.map((club) => club.id)).not.toContain('unknown');
});

it('has no duplicate ids', () => {
const ids = ALL_CLUBS.map((club) => club.id);
expect(new Set(ids).size).toBe(ids.length);
});

it.each(EXPECTED_IDS)('sends %p as a non-empty lowercase id', (id) => {
expect(id).not.toBe('');
expect(id).toBe(id.toLowerCase());
expect(id).toMatch(/^[a-z0-9-]+$/);
});

it('gives every club a non-empty label and name', () => {
for (const club of ALL_CLUBS) {
expect(club.label).not.toBe('');
expect(club.name).not.toBe('');
}
});
});

describe('CLUBS_BY_TYPE', () => {
it('groups the clubs as Irons, Hybrids, then Woods', () => {
expect(Object.keys(CLUBS_BY_TYPE)).toEqual(['Irons', 'Hybrids', 'Woods']);
});

it('flattens to ALL_CLUBS without dropping or reordering a group', () => {
expect(Object.values(CLUBS_BY_TYPE).flat()).toEqual(ALL_CLUBS);
});
});

describe('getClubName', () => {
it('resolves a known id to its prose name', () => {
expect(getClubName('7-iron')).toBe('7 Iron');
expect(getClubName('pw')).toBe('Pitching Wedge');
expect(getClubName('driver')).toBe('Driver');
});

it('falls back to the raw id for a club it does not know', () => {
expect(getClubName('unknown')).toBe('unknown');
expect(getClubName('42-iron')).toBe('42-iron');
});

it('falls back to the empty string rather than throwing on an empty id', () => {
expect(getClubName('')).toBe('');
});
});
64 changes: 64 additions & 0 deletions data/clubs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Canonical club list, hand-mirrored from the OpenFlight server.
//
// Source of truth: `ClubType` in `src/openflight/launch_monitor.py` of
// open-flight/openflight. These ids are the literal strings sent as the `club`
// field of the `set_club` Socket.IO event. The server resolves them with
// `ClubType(club_name)` and swallows a `ValueError` without emitting an error
// or a `club_changed` reply, so an unrecognized id fails silently at runtime.
//
// Grouping, labels, prose names, and order mirror the kiosk UI's
// `ui/src/data/clubs.ts` in the same repository, so both clients present the
// same picker.
//
// `ClubType.UNKNOWN` ("unknown") is deliberately omitted: it is the server's
// fallback for an unattributed shot, not a club a user selects. That is the
// only difference between the enum's 21 values and these 20.
//
// This repository has no build-time dependency on the server or web-UI repos
// (see AGENTS.md), so this list is mirrored by hand and must be updated
// together with the wire contract whenever `ClubType` changes.

export interface Club {
id: string;
/** Compact picker tile, e.g. "7i". */
label: string;
/** Prose name for the header, e.g. "7 Iron". */
name: string;
}

// Object insertion order is preserved and drives display order.
export const CLUBS_BY_TYPE: Record<string, Club[]> = {
Irons: [
{ id: '2-iron', label: '2i', name: '2 Iron' },
{ id: '3-iron', label: '3i', name: '3 Iron' },
{ id: '4-iron', label: '4i', name: '4 Iron' },
{ id: '5-iron', label: '5i', name: '5 Iron' },
{ id: '6-iron', label: '6i', name: '6 Iron' },
{ id: '7-iron', label: '7i', name: '7 Iron' },
{ id: '8-iron', label: '8i', name: '8 Iron' },
{ id: '9-iron', label: '9i', name: '9 Iron' },
{ id: 'pw', label: 'PW', name: 'Pitching Wedge' },
{ id: 'gw', label: 'GW', name: 'Gap Wedge' },
{ id: 'sw', label: 'SW', name: 'Sand Wedge' },
{ id: 'lw', label: 'LW', name: 'Lob Wedge' },
],
Hybrids: [
{ id: '3-hybrid', label: '3H', name: '3 Hybrid' },
{ id: '5-hybrid', label: '5H', name: '5 Hybrid' },
{ id: '7-hybrid', label: '7H', name: '7 Hybrid' },
{ id: '9-hybrid', label: '9H', name: '9 Hybrid' },
],
Woods: [
{ id: 'driver', label: 'DR', name: 'Driver' },
{ id: '3-wood', label: '3W', name: '3 Wood' },
{ id: '5-wood', label: '5W', name: '5 Wood' },
{ id: '7-wood', label: '7W', name: '7 Wood' },
],
};

export const ALL_CLUBS: Club[] = Object.values(CLUBS_BY_TYPE).flat();

/** Prose name for a club id, falling back to the raw id for anything unknown. */
export function getClubName(clubId: string): string {
return ALL_CLUBS.find((club) => club.id === clubId)?.name ?? clubId;
}
Loading