const { gradeMlbProp, calculateMlbEdge, isMlbStatType } = require('../../src/services/mlbGrader'); const { evaluateMlbKillConditions, classifyLineMove, checkWeather } = require('../../src/services/mlbKillConditions'); const { MLB_PARKS, getParkByTeam } = require('../../src/constants/mlbParks'); jest.mock('axios'); const axios = require('axios'); describe('mlbGrader', () => { describe('grade thresholds', () => { test('Grade A when edge >= 5%', () => { const result = gradeMlbProp({ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over', seasonAvg: 0.7, recentAvg: 0.8, }); expect(result.grade).toBe('A'); expect(result.edge_pct).toBeGreaterThanOrEqual(5); }); test('Grade B when edge 3-4%', () => { // seasonAvg=5.15, line=5, direction=over => seasonEdge=(5.15-5)/5*100=3% // recentAvg=5.2, line=5 => recentEdge=(5.2-5)/5*100=4% // composite = 3*0.6 + 4*0.4 = 1.8+1.6 = 3.4 const result = gradeMlbProp({ player: 'Test Player', stat_type: 'strikeouts', line: 5, direction: 'over', seasonAvg: 5.15, recentAvg: 5.2, }); expect(result.grade).toBe('B'); expect(result.edge_pct).toBeGreaterThanOrEqual(3); expect(result.edge_pct).toBeLessThan(5); }); test('Grade C when edge 1-2%', () => { // seasonAvg=5.05, line=5, direction=over => seasonEdge=1% // recentAvg=5.1 => recentEdge=2% // composite = 1*0.6 + 2*0.4 = 0.6+0.8 = 1.4 const result = gradeMlbProp({ player: 'Test Player', stat_type: 'hits', line: 5, direction: 'over', seasonAvg: 5.05, recentAvg: 5.1, }); expect(result.grade).toBe('C'); expect(result.edge_pct).toBeGreaterThanOrEqual(1); expect(result.edge_pct).toBeLessThan(3); }); test('Grade D when negative edge', () => { const result = gradeMlbProp({ player: 'Test Player', stat_type: 'hits', line: 2, direction: 'over', seasonAvg: 1.5, recentAvg: 1.3, }); expect(result.grade).toBe('D'); expect(result.edge_pct).toBeLessThan(1); }); }); describe('isMlbStatType', () => { test('returns true for valid hitting stat', () => { expect(isMlbStatType('hits')).toBe(true); expect(isMlbStatType('home_runs')).toBe(true); expect(isMlbStatType('stolen_bases')).toBe(true); }); test('returns true for valid pitching stat', () => { expect(isMlbStatType('strikeouts')).toBe(true); expect(isMlbStatType('earned_runs')).toBe(true); expect(isMlbStatType('pitches_thrown')).toBe(true); }); test('returns false for invalid stat type', () => { expect(isMlbStatType('three_pointers')).toBe(false); expect(isMlbStatType('touchdowns')).toBe(false); expect(isMlbStatType('')).toBe(false); }); }); describe('calculateMlbEdge', () => { test('calculates positive edge for over', () => { const edge = calculateMlbEdge(6, 5, 'over'); expect(edge).toBe(20); }); test('calculates positive edge for under', () => { const edge = calculateMlbEdge(4, 5, 'under'); expect(edge).toBe(20); }); test('returns 0 for null inputs', () => { expect(calculateMlbEdge(null, 5, 'over')).toBe(0); expect(calculateMlbEdge(5, null, 'over')).toBe(0); }); }); }); describe('mlbKillConditions', () => { function makeContext(overrides = {}) { return { inLineup: true, pitcherScratched: false, weather: { wind_speed: 5, wind_direction: 'OUT', temp: 75, humidity: 50 }, platoonDelta: 5, paVsHandedness: 100, lineMovement: 0, hoursFromOpen: 1, parkFactor: 1.0, rainProbability: 10, onInjuryReport: false, ...overrides, }; } test('LINEUP_OUT triggers when player not in lineup', () => { const result = evaluateMlbKillConditions(makeContext({ inLineup: false })); expect(result.some(c => c.code === 'LINEUP_OUT')).toBe(true); }); test('PITCHER_SCRATCH triggers when pitcher scratched', () => { const result = evaluateMlbKillConditions(makeContext({ pitcherScratched: true })); expect(result.some(c => c.code === 'PITCHER_SCRATCH')).toBe(true); }); test('WIND_IN triggers at 15mph+ blowing in', () => { const result = evaluateMlbKillConditions(makeContext({ weather: { wind_speed: 18, wind_direction: 'IN', temp: 75, humidity: 50 }, })); expect(result.some(c => c.code === 'WIND_IN')).toBe(true); }); test('PLATOON_DISADVANTAGE triggers when delta > 12%', () => { const result = evaluateMlbKillConditions(makeContext({ platoonDelta: 15 })); expect(result.some(c => c.code === 'PLATOON_DISADVANTAGE')).toBe(true); }); test('SMALL_SAMPLE triggers under 50 PA', () => { const result = evaluateMlbKillConditions(makeContext({ paVsHandedness: 30 })); expect(result.some(c => c.code === 'SMALL_SAMPLE')).toBe(true); }); test('LINE_MOVE_AGAINST triggers at 0.5+ movement', () => { const result = evaluateMlbKillConditions(makeContext({ lineMovement: 0.7, hoursFromOpen: 1 })); expect(result.some(c => c.code === 'LINE_MOVE_AGAINST')).toBe(true); }); test('PARK_SUPPRESSOR triggers below 0.90', () => { const result = evaluateMlbKillConditions(makeContext({ parkFactor: 0.85 })); expect(result.some(c => c.code === 'PARK_SUPPRESSOR')).toBe(true); }); test('WEATHER_RAIN triggers above 50% probability', () => { const result = evaluateMlbKillConditions(makeContext({ rainProbability: 65 })); expect(result.some(c => c.code === 'WEATHER_RAIN')).toBe(true); }); test('INJURY_REPORT triggers when on injury report', () => { const result = evaluateMlbKillConditions(makeContext({ onInjuryReport: true })); expect(result.some(c => c.code === 'INJURY_REPORT')).toBe(true); }); test('HUMIDITY_SUPPRESSOR triggers at humidity > 80% and temp < 60F', () => { const result = evaluateMlbKillConditions(makeContext({ weather: { wind_speed: 5, wind_direction: 'OUT', temp: 55, humidity: 85 }, })); expect(result.some(c => c.code === 'HUMIDITY_SUPPRESSOR')).toBe(true); }); }); describe('classifyLineMove', () => { test('returns sharp for movement within first 2 hours', () => { expect(classifyLineMove(0.7, 1)).toBe('sharp'); expect(classifyLineMove(-0.5, 0.5)).toBe('sharp'); }); test('returns public for movement after 4 hours', () => { expect(classifyLineMove(0.6, 5)).toBe('public'); expect(classifyLineMove(-0.8, 6)).toBe('public'); }); test('returns null for movement under 0.5', () => { expect(classifyLineMove(0.3, 1)).toBeNull(); }); }); describe('checkWeather', () => { beforeEach(() => { jest.clearAllMocks(); }); test('falls back to open-meteo on api.weather.gov timeout', async () => { // Mock weather.gov to timeout axios.get.mockImplementation((url) => { if (url.includes('weather.gov')) { return Promise.reject(new Error('timeout of 3000ms exceeded')); } // open-meteo fallback return Promise.resolve({ data: { hourly: { temperature_2m: Array(24).fill(72), relative_humidity_2m: Array(24).fill(50), wind_speed_10m: Array(24).fill(10), wind_direction_10m: Array(24).fill(180), precipitation_probability: Array(24).fill(20), }, }, }); }); const result = await checkWeather([40.8296, -73.9262], 3000); expect(result.wind_speed).toBe(10); expect(result.temp).toBe(72); // Verify weather.gov was attempted first expect(axios.get).toHaveBeenCalledWith( expect.stringContaining('weather.gov'), expect.any(Object) ); }); }); describe('mlbParks', () => { test('has exactly 30 entries', () => { expect(Object.keys(MLB_PARKS).length).toBe(30); }); test('getParkByTeam returns correct park for NYY', () => { const park = getParkByTeam('NYY'); expect(park).not.toBeNull(); expect(park.name).toBe('Yankee Stadium'); expect(park.coords).toEqual([40.8296, -73.9262]); }); test('getParkByTeam returns correct park for LAD', () => { const park = getParkByTeam('LAD'); expect(park.name).toBe('Dodger Stadium'); }); test('getParkByTeam returns null for invalid team', () => { expect(getParkByTeam('XXX')).toBeNull(); }); test('every park has name, coords, and team', () => { for (const [key, park] of Object.entries(MLB_PARKS)) { expect(park.name).toBeDefined(); expect(park.coords).toHaveLength(2); expect(park.team).toBeDefined(); } }); });