Files
vyndr/tests/unit/snapshotService.test.js
T
builtbykev f8b120c0aa Session 45: Snapshot pipeline + GameCard swap + live ticker (2100 tests)
The on-demand "Read" grade model is RETIRED. A scheduled pipeline pre-grades the
slate, locks grades to the line, tracks movement; the dashboard shows them already
there. Orchestrates existing services — nothing rebuilt.

- snapshotService.runSnapshot(sport): getOdds → gradeAndCacheSlate → classify
  archetype per player → lock gradedAt → line deltas vs previous snapshot → write
  snapshot:{sport}:latest/previous + grades:{sport} → ticker events. Fully
  injectable, zero-network unit tests. runAllSnapshots = cron entrypoint.
- Internal trigger POST /api/internal/snapshot/:sport + /all (requireInternalAuth).
  In-process cron (SNAPSHOT_CRON=1, UTC 14,19,22,1,3) in server.js, no new dep.
- Public reads: GET /api/snapshot/:sport (cache-only) + GET /api/ticker (merges
  TICKER_MANUAL pins) + Next proxies.
- GameCard swap: live Slate renders vyndr/GameCard (legacy kept for types only),
  overlays locked grades onto game props → player name once + archetype badge +
  "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0". Ungraded → "Awaiting next
  scan", NO Read button. On-demand onGrade flow deleted.
- Ticker polls /api/ticker every 30s, graceful fallback to hardcoded items.
- NBA/WNBA: espnStatsAdapter free fallback (defensive parse → found:false on shape
  mismatch) wired into resolvePlayerStats after the offline Python service.

Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, TICKER_MANUAL.
Backend 2061 -> 2100 tests (+39), 173 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:34:29 -04:00

150 lines
6.2 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('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);
});
});