Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// Soccer daily prefetch — tests the data transforms + Redis writes via
|
||||
// the cache-write spy. The football-data adapter is mocked at the
|
||||
// module boundary so no network is touched.
|
||||
|
||||
const mockGetLeagueStandings = jest.fn();
|
||||
const mockGetLeagueScorers = jest.fn();
|
||||
const mockHasApiKey = jest.fn(() => true);
|
||||
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
||||
getLeagueStandings: (...a) => mockGetLeagueStandings(...a),
|
||||
getLeagueScorers: (...a) => mockGetLeagueScorers(...a),
|
||||
hasApiKey: (...a) => mockHasApiKey(...a),
|
||||
}));
|
||||
|
||||
const mockCacheSets = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async (k, v, ttl) => { mockCacheSets.set(k, { value: v, ttl }); return true; },
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
const prefetch = require('../../scripts/soccer-data-prefetch');
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetLeagueStandings.mockReset();
|
||||
mockGetLeagueScorers.mockReset();
|
||||
mockHasApiKey.mockReset().mockReturnValue(true);
|
||||
mockCacheSets.clear();
|
||||
});
|
||||
|
||||
describe('soccer-data-prefetch', () => {
|
||||
describe('parseArgs', () => {
|
||||
test('default leagues=[WC]', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script']);
|
||||
expect(a.leagues).toEqual(['WC']);
|
||||
expect(a.dryRun).toBe(false);
|
||||
});
|
||||
test('--leagues=WC,PL,PD parsed and uppercased', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--leagues=wc,pl,pd']);
|
||||
expect(a.leagues).toEqual(['WC', 'PL', 'PD']);
|
||||
});
|
||||
test('--dry-run flag', () => {
|
||||
const a = prefetch.__internals.parseArgs(['node', 'script', '--dry-run']);
|
||||
expect(a.dryRun).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateTeamDefense', () => {
|
||||
const { aggregateTeamDefense } = prefetch.__internals;
|
||||
|
||||
test('computes goals_conceded_per_game and rank from full table', () => {
|
||||
const allRows = [
|
||||
{ teamName: 'Italy', goalsAgainst: 3, playedGames: 10 }, // 0.30 — best
|
||||
{ teamName: 'England', goalsAgainst: 6, playedGames: 10 }, // 0.60
|
||||
{ teamName: 'France', goalsAgainst: 9, playedGames: 10 }, // 0.90 — worst
|
||||
];
|
||||
const italy = aggregateTeamDefense(allRows[0], allRows);
|
||||
const england = aggregateTeamDefense(allRows[1], allRows);
|
||||
const france = aggregateTeamDefense(allRows[2], allRows);
|
||||
|
||||
expect(italy.goals_conceded_per_game).toBeCloseTo(0.3);
|
||||
expect(italy.defensive_rank).toBe(1);
|
||||
expect(italy.defensive_rank_norm).toBeCloseTo(0);
|
||||
|
||||
expect(england.defensive_rank).toBe(2);
|
||||
expect(england.defensive_rank_norm).toBeCloseTo(0.5);
|
||||
|
||||
expect(france.defensive_rank).toBe(3);
|
||||
expect(france.defensive_rank_norm).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
test('returns null when row has no played games', () => {
|
||||
const result = aggregateTeamDefense({ goalsAgainst: 0, playedGames: 0 }, []);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('clean_sheet_rate null when API does not provide it', () => {
|
||||
const allRows = [{ goalsAgainst: 2, playedGames: 4 }];
|
||||
const r = aggregateTeamDefense(allRows[0], allRows);
|
||||
expect(r.clean_sheet_rate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregatePlayerFromScorer', () => {
|
||||
const { aggregatePlayerFromScorer } = prefetch.__internals;
|
||||
|
||||
test('per-90 rates computed from minutes when present', () => {
|
||||
const r = aggregatePlayerFromScorer({
|
||||
name: 'Kane', team: 'England', goals: 3, assists: 1, playedMatches: 4, minutesPlayed: 360,
|
||||
});
|
||||
// 3 goals / (360/90) = 0.75 per 90.
|
||||
expect(r.goals_per_90).toBeCloseTo(0.75);
|
||||
expect(r.assists_per_90).toBeCloseTo(0.25);
|
||||
expect(r.minutes_per_game).toBe(90);
|
||||
});
|
||||
|
||||
test('per-90 falls back to per-match when minutes are missing', () => {
|
||||
const r = aggregatePlayerFromScorer({
|
||||
name: 'X', team: 'Y', goals: 4, assists: 2, playedMatches: 4, minutesPlayed: null,
|
||||
});
|
||||
// No minutes data → use goals/played as a rough proxy.
|
||||
expect(r.goals_per_90).toBeCloseTo(1.0);
|
||||
expect(r.minutes_per_game).toBeNull();
|
||||
});
|
||||
|
||||
test('xG fields are explicitly null on Day 1', () => {
|
||||
const r = aggregatePlayerFromScorer({ name: 'X', goals: 1, assists: 0, playedMatches: 2 });
|
||||
expect(r.xg_per_90).toBeNull();
|
||||
expect(r.xg_delta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processLeague — Redis writes', () => {
|
||||
test('writes one teamdefense key per table row + one player key per scorer', async () => {
|
||||
mockGetLeagueStandings.mockResolvedValueOnce([
|
||||
{
|
||||
type: 'TOTAL',
|
||||
table: [
|
||||
{ team: { name: 'Italy' }, goalsAgainst: 2, playedGames: 5 },
|
||||
{ team: { name: 'France' }, goalsAgainst: 8, playedGames: 5 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
mockGetLeagueScorers.mockResolvedValueOnce([
|
||||
{ name: 'Harry Kane', team: 'England', goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360 },
|
||||
{ name: 'Vinicius Junior', team: 'Brazil', goals: 3, assists: 2, playedMatches: 4, minutesPlayed: 340 },
|
||||
]);
|
||||
|
||||
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
|
||||
|
||||
expect(summary.standings).toBe(2);
|
||||
expect(summary.scorers).toBe(2);
|
||||
expect(summary.players).toBe(2);
|
||||
expect(summary.teamDefense).toBe(2);
|
||||
|
||||
// Cache keys present at the right path.
|
||||
expect(mockCacheSets.has('soccer:teamdefense:wc:Italy')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:teamdefense:wc:France')).toBe(true);
|
||||
expect(mockCacheSets.has(`soccer:player:${normalizeName('Harry Kane')}`)).toBe(true);
|
||||
expect(mockCacheSets.has(`soccer:player:${normalizeName('Vinicius Junior')}`)).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:wc:standings')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:wc:scorers')).toBe(true);
|
||||
|
||||
// TTLs match the constants.
|
||||
const kaneEntry = mockCacheSets.get(`soccer:player:${normalizeName('Harry Kane')}`);
|
||||
expect(kaneEntry.ttl).toBe(prefetch.__internals.PLAYER_TTL_SEC);
|
||||
const italyEntry = mockCacheSets.get('soccer:teamdefense:wc:Italy');
|
||||
expect(italyEntry.ttl).toBe(prefetch.__internals.DEFENSE_TTL_SEC);
|
||||
});
|
||||
|
||||
test('dry-run computes summary but writes nothing', async () => {
|
||||
mockGetLeagueStandings.mockResolvedValueOnce([
|
||||
{ type: 'TOTAL', table: [{ team: { name: 'X' }, goalsAgainst: 1, playedGames: 1 }] },
|
||||
]);
|
||||
mockGetLeagueScorers.mockResolvedValueOnce([
|
||||
{ name: 'X', team: 'X', goals: 1, assists: 0, playedMatches: 1, minutesPlayed: 90 },
|
||||
]);
|
||||
const summary = await prefetch.__internals.processLeague('WC', { dryRun: true });
|
||||
expect(summary.players).toBeGreaterThan(0);
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
|
||||
test('both API calls null → skipped flag', async () => {
|
||||
mockGetLeagueStandings.mockResolvedValueOnce(null);
|
||||
mockGetLeagueScorers.mockResolvedValueOnce(null);
|
||||
const summary = await prefetch.__internals.processLeague('WC', { dryRun: false });
|
||||
expect(summary.skipped).toBe(true);
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('main — top-level entry', () => {
|
||||
test('graceful skip when API key missing', async () => {
|
||||
mockHasApiKey.mockReturnValueOnce(false);
|
||||
const result = await prefetch.main(['node', 'script', '--leagues=WC']);
|
||||
expect(result.skipped).toBe(true);
|
||||
// Critically: adapter methods never invoked.
|
||||
expect(mockGetLeagueStandings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('processes each --leagues arg in turn', async () => {
|
||||
mockGetLeagueStandings.mockResolvedValue([]);
|
||||
mockGetLeagueScorers.mockResolvedValue([]);
|
||||
await prefetch.main(['node', 'script', '--leagues=WC,PL', '--dry-run']);
|
||||
expect(mockGetLeagueStandings).toHaveBeenCalledWith('WC');
|
||||
expect(mockGetLeagueStandings).toHaveBeenCalledWith('PL');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user