Files
vyndr/tests/unit/lineSnapshotService.test.js
T

105 lines
4.0 KiB
JavaScript

// Unit: line-snapshot service (Session 28). Redis-only; mocked.
const mockStore = {}; // key -> array of JSON strings (list)
const mockScan = jest.fn();
const mockRedis = {
rpush: jest.fn(async (k, v) => { (mockStore[k] = mockStore[k] || []).push(v); return mockStore[k].length; }),
ltrim: jest.fn(async (k, start, end) => {
if (mockStore[k]) mockStore[k] = mockStore[k].slice(start, end === -1 ? undefined : end + 1);
return 'OK';
}),
expire: jest.fn(async () => 1),
lrange: jest.fn(async (k) => mockStore[k] || []),
scan: mockScan,
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
isDegraded: () => false,
}));
const svc = require('../../src/services/lineSnapshotService');
beforeEach(() => {
for (const k of Object.keys(mockStore)) delete mockStore[k];
jest.clearAllMocks();
});
describe('lineSnapshotService — recording', () => {
test('records a snapshot per prop into the right key', async () => {
const n = await svc.recordSnapshots('nba', [
{ gameId: 'g1', player: 'Wemby', stat: 'points', line: 28.5, book: 'dk' },
], 1000);
expect(n).toBe(1);
const hist = await svc.getLineHistory('nba', 'g1', 'Wemby', 'points');
expect(hist).toEqual([{ time: 1000, line: 28.5, book: 'dk' }]);
});
test('skips props missing required fields', async () => {
const n = await svc.recordSnapshots('nba', [{ player: 'X' }, { gameId: 'g', player: 'Y', stat: 's', line: 'NaN' }], 1);
expect(n).toBe(0);
});
});
describe('lineSnapshotService — classifyMovement', () => {
test('empty / single → stable, no error', () => {
expect(svc.classifyMovement([]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 5 }]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 5 }]).delta).toBe(0);
});
test('rising vs dropping', () => {
expect(svc.classifyMovement([{ line: 26.5 }, { line: 28.5 }]).movement).toBe('rising');
expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).movement).toBe('dropping');
});
test('sharp signal at >= 1.5 point move', () => {
expect(svc.classifyMovement([{ line: 28.5 }, { line: 26.5 }]).sharpSignal).toBe(true); // 2.0
expect(svc.classifyMovement([{ line: 28.5 }, { line: 27.5 }]).sharpSignal).toBe(false); // 1.0
});
test('< 0.5 move is stable', () => {
expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.5 }]).movement).toBe('stable');
expect(svc.classifyMovement([{ line: 28.5 }, { line: 28.2 }]).movement).toBe('stable');
});
});
describe('lineSnapshotService — biggest movers', () => {
test('classifies + sorts by absolute delta desc', async () => {
mockScan.mockResolvedValueOnce(['0', [
'linehistory:mlb:g1:Acuna:hits',
'linehistory:mlb:g2:Soto:total_bases',
]]);
mockStore['linehistory:mlb:g1:Acuna:hits'] = [
JSON.stringify({ time: 1, line: 1.5 }),
JSON.stringify({ time: 2, line: 2.5 }), // +1.0
];
mockStore['linehistory:mlb:g2:Soto:total_bases'] = [
JSON.stringify({ time: 1, line: 3.5 }),
JSON.stringify({ time: 2, line: 1.0 }), // -2.5 (bigger, sharp)
];
const movers = await svc.getBiggestMovers('mlb');
expect(movers).toHaveLength(2);
expect(movers[0].player).toBe('Soto'); // bigger |delta| first
expect(movers[0].delta).toBe(-2.5);
expect(movers[0].sharpSignal).toBe(true);
expect(movers[1].player).toBe('Acuna');
});
test('filters out sub-threshold (stable) props', async () => {
mockScan.mockResolvedValueOnce(['0', ['linehistory:mlb:g1:Flat:hits']]);
mockStore['linehistory:mlb:g1:Flat:hits'] = [
JSON.stringify({ time: 1, line: 1.5 }),
JSON.stringify({ time: 2, line: 1.5 }),
];
const movers = await svc.getBiggestMovers('mlb');
expect(movers).toEqual([]);
});
test('parseKey extracts sport/game/player/stat', () => {
expect(svc.__internals.parseKey('linehistory:nba:g1:LeBron James:points')).toEqual({
sport: 'nba', gameId: 'g1', player: 'LeBron James', stat: 'points',
});
});
});