Files
vyndr/tests/unit/trapDetection.test.js

142 lines
5.6 KiB
JavaScript

// 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);
});
});