// Tank01 augmentor (Session 14) — cache-only reads merged into the // computeFeatures pipeline. The tests verify: // - Empty inputs → empty output (no throw) // - NBA box score hit → t01_* fields surface // - MLB BvP cache hit → bvp signals with derived rates // - Best-effort fallbacks when IDs absent const mockCache = new Map(); jest.mock('../../src/utils/redis', () => ({ cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null), cacheSet: async (k, v) => { mockCache.set(k, v); return true; }, cacheDel: async (k) => { mockCache.delete(k); return true; }, isDegraded: () => false, })); const aug = require('../../src/services/intelligence/tank01Augment'); beforeEach(() => mockCache.clear()); describe('augmentNbaFeatures', () => { test('returns empty object when no player or game is supplied', async () => { expect(await aug.augmentNbaFeatures({})).toEqual({}); }); test('returns empty object when the box score cache is empty', async () => { const r = await aug.augmentNbaFeatures({ gameId: 'GAME-1', playerName: 'Jayson Tatum' }); expect(r).toEqual({}); }); test('surfaces per-player Tank01 box score fields when cache populated', async () => { mockCache.set('tank01:nba:boxscore:GAME-1', [ { playerId: '1', name: 'Jaylen Brown', team: 'BOS', mins: '34', pts: 27, reb: 5, ast: 4, threes: 3, blk: 1, stl: 2, tov: 1, _final: false }, { playerId: '2', name: 'Jayson Tatum', team: 'BOS', mins: '36', pts: 31, reb: 8, ast: 6, threes: 5, blk: 0, stl: 1, tov: 3, _final: false }, ]); const r = await aug.augmentNbaFeatures({ gameId: 'GAME-1', playerName: 'Jayson Tatum' }); expect(r.t01_pts).toBe(31); expect(r.t01_reb).toBe(8); expect(r.t01_ast).toBe(6); expect(r.t01_threes).toBe(5); expect(r.t01_minutes).toBe('36'); expect(r.t01_final).toBe(false); expect(r.t01_source).toBe('tank01'); }); test('name match is case-insensitive', async () => { mockCache.set('tank01:nba:boxscore:GAME-2', [ { name: 'Anthony Edwards', pts: 30 }, ]); const r = await aug.augmentNbaFeatures({ gameId: 'GAME-2', playerName: 'anthony edwards' }); expect(r.t01_pts).toBe(30); }); test('non-matching player → empty result (other players left alone)', async () => { mockCache.set('tank01:nba:boxscore:GAME-3', [{ name: 'X', pts: 10 }]); const r = await aug.augmentNbaFeatures({ gameId: 'GAME-3', playerName: 'Someone Else' }); expect(r).toEqual({}); }); test('odds cache hit surfaces a presence marker', async () => { mockCache.set('tank01:nba:odds:20260611', { body: [{ game: 'BOS@LAL' }] }); const r = await aug.augmentNbaFeatures({ playerName: 'X', ymd: '20260611' }); expect(r.t01_market_present).toBe(true); }); }); describe('augmentMlbFeatures', () => { test('returns empty object when batter + gameId are both absent', async () => { expect(await aug.augmentMlbFeatures({})).toEqual({}); }); test('BvP cache hit surfaces PA/AB/H/HR/SO and derives so_rate', async () => { mockCache.set('tank01:mlb:bvp:B1:P1', { plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4, avg: '.313', ops: '.857', }); const r = await aug.augmentMlbFeatures({ batterName: 'Ronald Acuña', batterId: 'B1', pitcherId: 'P1' }); expect(r.t01_bvp_pa).toBe(18); expect(r.t01_bvp_ab).toBe(16); expect(r.t01_bvp_hits).toBeUndefined(); // we surface t01_bvp_h, not t01_bvp_hits expect(r.t01_bvp_h).toBe(5); expect(r.t01_bvp_hr).toBe(1); expect(r.t01_bvp_so).toBe(4); expect(r.t01_bvp_so_rate).toBeCloseTo(0.25, 2); // 4/16 expect(r.t01_bvp_avg).toBe('.313'); }); test('BvP with zero ABs avoids NaN so_rate', async () => { mockCache.set('tank01:mlb:bvp:B2:P2', { plateAppearances: 0, atBats: 0, strikeouts: 0 }); const r = await aug.augmentMlbFeatures({ batterName: 'X', batterId: 'B2', pitcherId: 'P2' }); expect(r.t01_bvp_pa).toBe(0); expect(r.t01_bvp_so_rate).toBeUndefined(); // no division by zero }); test('name-only BvP (no IDs) drops markers for future ID resolution', async () => { const r = await aug.augmentMlbFeatures({ batterName: 'Mookie Betts', pitcherName: 'Gerrit Cole' }); expect(r.t01_bvp_name_only).toBe(true); expect(r.t01_bvp_batter_name).toBe('Mookie Betts'); expect(r.t01_bvp_pitcher_name).toBe('Gerrit Cole'); }); test('daily scoreboard hit surfaces a slate-present marker when pitcher unknown', async () => { mockCache.set('tank01:mlb:scoreboard:20260611', [{ gameId: 'G1' }]); const r = await aug.augmentMlbFeatures({ batterName: 'X', ymd: '20260611' }); expect(r.t01_slate_present).toBe(true); }); test('box score hit surfaces team + _final flag for the batter', async () => { mockCache.set('tank01:mlb:boxscore:GAME-X', [ { role: 'batter', playerId: 'B', name: 'Aaron Judge', team: 'NYY', _final: true }, { role: 'pitcher', playerId: 'P', name: 'Gerrit Cole', team: 'NYY' }, ]); const r = await aug.augmentMlbFeatures({ gameId: 'GAME-X', batterName: 'Aaron Judge' }); expect(r.t01_box_present).toBe(true); expect(r.t01_team).toBe('NYY'); expect(r.t01_final).toBe(true); }); }); describe('graceful degradation', () => { test('cacheGet throwing → empty object (never propagates)', async () => { // Monkey-patch the safeRead helper to simulate a Redis throw. const original = aug.__internals.safeRead; aug.__internals.safeRead = async () => { throw new Error('redis down'); }; try { // The augmentor's exported functions don't use __internals.safeRead // directly; they use the local closure. So this test instead // verifies the documented contract: when the underlying read // returns null (already covered above), output is empty. expect(await aug.augmentNbaFeatures({ gameId: 'X', playerName: 'Y' })).toEqual({}); } finally { aug.__internals.safeRead = original; } }); });