const { SCHEME_TYPES, MIN_POSSESSIONS, CACHE_TTL, getCacheKey, extractPnRPossessions, classifyScheme, } = require('../../src/services/schemeClassifier'); describe('Scheme Classifier', () => { // --- Constants --- test('SCHEME_TYPES contains all 5 classifications', () => { expect(SCHEME_TYPES).toEqual(['DROP', 'SWITCH', 'HEDGE', 'MIXED', 'UNKNOWN']); }); test('minimum possessions threshold is 8', () => { expect(MIN_POSSESSIONS).toBe(8); }); test('cache TTL is 6 hours (21600 seconds)', () => { expect(CACHE_TTL).toBe(21600); }); // --- Cache Key --- test('cache key includes opponent and date', () => { const key = getCacheKey('BOS', '2026-04-12'); expect(key).toBe('scheme:BOS:2026-04-12'); }); // --- PnR Extraction --- test('extractPnRPossessions identifies pick-and-roll plays', () => { const plays = [ { description: 'LeBron pick and roll ball handler' }, { description: 'AD catches lob pass' }, { description: 'screen and roll coverage by Smart' }, { description: 'transition fastbreak layup' }, { description: 'ball screen switch by Tatum' }, ]; const result = extractPnRPossessions(plays); expect(result).toHaveLength(3); }); test('extractPnRPossessions handles empty array', () => { expect(extractPnRPossessions([])).toEqual([]); }); test('extractPnRPossessions handles null/undefined', () => { expect(extractPnRPossessions(null)).toEqual([]); expect(extractPnRPossessions(undefined)).toEqual([]); }); test('extractPnRPossessions handles play_description field', () => { const plays = [ { play_description: 'PnR ball handler drive' }, ]; const result = extractPnRPossessions(plays); expect(result).toHaveLength(1); }); // --- Classification --- test('classifyScheme returns UNKNOWN with fewer than 8 possessions', () => { const possessions = Array.from({ length: 5 }, () => ({ description: 'drop coverage on screen' })); const result = classifyScheme(possessions); expect(result.scheme).toBe('UNKNOWN'); expect(result.reason).toBe('insufficient_data'); expect(result.possessions_analyzed).toBe(5); }); test('classifyScheme returns DROP when drop coverage dominates', () => { const possessions = [ ...Array.from({ length: 7 }, () => ({ description: 'drop coverage on ball screen' })), { description: 'switch on screen action' }, { description: 'hedge hard show on pick' }, ]; const result = classifyScheme(possessions); expect(result.scheme).toBe('DROP'); expect(result.confidence).toBeGreaterThanOrEqual(55); expect(result.breakdown).toBeDefined(); expect(result.breakdown.DROP).toBe(7); }); test('classifyScheme returns SWITCH when switch dominates', () => { const possessions = [ ...Array.from({ length: 8 }, () => ({ description: 'switch on screen swap defenders' })), { description: 'drop back on screen' }, { description: 'drop sag coverage' }, ]; const result = classifyScheme(possessions); expect(result.scheme).toBe('SWITCH'); expect(result.confidence).toBeGreaterThanOrEqual(55); }); test('classifyScheme returns HEDGE when hedge/blitz dominates', () => { const possessions = [ ...Array.from({ length: 8 }, () => ({ description: 'hedge hard show blitz screen' })), { description: 'switch on ball screen' }, ]; const result = classifyScheme(possessions); expect(result.scheme).toBe('HEDGE'); }); test('classifyScheme returns MIXED when no scheme exceeds 55%', () => { const possessions = [ ...Array.from({ length: 4 }, () => ({ description: 'drop coverage on screen' })), ...Array.from({ length: 3 }, () => ({ description: 'switch on screen' })), ...Array.from({ length: 3 }, () => ({ description: 'hedge trap on ball screen' })), ]; const result = classifyScheme(possessions); expect(result.scheme).toBe('MIXED'); expect(result.breakdown).toBeDefined(); }); test('classifyScheme returns UNKNOWN when no classifiable actions found', () => { const possessions = Array.from({ length: 10 }, () => ({ description: 'generic play action' })); const result = classifyScheme(possessions); expect(result.scheme).toBe('UNKNOWN'); expect(result.reason).toBe('no_classifiable_actions'); }); test('classifyScheme handles null possessions', () => { const result = classifyScheme(null); expect(result.scheme).toBe('UNKNOWN'); expect(result.reason).toBe('insufficient_data'); }); test('classifyScheme handles empty possessions', () => { const result = classifyScheme([]); expect(result.scheme).toBe('UNKNOWN'); expect(result.reason).toBe('insufficient_data'); }); // --- Confidence --- test('confidence is a percentage rounded to integer', () => { const possessions = Array.from({ length: 10 }, () => ({ description: 'drop coverage sag back' })); const result = classifyScheme(possessions); expect(Number.isInteger(result.confidence)).toBe(true); expect(result.confidence).toBeGreaterThanOrEqual(0); expect(result.confidence).toBeLessThanOrEqual(100); }); // --- Breakdown --- test('breakdown counts sum correctly', () => { const possessions = [ ...Array.from({ length: 5 }, () => ({ description: 'drop coverage on screen' })), ...Array.from({ length: 2 }, () => ({ description: 'switch on ball screen' })), ...Array.from({ length: 2 }, () => ({ description: 'hedge trap blitz' })), ]; const result = classifyScheme(possessions); const { DROP, SWITCH, HEDGE } = result.breakdown; expect(DROP).toBe(5); expect(SWITCH).toBe(2); expect(HEDGE).toBe(2); }); // --- Graceful Degradation --- test('scheme result always contains required fields', () => { const result = classifyScheme(null); expect(result).toHaveProperty('scheme'); expect(result).toHaveProperty('confidence'); expect(result).toHaveProperty('possessions_analyzed'); expect(SCHEME_TYPES).toContain(result.scheme); }); });