From 12849a27263352be72bf7e976b98574b5d34a379 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 15 Sep 2026 06:04:53 -0400 Subject: [PATCH] feat(clubs): mirror the server's canonical club list The club ids are the literal strings the client sends in the `set_club` Socket.IO event. The server exposes no club list over Socket.IO or REST, so every client has to carry its own copy. An unrecognized id fails silently: `handle_set_club` resolves it with `ClubType(club_name)` and swallows the ValueError without emitting an error or a `club_changed` reply, so a wrong id is invisible at runtime. The tests pin the exact id list and order against the server enum so future drift is caught here rather than in the field. Mirrored from `ClubType` in the server's launch_monitor.py (source of truth) and the kiosk's ui/src/data/clubs.ts for grouping and order. `ClubType.UNKNOWN` is omitted: it is the server's fallback for an unattributed shot, not a selectable club. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvMGAzMMWRuxRht8aGciAv --- __tests__/clubs.test.ts | 83 +++++++++++++++++++++++++++++++++++++++++ data/clubs.ts | 64 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 __tests__/clubs.test.ts create mode 100644 data/clubs.ts 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; +}