'use strict'; /** * The WNBA possession/usage feed — the source, the derivations, the as-of rule. * * Two failures this suite exists to prevent. First, a derived rate that is not * actually the identity it claims to be: usage and possessions feed the chain's * opportunity term directly, so a wrong constant or a transposed term would be * invisible downstream and wrong everywhere. Second — and this is the one that * has bitten this codebase repeatedly — a read that quietly includes the game it * is trying to predict. */ const adapter = require('../../src/services/adapters/espnWnbaAdapter'); const usage = require('../../src/services/wnbaUsageService'); /** A real-shaped ESPN summary payload, trimmed to what the parser reads. */ const PLAYER_KEYS = [ 'minutes', 'points', 'fieldGoalsMade-fieldGoalsAttempted', 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'freeThrowsMade-freeThrowsAttempted', 'rebounds', 'assists', 'turnovers', 'steals', 'blocks', 'offensiveRebounds', 'defensiveRebounds', 'fouls', 'plusMinus', ]; const athlete = (id, name, stats, over = {}) => ({ athlete: { id, displayName: name }, starter: true, stats, ...over, }); const teamStats = (fg, ft, tov, oreb) => [ { name: 'fieldGoalsMade-fieldGoalsAttempted', displayValue: fg }, { name: 'freeThrowsMade-freeThrowsAttempted', displayValue: ft }, { name: 'totalTurnovers', displayValue: String(tov) }, { name: 'offensiveRebounds', displayValue: String(oreb) }, ]; function summary(over = {}) { return { header: { id: '401857134', season: { year: 2026 }, competitions: [{ date: '2026-08-11T23:30Z', status: { type: { name: 'STATUS_FINAL' } }, competitors: [ { homeAway: 'home', score: '106', team: { id: '5', abbreviation: 'IND' } }, { homeAway: 'away', score: '92', team: { id: '9', abbreviation: 'NY' } }, ], }], ...over.header, }, boxscore: { players: [ { team: { id: '9', abbreviation: 'NY' }, statistics: [{ keys: PLAYER_KEYS, athletes: [ // MIN PTS FG 3PT FT REB AST TO STL BLK OREB DREB PF +/- athlete('1', 'Breanna Stewart', ['30', '19', '8-21', '1-5', '2-2', '7', '5', '0', '1', '0', '2', '5', '2', '-2']), athlete('2', 'Sabrina Ionescu', ['34', '22', '7-18', '4-9', '4-4', '4', '6', '3', '2', '0', '0', '4', '1', '-5']), athlete('3', 'Deep Bench', null, { didNotPlay: true, stats: [] }), ], }], }, ], teams: [ { team: { id: '9', abbreviation: 'NY' }, statistics: teamStats('35-76', '11-13', 13, 13) }, { team: { id: '5', abbreviation: 'IND' }, statistics: teamStats('37-69', '16-19', 11, 4) }, ], }, ...over, }; } describe('THE SOURCE — ESPN returns components, and the parser reads them', () => { it('parses a real-shaped box score into per-player rows', () => { const { rows, game } = adapter.parseSummary(summary()); expect(game.game_id).toBe('401857134'); expect(rows).toHaveLength(2); const s = rows.find((r) => r.player_name === 'Breanna Stewart'); expect(s.minutes).toBe(30); expect(s.fga).toBe(21); expect(s.fta).toBe(2); expect(s.tov).toBe(0); expect(s.team).toBe('NY'); expect(s.opponent).toBe('IND'); expect(s.home_away).toBe('away'); }); it('a DNP is OMITTED, not recorded as a zero line', () => { // A zero-minute row asserts he was available and produced nothing. That is a // different fact from "he did not play", and the usage denominator would // divide by his zero minutes anyway. const { rows } = adapter.parseSummary(summary()); expect(rows.find((r) => r.player_name === 'Deep Bench')).toBeUndefined(); }); it('ET dating — a 23:30Z tip is the SAME ET evening, not the next day', () => { // Using the UTC date would file half of every slate a day late. const { rows } = adapter.parseSummary(summary()); expect(rows[0].game_date).toBe('2026-08-11'); }); it('an UNFINISHED game is refused — a partial box score is a moving denominator', () => { const live = summary(); live.header.competitions[0].status.type.name = 'STATUS_IN_PROGRESS'; const out = adapter.parseSummary(live); expect(out.rows).toHaveLength(0); expect(out.skipped).toMatch(/not_final/); }); it('a malformed made-attempted pair is ABSENT, never zero', () => { expect(adapter.madeAtt('8-21')).toEqual({ made: 8, att: 21 }); expect(adapter.madeAtt('--')).toEqual({ made: null, att: null }); expect(adapter.madeAtt(null)).toEqual({ made: null, att: null }); }); }); describe('THE DERIVATIONS — exact identities, not proxies', () => { const t = { fga: 76, fta: 13, tov: 13, oreb: 13 }; it('possessions = FGA - OREB + TOV + 0.44*FTA', () => { expect(adapter.possessions(t)).toBeCloseTo(76 - 13 + 13 + 0.44 * 13, 6); }); it('a missing term makes possessions ABSENT — never a partial sum', () => { // Treating an absent turnover count as zero understates possessions and // inflates every usage rate computed against it. expect(adapter.possessions({ ...t, tov: null })).toBeNull(); expect(adapter.possessions(null)).toBeNull(); }); it('usage matches the standard identity, recomputed independently', () => { const p = { fga: 21, fta: 2, tov: 0, minutes: 30 }; const teamMinutes = 200; const expected = 100 * ((21 + 0.44 * 2 + 0) * (teamMinutes / 5)) / (30 * (76 + 0.44 * 13 + 13)); expect(adapter.usageRate(p, t, teamMinutes)).toBeCloseTo(expected, 9); }); it('usage is ABSENT on a zero-minute or missing-term row, never Infinity or 0', () => { expect(adapter.usageRate({ fga: 5, fta: 0, tov: 1, minutes: 0 }, t, 200)).toBeNull(); expect(adapter.usageRate({ fga: null, fta: 0, tov: 1, minutes: 20 }, t, 200)).toBeNull(); expect(adapter.usageRate({ fga: 5, fta: 0, tov: 1, minutes: 20 }, t, 0)).toBeNull(); }); it('pace handles OVERTIME through team minutes, with no special case', () => { // 5 players x 40 min = 200 team-minutes in regulation; an OT game has more, // and the divisor follows rather than being branched on. const reg = adapter.pace(100, 200); const ot = adapter.pace(100, 225); expect(reg).toBeCloseTo(100, 6); // 100 poss per 40 min expect(ot).toBeLessThan(reg); // same possessions over more time }); it('true shooting and eFG are absent without attempts', () => { expect(adapter.trueShooting({ points: 19, fga: 21, fta: 2 })).toBeCloseTo(19 / (2 * (21 + 0.88)), 9); expect(adapter.trueShooting({ points: 0, fga: 0, fta: 0 })).toBeNull(); expect(adapter.efgPct({ fgm: 8, fg3m: 1, fga: 21 })).toBeCloseTo(8.5 / 21, 9); expect(adapter.efgPct({ fgm: 0, fg3m: 0, fga: 0 })).toBeNull(); }); it('END TO END: the stored rate equals the identity recomputed from the stored components', () => { // This is the factorFreeze rule applied to a feed: every component is stored // beside its derived value, so the rate is checkable rather than asserted. const { rows } = adapter.parseSummary(summary()); for (const r of rows) { const expected = 100 * ((r.fga + 0.44 * r.fta + r.tov) * (r.team_minutes / 5)) / (r.minutes * (r.team_fga + 0.44 * r.team_fta + r.team_tov)); expect(r.usage_rate).toBeCloseTo(expected, 3); expect(r.team_possessions) .toBeCloseTo(r.team_fga - r.team_oreb + r.team_tov + 0.44 * r.team_fta, 3); } }); it('carries the game state the redistribute hook reads', () => { const { rows } = adapter.parseSummary(summary()); const ny = rows[0]; expect(ny.team_score).toBe(92); expect(ny.opp_score).toBe(106); expect(ny.final_margin).toBe(-14); // a blowout the hook can see expect(ny.starter).toBe(true); }); }); describe('LEAGUE MEMBERSHIP — exhibitions and All-Star are not league games', () => { const teamsPayload = { sports: [{ leagues: [{ teams: [{ team: { abbreviation: 'NY' } }, { team: { abbreviation: 'IND' } }] }] }], }; const fetchImpl = (url) => Promise.resolve(url.includes('/teams') ? teamsPayload : summary()); it('a league game passes through', async () => { const out = await adapter.getGameRows('401857134', { fetchImpl, force: true }); expect(out.rows.length).toBe(2); }); it('a game against a NON-LEAGUE side is refused, and names who', async () => { // The season pull surfaced exactly two: an exhibition vs Nigeria and the // All-Star game. Their usage/pace context is meaningless for a forward // projection, so counting them would pollute every profile spanning them. const exhibition = summary(); exhibition.header.competitions[0].competitors[0].team.abbreviation = 'NIGER'; exhibition.boxscore.teams[1].team.abbreviation = 'NIGER'; const f = (url) => Promise.resolve(url.includes('/teams') ? teamsPayload : exhibition); const out = await adapter.getGameRows('401867794', { fetchImpl: f, force: true }); expect(out.rows).toHaveLength(0); expect(out.skipped).toMatch(/^not_a_league_game:/); expect(out.skipped).toContain('NIGER'); }); it('an EMPTY teams response does not silently filter the whole league away', async () => { // A failed feed that degrades to an empty index looks exactly like an honest // absence — the fielding_oaa lesson. Unknown membership means NO filtering, // never "nothing is in the league". const empty = { sports: [{ leagues: [{ teams: [] }] }] }; const f = (url) => Promise.resolve(url.includes('/teams') ? empty : summary()); const out = await adapter.getGameRows('401857134', { fetchImpl: f, force: true }); expect(out.rows.length).toBe(2); }); }); describe('THE AS-OF RULE — point-in-time is structural here', () => { const games = [ { game_date: '2026-08-01', minutes: 30, usage_rate: 24, team_pace: 96, team_possessions: 80, ts_pct: 0.55, efg_pct: 0.5, starter: true, final_margin: 5, team: 'NY', game_id: 'g1', source_id: '1' }, { game_date: '2026-08-04', minutes: 34, usage_rate: 28, team_pace: 98, team_possessions: 82, ts_pct: 0.58, efg_pct: 0.52, starter: true, final_margin: -3, team: 'NY', game_id: 'g2', source_id: '1' }, { game_date: '2026-08-07', minutes: 20, usage_rate: 30, team_pace: 94, team_possessions: 78, ts_pct: 0.50, efg_pct: 0.48, starter: false, final_margin: 12, team: 'NY', game_id: 'g3', source_id: '1' }, { game_date: '2026-08-11', minutes: 32, usage_rate: 26, team_pace: 99, team_possessions: 83, ts_pct: 0.60, efg_pct: 0.55, starter: true, final_margin: -14, team: 'NY', game_id: 'g4', source_id: '1' }, ].map((g) => ({ sport: 'wnba', player_key: 'test player', season: 2026, ...g })); /** A stub client driving the REAL paginate + read path. */ const client = (rows) => ({ from() { const st = { rows: rows.slice(), order: [] }; const q = { select: () => q, eq: (c, v) => { st.rows = st.rows.filter((r) => r[c] === v); return q; }, lt: (c, v) => { st.rows = st.rows.filter((r) => String(r[c]) < String(v)); return q; }, order: (c, o) => { st.order.push([c, !o || o.ascending !== false]); return q; }, range: async (a, b) => { const sorted = st.rows.slice().sort((x, y) => { for (const [c, asc] of st.order) { const cmp = String(x[c]).localeCompare(String(y[c])); if (cmp) return asc ? cmp : -cmp; } return 0; }); return { data: sorted.slice(a, b + 1), error: null }; }, }; return q; }, }); it('reads STRICTLY before the cutoff — a game ON the date is excluded', async () => { // The leak this prevents: a 2026-08-11 game may tip after grade time, so // counting it feeds the evening being predicted into the prediction. Same // rule as snapshotSettlementService.isPreGame. const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-11' }); expect(p.games).toBe(3); expect(p.last_game_date).toBe('2026-08-07'); }); it('a later cutoff sees more games — the profile MOVES with the date', async () => { const early = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-08' }); const late = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-12' }); expect(early.games).toBe(3); expect(late.games).toBe(4); // The whole point of a point-in-time read: the same player reads differently // on different dates, because the history behind him is different. expect(early.usage_rate).not.toBe(late.usage_rate); expect(early.last_game_date).toBe('2026-08-07'); expect(late.last_game_date).toBe('2026-08-11'); }); it('REFUSES on thin history rather than returning a league-average player', async () => { // Usage feeds the chain's opportunity term directly; a stand-in profile // would assert a usage rate about someone never observed. expect(await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-08-04' })).toBeNull(); expect(await usage.profileAsOf(client(games), { playerKey: 'nobody', asOf: '2026-12-01' })).toBeNull(); }); it('usage is MINUTES-WEIGHTED, not a flat mean across games', async () => { // The lineup-K-rate lesson: an unweighted aggregate counts a 20-minute // cameo like a 34-minute start, and unweighted HURT that model. const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-12-01' }); const flat = (24 + 28 + 30 + 26) / 4; const weighted = (24 * 30 + 28 * 34 + 30 * 20 + 26 * 32) / (30 + 34 + 20 + 32); expect(p.usage_rate).toBeCloseTo(weighted, 3); expect(p.usage_rate).not.toBeCloseTo(flat, 3); }); it('delivers every chainFn input, and the redistribute game-state', async () => { const p = await usage.profileAsOf(client(games), { playerKey: 'test player', asOf: '2026-12-01' }); for (const f of ['usage_rate', 'minutes_per_game', 'team_pace', 'team_possessions', 'ts_pct', 'efg_pct']) { expect(p[f]).not.toBeNull(); } expect(p.starter_rate).toBeCloseTo(0.75, 3); expect(p.avg_final_margin).toBeCloseTo(0, 3); expect(p.as_of).toBe('2026-12-01'); // the cutoff is reported, not implied }); }); describe('the feed is registered and scoped', () => { it('its unique key is in tableKeys, so safePaginate can walk it', () => { const { uniqueKeyFor } = require('../../src/utils/tableKeys'); expect(uniqueKeyFor('wnba_player_game')).toEqual(['game_id', 'source_id']); }); it('MLB and the chain shadow are untouched by this order', () => { const fs = require('fs'); const path = require('path'); const root = path.join(__dirname, '..', '..'); // The WNBA feed must not have reached into the MLB engine or the served path. for (const f of ['src/services/model/chainShadow.js', 'src/services/model/baseballChain.js', 'src/services/intelligence/analyzeViaEngine1.js']) { expect(fs.readFileSync(path.join(root, f), 'utf8')).not.toMatch(/wnbaUsage|espnWnbaAdapter|wnba_player_game/); } }); });