diff --git a/__tests__/clubs.test.ts b/__tests__/clubs.test.ts new file mode 100644 index 0000000..38e12a8 --- /dev/null +++ b/__tests__/clubs.test.ts @@ -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(''); + }); +}); diff --git a/data/clubs.ts b/data/clubs.ts new file mode 100644 index 0000000..c7c42de --- /dev/null +++ b/data/clubs.ts @@ -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 = { + 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; +}