// Mock the two services trap detection talks to externally. const mockLM = { reverse: null, juice: null, lm: null }; jest.mock('../../src/services/intelligence/lineMovement', () => ({ reverseLineMovement: async () => mockLM.reverse, juiceDegradation: async () => mockLM.juice, getLineMovement: async () => mockLM.lm, })); const mockResolutions = { current: [] }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => ({ from() { const proxy = { select() { return proxy; }, eq() { return proxy; }, then(resolve) { return resolve({ data: mockResolutions.current, error: null }); }, }; return proxy; }, }), })); const trap = require('../../src/services/intelligence/trapDetection'); beforeEach(() => { mockLM.reverse = null; mockLM.juice = null; mockLM.lm = null; mockResolutions.current = []; }); describe('trap signals (individual)', () => { test('reverse_line_movement: inactive without snapshots', async () => { mockLM.reverse = null; const r = await trap.__internals.signalReverseLineMovement({ gameId: 'g', playerName: 'P', statType: 'points' }); expect(r.active).toBe(false); }); test('reverse_line_movement: active and scoring when RLM detected', async () => { mockLM.reverse = { isReverse: true, score: 0.7, publicSide: 'over', lineDirection: 'under', movement: -1 }; const r = await trap.__internals.signalReverseLineMovement({ gameId: 'g', playerName: 'P', statType: 'points' }); expect(r.active).toBe(true); expect(r.score).toBe(0.7); }); test('new_context_trap: scales with flag count', () => { const noFlags = trap.__internals.signalNewContextTrap({ gameContext: {} }); expect(noFlags.active).toBe(false); const oneFlag = trap.__internals.signalNewContextTrap({ gameContext: { game_in_series: 1 } }); expect(oneFlag.score).toBeCloseTo(0.25, 5); const allFlags = trap.__internals.signalNewContextTrap({ gameContext: { game_in_series: 1, first_playoff_game: true, new_opponent_in_series: true, new_venue: true }, }); expect(allFlags.score).toBe(1); }); test('recency_inflation: scores when L5 hotter than L20', () => { const r = trap.__internals.signalRecencyInflation({ features: { l5_avg: 30, l20_avg: 22 } }); expect(r.active).toBe(true); expect(r.score).toBeCloseTo((30 - 22) / 22, 5); }); test('recency_inflation: inactive without L5/L20', () => { const r = trap.__internals.signalRecencyInflation({ features: { l5_avg: 25 } }); expect(r.active).toBe(false); }); test('juice_degradation: passes through lineMovement signal', async () => { mockLM.juice = { applicable: true, score: 0.4, worstSide: 'over' }; const r = await trap.__internals.signalJuiceDegradation({ gameId: 'g', playerName: 'P', statType: 'points' }); expect(r.score).toBe(0.4); }); test('teammate_return_trap: scales with returning usage', () => { const r = trap.__internals.signalTeammateReturnTrap({ gameContext: { returning_teammate_usage_rate: 0.32 } }); expect(r.active).toBe(true); expect(r.score).toBeCloseTo(0.16, 5); }); test('line_consensus_divergence: scores from |line - median| / stddev', () => { const r = trap.__internals.signalLineConsensusDivergence({ odds: { playerLine: 26.5, consensus: { median: 24.5, stddev: 1.0 } }, }); expect(r.active).toBe(true); expect(r.score).toBe(1.0); // |26.5-24.5|/1 = 2.0 capped to 1.0 }); test('historical_hit_rate_paradox: inactive with thin history', async () => { mockResolutions.current = [{ result: 'hit', direction: 'over', line: 25.5 }]; const r = await trap.__internals.signalHistoricalHitRateParadox({ playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g', }); expect(r.active).toBe(false); }); test('historical_hit_rate_paradox: active when line moves AGAINST a high hit-rate', async () => { mockResolutions.current = Array.from({ length: 25 }, (_, i) => ({ result: i < 18 ? 'hit' : 'miss', direction: 'over', line: 25.5, })); mockLM.lm = { movement: -1.0 }; // moved DOWN while player usually OVER const r = await trap.__internals.signalHistoricalHitRateParadox({ playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g', }); expect(r.active).toBe(true); expect(r.score).toBeGreaterThan(0); }); }); describe('getTrapScore (composite)', () => { test('only ACTIVE signals average — null signals do not dilute', async () => { // Two active signals: new_context_trap (0.25 = 1 flag) + recency_inflation // (~0.36). The other five inactive. const result = await trap.getTrapScore({ features: { l5_avg: 30, l20_avg: 22 }, gameContext: { game_in_series: 1 }, }); expect(result.active_count).toBe(2); expect(result.composite).toBeCloseTo((0.25 + (30 - 22) / 22) / 2, 5); expect(result.recommendation).toBe('caution'); }); test('recommendation thresholds', () => { expect(trap.__internals.recommend(0.1)).toBe('proceed'); expect(trap.__internals.recommend(0.3)).toBe('caution'); expect(trap.__internals.recommend(0.6)).toBe('avoid'); }); test('KAT-scenario: G1 of Finals + recency inflation → avoid/caution', async () => { // High recency (L5 32 vs L20 22 → 0.45) + 2 context flags (0.5). // Composite = (0.45 + 0.5) / 2 = 0.475 → caution. const result = await trap.getTrapScore({ features: { l5_avg: 32, l20_avg: 22 }, gameContext: { game_in_series: 1, first_playoff_game: true }, }); expect(result.active_count).toBe(2); expect(['caution', 'avoid']).toContain(result.recommendation); }); });