/* ============================================================ Session 74 — PUBLIC PARK BASE (source-pluggable) + GAME-CONTEXT CAPTURE. ============================================================ */ const pb = require('../../src/services/parkBase'); const pf = require('../../src/services/parkFactors'); const gc = require('../../src/services/gameContext'); const ch = require('../../src/services/challengerProjection'); describe('public base — commodity, and labelled as such', () => { it('converts the 100-indexed public table to a multiplier', () => { const col = pb.resolveParkBase({ teamAbbr: 'COL' }); expect(col.state).toBe('present'); expect(col.hr_base).toBe(1.28); // 128 → 1.28 expect(col.run_base).toBe(1.2); }); it('NEVER claims the public number is proprietary', () => { const col = pb.resolveParkBase({ teamAbbr: 'COL' }); expect(col.provenance.proprietary).toBe(false); expect(col.provenance.label).toBe('PUBLIC PARK FACTOR'); expect(col.provenance.note).toMatch(/Not a VYNDR derivation/); }); it('a pitcher park reads below 1', () => { expect(pb.resolveParkBase({ teamAbbr: 'SF' }).hr_base).toBeLessThan(1); }); it('HONEST-ABSENT where even the PUBLIC factor is thin/unstable', () => { // A relocated club in a temporary venue: a public number here is no more // trustworthy than ours would be. for (const t of ['ATH', 'SAC']) { const r = pb.resolveParkBase({ teamAbbr: t }); expect(r.state).toBe('absent'); expect(r.hr_base).toBeNull(); expect(r.reason).toMatch(/thin\/unstable/); } }); it('unknown team → absent, never a neutral guess', () => { expect(pb.resolveParkBase({ teamAbbr: 'ZZZ' }).state).toBe('absent'); expect(pb.resolveParkBase({}).state).toBe('absent'); }); it('flags domes weather_na while the park factor STILL applies', () => { const tb = pb.resolveParkBase({ teamAbbr: 'TB' }); expect(tb.weather_na).toBe(true); expect(tb.state).toBe('present'); // N/A is not absent }); }); describe('SOURCE-PLUGGABLE — same slot, no rearchitecting', () => { const derived = { state: 'present', hr_base: 1.11, run_base: 1.05, venue: 'Coors Field', weather_na: false, games: 162, seasons: 4 }; it('self-derived swaps into the same shape and IS proprietary', () => { const d = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived }); expect(d.state).toBe('present'); expect(d.hr_base).toBe(1.11); expect(d.provenance.proprietary).toBe(true); expect(d.provenance.label).toBe('VYNDR PARK FACTOR'); // identical keys to the public answer → callers never branch on source const p = pb.resolveParkBase({ teamAbbr: 'COL' }); expect(Object.keys(d)).toEqual(expect.arrayContaining(Object.keys(p))); }); it('derived-but-unavailable is absent, never silently falls back', () => { const r = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived: null }); expect(r.state).toBe('absent'); expect(r.reason).toMatch(/self-derived factor not available/); }); }); describe('composes into the challenger, directional by prop-owner', () => { const env = (b, stat) => ({ multiplier: pf.parkMultiplier({ factor: b, statType: stat }), label: b.provenance.label, venue: b.venue, weather_na: b.weather_na }); const col = pb.resolveParkBase({ teamAbbr: 'COL' }); it('hitter HR and pitcher HR-allowed move the SAME way at the same park', () => { const h = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') }); const p = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs_allowed', environment: env(col, 'home_runs_allowed') }); expect(h.delta).toBeGreaterThan(0); expect(p.delta).toBe(h.delta); }); it('mirrors on the under and does nothing for an unrelated stat', () => { const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') }); const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env(col, 'home_runs') }); expect(under.delta).toBeCloseTo(-over.delta, 3); expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', environment: env(col, 'strikeouts') }).delta).toBe(0); }); it('an absent park adjusts nothing', () => { const ath = pb.resolveParkBase({ teamAbbr: 'ATH' }); expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(ath, 'home_runs') }).delta).toBe(0); }); }); describe('game-context capture — game grain, named purpose, finals only', () => { const payload = { dates: [{ date: '2025-07-19', games: [ { gamePk: 777094, season: '2025', status: { detailedState: 'Final' }, venue: { id: 14, name: 'Rogers Centre' }, teams: { home: { team: { name: 'Toronto Blue Jays' } }, away: { team: { name: 'New York Yankees' } } }, linescore: { teams: { home: { runs: 5 }, away: { runs: 4 } } }, scoringPlays: [ { result: { event: 'Home Run' }, about: { halfInning: 'bottom' } }, { result: { event: 'Home Run' }, about: { halfInning: 'top' } }, { result: { event: 'Single' }, about: { halfInning: 'top' } }, ] }, { gamePk: 777095, season: '2025', status: { detailedState: 'In Progress' }, venue: { id: 15, name: 'Other Park' }, linescore: { teams: { home: { runs: 2 }, away: { runs: 1 } } } }, ], }], }; it('one row per game, keyed for dedupe', () => { const rows = gc.rowsFromSchedule(payload); expect(rows).toHaveLength(1); // the in-progress game is excluded expect(rows[0].game_id).toBe('mlb:777094'); expect(new Set(rows.map((r) => r.game_id)).size).toBe(rows.length); }); it('EXCLUDES non-final games — in-progress totals are not results', () => { expect(gc.rowsFromSchedule(payload).every((r) => r.status === 'Final')).toBe(true); }); it('captures exactly the NAMED-PURPOSE fields', () => { const r = gc.rowsFromSchedule(payload)[0]; expect(r.venue_id).toBe(14); // → self-derived park factors expect(r.total_runs).toBe(9); // → run-environment factors expect(r.total_hr).toBe(2); // → HR park factors expect(r.home_hr).toBe(1); expect(r.away_hr).toBe(1); // No speculative fields hoarded "just in case". expect(Object.keys(r).sort()).toEqual([ 'away_hr', 'away_runs', 'away_team', 'game_date', 'game_id', 'home_hr', 'home_runs', 'home_team', 'season', 'sport', 'status', 'total_hr', 'total_runs', 'venue_id', 'venue_name', ]); }); it('HR totals come from scoring plays — every HR scores, so none are missed', () => { const r = gc.rowsFromSchedule(payload)[0]; expect(r.total_hr).toBe(2); // the Single is not counted }); it('a game with no scoring plays reports HR as ABSENT, not zero', () => { const noPlays = { dates: [{ date: '2025-07-19', games: [{ gamePk: 1, status: { detailedState: 'Final' }, venue: { id: 1 }, linescore: { teams: { home: { runs: 0 }, away: { runs: 0 } } } }] }] }; expect(gc.rowsFromSchedule(noPlays)[0].total_hr).toBeNull(); }); it('upserts on game_id — capture is idempotent per date', async () => { let opts = null; const sb = { from: () => ({ upsert: async (_r, o) => { opts = o; return { error: null }; } }) }; await gc.captureDate('2025-07-19', { sb, fetchJson: async () => payload }); expect(opts.onConflict).toBe('game_id'); }); it('one call per DATE — not per game, not per prop', () => { expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/startDate=2025-07-19&endDate=2025-07-19/); expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/hydrate=linescore,venue,scoringplays/); }); }); describe('accrual target — "later" is measurable', () => { it('states the per-venue floor and the season estimate', async () => { const sb = { from: () => ({ select: () => ({ eq: () => ({ limit: async () => ({ data: [ ...Array(120).fill({ venue_id: 14, venue_name: 'Rogers Centre' }), ...Array(160).fill({ venue_id: 19, venue_name: 'Coors Field' }), ], error: null }) }) }) }) }; const s = await gc.accrualStatus({ sb }); expect(s.min_games_per_venue).toBe(pf.MIN_GAMES); expect(s.venues_ready).toBe(1); // Coors has 160 ≥ 150 expect(s.seasons_needed_estimate).toBeGreaterThan(1); // ~2 seasons expect(s.venues.find((v) => v.venue_id === 14).remaining).toBe(30); }); });