66d52a9ce0
The product argued with itself: FAB/nav said "Scan", Free tier "5 scans", ticker "MLB slate scanned" — while the Ledger says "MY READS". Swept every user-visible surface to READ: - BottomTabBar FAB + Nav link: 'Scan' → 'Read' - Pricing free tier: '5 scans to try the model' → '5 reads …' - StatStrip: 'Awaiting next scan' → 'Awaiting next read' - Ticker badge + snapshotService event: tag 'SCAN' → 'READ', 'slate scanned' → 'slate read' (readSportOf parses BOTH old and new so cached ticker items dedupe cleanly through the rollover) - upgradePitch: 'You've scanned N parlays' / 'unlimited scans' → read/reads Internal untouched (not user-visible): /api/scan routes, scan_count column, scanning state, DemoScan/ScanIcon, scanlines CSS, the transitional SCAN color-map key. tests/unit/verbLaw.test.js is the enforcement: it fails on user-visible scan/scanned/scans copy across web/src + src/services (skips comments). Suite 270/3254 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
8.0 KiB
JavaScript
183 lines
8.0 KiB
JavaScript
// Session 45 — snapshot pipeline. Every dependency is injected, so the whole
|
|
// cycle runs with zero network / Redis.
|
|
|
|
const svc = require('../../src/services/snapshotService');
|
|
|
|
// A tiny in-memory cache to back cacheGet/cacheSet.
|
|
function memCache() {
|
|
const store = {};
|
|
return {
|
|
store,
|
|
cacheGet: async (k) => (k in store ? store[k] : null),
|
|
cacheSet: async (k, v) => { store[k] = v; },
|
|
};
|
|
}
|
|
|
|
const sampleProps = [
|
|
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, over_odds: -115, under_odds: -105, book: 'dk' },
|
|
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, over_odds: -130, under_odds: 100, book: 'dk' },
|
|
];
|
|
|
|
// Fake grader output captured via gradeAndCacheSlate's cacheSet.
|
|
function fakeGradeAndCacheSlate(grades) {
|
|
return async (_sport, _props, opts) => {
|
|
await opts.cacheSet(`grades:x`, { grades, updated_at: opts.now(), source: 'test' });
|
|
return { written: true, count: grades.length };
|
|
};
|
|
}
|
|
|
|
describe('computeLineDeltas', () => {
|
|
it('detects movements >= 0.5 and ignores noise', () => {
|
|
const prev = [{ player: 'A', stat_type: 'pts', direction: 'over', gradedAt: { line: 26.5 } }];
|
|
const cur = [
|
|
{ player: 'A', stat_type: 'pts', direction: 'over', line: 27.5, grade: 'A' }, // +1.0
|
|
{ player: 'B', stat_type: 'reb', direction: 'over', line: 9.5, grade: 'B' }, // no prev
|
|
];
|
|
const d = svc.computeLineDeltas(cur, prev);
|
|
expect(d).toHaveLength(1);
|
|
expect(d[0].delta).toBe(1);
|
|
expect(d[0].gradedLine).toBe(26.5);
|
|
expect(d[0].currentLine).toBe(27.5);
|
|
});
|
|
|
|
it('marks direction toward/away by graded side', () => {
|
|
const prev = [
|
|
{ player: 'A', stat_type: 'pts', direction: 'over', gradedAt: { line: 26.5 } },
|
|
{ player: 'B', stat_type: 'pts', direction: 'under', gradedAt: { line: 20.5 } },
|
|
];
|
|
const cur = [
|
|
{ player: 'A', stat_type: 'pts', direction: 'over', line: 28, grade: 'A' }, // over + rising = toward
|
|
{ player: 'B', stat_type: 'pts', direction: 'under', line: 22, grade: 'B' }, // under + rising = away
|
|
];
|
|
const d = svc.computeLineDeltas(cur, prev);
|
|
expect(d.find((x) => x.player === 'A').direction).toBe('toward');
|
|
expect(d.find((x) => x.player === 'B').direction).toBe('away');
|
|
});
|
|
});
|
|
|
|
describe('generateTickerEvents', () => {
|
|
const grades = [
|
|
{ player: 'Aaron Judge', stat_type: 'TB', line: 1.5, direction: 'over', grade: 'A+', archetype: 'BOMBER' },
|
|
{ player: 'Mookie Betts', stat_type: 'Hits', line: 1.5, direction: 'over', grade: 'B' },
|
|
];
|
|
it('emits a SCAN summary + GRADE events for A/A+ only', () => {
|
|
const ev = svc.generateTickerEvents('mlb', grades, [], '2026-06-18T20:00:00Z');
|
|
expect(ev[0].tag).toBe('READ');
|
|
expect(ev[0].text).toContain('2 props graded');
|
|
const grade = ev.find((e) => e.tag === 'A+');
|
|
expect(grade.text).toContain('BOMBER');
|
|
expect(grade.text).toContain('Judge');
|
|
expect(ev.find((e) => e.tag === 'B')).toBeUndefined(); // only top grades
|
|
});
|
|
it('emits MOVE events for |delta| >= 1.0', () => {
|
|
const deltas = [{ player: 'V Wemby', stat: 'pts', side: 'O', gradedLine: 26.5, currentLine: 27.5, delta: 1.0, direction: 'toward' }];
|
|
const ev = svc.generateTickerEvents('nba', grades, deltas, 't');
|
|
const move = ev.find((e) => e.tag === 'MOVE');
|
|
expect(move.text).toContain('▲+1');
|
|
});
|
|
});
|
|
|
|
describe('runSnapshot (fully injected)', () => {
|
|
const baseGrades = [
|
|
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 80 },
|
|
{ player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 65 },
|
|
];
|
|
const deps = (cache) => ({
|
|
getOdds: async () => ({ sport: 'mlb', props: sampleProps, provider: 'propline' }),
|
|
gradeAndCacheSlate: fakeGradeAndCacheSlate(baseGrades),
|
|
resolveStats: async (player) => (player === 'Aaron Judge'
|
|
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 } }
|
|
: { found: false }),
|
|
classify: require('../../src/services/archetypeService').classify,
|
|
cacheGet: cache.cacheGet,
|
|
cacheSet: cache.cacheSet,
|
|
now: () => '2026-06-18T20:00:00Z',
|
|
nowMs: () => 1000,
|
|
});
|
|
|
|
it('returns an ok summary with gradeCount + topGrades', async () => {
|
|
const cache = memCache();
|
|
const r = await svc.runSnapshot('mlb', deps(cache));
|
|
expect(r.status).toBe('ok');
|
|
expect(r.gradeCount).toBe(2);
|
|
expect(r.topGrades[0].player).toBe('Aaron Judge');
|
|
expect(r.topGrades[0].archetype).toBe('BOMBER'); // classified from real stats
|
|
});
|
|
|
|
it('writes snapshot:{sport}:latest + grades:{sport} with gradedAt + archetype', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('mlb', deps(cache));
|
|
const snap = cache.store['snapshot:mlb:latest'];
|
|
expect(snap).toBeTruthy();
|
|
expect(snap.grades[0].gradedAt.line).toBe(1.5);
|
|
expect(snap.grades[0].gradedAt.timestamp).toBe('2026-06-18T20:00:00Z');
|
|
expect(snap.grades[0].archetype).toBe('BOMBER');
|
|
expect(cache.store['grades:mlb'].grades).toHaveLength(2);
|
|
});
|
|
|
|
it('Wave 2A — threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => {
|
|
const cache = memCache();
|
|
const d = deps(cache);
|
|
// Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape).
|
|
d.resolveStats = async (player) => (player === 'Aaron Judge'
|
|
? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 }
|
|
: { found: true, classifierInput: {}, espnId: 4433403 });
|
|
await svc.runSnapshot('mlb', d);
|
|
const snap = cache.store['snapshot:mlb:latest'];
|
|
const judge = snap.grades.find((g) => g.player === 'Aaron Judge');
|
|
const betts = snap.grades.find((g) => g.player === 'Mookie Betts');
|
|
expect(judge.playerId).toBe(592450);
|
|
expect(betts.espnId).toBe(4433403);
|
|
// grades:{sport} inherits the same ids (GameCard/Explore read from it).
|
|
const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge');
|
|
expect(g.playerId).toBe(592450);
|
|
});
|
|
|
|
it('Wave 2A — no resolved id → enriched grade carries null ids (monogram path)', async () => {
|
|
const cache = memCache();
|
|
const d = deps(cache);
|
|
d.resolveStats = async () => ({ found: false }); // nothing resolves
|
|
await svc.runSnapshot('mlb', d);
|
|
const snap = cache.store['snapshot:mlb:latest'];
|
|
expect(snap.grades[0].playerId).toBeNull();
|
|
expect(snap.grades[0].espnId).toBeNull();
|
|
});
|
|
|
|
it('rotates latest → previous and computes deltas on the second run', async () => {
|
|
const cache = memCache();
|
|
let line = 1.5;
|
|
const d = deps(cache);
|
|
d.gradeAndCacheSlate = (sport, props, opts) => fakeGradeAndCacheSlate([
|
|
{ player: 'Aaron Judge', stat_type: 'total_bases', line, direction: 'over', grade: 'A+', confidence: 80 },
|
|
])(sport, props, opts);
|
|
await svc.runSnapshot('mlb', d);
|
|
const firstLatest = cache.store['snapshot:mlb:latest'];
|
|
line = 2.5; // line moved +1.0
|
|
const r2 = await svc.runSnapshot('mlb', d);
|
|
expect(cache.store['snapshot:mlb:previous']).toBeTruthy();
|
|
expect(r2.deltas).toBe(1);
|
|
// Session 52 trace: the previous snapshot is preserved verbatim so the next
|
|
// run can diff against the exact locked lines (the delta pipeline's input).
|
|
expect(cache.store['snapshot:mlb:previous']).toEqual(firstLatest);
|
|
expect(cache.store['snapshot:mlb:latest'].deltas[0]).toMatchObject({ delta: 1, gradedLine: 1.5, currentLine: 2.5 });
|
|
});
|
|
|
|
it('pushes ticker events (capped) into ticker:items', async () => {
|
|
const cache = memCache();
|
|
await svc.runSnapshot('mlb', deps(cache));
|
|
const items = cache.store['ticker:items'];
|
|
expect(Array.isArray(items)).toBe(true);
|
|
expect(items.find((e) => e.tag === 'READ')).toBeTruthy();
|
|
expect(items.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('skips gracefully on an empty slate', async () => {
|
|
const cache = memCache();
|
|
const d = deps(cache);
|
|
d.getOdds = async () => ({ sport: 'mlb', props: [] });
|
|
const r = await svc.runSnapshot('mlb', d);
|
|
expect(r.status).toBe('skipped');
|
|
expect(r.gradeCount).toBe(0);
|
|
});
|
|
});
|