// Mocked deps for the feature cache. Every sub-service is replaced so we // can verify the cache OMITS features whose source returns null. const mockGameLogs = { logs: null, careerPlayoff: null }; jest.mock('../../src/services/intelligence/gameLogService', () => ({ getGameLogs: async () => mockGameLogs.logs, getCareerPlayoffGames: async () => mockGameLogs.careerPlayoff, getWithWithoutStats: async () => null, })); const mockTeam = { stats: null, rank: null }; jest.mock('../../src/services/intelligence/teamStatsCache', () => ({ getTeamStats: async () => mockTeam.stats, getOpponentRank: async () => mockTeam.rank, refreshTeamStats: async () => ({ captured: 0 }), })); const mockRef = { impact: null }; jest.mock('../../src/services/intelligence/refSignals', () => ({ getRefImpact: async () => mockRef.impact, setRefAssignment: async () => ({ ok: true }), })); const mockCoach = { impact: null }; jest.mock('../../src/services/intelligence/coachSignals', () => ({ getCoachImpact: async () => mockCoach.impact, getCoachProfile: async () => null, tenureAdjustment: () => 0.5, loadSeed: () => ({ coaches: [] }), })); const mockLineup = { roleValue: 1.0 }; jest.mock('../../src/services/intelligence/lineupSignals', () => ({ roleValue: () => mockLineup.roleValue, classifyByUsage: () => 'primary_handler', getProjectedStarters: async () => ({ home: [], away: [] }), })); const mockInjuries = { list: [] }; jest.mock('../../src/services/intelligence/injuryParser', () => ({ getTeamInjuries: async () => mockInjuries.list, isPlayerOut: async () => false, getMissingStarters: async () => [], })); const mockLineMovement = { lm: null }; jest.mock('../../src/services/intelligence/lineMovement', () => ({ getLineMovement: async () => mockLineMovement.lm, })); const mockCache = { current: new Map() }; jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => mockCache.current.get(k) ?? null, cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; }, cacheDel: async (k) => { mockCache.current.delete(k); return true; }, })); const fc = require('../../src/services/intelligence/featureCache'); beforeEach(() => { mockGameLogs.logs = null; mockGameLogs.careerPlayoff = null; mockTeam.stats = null; mockTeam.rank = null; mockRef.impact = null; mockCoach.impact = null; mockLineup.roleValue = 1.0; mockInjuries.list = []; mockLineMovement.lm = null; mockCache.current.clear(); }); describe('feature cache — missing data omits features', () => { test('with no upstream data, only context + injury_severity_score (=0) populate', async () => { const payload = await fc.getFeatures({ playerId: 'p1', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g1', gameContext: { home_away: 'home', rest_days: 1 }, teamId: 18, }); expect(payload.features.home_away).toBe(1.0); expect(payload.features.rest_days).toBe(1); expect(payload.features.injury_severity_score).toBe(0); // Missing because upstreams returned null: expect(payload.features.l5_avg).toBeUndefined(); expect(payload.features.opp_rank_stat).toBeUndefined(); expect(payload.features.ref_pace_adjustment).toBeUndefined(); expect(payload.meta.features_missing).toContain('l5_avg'); expect(payload.meta.features_missing).toContain('ref_pace_adjustment'); expect(payload.meta.features_available).toContain('home_away'); }); }); describe('feature cache — game log features', () => { test('computes l5_avg, l20_avg, l10_stddev from a 10-game log', async () => { mockGameLogs.logs = [ { points: 30 }, { points: 25 }, { points: 28 }, { points: 22 }, { points: 35 }, { points: 20 }, { points: 18 }, { points: 26 }, { points: 31 }, { points: 24 }, ]; const payload = await fc.getFeatures({ playerId: 'p2', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g2', gameContext: {}, }); expect(payload.features.l5_avg).toBeCloseTo(28.0, 1); expect(payload.features.l20_avg).toBeCloseTo(25.9, 1); expect(payload.features.l10_stddev).toBeGreaterThan(0); }); test('combo stats (pts_reb_ast) sum components per game', async () => { mockGameLogs.logs = [ { points: 25, rebounds: 5, assists: 7 }, { points: 22, rebounds: 6, assists: 8 }, { points: 30, rebounds: 4, assists: 5 }, ]; const payload = await fc.getFeatures({ playerId: 'p3', playerName: 'P', statType: 'pts_reb_ast', sport: 'nba', gameId: 'g3', gameContext: {}, }); // (37 + 36 + 39) / 3 = 37.33 expect(payload.features.l5_avg).toBeCloseTo(37.33, 1); }); }); describe('feature cache — team features', () => { test('opp_rank_stat + pace_factor + team_pace come from team cache', async () => { mockTeam.stats = { pace: 102.4 }; mockTeam.rank = 7; const payload = await fc.getFeatures({ playerId: 'p4', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g4', opponentAbbr: 'BOS', gameContext: {}, }); expect(payload.features.opp_rank_stat).toBe(7); expect(payload.features.pace_factor).toBe(102.4); expect(payload.features.team_pace).toBe(102.4); }); }); describe('feature cache — injury features', () => { test('counts missing starters', async () => { mockInjuries.list = [ { playerId: '1', playerName: 'A', status: 'OUT' }, { playerId: '2', playerName: 'B', status: 'OUT' }, { playerId: '3', playerName: 'C', status: 'QUESTIONABLE' }, ]; const payload = await fc.getFeatures({ playerId: 'p5', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g5', teamId: 18, knownStarterIds: ['1', '2', '3', '4'], gameContext: {}, }); // Only OUT counts as missing starter (status=QUESTIONABLE skipped). expect(payload.features.injury_severity_score).toBe(2); expect(payload.features.teammate_absence_bump).toBeCloseTo(0.10, 5); }); }); describe('feature cache — context features cover all knobs', () => { test('every documented context field round-trips', async () => { const payload = await fc.getFeatures({ playerId: 'p6', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g6', gameContext: { home_away: 'away', rest_days: 0, game_count_in_7d: 4, season_type: 2, game_in_series: 1, season_phase: 0.95, }, }); const f = payload.features; expect(f.home_away).toBe(0.0); expect(f.rest_days).toBe(0); expect(f.game_count_in_7d).toBe(4); expect(f.season_type).toBe(2); expect(f.game_in_series).toBe(1); expect(f.season_phase).toBe(0.95); }); }); describe('feature cache — cache key reuse', () => { test('second call returns the cached vector', async () => { const args = { playerId: 'p7', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g7', gameContext: {} }; const first = await fc.getFeatures(args); mockGameLogs.logs = [{ points: 99 }]; // upstream changes const second = await fc.getFeatures(args); expect(second).toEqual(first); }); }); describe('feature cache — coach + ref + lineup features', () => { test('coach delta + ref pace + lineup role pass through', async () => { mockCoach.impact = { adjusted_pace_delta: 1.4, without_primary_pace_shift: 0.5 }; mockRef.impact = { pace_impact: 0.6, foul_adjustment: 22 }; const payload = await fc.getFeatures({ playerId: 'p8', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g8', teamAbbr: 'NYK', role: 'primary_handler', gameContext: {}, }); expect(payload.features.coach_pace_delta).toBe(1.4); expect(payload.features.coach_player_interaction).toBe(0.5); expect(payload.features.ref_pace_adjustment).toBe(0.6); expect(payload.features.ref_foul_adjustment).toBe(22); expect(payload.features.lineup_ball_handler_role).toBe(1.0); }); }); describe('feature cache — line movement', () => { test('line_delta surfaces the movement value', async () => { mockLineMovement.lm = { movement: -0.5 }; const payload = await fc.getFeatures({ playerId: 'p9', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g9', gameContext: {}, }); expect(payload.features.line_delta).toBe(-0.5); }); });