230 lines
8.3 KiB
JavaScript
230 lines
8.3 KiB
JavaScript
// 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');
|
|
});
|
|
});
|
|
});
|