S11 (a1): live tracking — the read locked, the game watched
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GET /api/live/:sport (A1 Session 11) — route wiring over the cache-aside
|
||||
* service. The service's cache behavior is unit-tested with injected deps
|
||||
* (liveTrackingService.test.js); here we lock the route contract: sport
|
||||
* gating BEFORE the service, envelope pass-through, and fail-open 200s.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../../src/services/liveTrackingService', () => ({
|
||||
getLiveTracking: jest.fn(),
|
||||
}));
|
||||
const { getLiveTracking } = require('../../src/services/liveTrackingService');
|
||||
|
||||
function mountLive() {
|
||||
delete require.cache[require.resolve('../../src/routes/live')];
|
||||
const liveRoutes = require('../../src/routes/live');
|
||||
const app = express();
|
||||
app.use('/api/live', liveRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('GET /api/live/:sport', () => {
|
||||
it('returns the service envelope for a wired sport (mlb)', async () => {
|
||||
const envelope = {
|
||||
sport: 'mlb', date: '2026-07-11', hasLive: true, updated_at: 'x',
|
||||
games: [{ id: '824249', progress: { label: '▼8th', fraction: 0.83 }, players: { 'bryce harper': { name: 'Bryce Harper', values: { total_bases: 3 } } } }],
|
||||
};
|
||||
getLiveTracking.mockResolvedValue(envelope);
|
||||
const res = await request(mountLive()).get('/api/live/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(envelope);
|
||||
expect(getLiveTracking).toHaveBeenCalledWith('mlb');
|
||||
expect(res.headers['cache-control']).toContain('max-age=30');
|
||||
});
|
||||
|
||||
it('wnba is wired', async () => {
|
||||
getLiveTracking.mockResolvedValue({ sport: 'wnba', hasLive: false, games: [] });
|
||||
const res = await request(mountLive()).get('/api/live/wnba');
|
||||
expect(res.status).toBe(200);
|
||||
expect(getLiveTracking).toHaveBeenCalledWith('wnba');
|
||||
});
|
||||
|
||||
it('an unwired sport short-circuits WITHOUT touching the service (no quota risk)', async () => {
|
||||
const res = await request(mountLive()).get('/api/live/nba');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ sport: 'nba', hasLive: false, games: [] });
|
||||
expect(getLiveTracking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a service failure fails OPEN — 200 with an honest empty envelope', async () => {
|
||||
getLiveTracking.mockRejectedValue(new Error('redis down'));
|
||||
const res = await request(mountLive()).get('/api/live/mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ sport: 'mlb', hasLive: false, games: [] });
|
||||
});
|
||||
|
||||
it('sport param is case-insensitive', async () => {
|
||||
getLiveTracking.mockResolvedValue({ sport: 'mlb', hasLive: false, games: [] });
|
||||
const res = await request(mountLive()).get('/api/live/MLB');
|
||||
expect(res.status).toBe(200);
|
||||
expect(getLiveTracking).toHaveBeenCalledWith('mlb');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* lib/liveProgress (A1 Session 11) — pure prop-state math + the strip join.
|
||||
* The read is locked pre-game; these are proto-outcomes for the ROW-GRAMMAR
|
||||
* outcome slot. Color law: green = hit/on-pace/holding, amber = needs/past,
|
||||
* NEVER red in-progress.
|
||||
*/
|
||||
|
||||
const {
|
||||
propState,
|
||||
buildLiveIndex,
|
||||
attachLiveProgress,
|
||||
gameLiveProximity,
|
||||
sortLiveFirst,
|
||||
} = require('../../web/src/lib/liveProgress');
|
||||
|
||||
describe('propState — over semantics', () => {
|
||||
test('over already cleared → HIT ✓', () => {
|
||||
expect(propState({ side: 'O', line: 1.5, current: 2, progress: 0.5 }))
|
||||
.toEqual({ state: 'hit', label: 'HIT ✓', needs: 0 });
|
||||
});
|
||||
test('current == integer line is NOT a hit (push at best) → needs 2 to beat it', () => {
|
||||
const s = propState({ side: 'O', line: 2, current: 2, progress: 0.9 });
|
||||
expect(s.state).not.toBe('hit');
|
||||
expect(s.needs).toBe(1); // needs 1 more to reach 3 (> 2)
|
||||
});
|
||||
test('on pace: projection clears floor(line)+1', () => {
|
||||
// 1 TB at 50% of the game → projects 2 = clears O1.5.
|
||||
expect(propState({ side: 'O', line: 1.5, current: 1, progress: 0.5 }).state).toBe('on_pace');
|
||||
});
|
||||
test('behind pace → NEEDS N amber state', () => {
|
||||
const s = propState({ side: 'O', line: 1.5, current: 0, progress: 0.833 });
|
||||
expect(s.state).toBe('needs');
|
||||
expect(s.label).toBe('NEEDS 2');
|
||||
expect(s.needs).toBe(2);
|
||||
});
|
||||
test('NEEDS N beats the push on integer lines', () => {
|
||||
const s = propState({ side: 'over', line: 2, current: 1, progress: 0.9 });
|
||||
expect(s.needs).toBe(2); // to reach 3, not the push at 2
|
||||
});
|
||||
test('no progress → no pace optimism, stays NEEDS', () => {
|
||||
const s = propState({ side: 'O', line: 1.5, current: 1, progress: null });
|
||||
expect(s.state).toBe('needs');
|
||||
expect(s.needs).toBe(1);
|
||||
});
|
||||
test('fractional stats (IP) ceil the needs count — over-strict beats over-claimed', () => {
|
||||
// 3⅔ IP with 70% of the game gone projects 5.24 < clear-at-6 → behind.
|
||||
const s = propState({ side: 'O', line: 5.5, current: 3 + 2 / 3, progress: 0.7 });
|
||||
expect(s.state).toBe('needs');
|
||||
expect(s.needs).toBe(3); // to 6, from 3.667 → ceil(2.33)
|
||||
});
|
||||
});
|
||||
|
||||
describe('propState — under HOLDS-IF semantics (never hit until final)', () => {
|
||||
test('count below the line → holding (green), NOT hit', () => {
|
||||
const s = propState({ side: 'U', line: 1.5, current: 0, progress: 0.9 });
|
||||
expect(s.state).toBe('holding');
|
||||
expect(s.label).toBe('HOLDS');
|
||||
});
|
||||
test('count reached/passed the line → past (amber), NOT a settled miss', () => {
|
||||
expect(propState({ side: 'U', line: 1.5, current: 2, progress: 0.5 }).state).toBe('past');
|
||||
expect(propState({ side: 'under', line: 2, current: 2, progress: 0.5 }).state).toBe('past');
|
||||
});
|
||||
});
|
||||
|
||||
describe('propState — data semantics', () => {
|
||||
test('absent current or line → null (never a fabricated state)', () => {
|
||||
expect(propState({ side: 'O', line: 1.5, current: null, progress: 0.5 })).toBeNull();
|
||||
expect(propState({ side: 'O', line: null, current: 2, progress: 0.5 })).toBeNull();
|
||||
expect(propState({})).toBeNull();
|
||||
});
|
||||
test('a REAL zero is a real value, not absent', () => {
|
||||
expect(propState({ side: 'U', line: 0.5, current: 0, progress: 0.5 }).state).toBe('holding');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Join fixtures ────────────────────────────────────────────────────
|
||||
const liveResponse = {
|
||||
sport: 'mlb',
|
||||
hasLive: true,
|
||||
games: [{
|
||||
id: '824249',
|
||||
home: 'Detroit Tigers',
|
||||
away: 'Philadelphia Phillies',
|
||||
progress: { label: '▼8th', fraction: 7.5 / 9 },
|
||||
players: {
|
||||
'bryce harper': { name: 'Bryce Harper', team: 'Philadelphia Phillies', values: { hits: 2, total_bases: 3 } },
|
||||
'casey mize': { name: 'Casey Mize', team: 'Detroit Tigers', values: { strikeouts: 5, earned_runs: 3 } },
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
const strips = [
|
||||
{
|
||||
player: 'Bryce Harper', team: 'Philadelphia Phillies',
|
||||
props: [
|
||||
{ stat: 'TB', statType: 'total_bases', line: 1.5, side: 'O', grade: 'A-', gradedAt: { line: 1.5 } },
|
||||
{ stat: 'HR', statType: 'home_runs', line: 0.5, side: 'O', grade: 'B' }, // no box value → absent
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Casey Mize', team: 'Detroit Tigers',
|
||||
props: [
|
||||
{ stat: 'Ks', statType: 'strikeouts', line: 5.5, side: 'O', grade: 'B+', gradedAt: { line: 5.5 } },
|
||||
{ stat: 'ER', statType: 'earned_runs', line: 2.5, side: 'U', grade: 'A', gradedAt: { line: 2.5 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Kyle Schwarber', team: 'Philadelphia Phillies',
|
||||
props: [{ stat: 'HR', statType: 'home_runs', line: 0.5, side: 'O', grade: 'B-' }], // not in the box at all
|
||||
},
|
||||
];
|
||||
|
||||
describe('buildLiveIndex', () => {
|
||||
test('flattens response(s) into a nameKey join index', () => {
|
||||
const idx = buildLiveIndex(liveResponse);
|
||||
expect(idx.hasLive).toBe(true);
|
||||
expect(idx.count).toBe(2);
|
||||
expect(idx.players['bryce harper'].values.total_bases).toBe(3);
|
||||
expect(idx.players['casey mize'].progress.label).toBe('▼8th');
|
||||
});
|
||||
test('merges an array of per-sport responses', () => {
|
||||
const idx = buildLiveIndex([liveResponse, { hasLive: false, games: [] }, null]);
|
||||
expect(idx.count).toBe(2);
|
||||
expect(idx.hasLive).toBe(true);
|
||||
});
|
||||
test('empty in → empty index', () => {
|
||||
expect(buildLiveIndex(null).count).toBe(0);
|
||||
expect(buildLiveIndex([]).players).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachLiveProgress — the strip join', () => {
|
||||
const idx = buildLiveIndex(liveResponse);
|
||||
const out = attachLiveProgress(strips, idx);
|
||||
|
||||
test('graded prop with a live box value gets the proto-outcome vs the LOCKED line', () => {
|
||||
const tb = out[0].props[0];
|
||||
expect(tb.live).toBeDefined();
|
||||
expect(tb.live.current).toBe(3);
|
||||
expect(tb.live.line).toBe(1.5);
|
||||
expect(tb.live.state).toBe('hit'); // 3 > 1.5, over already cleared
|
||||
expect(tb.live.progressLabel).toBe('▼8th');
|
||||
});
|
||||
|
||||
test('under uses HOLDS-IF semantics', () => {
|
||||
const er = out[1].props[1];
|
||||
expect(er.live.state).toBe('past'); // 3 ER vs U2.5 — line passed, amber
|
||||
});
|
||||
|
||||
test('a stat missing from the box → NO live mark (absent, never 0)', () => {
|
||||
expect(out[0].props[1].live).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a player not in the box at all → strip untouched (same reference)', () => {
|
||||
expect(out[2]).toBe(strips[2]);
|
||||
});
|
||||
|
||||
test('settled, dead and awaiting props are never touched', () => {
|
||||
const settled = [{
|
||||
player: 'Bryce Harper', team: 'PHI',
|
||||
props: [
|
||||
{ stat: 'TB', statType: 'total_bases', line: 1.5, side: 'O', grade: 'A-', outcome: { result: 'hit', actual: 3 } },
|
||||
{ stat: 'Hits', statType: 'hits', line: 0.5, side: 'O', grade: 'B', dead: true },
|
||||
{ stat: 'HR', statType: 'home_runs', line: 0.5, side: '', grade: null, awaiting: true },
|
||||
],
|
||||
}];
|
||||
const res = attachLiveProgress(settled, idx);
|
||||
expect(res[0].props[0].live).toBeUndefined();
|
||||
expect(res[0].props[1].live).toBeUndefined();
|
||||
expect(res[0].props[2].live).toBeUndefined();
|
||||
});
|
||||
|
||||
test('empty index → strips returned as-is', () => {
|
||||
expect(attachLiveProgress(strips, buildLiveIndex(null))).toBe(strips);
|
||||
expect(attachLiveProgress(strips, null)).toBe(strips);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameLiveProximity + sortLiveFirst — the slate float', () => {
|
||||
const idx = buildLiveIndex(liveResponse);
|
||||
// Raw odds props as the Slate carries them + the snapshot grade index.
|
||||
const gradeIndex = {
|
||||
'bryce harper|total_bases': { grade: 'A-', direction: 'over', line: 1.5, gradedAt: { line: 1.5 } },
|
||||
'casey mize|strikeouts': { grade: 'B+', direction: 'over', line: 5.5, gradedAt: { line: 5.5 } },
|
||||
};
|
||||
|
||||
test('proximity = current / (floor(line)+1) capped at 1; hit counts 1', () => {
|
||||
const harperGame = gameLiveProximity([{ player: 'Bryce Harper', stat_type: 'total_bases' }], gradeIndex, idx);
|
||||
expect(harperGame.tracked).toBe(true);
|
||||
expect(harperGame.proximity).toBe(1); // 3 TB vs clear-at-2 → capped
|
||||
const mizeGame = gameLiveProximity([{ player: 'Casey Mize', stat_type: 'strikeouts' }], gradeIndex, idx);
|
||||
expect(mizeGame.tracked).toBe(true);
|
||||
expect(mizeGame.proximity).toBeCloseTo(5 / 6, 5);
|
||||
});
|
||||
|
||||
test('ungraded or box-absent props are not tracked', () => {
|
||||
expect(gameLiveProximity([{ player: 'Kyle Schwarber', stat_type: 'home_runs' }], gradeIndex, idx).tracked).toBe(false);
|
||||
expect(gameLiveProximity([{ player: 'Bryce Harper', stat_type: 'home_runs' }], gradeIndex, idx).tracked).toBe(false);
|
||||
});
|
||||
|
||||
test('sortLiveFirst floats tracked games by proximity desc, keeps the rest stable', () => {
|
||||
const games = [
|
||||
{ id: 'pre-1' },
|
||||
{ id: 'mize', s: { tracked: true, proximity: 5 / 6 } },
|
||||
{ id: 'pre-2' },
|
||||
{ id: 'harper', s: { tracked: true, proximity: 1 } },
|
||||
];
|
||||
const sorted = sortLiveFirst(games, (g) => g.s || { tracked: false, proximity: 0 });
|
||||
expect(sorted.map((g) => g.id)).toEqual(['harper', 'mize', 'pre-1', 'pre-2']);
|
||||
});
|
||||
|
||||
test('no tracked games → original order untouched', () => {
|
||||
const games = [{ id: 'a' }, { id: 'b' }];
|
||||
expect(sortLiveFirst(games, () => ({ tracked: false, proximity: 0 })).map((g) => g.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* liveTrackingService (A1 Session 11) — parsers + cache-aside refresh.
|
||||
*
|
||||
* Fixtures below are TRIMMED FROM REAL FEEDS captured live on 2026-07-11
|
||||
* while PHI @ DET (gamePk 824249) was in the bottom of the 8th:
|
||||
* statsapi.mlb.com/api/v1/schedule?sportId=1&date=2026-07-11&hydrate=linescore
|
||||
* statsapi.mlb.com/api/v1/game/824249/boxscore
|
||||
* site.api.espn.com/.../wnba/scoreboard + summary?event=401857057
|
||||
* Player stat values are the real box numbers from that capture.
|
||||
*/
|
||||
|
||||
const svc = require('../../src/services/liveTrackingService');
|
||||
const {
|
||||
parseMlbLiveSchedule,
|
||||
parseMlbBoxscore,
|
||||
parseWnbaLiveScoreboard,
|
||||
parseWnbaBoxscore,
|
||||
mlbProgress,
|
||||
fetchLiveTracking,
|
||||
getLiveTracking,
|
||||
} = svc;
|
||||
const { ipToDecimal, LIVE_TTL } = svc.__internals;
|
||||
|
||||
// ── MLB fixtures (real shape, captured 2026-07-11) ─────────────────────
|
||||
const mlbSchedule = {
|
||||
dates: [{
|
||||
games: [
|
||||
{
|
||||
gamePk: 823357,
|
||||
status: { abstractGameState: 'Final', detailedState: 'Final' },
|
||||
teams: { away: { team: { name: 'Milwaukee Brewers' } }, home: { team: { name: 'Pittsburgh Pirates' } } },
|
||||
},
|
||||
{
|
||||
gamePk: 824249,
|
||||
status: { abstractGameState: 'Live', detailedState: 'In Progress' },
|
||||
teams: { away: { team: { name: 'Philadelphia Phillies' } }, home: { team: { name: 'Detroit Tigers' } } },
|
||||
linescore: { currentInning: 8, inningState: 'Bottom', isTopInning: false, scheduledInnings: 9 },
|
||||
},
|
||||
{
|
||||
gamePk: 823276,
|
||||
status: { abstractGameState: 'Preview', detailedState: 'Pre-Game' },
|
||||
teams: { away: { team: { name: 'Toronto Blue Jays' } }, home: { team: { name: 'San Diego Padres' } } },
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
const mlbBoxscore = {
|
||||
teams: {
|
||||
home: {
|
||||
team: { name: 'Detroit Tigers' },
|
||||
players: {
|
||||
ID669373: { person: { id: 669373, fullName: 'Tarik Skubal' }, position: { abbreviation: 'P' }, stats: { batting: {}, pitching: {} } }, // not in game — absent
|
||||
ID123456: {
|
||||
person: { id: 123456, fullName: 'Hao-Yu Lee' }, position: { abbreviation: '2B' },
|
||||
stats: { batting: { hits: 2, totalBases: 2, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 0, baseOnBalls: 0, strikeOuts: 0, atBats: 4 }, pitching: {} },
|
||||
},
|
||||
ID663554: {
|
||||
person: { id: 663554, fullName: 'Casey Mize' }, position: { abbreviation: 'P' },
|
||||
stats: { batting: {}, pitching: { strikeOuts: 5, earnedRuns: 3, inningsPitched: '5.2', outs: 17, hits: 5, baseOnBalls: 2 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
away: {
|
||||
team: { name: 'Philadelphia Phillies' },
|
||||
players: {
|
||||
ID547180: {
|
||||
person: { id: 547180, fullName: 'Bryce Harper' }, position: { abbreviation: '1B' },
|
||||
stats: { batting: { hits: 2, totalBases: 3, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 1, baseOnBalls: 0, strikeOuts: 0 }, pitching: {} },
|
||||
},
|
||||
ID650911: {
|
||||
person: { id: 650911, fullName: 'Cristopher Sánchez' }, position: { abbreviation: 'P' },
|
||||
stats: { batting: {}, pitching: { strikeOuts: 7, earnedRuns: 1, inningsPitched: '7.0', outs: 21, hits: 9, baseOnBalls: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── WNBA fixtures (real ESPN shapes, captured 2026-07-11; the scoreboard's
|
||||
// live event is constructed on the documented shape with status.state 'in'
|
||||
// since all three games were final at capture time — stated honestly) ────
|
||||
const wnbaScoreboard = {
|
||||
events: [
|
||||
{
|
||||
id: '401857057',
|
||||
status: { type: { state: 'in' }, period: 3, displayClock: '4:12' },
|
||||
competitions: [{
|
||||
competitors: [
|
||||
{ homeAway: 'home', team: { displayName: 'Minnesota Lynx' } },
|
||||
{ homeAway: 'away', team: { displayName: 'New York Liberty' } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: '401857059',
|
||||
status: { type: { state: 'post' }, period: 4 },
|
||||
competitions: [{ competitors: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wnbaSummary = {
|
||||
boxscore: {
|
||||
players: [
|
||||
{
|
||||
team: { abbreviation: 'NY', displayName: 'New York Liberty' },
|
||||
statistics: [{
|
||||
keys: ['minutes', 'points', 'fieldGoalsMade-fieldGoalsAttempted', 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'freeThrowsMade-freeThrowsAttempted', 'rebounds', 'assists', 'turnovers', 'steals', 'blocks', 'offensiveRebounds', 'defensiveRebounds', 'fouls', 'plusMinus'],
|
||||
athletes: [
|
||||
{ athlete: { displayName: 'Breanna Stewart' }, starter: true, didNotPlay: false, stats: ['37', '17', '7-16', '2-3', '1-1', '7', '3', '4', '2', '1', '3', '4', '1', '-4'] },
|
||||
{ athlete: { displayName: 'Satou Sabally' }, didNotPlay: true, stats: [] }, // DNP — absent
|
||||
],
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ipToDecimal — innings in thirds', () => {
|
||||
test('parses MLB innings notation correctly (5.2 = 5⅔, NOT parseFloat)', () => {
|
||||
expect(ipToDecimal('5.2')).toBeCloseTo(5 + 2 / 3, 5);
|
||||
expect(ipToDecimal('7.0')).toBe(7);
|
||||
expect(ipToDecimal('0.1')).toBeCloseTo(1 / 3, 5);
|
||||
});
|
||||
test('null-strict — absent is null, never 0', () => {
|
||||
expect(ipToDecimal(null)).toBeNull();
|
||||
expect(ipToDecimal('')).toBeNull();
|
||||
expect(ipToDecimal('x')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMlbLiveSchedule', () => {
|
||||
test('returns ONLY Live games with inning progress', () => {
|
||||
const live = parseMlbLiveSchedule(mlbSchedule);
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].gamePk).toBe(824249);
|
||||
expect(live[0].home).toBe('Detroit Tigers');
|
||||
expect(live[0].away).toBe('Philadelphia Phillies');
|
||||
expect(live[0].progress.label).toBe('▼8th');
|
||||
expect(live[0].progress.fraction).toBeCloseTo(7.5 / 9, 5);
|
||||
expect(live[0].progress.half).toBe('bottom');
|
||||
});
|
||||
test('empty/malformed schedule → no live games, no throw', () => {
|
||||
expect(parseMlbLiveSchedule(null)).toEqual([]);
|
||||
expect(parseMlbLiveSchedule({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbProgress', () => {
|
||||
test('top of an inning counts the full inning as remaining', () => {
|
||||
const p = mlbProgress({ currentInning: 4, inningState: 'Top', scheduledInnings: 9 });
|
||||
expect(p.label).toBe('▲4th');
|
||||
expect(p.fraction).toBeCloseTo(3 / 9, 5);
|
||||
});
|
||||
test('Middle (between halves) counts as the completed top', () => {
|
||||
const p = mlbProgress({ currentInning: 4, inningState: 'Middle', scheduledInnings: 9 });
|
||||
expect(p.fraction).toBeCloseTo(3.5 / 9, 5);
|
||||
});
|
||||
test('extra innings clamp at 1', () => {
|
||||
const p = mlbProgress({ currentInning: 11, inningState: 'Bottom', scheduledInnings: 9 });
|
||||
expect(p.fraction).toBe(1);
|
||||
});
|
||||
test('no inning yet → null (absent beats wrong)', () => {
|
||||
expect(mlbProgress({})).toBeNull();
|
||||
expect(mlbProgress(null)).toBeNull();
|
||||
});
|
||||
test('ordinals — 1st/2nd/3rd/11th', () => {
|
||||
expect(mlbProgress({ currentInning: 1, inningState: 'Top' }).label).toBe('▲1st');
|
||||
expect(mlbProgress({ currentInning: 2, inningState: 'Top' }).label).toBe('▲2nd');
|
||||
expect(mlbProgress({ currentInning: 3, inningState: 'Bottom' }).label).toBe('▼3rd');
|
||||
expect(mlbProgress({ currentInning: 11, inningState: 'Top' }).label).toBe('▲11th');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMlbBoxscore — real live box values, absent beats wrong', () => {
|
||||
const players = parseMlbBoxscore(mlbBoxscore);
|
||||
|
||||
test('batter values map to VYNDR stat types (real Bryce Harper line)', () => {
|
||||
const harper = players['bryce harper'];
|
||||
expect(harper).toBeDefined();
|
||||
expect(harper.team).toBe('Philadelphia Phillies');
|
||||
expect(harper.values.hits).toBe(2);
|
||||
expect(harper.values.total_bases).toBe(3);
|
||||
expect(harper.values.doubles).toBe(1);
|
||||
expect(harper.values.home_runs).toBe(0); // he HAS batted — real 0, not fabricated
|
||||
});
|
||||
|
||||
test('pitcher values come from stats.pitching incl. IP in thirds (real Casey Mize line)', () => {
|
||||
const mize = players['casey mize'];
|
||||
expect(mize.values.strikeouts).toBe(5);
|
||||
expect(mize.values.earned_runs).toBe(3);
|
||||
expect(mize.values.outs).toBe(17);
|
||||
expect(mize.values.hits_allowed).toBe(5);
|
||||
expect(mize.values.innings_pitched).toBeCloseTo(5 + 2 / 3, 5);
|
||||
});
|
||||
|
||||
test('a player with EMPTY stats objects has not appeared → absent, never 0', () => {
|
||||
expect(players['tarik skubal']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('accented names key on the folded nameKey', () => {
|
||||
expect(players['cristopher sanchez']).toBeDefined();
|
||||
expect(players['cristopher sanchez'].values.strikeouts).toBe(7);
|
||||
});
|
||||
|
||||
test('malformed input → empty map, no throw', () => {
|
||||
expect(parseMlbBoxscore(null)).toEqual({});
|
||||
expect(parseMlbBoxscore({ teams: {} })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWnbaLiveScoreboard', () => {
|
||||
test('returns only in-progress events with quarter progress', () => {
|
||||
const live = parseWnbaLiveScoreboard(wnbaScoreboard);
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].id).toBe('401857057');
|
||||
expect(live[0].home).toBe('Minnesota Lynx');
|
||||
expect(live[0].progress.label).toBe('Q3');
|
||||
expect(live[0].progress.fraction).toBeCloseTo(2.5 / 4, 5);
|
||||
});
|
||||
test('overtime labels + clamp', () => {
|
||||
const board = { events: [{ id: '1', status: { type: { state: 'in' }, period: 5 }, competitions: [{ competitors: [] }] }] };
|
||||
const live = parseWnbaLiveScoreboard(board);
|
||||
expect(live[0].progress.label).toBe('OT');
|
||||
expect(live[0].progress.fraction).toBe(1);
|
||||
});
|
||||
test('empty board → []', () => {
|
||||
expect(parseWnbaLiveScoreboard(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWnbaBoxscore — real ESPN summary shape', () => {
|
||||
const players = parseWnbaBoxscore(wnbaSummary);
|
||||
|
||||
test('maps the keys array onto per-athlete stat rows (real Stewart line)', () => {
|
||||
const stew = players['breanna stewart'];
|
||||
expect(stew).toBeDefined();
|
||||
expect(stew.team).toBe('New York Liberty');
|
||||
expect(stew.values.points).toBe(17);
|
||||
expect(stew.values.rebounds).toBe(7);
|
||||
expect(stew.values.assists).toBe(3);
|
||||
expect(stew.values.threes).toBe(2); // made, parsed from '2-3'
|
||||
expect(stew.values.steals).toBe(2);
|
||||
expect(stew.values.blocks).toBe(1);
|
||||
expect(stew.values.turnovers).toBe(4);
|
||||
expect(stew.values.pra).toBe(17 + 7 + 3);
|
||||
});
|
||||
|
||||
test('didNotPlay / empty stats row → absent, never 0', () => {
|
||||
expect(players['satou sabally']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('malformed input → empty map, no throw', () => {
|
||||
expect(parseWnbaBoxscore(null)).toEqual({});
|
||||
expect(parseWnbaBoxscore({ boxscore: {} })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLiveTracking — schedule identifies live games, boxscores only for them', () => {
|
||||
test('MLB: 1 schedule call + 1 boxscore per LIVE game', async () => {
|
||||
const calls = [];
|
||||
const fetchJson = jest.fn(async (url) => {
|
||||
calls.push(url);
|
||||
if (url.includes('/schedule')) return mlbSchedule;
|
||||
if (url.includes('/game/824249/boxscore')) return mlbBoxscore;
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
|
||||
expect(out.hasLive).toBe(true);
|
||||
expect(out.games).toHaveLength(1);
|
||||
expect(out.games[0].id).toBe('824249');
|
||||
expect(out.games[0].progress.label).toBe('▼8th');
|
||||
expect(out.games[0].players['bryce harper'].values.total_bases).toBe(3);
|
||||
// Quota math: exactly 1 schedule + 1 boxscore (one live game).
|
||||
expect(calls.filter((u) => u.includes('/schedule'))).toHaveLength(1);
|
||||
expect(calls.filter((u) => u.includes('/boxscore'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('MLB: no live games → schedule only, ZERO boxscore calls', async () => {
|
||||
const fetchJson = jest.fn(async () => ({ dates: [{ games: [{ gamePk: 1, status: { abstractGameState: 'Preview' } }] }] }));
|
||||
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
|
||||
expect(out.hasLive).toBe(false);
|
||||
expect(out.games).toEqual([]);
|
||||
expect(fetchJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('WNBA: scoreboard + one summary per live event', async () => {
|
||||
const fetchJson = jest.fn(async (url) => {
|
||||
if (url.includes('/scoreboard')) return wnbaScoreboard;
|
||||
if (url.includes('summary?event=401857057')) return wnbaSummary;
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
});
|
||||
const out = await fetchLiveTracking('wnba', '2026-07-11', { fetchJson });
|
||||
expect(out.hasLive).toBe(true);
|
||||
expect(out.games[0].players['breanna stewart'].values.points).toBe(17);
|
||||
expect(fetchJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('a per-game boxscore failure degrades that game, not the envelope', async () => {
|
||||
const fetchJson = jest.fn(async (url) => {
|
||||
if (url.includes('/schedule')) return mlbSchedule;
|
||||
throw new Error('boom');
|
||||
});
|
||||
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
|
||||
expect(out.hasLive).toBe(true);
|
||||
expect(out.games[0].players).toEqual({});
|
||||
});
|
||||
|
||||
test('unwired sport → honest empty, no fetches', async () => {
|
||||
const fetchJson = jest.fn();
|
||||
const out = await fetchLiveTracking('nba', '2026-07-11', { fetchJson });
|
||||
expect(out).toEqual({ sport: 'nba', date: '2026-07-11', hasLive: false, games: [] });
|
||||
expect(fetchJson).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLiveTracking — cache-aside, TTL 90s (the POLLING RULE)', () => {
|
||||
test('cache HIT → no upstream fetch at all', async () => {
|
||||
const cached = { sport: 'mlb', hasLive: true, games: [{ id: 'x' }] };
|
||||
const fetchJson = jest.fn();
|
||||
const cacheGet = jest.fn(async () => cached);
|
||||
const cacheSet = jest.fn();
|
||||
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
|
||||
expect(out).toBe(cached);
|
||||
expect(fetchJson).not.toHaveBeenCalled();
|
||||
expect(cacheSet).not.toHaveBeenCalled();
|
||||
expect(cacheGet).toHaveBeenCalledWith('live:mlb:2026-07-11');
|
||||
});
|
||||
|
||||
test('cache MISS + live games → fetch + write with LIVE_TTL', async () => {
|
||||
const fetchJson = jest.fn(async (url) => (url.includes('/schedule') ? mlbSchedule : mlbBoxscore));
|
||||
const cacheGet = jest.fn(async () => null);
|
||||
const cacheSet = jest.fn();
|
||||
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
|
||||
expect(out.hasLive).toBe(true);
|
||||
expect(out.updated_at).toBeTruthy();
|
||||
expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: true }), LIVE_TTL);
|
||||
});
|
||||
|
||||
test('cache MISS + nothing live → the no-live envelope is ALSO cached (idle polling stays cheap)', async () => {
|
||||
const fetchJson = jest.fn(async () => ({ dates: [] }));
|
||||
const cacheGet = jest.fn(async () => null);
|
||||
const cacheSet = jest.fn();
|
||||
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
|
||||
expect(out.hasLive).toBe(false);
|
||||
expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: false }), LIVE_TTL);
|
||||
});
|
||||
|
||||
test('upstream failure → empty envelope, never a throw', async () => {
|
||||
const fetchJson = jest.fn(async () => { throw new Error('down'); });
|
||||
const cacheGet = jest.fn(async () => null);
|
||||
const cacheSet = jest.fn();
|
||||
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
|
||||
expect(out.hasLive).toBe(false);
|
||||
expect(out.error).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,8 @@ describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', ()
|
||||
'<MovementChip p={p} />', // slot 4b — market movement
|
||||
'p.revisedFrom', // slot 5a — revision strikethrough
|
||||
'<GradeBadge grade={p.grade}', // slot 5b — the current grade
|
||||
'<OutcomeChip p={p} />', // slot 6 — settled result
|
||||
'<LiveTracker p={p} />', // slot 6a — live proto-outcome (S11)
|
||||
'<OutcomeChip p={p} />', // slot 6b — settled result
|
||||
'<ParlayBtn p={p} />', // slot 7a — action
|
||||
'<BookItTeaser p={p} />', // slot 7b — action
|
||||
'Graded {p.gradedAt.ago}', // slot 8 — provenance, always last
|
||||
@@ -60,9 +61,9 @@ describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', ()
|
||||
], base);
|
||||
});
|
||||
|
||||
test('settled/dead rows suppress actions (the bet is over)', () => {
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && <ParlayBtn p={p} />}');
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && <BookItTeaser p={p} />}');
|
||||
test('live/settled/dead rows suppress actions (the bet window is over) — S11 amendment', () => {
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && !p.live && <ParlayBtn p={p} />}');
|
||||
expect(strip).toContain('{!p.outcome && !p.dead && !p.live && <BookItTeaser p={p} />}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +95,17 @@ describe('ROW-GRAMMAR §3 — one meaning per color', () => {
|
||||
expect(src).toContain("steam ? 'var(--amber");
|
||||
expect(src).not.toContain('--miss');
|
||||
});
|
||||
|
||||
test('live TRACKING mark is green/amber only — an in-progress prop is never red (S11)', () => {
|
||||
const src = section('export function LiveTracker', 'export function DotStrip');
|
||||
expect(src).toContain('var(--g-a');
|
||||
expect(src).toContain('var(--amber');
|
||||
expect(src).not.toContain('--miss');
|
||||
// The label never implies re-grading: TRACKING, read locked pre-game.
|
||||
expect(src).toContain('TRACKING — read locked pre-game');
|
||||
// Data → mono.
|
||||
expect(src).toContain('className="mono"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROW-GRAMMAR §1.4 — no truncation of names/times/pitchers', () => {
|
||||
|
||||
Reference in New Issue
Block a user