b5d3fd14bb
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>
219 lines
9.1 KiB
JavaScript
219 lines
9.1 KiB
JavaScript
'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']);
|
|
});
|
|
});
|