fceb3707b5
The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).
snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).
getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.
Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
11 KiB
JavaScript
205 lines
11 KiB
JavaScript
// Wave 2B (WIRING & DATA TRAIN, Step 2 follow-up) — the ESPN athlete index.
|
|
//
|
|
// THE GAP: NBA/WNBA `espnId` was captured ONLY from espnStatsAdapter (the
|
|
// offline-Python fallback), unreliable in prod. ESPN's own summary/boxscore/
|
|
// leaders/injuries/roster payloads — already free, already called for a slate —
|
|
// carry each athlete's id AND often a DIRECT headshot href. This suite locks:
|
|
// (a) an ESPN summary fixture → { nameKey → { espnId, headshotHref } } index
|
|
// (b) a grade with NO stats-espnId gets the id/href from the index by nameKey
|
|
// (c) a direct headshotHref WINS over the constructed (sport,id) URL
|
|
// (d) soccer with no reliable href → absent → monogram (honest)
|
|
// (e) a malformed ESPN shape → empty index, never a throw
|
|
// NO network — every dep is injected.
|
|
|
|
const { nameKey } = require('../../src/utils/playerName');
|
|
const idx = require('../../src/services/espnAthleteIndex');
|
|
const svc = require('../../src/services/snapshotService');
|
|
const adapter = require('../../web/src/lib/slateAdapter');
|
|
const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl');
|
|
|
|
function memCache() {
|
|
const store = {};
|
|
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
|
|
}
|
|
|
|
// A realistic ESPN summary: athletes surface via injuries/leaders/boxscore/
|
|
// roster wrappers; a bare TEAM object must NOT be harvested as a player.
|
|
const HREF = (id) => `https://a.espncdn.com/i/headshots/wnba/players/full/${id}.png`;
|
|
const summaryFixture = {
|
|
injuries: [
|
|
{ team: { displayName: 'Indiana Fever' }, injuries: [
|
|
{ status: 'OUT', athlete: { id: '4433403', displayName: 'Caitlin Clark', headshot: { href: HREF('4433403') } } },
|
|
] },
|
|
],
|
|
leaders: [
|
|
{ leaders: [ { leaders: [
|
|
{ athlete: { id: '2529140', displayName: 'Kelsey Mitchell', headshot: { href: HREF('2529140') } }, value: 21 },
|
|
] } ] },
|
|
],
|
|
boxscore: { players: [
|
|
{ statistics: [ { athletes: [
|
|
{ athlete: { id: '3906972', displayName: 'Aliyah Boston', headshot: { href: HREF('3906972') } }, stats: ['12'] },
|
|
] } ] },
|
|
] },
|
|
rosters: [
|
|
// headshot as a bare string (ESPN varies the shape) + no numeric id-only team.
|
|
{ roster: [ { athlete: { id: '4281190', displayName: 'Sophie Cunningham', headshot: HREF('4281190') } } ] },
|
|
],
|
|
// A team object (numeric id + displayName, NO athlete marker) — must be ignored.
|
|
teams: [ { team: { id: '5', displayName: 'Indiana Fever' } } ],
|
|
};
|
|
|
|
describe('(a) harvestAthletes — ESPN summary → { nameKey → {espnId, headshotHref} }', () => {
|
|
it('pulls athletes from injuries, leaders, boxscore, and roster wrappers', () => {
|
|
const index = idx.harvestAthletes(summaryFixture);
|
|
expect(index[nameKey('Caitlin Clark')]).toEqual({ espnId: '4433403', headshotHref: HREF('4433403') });
|
|
expect(index[nameKey('Kelsey Mitchell')].espnId).toBe('2529140');
|
|
expect(index[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
|
|
// headshot given as a bare string still resolves.
|
|
expect(index[nameKey('Sophie Cunningham')].headshotHref).toBe(HREF('4281190'));
|
|
});
|
|
|
|
it('does NOT harvest a bare team object as an athlete', () => {
|
|
const index = idx.harvestAthletes(summaryFixture);
|
|
expect(index[nameKey('Indiana Fever')]).toBeUndefined();
|
|
});
|
|
|
|
it('buildEspnAthleteIndex fans out per game (injected schedule + summary, no network)', async () => {
|
|
const cache = memCache();
|
|
const built = await idx.buildEspnAthleteIndex('wnba', {
|
|
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
|
getSchedule: async () => [{ id: '401' }, { id: '402' }],
|
|
getGameSummary: async () => summaryFixture,
|
|
date: '2026-07-10',
|
|
});
|
|
expect(built[nameKey('Caitlin Clark')].espnId).toBe('4433403');
|
|
expect(built[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
|
|
// Cached under the sport+date key.
|
|
expect(cache.store['espnindex:wnba:2026-07-10']).toBeTruthy();
|
|
});
|
|
|
|
it('MLB → {} (MLBAM path is untouched); returns {} on any error, never throws', async () => {
|
|
const cache = memCache();
|
|
expect(await idx.buildEspnAthleteIndex('mlb', { cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toEqual({});
|
|
// A schedule that throws degrades to {} (graceful).
|
|
const built = await idx.buildEspnAthleteIndex('nba', {
|
|
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
|
getSchedule: async () => { throw new Error('espn down'); },
|
|
getGameSummary: async () => ({}),
|
|
date: '2026-07-10',
|
|
});
|
|
expect(built).toEqual({});
|
|
});
|
|
});
|
|
|
|
// A generic grade-capture stub (mirrors gradeAndCacheSlate's envelope contract).
|
|
function fakeGrade(grades) {
|
|
return async (_sport, _props, opts) => { await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' }); };
|
|
}
|
|
|
|
describe('(b) snapshot merge — no stats-espnId → index fills espnId + headshotUrl', () => {
|
|
it('a WNBA grade with no resolved id inherits the ESPN index id + direct href', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('wnba', {
|
|
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, over_odds: -115, under_odds: -105, book: 'dk' }], provider: 'test' }),
|
|
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
|
|
resolveStats: async () => ({ found: true, classifierInput: {} }), // NO espnId from the flaky fallback
|
|
classify: () => ({ primary: null }),
|
|
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
|
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
|
|
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
|
|
});
|
|
const snap = cache.store['snapshot:wnba:latest'];
|
|
const cc = snap.grades.find((g) => nameKey(g.player) === nameKey('Caitlin Clark'));
|
|
expect(cc.espnId).toBe('4433403');
|
|
expect(cc.headshotUrl).toBe(HREF('4433403'));
|
|
// grades:{sport} inherits it too (GameCard/Explore read from here).
|
|
const g = cache.store['grades:wnba'].grades.find((x) => nameKey(x.player) === nameKey('Caitlin Clark'));
|
|
expect(g.headshotUrl).toBe(HREF('4433403'));
|
|
});
|
|
|
|
it('the stats-resolve espnId still wins when present (index is only a fallback)', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('wnba', {
|
|
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, book: 'dk' }], provider: 'test' }),
|
|
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
|
|
resolveStats: async () => ({ found: true, classifierInput: {}, espnId: '999999' }),
|
|
classify: () => ({ primary: null }),
|
|
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
|
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
|
|
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
|
|
});
|
|
const cc = cache.store['snapshot:wnba:latest'].grades[0];
|
|
expect(cc.espnId).toBe('999999'); // primary resolve wins for the id
|
|
expect(cc.headshotUrl).toBe(HREF('4433403')); // but the direct href still applies
|
|
});
|
|
});
|
|
|
|
describe('(c) a direct headshotHref WINS over the constructed (sport,id) URL — in the strip', () => {
|
|
it('buildPlayerStripsFromProps threads headshotUrl; getHeadshotUrl prefers it', () => {
|
|
const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }];
|
|
const gradeIndex = adapter.indexGrades([
|
|
{
|
|
player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A',
|
|
espnId: '4433403', headshotUrl: HREF('4433403'), team: 'IND',
|
|
gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' },
|
|
},
|
|
]);
|
|
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
|
|
expect(strips).toHaveLength(1);
|
|
expect(strips[0].headshotUrl).toBe(HREF('4433403'));
|
|
// The direct href resolves verbatim, never the constructed a.espncdn combiner URL.
|
|
const resolved = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId, headshotUrl: strips[0].headshotUrl });
|
|
expect(resolved).toBe(HREF('4433403'));
|
|
expect(resolved).not.toContain('combiner');
|
|
// Without the href, it falls back to the constructed URL (still honest).
|
|
const constructed = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId });
|
|
expect(constructed).toContain('/headshots/wnba/players/full/4433403.png');
|
|
});
|
|
});
|
|
|
|
describe('(d) soccer with no reliable href → absent → monogram (honest)', () => {
|
|
it('a soccer index that yields no href leaves the resolver at the silhouette sentinel', async () => {
|
|
const cache = memCache();
|
|
// ESPN soccer summaries carry no athlete id/headshot via getGameSummary (no
|
|
// soccer path) → the index is empty → nothing to thread.
|
|
const built = await idx.buildEspnAthleteIndex('soccer', {
|
|
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
|
|
getSchedule: async () => [{ id: '9' }],
|
|
getGameSummary: async () => ({ injuries: [], leaders: [], boxscore: null, rosters: [] }),
|
|
date: '2026-07-10',
|
|
});
|
|
expect(idx.lookup(built, 'Lionel Messi')).toBeNull();
|
|
// No id + no href → the silhouette sentinel (PlayerAvatar swaps to a monogram).
|
|
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: null })).toBe('/images/player-silhouette.svg');
|
|
// We NEVER construct a soccer URL from an id (would 404).
|
|
expect(getHeadshotUrl({ sport: 'soccer', espnId: '12345' })).toBe('/images/player-silhouette.svg');
|
|
});
|
|
|
|
it('BUT a soccer athlete WITH a direct href is honored (best-effort)', () => {
|
|
const href = 'https://a.espncdn.com/i/headshots/soccer/players/full/45843.png';
|
|
const index = idx.harvestAthletes({ rosters: [{ roster: [{ athlete: { id: '45843', displayName: 'Lionel Messi', headshot: { href } } }] }] });
|
|
expect(index[nameKey('Lionel Messi')].headshotHref).toBe(href);
|
|
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: href })).toBe(href);
|
|
});
|
|
});
|
|
|
|
describe('(e) defensive parse — malformed ESPN shapes never throw', () => {
|
|
it('null / non-object / garbage → empty index', () => {
|
|
expect(idx.harvestAthletes(null)).toEqual({});
|
|
expect(idx.harvestAthletes(undefined)).toEqual({});
|
|
expect(idx.harvestAthletes(42)).toEqual({});
|
|
expect(idx.harvestAthletes('nope')).toEqual({});
|
|
expect(idx.harvestAthletes({ nonsense: true, boxscore: { players: 'not-an-array' } })).toEqual({});
|
|
// An athlete with no id AND no headshot contributes nothing (absent beats noise).
|
|
expect(idx.harvestAthletes({ leaders: [{ athlete: { displayName: 'No Id Here' } }] })).toEqual({});
|
|
});
|
|
|
|
it('a cyclic object does not hang the walker', () => {
|
|
const a = { athlete: { id: '1', displayName: 'Loop Player', headshot: { href: 'https://x/1.png' } } };
|
|
a.self = a; // cycle
|
|
const index = idx.harvestAthletes(a);
|
|
expect(index[nameKey('Loop Player')].espnId).toBe('1');
|
|
});
|
|
});
|