// Soccer-branch tests for trapDetection. NBA path is covered by the // existing trapDetection.test.js; we only need to verify the soccer // signals fire on the right conditions and that the sport dispatch // keeps the two trap sets isolated. // // Soccer signals are synchronous and pure over `input.features` — no // Redis or DB mocks required. const trap = require('../../src/services/intelligence/trapDetection'); function soccerInput(features = {}, statType = 'goals') { return { sport: 'soccer', statType, features: { stat_type: statType, ...features }, odds: { playerLine: 0.5, consensus: null }, }; } describe('trapDetection — soccer branch', () => { describe('getTrapScore dispatches on sport', () => { test('soccer input runs ONLY the soccer signals (no NBA signals fire)', async () => { const r = await trap.getTrapScore(soccerInput({ goals_per_90: 0.5 })); const names = Object.keys(r.signals); // The NBA names should be absent; the soccer names should be present. expect(names).not.toContain('reverse_line_movement'); expect(names).not.toContain('historical_hit_rate_paradox'); expect(names).toContain('xg_regression'); expect(names).toContain('altitude_risk'); expect(names).toContain('rotation_risk'); expect(names).toContain('minute_discount'); expect(names).toContain('referee_card_bias'); expect(names).toContain('strong_defense'); }); test('nba input still runs the NBA signal set unchanged', async () => { const r = await trap.getTrapScore({ sport: 'nba', gameId: 'gX', playerName: 'A', statType: 'points', odds: { playerLine: 25.5 }, }); const names = Object.keys(r.signals); expect(names).toContain('reverse_line_movement'); expect(names).toContain('historical_hit_rate_paradox'); expect(names).not.toContain('xg_regression'); }); }); describe('signalXgRegression', () => { test('fires when xg_delta > 0.3', () => { const result = trap.__internals.signalXgRegression( soccerInput({ xg_delta: 0.6 }) ); expect(result.active).toBe(true); expect(result.score).toBeGreaterThan(0); expect(result.explanation).toMatch(/above expected goals/); }); test('does NOT fire when xg_delta is near zero', () => { const result = trap.__internals.signalXgRegression( soccerInput({ xg_delta: 0.05 }) ); expect(result.active).toBe(true); expect(result.score).toBe(0); }); test('inactive when xg_delta is null', () => { const result = trap.__internals.signalXgRegression(soccerInput({ xg_delta: null })); expect(result.active).toBe(false); }); }); describe('signalAltitudeRisk', () => { test('fires for non-host-continent team at high altitude', () => { const r = trap.__internals.signalAltitudeRisk( soccerInput({ altitude_impact: 'high', home_continent: false, venue_altitude_ft: 7349 }) ); expect(r.active).toBe(true); expect(r.score).toBeGreaterThan(0); expect(r.explanation).toMatch(/altitude/); }); test('host-continent team gets a pass (acclimated)', () => { const r = trap.__internals.signalAltitudeRisk( soccerInput({ altitude_impact: 'high', home_continent: true }) ); expect(r.active).toBe(false); }); test('moderate or no altitude → inactive', () => { expect(trap.__internals.signalAltitudeRisk( soccerInput({ altitude_impact: 'moderate', home_continent: false }) ).active).toBe(false); expect(trap.__internals.signalAltitudeRisk( soccerInput({ altitude_impact: 'none' }) ).active).toBe(false); }); }); describe('signalRotationRisk', () => { test('fires for low start_rate + short rest', () => { const r = trap.__internals.signalRotationRisk( soccerInput({ start_rate: 0.5, rest_days: 1 }) ); expect(r.active).toBe(true); expect(r.score).toBeGreaterThan(0); }); test('does NOT fire when start_rate is high', () => { const r = trap.__internals.signalRotationRisk( soccerInput({ start_rate: 0.95, rest_days: 1 }) ); expect(r.active).toBe(true); expect(r.score).toBe(0); }); test('does NOT fire when rest_days is sufficient', () => { const r = trap.__internals.signalRotationRisk( soccerInput({ start_rate: 0.5, rest_days: 5 }) ); expect(r.active).toBe(true); expect(r.score).toBe(0); }); test('inactive when fields missing', () => { const r = trap.__internals.signalRotationRisk(soccerInput({})); expect(r.active).toBe(false); }); }); describe('signalMinuteDiscount', () => { test('fires when minutes_per_game < 70', () => { const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 55 })); expect(r.active).toBe(true); expect(r.score).toBeGreaterThan(0); }); test('does NOT fire when minutes_per_game >= 70', () => { const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 88 })); expect(r.active).toBe(true); expect(r.score).toBe(0); }); }); describe('signalRefereeCardBias (POSITIVE)', () => { test('marks positive signal for card-heavy ref on a CARDS prop', () => { const r = trap.__internals.signalRefereeCardBias( soccerInput({ referee_cards_per_game: 6.2, referee_name: 'Anthony Taylor' }, 'cards') ); expect(r.positive).toBe(true); expect(r.active).toBe(false); // explicit: positive signals do NOT count active expect(r.score).toBe(0); expect(r.explanation).toMatch(/favorable for card over/); }); test('does NOT mark positive for a non-cards stat type', () => { const r = trap.__internals.signalRefereeCardBias( soccerInput({ referee_cards_per_game: 6.2 }, 'goals') ); expect(r.positive).not.toBe(true); expect(r.active).toBe(false); }); test('composite EXCLUDES positive signals even when many fire', async () => { // Drop one trap + one positive — composite should reflect ONLY the trap. const r = await trap.getTrapScore(soccerInput( { xg_delta: 0.6, // trap fires referee_cards_per_game: 6.5, // positive fires }, 'cards', // makes the positive applicable )); expect(r.active_count).toBe(1); // only the xG regression counts expect(r.composite).toBeGreaterThan(0); }); }); describe('signalStrongDefense', () => { test('fires for top-5 defense on goals over', () => { const r = trap.__internals.signalStrongDefense( soccerInput({ opp_defensive_rank: 3 }, 'goals') ); expect(r.active).toBe(true); expect(r.score).toBeGreaterThan(0); }); test('fires for top-5 defense on shots_on_target', () => { const r = trap.__internals.signalStrongDefense( soccerInput({ opp_defensive_rank: 5 }, 'shots_on_target') ); expect(r.active).toBe(true); }); test('inactive for non-scoring stat types', () => { const r = trap.__internals.signalStrongDefense( soccerInput({ opp_defensive_rank: 3 }, 'cards') ); expect(r.active).toBe(false); }); test('does NOT fire when defense is mid-table', () => { const r = trap.__internals.signalStrongDefense( soccerInput({ opp_defensive_rank: 18 }, 'goals') ); expect(r.active).toBe(true); expect(r.score).toBe(0); }); }); describe('composite scoring', () => { test('multiple soccer traps → composite >= 0.5 → avoid', async () => { const r = await trap.getTrapScore(soccerInput( { xg_delta: 0.5, altitude_impact: 'high', home_continent: false, start_rate: 0.5, rest_days: 1, opp_defensive_rank: 3, }, 'goals', )); expect(r.composite).toBeGreaterThanOrEqual(0.5); expect(r.recommendation).toBe('avoid'); }); test('no soccer traps → composite 0 → proceed', async () => { const r = await trap.getTrapScore(soccerInput({ xg_delta: 0.05, altitude_impact: 'none', start_rate: 0.95, rest_days: 5, minutes_per_game: 85, opp_defensive_rank: 20, }, 'goals')); expect(r.composite).toBe(0); expect(r.recommendation).toBe('proceed'); }); }); });