131 lines
5.2 KiB
JavaScript
131 lines
5.2 KiB
JavaScript
// 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);
|
|
});
|
|
});
|