'use strict'; /** * snapshotSettlementService — Fix A3. * Every dependency injected; nothing here touches a network or a database. */ const svc = require('../../src/services/snapshotSettlementService'); const line = (over) => ({ hits: over, totalBases: over, rbi: over, runs: over }); describe('decide — side-aligned outcomes', () => { const base = { id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a' }; const lines = { '2026-08-05|a': line(2) }; it('an OVER that cleared the line is a hit', () => { const { updates } = svc.decide([{ ...base, line: 0.5, side: 'over' }], lines); expect(updates).toEqual([{ id: 1, outcome: 'hit', actual_value: 2 }]); }); it('an UNDER on the SAME row is the mirror — a miss, not a hit', () => { // A raw `realized > line` indicator is the OVER perspective and would invert // the target on every under row. const { updates } = svc.decide([{ ...base, line: 0.5, side: 'under' }], lines); expect(updates).toEqual([{ id: 1, outcome: 'miss', actual_value: 2 }]); }); it('an UNDER that held is a hit', () => { const { updates } = svc.decide([{ ...base, line: 3.5, side: 'under' }], lines); expect(updates[0].outcome).toBe('hit'); }); it('actual_value is the RAW realized stat, unopinionated by side', () => { const o = svc.decide([{ ...base, line: 0.5, side: 'over' }], lines).updates[0]; const u = svc.decide([{ ...base, line: 0.5, side: 'under' }], lines).updates[0]; expect(o.actual_value).toBe(2); expect(u.actual_value).toBe(2); }); it('reads the right box-score field per stat', () => { const l = { '2026-08-05|a': { hits: 1, totalBases: 4, rbi: 3, runs: 0 } }; const at = (stat) => svc.decide([{ ...base, stat, line: 1.5, side: 'over' }], l).updates[0]; expect(at('total_bases').actual_value).toBe(4); expect(at('rbi').actual_value).toBe(3); // hits=1 does not clear 1.5 — that is a settled MISS, not an absence. expect(at('hits')).toEqual({ id: 1, outcome: 'miss', actual_value: 1 }); }); }); describe('decide — a row logged after first pitch is not a prediction', () => { const lines = { '2026-08-05|a': line(2) }; const row = (capturedAt) => ({ id: 1, game_date: '2026-08-05', captured_at: capturedAt, stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }); it('accepts a capture from the morning of the game', () => { // 14:00 UTC = 10:00 ET, well before first pitch. expect(svc.decide([row('2026-08-05T14:00:00Z')], lines).counts.settled).toBe(1); }); it('accepts a capture from the day before', () => { expect(svc.decide([row('2026-08-04T22:00:00Z')], lines).counts.settled).toBe(1); }); it('REFUSES a 01:00-UTC capture, which is 21:00 ET the same evening — in-game', () => { // The trap: a UTC date compare would keep this row. const r = svc.decide([{ ...row('2026-08-06T01:00:00Z'), game_date: '2026-08-05' }], lines); expect(r.counts.settled).toBe(0); expect(r.counts.post_hoc_logged).toBe(1); }); it('REFUSES a capture from the following day', () => { const r = svc.decide([row('2026-08-06T14:00:00Z')], lines); expect(r.counts.post_hoc_logged).toBe(1); }); it('isPreGame is absent-safe', () => { expect(svc.isPreGame(null, '2026-08-05')).toBe(false); expect(svc.isPreGame('2026-08-05T14:00:00Z', null)).toBe(false); expect(svc.isPreGame('not-a-date', '2026-08-05')).toBe(false); }); }); describe('decide — integrity', () => { it('every candidate lands in exactly one bucket (conservation)', () => { const lines = { '2026-08-05|a': line(2) }; const rows = [ { id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }, { id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'ghost', line: 0.5, side: 'over' }, { id: 3, game_date: '2026-08-05', captured_at: '2026-08-06T01:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }, { id: 4, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: null, side: 'over' }, ]; const { counts } = svc.decide(rows, lines); expect(counts.settled).toBe(1); expect(counts.orphaned).toBe(1); expect(counts.unresolvable).toBe(2); expect(counts.settled + counts.orphaned + counts.unresolvable).toBe(counts.candidates); }); it('THROWS on a duplicate row id — a doubled prediction poisons every measure', () => { const r = { id: 7, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }; expect(() => svc.decide([r, r], { '2026-08-05|a': line(2) })).toThrow(/duplicate snapshot id 7/); }); it('a player with no box-score line is ORPHANED, never settled at zero', () => { const r = svc.decide([{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'nobody', line: 0.5, side: 'over' }], {}); expect(r.counts.orphaned).toBe(1); expect(r.updates).toHaveLength(0); }); }); describe('battingLines — doubleheaders sum, non-batters are absent', () => { const sched = (pks) => ({ dates: [{ games: pks.map((pk) => ({ gamePk: pk, officialDate: '2026-08-05', status: { detailedState: 'Final' } })) }] }); const box = (rows) => ({ teams: { home: { batters: rows.map((_, i) => i + 1), players: Object.fromEntries(rows.map((r, i) => [`ID${i + 1}`, { person: { fullName: r.name }, stats: { batting: r.b } }])) }, away: { batters: [], players: {} }, }, }); it('sums both games of a doubleheader — the prop covers the day', async () => { const getJson = async (url) => { if (url.includes('schedule')) return sched([1, 2]); return box([{ name: 'Aaron Judge', b: { atBats: 4, hits: 1, totalBases: 2, rbi: 1, runs: 0 } }]); }; const lines = await svc.battingLines(['2026-08-05'], { getJson }); const k = Object.keys(lines)[0]; expect(lines[k].hits).toBe(2); // 1 + 1 expect(lines[k].totalBases).toBe(4); }); it('omits a player who did not bat (atBats null) rather than recording zero', async () => { const getJson = async (url) => (url.includes('schedule') ? sched([1]) : box([{ name: 'Benchy McBench', b: { atBats: null } }])); const lines = await svc.battingLines(['2026-08-05'], { getJson }); expect(Object.keys(lines)).toHaveLength(0); }); it('ignores a game that is not Final', async () => { const getJson = async (url) => (url.includes('schedule') ? { dates: [{ games: [{ gamePk: 1, status: { detailedState: 'In Progress' } }] }] } : box([])); expect(Object.keys(await svc.battingLines(['2026-08-05'], { getJson }))).toHaveLength(0); }); it('a failed date is absent, not fatal', async () => { const getJson = async () => { throw new Error('502'); }; await expect(svc.battingLines(['2026-08-05'], { getJson })).resolves.toEqual({}); }); }); describe('settleSnapshots — orchestration', () => { const fakeSb = (rows, captured = []) => ({ from() { const q = { select() { return q; }, eq() { return q; }, in() { return q; }, is() { return q; }, lt() { return q; }, order() { return q; }, async range(from, to) { return { data: rows.slice(from, to + 1), error: null }; }, update(patch) { return { eq: (_c, id) => ({ is: async () => { captured.push({ id, patch }); return { error: null }; } }) }; }, }; return q; }, }); it('no client → skipped, never a silent zero', async () => { expect(await svc.settleSnapshots({})).toMatchObject({ skipped: 'supabase not configured' }); }); it('settles and reports repaired-champion rows separately', async () => { const rows = [ { id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over', model_version: 'engine1@2026-08-07-fullwindow' }, { id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over', model_version: 'engine1@2026-07-20' }, ]; const captured = []; const res = await svc.settleSnapshots({ sb: fakeSb(rows, captured), now: () => new Date('2026-08-09T12:00:00Z'), getJson: async (url) => (url.includes('schedule') ? { dates: [{ games: [{ gamePk: 1, officialDate: '2026-08-05', status: { detailedState: 'Final' } }] }] } : { teams: { home: { batters: [1], players: { ID1: { person: { fullName: 'A' }, stats: { batting: { atBats: 4, hits: 2, totalBases: 2, rbi: 0, runs: 0 } } } } }, away: { batters: [], players: {} } } }), }); expect(res.written).toBe(2); expect(res.repaired_champion_settled).toBe(1); // Real outcomes, not nulls. for (const c of captured) { expect(c.patch.outcome).toBe('hit'); expect(c.patch.actual_value).toBe(2); expect(c.patch.settlement_source).toBe('statsapi_boxscore'); } }); it('bounds the dates per run and drains NEWEST first (oldest-first deadlocks)', async () => { const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, game_date: `2026-07-${String(i + 10).padStart(2, '0')}`, captured_at: `2026-07-${String(i + 10).padStart(2, '0')}T14:00:00Z`, stat: 'hits', player_key: 'a', line: 0.5, side: 'over', })); const res = await svc.settleSnapshots({ sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'), maxDates: 3, maxWindows: 1, getJson: async () => ({ dates: [] }), }); expect(res.dates).toHaveLength(3); expect(res.dates_remaining).toBe(9); // Newest first: an old date whose remaining rows are all post-hoc or // orphaned would otherwise re-process forever and block the window. expect(res.dates[0]).toBe('2026-07-21'); }); it('ADVANCES past a drained window so older settleable dates are reached', async () => { // The newest dates keep rows that can never settle (no box-score line), so a // fixed window would sit on them forever. Only the OLDEST date here has a // matching box score; the run must walk to it. const rows = Array.from({ length: 4 }, (_, i) => ({ id: i + 1, game_date: `2026-08-0${i + 1}`, captured_at: `2026-08-0${i + 1}T14:00:00Z`, stat: 'hits', player_key: i === 0 ? 'a' : `ghost${i}`, line: 0.5, side: 'over', })); const res = await svc.settleSnapshots({ sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'), maxDates: 1, maxWindows: 4, getJson: async (url) => (url.includes('schedule') ? { dates: [{ games: [{ gamePk: 1, officialDate: '2026-08-01', status: { detailedState: 'Final' } }] }] } : { teams: { home: { batters: [1], players: { ID1: { person: { fullName: 'A' }, stats: { batting: { atBats: 4, hits: 2, totalBases: 2, rbi: 0, runs: 0 } } } } }, away: { batters: [], players: {} } } }), }); expect(res.windows_scanned).toBe(4); // walked past three drained windows expect(res.written).toBe(1); expect(res.dates).toEqual(['2026-08-01']); }); it('counts permanently-unsettleable rows rather than hiding them', async () => { const rows = [ // logged at 01:00 UTC = 21:00 ET the same evening — in-game, never a prediction { id: 1, game_date: '2026-08-05', captured_at: '2026-08-06T01:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }, { id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }, ]; const res = await svc.settleSnapshots({ sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'), getJson: async () => ({ dates: [] }), }); expect(res.permanently_unsettleable).toBe(1); }); it('dry run writes nothing', async () => { const captured = []; const rows = [{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }]; const res = await svc.settleSnapshots({ sb: fakeSb(rows, captured), write: false, now: () => new Date('2026-08-09T12:00:00Z'), getJson: async () => ({ dates: [] }), }); expect(res.mode).toBe('dry-run'); expect(captured).toHaveLength(0); }); }); describe('settlement does NOT reconstruct context', () => { it('touches no context table and no context column', () => { const src = require('fs').readFileSync( require('path').join(__dirname, '..', '..', 'src/services/snapshotSettlementService.js'), 'utf8'); for (const t of ['batter_spray', 'team_defense', 'platoon_splits', 'park_dimensions', 'game_context', 'hitter_opportunity', 'statcast_aggregates', 'lineup_context']) { expect(src).not.toContain(`'${t}'`); } // It writes outcome columns only. expect(src).toMatch(/outcome: u\.outcome/); expect(src).not.toMatch(/features:/); }); });