// 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('SCAN'); 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('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); 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); }); 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 === 'SCAN')).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); }); });