Session 23: All-day intelligence layer — schedule, game lines, streaks, hot lists, stat filtering, ParlayAPI dead (1567 tests)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Unit: streaks engine (Session 23). Pure function — no mocks needed.
|
||||
|
||||
const { computeStreaks, computePlayerStreaks } = require('../../src/services/streaksService');
|
||||
|
||||
// Most-recent-first game logs.
|
||||
function nbaGames(...rows) { return rows; }
|
||||
|
||||
describe('streaksService — NBA', () => {
|
||||
test('detects a consecutive 25+ points streak from the latest games', () => {
|
||||
const player = {
|
||||
name: 'Wembanyama', playerId: 'W1', team: 'SA',
|
||||
games: [{ points: 31 }, { points: 28 }, { points: 33 }, { points: 22 }, { points: 40 }],
|
||||
};
|
||||
const streaks = computePlayerStreaks(player, 'nba');
|
||||
const pts = streaks.find((s) => s.category === 'points');
|
||||
expect(pts).toBeDefined();
|
||||
// 31,28,33 meet 25+, then 22 breaks → run of 3.
|
||||
expect(pts.currentStreak).toBe(3);
|
||||
expect(pts.description).toBe('3-game 25+ pts streak');
|
||||
});
|
||||
|
||||
test('streak breaks immediately when the latest game misses the threshold', () => {
|
||||
const player = { name: 'X', games: [{ points: 10 }, { points: 30 }, { points: 30 }] };
|
||||
const streaks = computePlayerStreaks(player, 'nba');
|
||||
expect(streaks.find((s) => s.category === 'points')).toBeUndefined(); // run of 0 < MIN_STREAK
|
||||
});
|
||||
|
||||
test('only the strongest streak per category surfaces', () => {
|
||||
// 20+ and 25+ both qualify; keep the 25+ (higher run not guaranteed, but
|
||||
// one points entry only).
|
||||
const player = { name: 'Y', games: [{ points: 26 }, { points: 27 }, { points: 21 }] };
|
||||
const streaks = computePlayerStreaks(player, 'nba');
|
||||
const ptsEntries = streaks.filter((s) => s.category === 'points');
|
||||
expect(ptsEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('double-double streak counts categories >= 10', () => {
|
||||
const player = {
|
||||
name: 'Jokic', games: [
|
||||
{ points: 20, rebounds: 12, assists: 11 },
|
||||
{ points: 15, rebounds: 10, assists: 9 },
|
||||
{ points: 8, rebounds: 11, assists: 3 },
|
||||
],
|
||||
};
|
||||
const streaks = computePlayerStreaks(player, 'nba');
|
||||
const dd = streaks.find((s) => s.type === 'double_double');
|
||||
expect(dd).toBeDefined();
|
||||
// g0: 3 doubles, g1: 2 doubles, g2: 1 double → dd (>=2) run breaks at g2.
|
||||
expect(dd.currentStreak).toBe(2);
|
||||
expect(dd.description).toBe('2-game double-double streak');
|
||||
});
|
||||
|
||||
test('empty game logs → empty streaks (not error)', () => {
|
||||
expect(computePlayerStreaks({ name: 'Z', games: [] }, 'nba')).toEqual([]);
|
||||
expect(computeStreaks([], 'nba')).toEqual([]);
|
||||
});
|
||||
|
||||
test('chronological input is reversed before counting', () => {
|
||||
const player = { name: 'C', games: [{ points: 22 }, { points: 30 }, { points: 31 }] };
|
||||
// As chronological (oldest first) the latest is 31,30 → run 2.
|
||||
const streaks = computePlayerStreaks(player, 'nba', { chronological: true });
|
||||
const pts = streaks.find((s) => s.category === 'points');
|
||||
expect(pts.currentStreak).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaksService — MLB', () => {
|
||||
test('classic hit streak counts consecutive games with a hit', () => {
|
||||
const player = {
|
||||
name: 'Acuna', team: 'ATL',
|
||||
games: [{ hits: 2 }, { hits: 1 }, { hits: 3 }, { hits: 0 }, { hits: 1 }],
|
||||
};
|
||||
const streaks = computePlayerStreaks(player, 'mlb');
|
||||
const hit = streaks.find((s) => s.type === 'hit_streak');
|
||||
expect(hit.currentStreak).toBe(3);
|
||||
expect(hit.description).toBe('3-game hit streak');
|
||||
});
|
||||
|
||||
test('quality-start streak uses IP + ER', () => {
|
||||
const pitcher = {
|
||||
name: 'Strider',
|
||||
games: [
|
||||
{ inningsPitched: 7, earnedRuns: 2 },
|
||||
{ inningsPitched: 6, earnedRuns: 3 },
|
||||
{ inningsPitched: 5, earnedRuns: 1 }, // < 6 IP breaks it
|
||||
],
|
||||
};
|
||||
const streaks = computePlayerStreaks(pitcher, 'mlb');
|
||||
const qs = streaks.find((s) => s.type === 'qs_streak');
|
||||
expect(qs.currentStreak).toBe(2);
|
||||
});
|
||||
|
||||
test('MLB specs do not produce NBA streak types', () => {
|
||||
const player = { name: 'P', games: [{ hits: 2 }, { hits: 2 }] };
|
||||
const streaks = computePlayerStreaks(player, 'mlb');
|
||||
expect(streaks.every((s) => s.type !== 'points_25')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaksService — fan-out + filtering', () => {
|
||||
const roster = [
|
||||
{ name: 'A', games: [{ points: 30 }, { points: 30 }, { points: 30 }] },
|
||||
{ name: 'B', games: [{ assists: 9 }, { assists: 8 }] },
|
||||
{ name: 'C', games: [{ points: 26 }, { points: 27 }] },
|
||||
];
|
||||
|
||||
test('sorts by streak length descending', () => {
|
||||
const streaks = computeStreaks(roster, 'nba');
|
||||
expect(streaks[0].player).toBe('A'); // 3-game points streak leads
|
||||
expect(streaks[0].currentStreak).toBe(3);
|
||||
});
|
||||
|
||||
test('stat filter narrows to a single category', () => {
|
||||
const streaks = computeStreaks(roster, 'nba', { stat: 'points' });
|
||||
expect(streaks.every((s) => s.category === 'points')).toBe(true);
|
||||
expect(streaks.map((s) => s.player).sort()).toEqual(['A', 'C']);
|
||||
});
|
||||
|
||||
test('limit caps the result', () => {
|
||||
const streaks = computeStreaks(roster, 'nba', { limit: 1 });
|
||||
expect(streaks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('all filter (default) returns every category', () => {
|
||||
const streaks = computeStreaks(roster, 'nba', { stat: 'all' });
|
||||
const cats = new Set(streaks.map((s) => s.category));
|
||||
expect(cats.has('points')).toBe(true);
|
||||
expect(cats.has('assists')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user