Session 7j: Soccer intelligence - 9 leagues, 11 signals, 6 traps, poller, prefetch, 131 new tests (1173 total)

This commit is contained in:
Kev
2026-06-10 14:50:13 -04:00
parent b9084408bf
commit ad5ea8d5a8
28 changed files with 3175 additions and 49 deletions
+190
View File
@@ -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');
});
});
});