'use strict'; /** * The lineup / baserunner-context ingest. * * This is the input RBI and runs have always been missing, so the tests are * about the two ways it could quietly lie: inventing a batting order that was * never posted, and turning an unmeasured hitter into one who never bats with * runners on. */ const svc = require('../../src/services/lineupContextService'); const schedule = (lineups) => ({ dates: [{ games: [{ gamePk: 777, gameDate: '2026-08-04T23:05:00Z', teams: { home: { team: { name: 'Chicago Cubs' } }, away: { team: { name: 'St. Louis Cardinals' } } }, ...(lineups ? { lineups } : {}), }], }], }); const nine = (prefix) => Array.from({ length: 9 }, (_, i) => ({ id: 100 + i, fullName: `${prefix} Player${i}` })); describe('RUNG 1 — batting order', () => { it('array ORDER is the batting order; index 0 is leadoff', async () => { const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => schedule({ awayPlayers: nine('A'), homePlayers: nine('H') }), }); expect(rows).toHaveLength(18); const away = rows.filter((r) => r.side === 'away'); expect(away[0].batting_order).toBe(1); expect(away[0].player_name).toContain('Player0'); expect(away[8].batting_order).toBe(9); expect(away[0].team).toBe('St. Louis Cardinals'); }); it('carries the ids and normalized keys the rest of the pipeline joins on', async () => { const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => schedule({ homePlayers: [{ id: 656941, fullName: 'Kyle Schwarber' }] }), }); expect(rows[0].source_id).toBe(656941); expect(rows[0].player_key).toBe('kyle schwarber'); expect(rows[0].game_pk).toBe(777); }); it('NO lineup posted yet → empty, never a guessed order', async () => { const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => schedule(null) }); expect(rows).toEqual([]); }); it('a SHORT lineup records fewer slots rather than padding to nine', async () => { const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => schedule({ homePlayers: nine('H').slice(0, 4) }), }); expect(rows).toHaveLength(4); expect(rows[3].batting_order).toBe(4); }); it('a failed feed is an empty slate, not a thrown pipeline', async () => { const rows = await svc.fetchLineups('2026-08-04', { fetchImpl: async () => { throw new Error('502'); }, }); expect(rows).toEqual([]); }); }); describe('RUNG 2 — runners in scoring position', () => { const splits = (risp, empty) => ({ stats: [{ splits: [ ...(risp ? [{ split: { code: 'risp' }, stat: risp }] : []), ...(empty ? [{ split: { code: 'r0' }, stat: empty }] : []), ], }], }); it('derives the RISP share from the two splits', async () => { const o = await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => splits({ plateAppearances: 87, rbi: 25 }, { plateAppearances: 302 }), }); expect(o.risp_pa).toBe(87); expect(o.risp_rbi).toBe(25); expect(o.bases_empty_pa).toBe(302); expect(o.total_pa).toBe(389); expect(o.risp_share).toBeCloseTo(87 / 389, 6); }); it('separates two hitters with the same power but different opportunity', async () => { const cleanup = await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => splits({ plateAppearances: 150, rbi: 60 }, { plateAppearances: 200 }), }); const leadoff = await svc.fetchOpportunity(2, 2026, { fetchImpl: async () => splits({ plateAppearances: 50, rbi: 18 }, { plateAppearances: 350 }), }); // This is the half of RBI no amount of exit velocity can tell you. expect(cleanup.risp_share).toBeGreaterThan(leadoff.risp_share * 2); }); it('NO splits → null, never a zero share', async () => { // A 0 share would assert he never bats with runners on — a strong claim, // and usually a false one. expect(await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => ({ stats: [] }) })).toBeNull(); expect(await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => { throw new Error('x'); } })).toBeNull(); expect(await svc.fetchOpportunity(null, 2026, {})).toBeNull(); }); it('one split present is still usable; the missing side stays null', async () => { const o = await svc.fetchOpportunity(1, 2026, { fetchImpl: async () => splits({ plateAppearances: 87, rbi: 25 }, null), }); expect(o.risp_pa).toBe(87); expect(o.bases_empty_pa).toBeNull(); expect(o.risp_share).toBe(1); // of the PAs we can classify — stated exactly }); }); describe('the ingest as a whole', () => { it('writes both tables and reports coverage', async () => { const writes = {}; const sb = { from: (t) => ({ upsert: async (rows) => { writes[t] = rows; return { error: null }; } }) }; const out = await svc.refreshContext({ supabase: sb, date: '2026-08-04', season: 2026, fetchLineups: async () => [ { game_pk: 1, game_date: '2026-08-04', team: 'Cubs', side: 'home', player_name: 'A B', player_key: 'a b', source_id: 5, batting_order: 1 }, ], fetchOpportunity: async () => ({ risp_pa: 10, risp_rbi: 4, bases_empty_pa: 40, total_pa: 50, risp_share: 0.2 }), }); expect(out.ok).toBe(true); expect(out.lineups).toBe(1); expect(out.opportunity).toBe(1); expect(writes.lineup_context[0].as_of_date).toBeTruthy(); // DATED, always expect(writes.hitter_opportunity[0].as_of_date).toBeTruthy(); }); it('NEVER throws — it rides alongside a snapshot', async () => { const out = await svc.refreshContext({ supabase: null, fetchLineups: async () => { throw new Error('feed down'); }, }); expect(out.ok).toBe(false); expect(out.reason).toMatch(/feed down/); }); it('fetches opportunity ONCE PER HITTER, not once per game', async () => { let calls = 0; await svc.refreshContext({ supabase: null, fetchLineups: async () => ([ { game_pk: 1, player_key: 'a b', player_name: 'A B', source_id: 5, batting_order: 1 }, { game_pk: 2, player_key: 'a b', player_name: 'A B', source_id: 5, batting_order: 3 }, ]), fetchOpportunity: async () => { calls += 1; return { risp_pa: 1, total_pa: 2, risp_share: 0.5 }; }, }); // The season aggregate is per player — this is why Rung 2 is cheap. expect(calls).toBe(1); }); });