Files
vyndr/tests/unit/slipReader.test.js
builtbykev 4e49ee0990 S9 (a1): slip reader — zero-API OCR
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:17 -04:00

221 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A1 S9 — Slip Reader parser suite. PURE parsers on realistic OCR-text
// fixtures per book. The contract under test: correct extraction on the
// rigid slip grammars, per-field confidence, and the NEVER-GUESS rule
// (unreadable → null + needs_review, never a fabricated value).
const fs = require('fs');
const path = require('path');
const slipReader = require('../../src/services/slipReader');
const { parseSlipText, detectBook, CONFIDENCE_THRESHOLD } = slipReader;
const { normalizeStat, splitPlayerStat, parseOdds, buildLeg } = slipReader.__internals;
const fixture = (book) =>
fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'slips', `${book}.txt`), 'utf8');
describe('slipReader — book detection', () => {
it.each([
['draftkings'], ['fanduel'], ['betmgm'], ['caesars'],
])('detects %s from its fixture', (book) => {
expect(detectBook(fixture(book))).toBe(book);
});
it('returns null for unbranded text', () => {
expect(detectBook('Aaron Judge Over 1.5 Total Bases')).toBeNull();
});
});
describe('slipReader — DraftKings layout', () => {
const result = parseSlipText(fixture('draftkings'));
it('extracts all three legs completely', () => {
expect(result.book).toBe('draftkings');
expect(result.legs).toHaveLength(3);
expect(result.needs_review).toBe(false);
expect(result.source).toBe('user_slip');
});
it('extracts player/stat/line/side/odds on leg 1', () => {
const leg = result.legs[0];
expect(leg.player).toBe('Aaron Judge');
expect(leg.player_key).toBe('aaron judge');
expect(leg.stat).toBe('total_bases');
expect(leg.line).toBe(1.5);
expect(leg.side).toBe('over');
expect(leg.odds).toBe(-115);
expect(leg.needs_review).toBe(false);
});
it('normalizes book market labels through the stat vocabulary', () => {
// "Strikeouts Thrown" (DK pitcher label) → strikeouts
expect(result.legs[1].stat).toBe('strikeouts');
expect(result.legs[1].line).toBe(7.5);
// plus-odds leg
expect(result.legs[2].stat).toBe('home_runs');
expect(result.legs[2].odds).toBe(320);
});
it('carries per-field confidences at or above threshold on clean legs', () => {
for (const leg of result.legs) {
for (const field of ['player', 'stat', 'line', 'side', 'odds']) {
expect(leg.confidence[field]).toBeGreaterThanOrEqual(CONFIDENCE_THRESHOLD);
}
}
});
});
describe('slipReader — FanDuel layout', () => {
const result = parseSlipText(fixture('fanduel'));
it('parses To Record N+ / Any Time / Over grammars', () => {
expect(result.book).toBe('fanduel');
expect(result.legs).toHaveLength(3);
// "To Record 2+ Total Bases" → line 1.5 over
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over' });
// "Any Time Home Run" → home_runs 0.5 over
expect(result.legs[1]).toMatchObject({ player: 'Giancarlo Stanton', stat: 'home_runs', line: 0.5, side: 'over' });
// plain Over grammar
expect(result.legs[2]).toMatchObject({ player: 'Gerrit Cole', stat: 'strikeouts', line: 6.5, side: 'over' });
});
it('SGP legs without per-leg odds → odds null + needs_review (never guessed)', () => {
for (const leg of result.legs) {
expect(leg.odds).toBeNull();
expect(leg.confidence.odds).toBeLessThan(CONFIDENCE_THRESHOLD);
expect(leg.needs_review).toBe(true);
}
expect(result.needs_review).toBe(true);
});
});
describe('slipReader — BetMGM layout', () => {
const result = parseSlipText(fixture('betmgm'));
it('parses inline "@ odds" and trailing-odds legs', () => {
expect(result.book).toBe('betmgm');
expect(result.legs).toHaveLength(3);
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115 });
expect(result.legs[1]).toMatchObject({ player: 'Shohei Ohtani', stat: 'strikeouts', line: 7.5, side: 'under', odds: 105 });
expect(result.legs[2]).toMatchObject({ player: 'Mookie Betts', stat: 'hits', line: 1.5, side: 'over', odds: 140 });
expect(result.needs_review).toBe(false);
});
});
describe('slipReader — Caesars layout', () => {
const result = parseSlipText(fixture('caesars'));
it('splits the ambiguous player/stat head on known stat aliases', () => {
expect(result.book).toBe('caesars');
expect(result.legs).toHaveLength(3);
// "Pete Alonso Home Runs" must NOT become player "Pete Alonso Home" + stat "runs"
expect(result.legs[1]).toMatchObject({ player: 'Pete Alonso', stat: 'home_runs', line: 0.5, side: 'over', odds: 340 });
expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', odds: -115 });
expect(result.legs[2]).toMatchObject({ player: 'Freddie Freeman', stat: 'hits', side: 'under', odds: -105 });
});
it('unknown stat head → stat AND player null (boundary unknowable), needs_review', () => {
const r = parseSlipText('Caesars\nAaron Judge Fantasy Score Over 32.5 (-115)');
expect(r.legs).toHaveLength(1);
expect(r.legs[0].stat).toBeNull();
expect(r.legs[0].player).toBeNull(); // boundary uncertain → below threshold → nulled
expect(r.legs[0].needs_review).toBe(true);
// The readable fields still come through — nothing over-nulled.
expect(r.legs[0].line).toBe(32.5);
expect(r.legs[0].odds).toBe(-115);
});
});
describe('slipReader — never guess', () => {
it('garbage text → zero legs, needs_review envelope, no fabrication', () => {
const r = parseSlipText('completely unrelated text\nnothing to see 12345\nlorem ipsum');
expect(r.legs).toEqual([]);
expect(r.needs_review).toBe(true);
expect(r.book).toBeNull();
});
it('empty/nullish input is safe', () => {
expect(parseSlipText('').legs).toEqual([]);
expect(parseSlipText(null).legs).toEqual([]);
expect(parseSlipText(undefined).legs).toEqual([]);
});
it('a below-threshold field is nulled, not passed through', () => {
const leg = buildLeg({
player: 'judge', // lowercase single token → low confidence
statLabel: 'Total Bases',
line: 1.5,
side: 'over',
odds: -115,
});
expect(leg.player).toBeNull();
expect(leg.player_key).toBeNull();
expect(leg.needs_review).toBe(true);
expect(leg.stat).toBe('total_bases'); // readable fields survive
});
it('book hint routes to the hinted layout parser', () => {
const dk = fixture('draftkings');
const hinted = parseSlipText(dk, 'draftkings');
expect(hinted.book).toBe('draftkings');
expect(hinted.legs).toHaveLength(3);
});
it('unknown book → best layout wins without inventing values', () => {
const unbranded = fixture('draftkings').replace(/DraftKings Sportsbook\n/, '');
const r = parseSlipText(unbranded);
expect(r.legs.length).toBeGreaterThanOrEqual(3);
for (const leg of r.legs) {
expect(leg.player).not.toBeNull();
expect(leg.stat).not.toBeNull();
}
});
});
describe('slipReader — vocabulary + helpers', () => {
it('normalizeStat maps slip labels to canonical stat_types', () => {
expect(normalizeStat('Total Bases')).toBe('total_bases');
expect(normalizeStat('Strikeouts Thrown')).toBe('strikeouts');
expect(normalizeStat('Alt Total Bases')).toBe('total_bases');
expect(normalizeStat('Total Bases O/U')).toBe('total_bases');
expect(normalizeStat('Pts + Reb + Ast')).toBe('pra');
expect(normalizeStat('3 Pointers Made')).toBe('threes');
expect(normalizeStat('Runs Batted In')).toBe('rbi');
expect(normalizeStat('Fantasy Score')).toBeNull(); // unknown → null, never a guess
});
it('canonical stat_types match the scan-route vocabulary exactly', () => {
// Mirror of VALID_STAT_TYPES in src/routes/scan.js — a drifted alias
// here would 400 at the grade gate.
const VALID = new Set([
'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers',
'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'corners', 'saves',
'goals_conceded', 'passes', 'clean_sheet',
'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases', 'walks', 'runs',
'earned_runs', 'innings_pitched', 'hits_allowed', 'stolen_bases', 'doubles', 'outs',
]);
for (const stat of Object.values(slipReader.__internals.STAT_ALIASES)) {
expect(VALID.has(stat)).toBe(true);
}
});
it('splitPlayerStat prefers the longest alias suffix', () => {
expect(splitPlayerStat('Pete Alonso Home Runs')).toMatchObject({ player: 'Pete Alonso', statLabel: 'Home Runs' });
expect(splitPlayerStat('Aaron Judge Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
expect(splitPlayerStat('Aaron Judge Alt Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
});
it('parseOdds normalizes OCR minus glyphs and rejects non-odds', () => {
expect(parseOdds('-115')).toBe(-115);
expect(parseOdds('115')).toBe(-115); // unicode minus
expect(parseOdds('+320')).toBe(320);
expect(parseOdds('-15')).toBeNull(); // |odds| < 100 is a line, not odds
expect(parseOdds('banana')).toBeNull();
expect(parseOdds(null)).toBeNull();
});
it('player names normalize through playerName (display + key)', () => {
const r = parseSlipText('DraftKings\nA.J. Ewing Over 1.5\nTotal Bases\n-115');
expect(r.legs[0].player).toBe('AJ Ewing');
expect(r.legs[0].player_key).toBe('aj ewing');
});
});