diff --git a/src/services/intelligence/teamStatsCache.js b/src/services/intelligence/teamStatsCache.js index 141ccde..24c0d23 100644 --- a/src/services/intelligence/teamStatsCache.js +++ b/src/services/intelligence/teamStatsCache.js @@ -68,7 +68,16 @@ function flattenTeamStats(payload) { // ESPN returns: { team, season, splits: [...], stats: [...] } depending on // endpoint. Most commonly: payload.results.stats[]/categories[] for // /teams/{id}/statistics - const buckets = payload?.results?.stats || payload?.stats || []; + // Session 64 — ESPN's CURRENT shape is `results.stats` = an OBJECT carrying + // `categories[]` (general / offensive / defensive), each with `stats[]`. The + // old code assumed an ARRAY and did `for...of` on it, which threw "buckets is + // not iterable" for EVERY team — verified live: captured 0, errored 15/15. + // Because `refreshTeamStats` had no production callers until S63, that crash + // was invisible. Normalize every known shape to an array of buckets. + const raw = payload?.results?.stats ?? payload?.stats ?? []; + const buckets = Array.isArray(raw) + ? raw + : (Array.isArray(raw?.categories) ? raw.categories : []); const all = []; for (const b of buckets) { if (Array.isArray(b?.stats)) all.push(...b.stats); diff --git a/tests/unit/teamStatsShape.test.js b/tests/unit/teamStatsShape.test.js new file mode 100644 index 0000000..0a67c4b --- /dev/null +++ b/tests/unit/teamStatsShape.test.js @@ -0,0 +1,55 @@ +/** + * Session 64 — ESPN team-statistics shape regression. + * + * `results.stats` is an OBJECT carrying `categories[]`, not an array. The old + * parser did `for...of` on it and threw "buckets is not iterable" for EVERY + * team (verified live: captured 0, errored 15/15). It was invisible because + * refreshTeamStats had no production callers until S63 wired it in. + */ +const { __internals } = require('../../src/services/intelligence/teamStatsCache'); +const { normalize } = __internals; + +// The real ESPN shape, trimmed. +const CURRENT_SHAPE = { + results: { + stats: { + categories: [ + { name: 'general', stats: [{ name: 'avgRebounds', value: 33.8 }] }, + { name: 'offensive', stats: [{ name: 'fieldGoalPct', value: 47.35 }, { name: 'avgPoints', value: 84.2 }] }, + { name: 'defensive', stats: [{ name: 'avgBlocks', value: 4.1 }] }, + ], + }, + }, +}; + +// The legacy array shape must keep working. +const LEGACY_SHAPE = { + results: { stats: [{ stats: [{ name: 'fieldGoalPct', value: 44.0 }] }] }, +}; + +describe('ESPN team-statistics parsing', () => { + test('does not throw on the CURRENT object+categories shape', () => { + expect(() => normalize('wnba', CURRENT_SHAPE)).not.toThrow(); + }); + + test('extracts stats that exist in the current shape', () => { + expect(normalize('wnba', CURRENT_SHAPE).team_fg_pct).toBeCloseTo(47.35, 1); + }); + + test('still parses the legacy array shape', () => { + expect(normalize('wnba', LEGACY_SHAPE).team_fg_pct).toBeCloseTo(44.0, 1); + }); + + test('missing/empty payloads degrade to nulls, never throw', () => { + expect(() => normalize('wnba', {})).not.toThrow(); + expect(() => normalize('wnba', null)).not.toThrow(); + }); + + test('DOCUMENTS THE GAP: the endpoint carries no defensive-strength metric, so opponent rank stays underivable', () => { + const n = normalize('wnba', CURRENT_SHAPE); + expect(n.defensive_rating).toBeNull(); + expect(n.opponent_ppg).toBeNull(); + // If this test ever fails because these populate, the opponent-rank factor + // can be revived — see specs/audit-data/grade-collapse.md. + }); +});