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,97 @@
|
||||
const request = require('supertest');
|
||||
|
||||
const mockGetOdds = jest.fn();
|
||||
jest.mock('../../src/services/oddsService', () => {
|
||||
const actual = jest.requireActual('../../src/services/oddsService');
|
||||
return {
|
||||
...actual,
|
||||
getOdds: (...args) => mockGetOdds(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { SOCCER_SPORT_KEYS } = require('../../src/services/oddsService');
|
||||
const app = require('../../src/app');
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetOdds.mockReset();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('SOCCER_SPORT_KEYS export', () => {
|
||||
test('contains all 9 launch leagues', () => {
|
||||
expect(SOCCER_SPORT_KEYS).toEqual(expect.arrayContaining([
|
||||
'soccer_wc',
|
||||
'soccer_epl',
|
||||
'soccer_laliga',
|
||||
'soccer_bundesliga',
|
||||
'soccer_seriea',
|
||||
'soccer_ligue1',
|
||||
'soccer_ucl',
|
||||
'soccer_mls',
|
||||
'soccer_ligamx',
|
||||
]));
|
||||
expect(SOCCER_SPORT_KEYS).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/odds/soccer/:league', () => {
|
||||
test('valid league reaches getOdds with the prefixed key', async () => {
|
||||
mockGetOdds.mockResolvedValueOnce({
|
||||
props: [], updated_at: '2026-06-15T00:00:00Z', source: 'live', quota_remaining: 4000,
|
||||
});
|
||||
const res = await request(app).get('/api/odds/soccer/wc').expect(200);
|
||||
expect(mockGetOdds).toHaveBeenCalledWith('soccer_wc');
|
||||
expect(res.body.sport).toBe('soccer_wc');
|
||||
expect(Array.isArray(res.body.props)).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown league returns 400 with valid-list hint', async () => {
|
||||
const res = await request(app).get('/api/odds/soccer/spaceleague').expect(400);
|
||||
expect(res.body.error).toMatch(/Unknown soccer league/);
|
||||
expect(res.body.error).toMatch(/wc/);
|
||||
expect(mockGetOdds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('EPL route works (proves it is not WC-only)', async () => {
|
||||
mockGetOdds.mockResolvedValueOnce({
|
||||
props: [{ player: 'X', stat_type: 'goals', line: 0.5 }], updated_at: '2026-06-15T00:00:00Z', source: 'cache',
|
||||
});
|
||||
const res = await request(app).get('/api/odds/soccer/epl').expect(200);
|
||||
expect(mockGetOdds).toHaveBeenCalledWith('soccer_epl');
|
||||
expect(res.body.sport).toBe('soccer_epl');
|
||||
});
|
||||
|
||||
test('case-insensitive league path', async () => {
|
||||
mockGetOdds.mockResolvedValueOnce({ props: [], updated_at: 't', source: 'cache' });
|
||||
await request(app).get('/api/odds/soccer/LIGAMX').expect(200);
|
||||
expect(mockGetOdds).toHaveBeenCalledWith('soccer_ligamx');
|
||||
});
|
||||
|
||||
test('getOdds throwing surfaces as a status code, not a 500 leak', async () => {
|
||||
const err = new Error('Odds data temporarily unavailable.');
|
||||
err.statusCode = 429;
|
||||
mockGetOdds.mockRejectedValueOnce(err);
|
||||
const res = await request(app).get('/api/odds/soccer/wc').expect(429);
|
||||
expect(res.body.error).toMatch(/temporarily unavailable/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing NBA/NCAAB routes still work (no regression)', () => {
|
||||
test('/api/odds/nba still returns the NBA shape', async () => {
|
||||
mockGetOdds.mockResolvedValueOnce({
|
||||
props: [], updated_at: 't', source: 'cache', quota_remaining: 4000,
|
||||
});
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
expect(res.body.sport).toBe('nba');
|
||||
expect(mockGetOdds).toHaveBeenCalledWith('nba');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// Soccer reasoning tests. We mock computeFeaturesForProp so the test
|
||||
// only exercises buildConcreteReasoning's soccer branch + the
|
||||
// downstream toLegacyShape adapter; data layer is out of scope here.
|
||||
|
||||
const mockComputeFeaturesForProp = jest.fn();
|
||||
jest.mock('../../src/services/intelligence/computeFeatures', () => ({
|
||||
computeFeaturesForProp: (...args) => mockComputeFeaturesForProp(...args),
|
||||
}));
|
||||
|
||||
const { analyzeViaEngine1 } = require('../../src/services/intelligence/analyzeViaEngine1');
|
||||
|
||||
function soccerFeatureResult(features = {}, meta = {}) {
|
||||
return {
|
||||
features: {
|
||||
l5_avg: null,
|
||||
l20_avg: null,
|
||||
home_away: null,
|
||||
opp_rank_stat: null,
|
||||
rest_days: null,
|
||||
...features,
|
||||
},
|
||||
trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' },
|
||||
consistency: { consistency: 'unknown', score: null, games: 0 },
|
||||
prop: { line: 0.5, direction: 'over' },
|
||||
meta: {
|
||||
player: 'Test Player', statType: 'goals', line: 0.5, direction: 'over',
|
||||
book: 'unknown', sport: 'soccer', league: 'WC',
|
||||
teamAbbr: 'England', opponentAbbr: 'Brazil',
|
||||
venue: 'MetLife Stadium', referee: null,
|
||||
isHome: true, gameLogs: [], errors: [],
|
||||
...meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockComputeFeaturesForProp.mockReset();
|
||||
});
|
||||
|
||||
describe('analyzeViaEngine1 — soccer reasoning', () => {
|
||||
test('uses "matches" language and surfaces goals_per_90', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 0.82,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/0\.82 goals per 90 minutes/);
|
||||
// Sanity: no NBA-flavored language.
|
||||
expect(result.reasoning.summary).not.toMatch(/last 5 games/);
|
||||
expect(result.reasoning.summary).not.toMatch(/back-to-back/i);
|
||||
});
|
||||
|
||||
test('xG overperformance triggers the regression line', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Striker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/Expected goals \(xG\): 0\.70 per 90/);
|
||||
expect(result.reasoning.summary).toMatch(/overperforming.*regression risk/i);
|
||||
});
|
||||
|
||||
test('penalty taker status surfaced when true', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 0.5, is_penalty_taker: true,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'PK Taker', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/Designated penalty taker/);
|
||||
});
|
||||
|
||||
test('altitude impact surfaces with venue context', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
|
||||
}, { venue: 'Estadio Azteca' }));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Visitor', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/7349ft altitude/);
|
||||
expect(result.reasoning.summary).toMatch(/non-acclimatized/i);
|
||||
});
|
||||
|
||||
test('low minutes per game triggers the discount note', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 0.5, minutes_per_game: 58,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Rotation Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/58 minutes per match/);
|
||||
expect(result.reasoning.summary).toMatch(/line may assume full 90/);
|
||||
});
|
||||
|
||||
test('referee card rate surfaces when present', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).toMatch(/Anthony Taylor averages 5\.4 cards per match/);
|
||||
});
|
||||
|
||||
test('soccer path skips NBA-only sentences (no injuries / no back-to-back)', async () => {
|
||||
// Even if soccer features somehow carry an injury_severity_score (they
|
||||
// shouldn't), the soccer branch must not surface it with NBA language.
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 0.5, injury_severity_score: 3, game_count_in_7d: 5,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Player', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
expect(result.reasoning.summary).not.toMatch(/opponent starter\(s\)/i);
|
||||
expect(result.reasoning.summary).not.toMatch(/games in the last week/i);
|
||||
expect(result.reasoning.summary).not.toMatch(/back-to-back/i);
|
||||
});
|
||||
|
||||
test('engine1 grade closer still applies on soccer (sport-agnostic)', async () => {
|
||||
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
|
||||
goals_per_90: 1.5, l5_avg: 1.5, l20_avg: 1.2,
|
||||
}));
|
||||
const result = await analyzeViaEngine1({
|
||||
player: 'Top Scorer', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
// The engine grade line is appended for every sport.
|
||||
expect(result.reasoning.summary).toMatch(/Engine 1 graded/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Verify computeFeaturesForProp routes soccer → soccerFeatureExtractor
|
||||
// and NBA → existing path. The NBA path's full behavior is covered by
|
||||
// computeFeatures.test.js (existing).
|
||||
|
||||
const mockExtractSoccerFeatures = jest.fn();
|
||||
jest.mock('../../src/services/intelligence/soccerFeatureExtractor', () => ({
|
||||
extractSoccerFeatures: (...args) => mockExtractSoccerFeatures(...args),
|
||||
isSoccerSport: (s) => ['soccer', 'football'].includes(String(s || '').toLowerCase()),
|
||||
}));
|
||||
|
||||
// Mock the rest of the upstream chain — none of it should be called on
|
||||
// the soccer branch.
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => ({ from: jest.fn() }),
|
||||
}));
|
||||
jest.mock('axios');
|
||||
jest.mock('../../src/services/intelligence/featureCache', () => ({
|
||||
getFeatures: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/intelligence/trapDetection', () => ({
|
||||
getTrapScore: jest.fn(async () => ({ composite: 0.2, signals: {}, active_count: 1, recommendation: 'caution' })),
|
||||
}));
|
||||
jest.mock('../../src/services/intelligence/consistencyScore', () => ({
|
||||
getConsistency: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../src/services/intelligence/gameLogService', () => ({
|
||||
getGameLogs: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
const { computeFeaturesForProp } = require('../../src/services/intelligence/computeFeatures');
|
||||
|
||||
beforeEach(() => {
|
||||
mockExtractSoccerFeatures.mockReset();
|
||||
});
|
||||
|
||||
describe('computeFeaturesForProp — sport dispatch', () => {
|
||||
test('sport=soccer routes to soccerFeatureExtractor (NBA path NOT invoked)', async () => {
|
||||
mockExtractSoccerFeatures.mockResolvedValueOnce({
|
||||
features: { goals_per_90: 0.4 },
|
||||
trap: { composite: 0, signals: {}, active_count: 0, recommendation: 'proceed' },
|
||||
consistency: { consistency: 'unknown', score: null, games: 0 },
|
||||
prop: { line: 0.5, direction: 'over' },
|
||||
meta: { player: 'Test', sport: 'soccer', statType: 'goals', errors: [] },
|
||||
});
|
||||
|
||||
const result = await computeFeaturesForProp({
|
||||
player: 'Test', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
|
||||
});
|
||||
|
||||
expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1);
|
||||
expect(result.features.goals_per_90).toBe(0.4);
|
||||
expect(result.meta.sport).toBe('soccer');
|
||||
// The branch re-runs trap detection so the trap object is populated.
|
||||
expect(result.trap.composite).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('sport=football is normalized into the soccer branch', async () => {
|
||||
mockExtractSoccerFeatures.mockResolvedValueOnce({
|
||||
features: {}, trap: {}, consistency: {}, prop: {}, meta: { sport: 'soccer', errors: [] },
|
||||
});
|
||||
await computeFeaturesForProp({ player: 'X', stat_type: 'goals', line: 0.5, sport: 'football' });
|
||||
expect(mockExtractSoccerFeatures).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('sport=nba does NOT invoke the soccer extractor', async () => {
|
||||
const featureCache = require('../../src/services/intelligence/featureCache');
|
||||
featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 28 } });
|
||||
await computeFeaturesForProp({
|
||||
player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over', sport: 'nba',
|
||||
});
|
||||
expect(mockExtractSoccerFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sport omitted defaults to NBA (legacy contract)', async () => {
|
||||
const featureCache = require('../../src/services/intelligence/featureCache');
|
||||
featureCache.getFeatures.mockResolvedValueOnce({ features: { l5_avg: 30 } });
|
||||
await computeFeaturesForProp({ player: 'A', stat_type: 'points', line: 25, direction: 'over' });
|
||||
expect(mockExtractSoccerFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// Mock axios and the Redis cache surface BEFORE requiring the adapter so
|
||||
// jest's module-mock hoisting captures the calls.
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
const mockCacheStore = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const adapter = require('../../src/services/adapters/footballDataAdapter');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockCacheStore.clear();
|
||||
adapter.__internals.resetBucketForTests();
|
||||
});
|
||||
|
||||
describe('footballDataAdapter', () => {
|
||||
describe('graceful degradation when API key is missing', () => {
|
||||
const original = process.env.FOOTBALL_DATA_API_KEY;
|
||||
beforeAll(() => { delete process.env.FOOTBALL_DATA_API_KEY; });
|
||||
afterAll(() => { if (original !== undefined) process.env.FOOTBALL_DATA_API_KEY = original; });
|
||||
|
||||
test('hasApiKey reports false', () => {
|
||||
expect(adapter.hasApiKey()).toBe(false);
|
||||
});
|
||||
|
||||
test('getWorldCupFixtures returns null (does NOT hit axios)', async () => {
|
||||
const result = await adapter.getWorldCupFixtures();
|
||||
expect(result).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getTeamSquad returns null (does NOT hit axios)', async () => {
|
||||
const result = await adapter.getTeamSquad(42);
|
||||
expect(result).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path with API key configured', () => {
|
||||
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
|
||||
|
||||
test('getLeagueFixtures projects API response to stable shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
matches: [
|
||||
{
|
||||
id: 1, homeTeam: { name: 'England' }, awayTeam: { name: 'Brazil' },
|
||||
utcDate: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
|
||||
score: { winner: null }, matchday: 1, venue: 'MetLife Stadium',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0]).toMatchObject({
|
||||
id: 1, homeTeam: 'England', awayTeam: 'Brazil', status: 'SCHEDULED',
|
||||
matchday: 1, venue: 'MetLife Stadium', competition: 'WC',
|
||||
});
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
// Auth header carries the API key — never logged elsewhere.
|
||||
const [, opts] = mockAxiosGet.mock.calls[0];
|
||||
expect(opts.headers['X-Auth-Token']).toBe('test-key-123');
|
||||
});
|
||||
|
||||
test('second identical call serves from cache (axios not re-invoked)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 7 }] } });
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('different competition codes use separate cache keys', async () => {
|
||||
mockAxiosGet
|
||||
.mockResolvedValueOnce({ data: { matches: [{ id: 1 }] } })
|
||||
.mockResolvedValueOnce({ data: { matches: [{ id: 2 }] } });
|
||||
await adapter.getLeagueFixtures('PL');
|
||||
await adapter.getLeagueFixtures('PD');
|
||||
expect(mockAxiosGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('getLeagueScorers projects to flat shape with goals + assists', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
scorers: [
|
||||
{
|
||||
player: { name: 'Harry Kane', position: 'Striker', nationality: 'England' },
|
||||
team: { name: 'England' },
|
||||
goals: 5, assists: 1, playedMatches: 4, minutesPlayed: 360,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const scorers = await adapter.getLeagueScorers('WC');
|
||||
expect(scorers[0]).toMatchObject({
|
||||
name: 'Harry Kane', team: 'England', goals: 5, assists: 1,
|
||||
playedMatches: 4, minutesPlayed: 360,
|
||||
});
|
||||
});
|
||||
|
||||
test('getTeamSquad projects squad rows with position and shirt', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: { squad: [{ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 }] },
|
||||
});
|
||||
const squad = await adapter.getTeamSquad(101);
|
||||
expect(squad[0]).toMatchObject({ id: 9, name: 'Pulisic', position: 'Forward', shirtNumber: 10 });
|
||||
});
|
||||
|
||||
test('empty/missing arrays in upstream → empty list (not null)', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: {} });
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures).toEqual([]);
|
||||
});
|
||||
|
||||
test('axios throw → returns null (graceful degradation)', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('network down'));
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures).toBeNull();
|
||||
});
|
||||
|
||||
test('axios throw + prior :stale value → stale-while-revalidate', async () => {
|
||||
// Prime the stale cache.
|
||||
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 999 }] });
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
// The stale value goes through the same projection.
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0].id).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token bucket rate limiting', () => {
|
||||
beforeAll(() => { process.env.FOOTBALL_DATA_API_KEY = 'test-key-123'; });
|
||||
|
||||
test('refuses network call when bucket is drained, falls to stale', async () => {
|
||||
// Drain the bucket by consuming all tokens.
|
||||
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
|
||||
expect(adapter.__internals.tryConsumeToken()).toBe(true);
|
||||
}
|
||||
// Next consume should fail.
|
||||
expect(adapter.__internals.tryConsumeToken()).toBe(false);
|
||||
|
||||
// Prime a stale value.
|
||||
mockCacheStore.set('soccer:wc:fixtures:stale', { matches: [{ id: 42 }] });
|
||||
const fixtures = await adapter.getLeagueFixtures('WC');
|
||||
expect(fixtures[0].id).toBe(42);
|
||||
// Critically: axios was NOT called — the bucket short-circuited.
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns null when bucket drained AND no stale value', async () => {
|
||||
for (let i = 0; i < adapter.__internals.BUCKET_MAX; i += 1) {
|
||||
adapter.__internals.tryConsumeToken();
|
||||
}
|
||||
const fixtures = await adapter.getLeagueFixtures('UNKNOWN_LEAGUE');
|
||||
expect(fixtures).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('input guards', () => {
|
||||
test('getLeagueFixtures(null) returns null without touching network', async () => {
|
||||
const r = await adapter.getLeagueFixtures(null);
|
||||
expect(r).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
test('getTeamSquad(null) returns null without touching network', async () => {
|
||||
const r = await adapter.getTeamSquad(null);
|
||||
expect(r).toBeNull();
|
||||
expect(mockAxiosGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe('oddsNormalizer', () => {
|
||||
expect(result[0].book).toBe('draftkings');
|
||||
});
|
||||
|
||||
it('maps all 8 market keys to correct internal stat_types', () => {
|
||||
it('maps every market key to its internal stat_type (NBA + soccer)', () => {
|
||||
const markets = Object.entries(MARKET_MAP);
|
||||
const bookmaker = makeBookmaker(
|
||||
'draftkings',
|
||||
@@ -109,6 +109,18 @@ describe('oddsNormalizer', () => {
|
||||
expect(statTypes).toEqual(expected);
|
||||
});
|
||||
|
||||
it('exposes the soccer market keys added in Session 7j', () => {
|
||||
// Sanity: soccer odds flow through the same normalizer as NBA. If a
|
||||
// future refactor splits MARKET_MAP per-sport, this test makes the
|
||||
// surface visible.
|
||||
const soccerStatTypes = ['goals', 'shots_on_target', 'shots', 'tackles',
|
||||
'cards', 'corners', 'saves', 'goals_conceded', 'passes', 'clean_sheet'];
|
||||
const values = Object.values(MARKET_MAP);
|
||||
for (const t of soccerStatTypes) {
|
||||
expect(values).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles missing/null odds gracefully (skips incomplete outcomes)', () => {
|
||||
const event = makeEvent({
|
||||
bookmakers: [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
// Mock Redis cache — populate per-test to simulate prefetched data.
|
||||
const mockCacheStore = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
const extractor = require('../../src/services/intelligence/soccerFeatureExtractor');
|
||||
|
||||
beforeEach(() => {
|
||||
mockCacheStore.clear();
|
||||
});
|
||||
|
||||
function primePlayer(name, profile) {
|
||||
mockCacheStore.set(`soccer:player:${normalizeName(name)}`, profile);
|
||||
}
|
||||
|
||||
describe('soccerFeatureExtractor', () => {
|
||||
describe('isSoccerSport', () => {
|
||||
test('accepts soccer + football, rejects nba/mlb/random', () => {
|
||||
expect(extractor.isSoccerSport('soccer')).toBe(true);
|
||||
expect(extractor.isSoccerSport('SOCCER')).toBe(true);
|
||||
expect(extractor.isSoccerSport('football')).toBe(true);
|
||||
expect(extractor.isSoccerSport('nba')).toBe(false);
|
||||
expect(extractor.isSoccerSport('mlb')).toBe(false);
|
||||
expect(extractor.isSoccerSport(null)).toBe(false);
|
||||
expect(extractor.isSoccerSport(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache-miss path (everything null)', () => {
|
||||
test('returns engine1-shaped result with errors flagged, no throw', async () => {
|
||||
const result = await extractor.extractSoccerFeatures({
|
||||
player: 'Unknown Player', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(result).toHaveProperty('features');
|
||||
expect(result).toHaveProperty('trap');
|
||||
expect(result).toHaveProperty('consistency');
|
||||
expect(result).toHaveProperty('prop');
|
||||
expect(result).toHaveProperty('meta');
|
||||
// Critical: numeric features default to null (NOT 0 — 0 would
|
||||
// confuse engine1 into thinking we have a real signal).
|
||||
expect(result.features.goals_per_90).toBeNull();
|
||||
expect(result.features.xg_delta).toBeNull();
|
||||
expect(result.features.minutes_per_game).toBeNull();
|
||||
// Errors surface the misses.
|
||||
expect(result.meta.errors).toContain('player_not_found_in_cache');
|
||||
expect(result.meta.errors).toContain('team_not_resolved');
|
||||
});
|
||||
|
||||
test('does not throw on null player input — surfaces error', async () => {
|
||||
const result = await extractor.extractSoccerFeatures({
|
||||
stat_type: 'goals', line: 0.5,
|
||||
});
|
||||
expect(result.meta.errors).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/missing required fields/)])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('player profile resolution', () => {
|
||||
test('reads cached profile by normalized name', async () => {
|
||||
primePlayer('Harry Kane', {
|
||||
team: 'England', goals_per_90: 0.85, assists_per_90: 0.15,
|
||||
minutes_per_game: 84, start_rate: 0.95, season_per_90: 0.78,
|
||||
recent_form_per_90: 1.05,
|
||||
});
|
||||
|
||||
const result = await extractor.extractSoccerFeatures({
|
||||
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(result.features.goals_per_90).toBe(0.85);
|
||||
expect(result.features.minutes_per_game).toBe(84);
|
||||
expect(result.features.start_rate).toBe(0.95);
|
||||
// engine1-canonical mapping: l5_avg from recent_form_per_90,
|
||||
// l20_avg from season_per_90.
|
||||
expect(result.features.l5_avg).toBe(1.05);
|
||||
expect(result.features.l20_avg).toBe(0.78);
|
||||
expect(result.meta.teamAbbr).toBe('England');
|
||||
expect(result.meta.errors).not.toContain('player_not_found_in_cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('xG regression risk', () => {
|
||||
test('fires when actual goals significantly outpace expected', async () => {
|
||||
primePlayer('Striker A', {
|
||||
team: 'France', goals_per_90: 1.2, xg_per_90: 0.7, xg_delta: 0.71,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Striker A', stat_type: 'goals', line: 1.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.xg_regression_risk).toBe(true);
|
||||
expect(r.features.xg_delta).toBeCloseTo(0.71);
|
||||
});
|
||||
|
||||
test('does NOT fire when xG and goals track each other', async () => {
|
||||
primePlayer('Striker B', { team: 'France', goals_per_90: 0.8, xg_per_90: 0.78, xg_delta: 0.025 });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Striker B', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.xg_regression_risk).toBe(false);
|
||||
});
|
||||
|
||||
test('does NOT fire when xG data is missing', async () => {
|
||||
primePlayer('Striker C', { team: 'France' });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Striker C', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.xg_regression_risk).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('venue + altitude + home-continent overlay', () => {
|
||||
test('Estadio Azteca venue surfaces high altitude impact', async () => {
|
||||
primePlayer('Player X', { team: 'England', goals_per_90: 0.5 });
|
||||
mockCacheStore.set('soccer:nextmatch:England', {
|
||||
opponent: 'USA', venue: 'Estadio Azteca', isHome: false, referee: 'Bjorn Kuipers',
|
||||
});
|
||||
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Player X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.venue_altitude_ft).toBeGreaterThan(7000);
|
||||
expect(r.features.altitude_impact).toBe('high');
|
||||
expect(r.features.home_continent).toBe(false); // England isn't CONCACAF
|
||||
expect(r.features.home_away).toBe(0.0); // away match
|
||||
});
|
||||
|
||||
test('USA at MetLife Stadium → home-continent true, altitude none', async () => {
|
||||
primePlayer('Christian Pulisic', { team: 'USA', goals_per_90: 0.3 });
|
||||
mockCacheStore.set('soccer:nextmatch:USA', {
|
||||
opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: null,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Christian Pulisic', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.home_continent).toBe(true);
|
||||
expect(r.features.altitude_impact).toBe('none');
|
||||
expect(r.features.home_away).toBe(1.0);
|
||||
// Static-data lookup catches the penalty/corner role.
|
||||
expect(r.features.is_penalty_taker).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('referee profile overlay', () => {
|
||||
test('reads cards/penalties per game for upcoming referee', async () => {
|
||||
primePlayer('Card Heavy', { team: 'Argentina', goals_per_90: 0.1 });
|
||||
mockCacheStore.set('soccer:nextmatch:Argentina', {
|
||||
opponent: 'Brazil', venue: 'MetLife Stadium', isHome: true, referee: 'Anthony Taylor',
|
||||
});
|
||||
mockCacheStore.set('soccer:referee:Anthony Taylor', {
|
||||
cards_per_game: 5.4, penalties_per_game: 0.6,
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Card Heavy', stat_type: 'cards', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.referee_cards_per_game).toBeCloseTo(5.4);
|
||||
expect(r.features.referee_penalties_per_game).toBeCloseTo(0.6);
|
||||
expect(r.features.referee_name).toBe('Anthony Taylor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rest_days from last fixture', () => {
|
||||
test('computes days since last finished fixture', async () => {
|
||||
primePlayer('Worn Out', { team: 'Brazil', goals_per_90: 0.6 });
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();
|
||||
mockCacheStore.set('soccer:lastfixture:Brazil', { utcDate: threeDaysAgo });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Worn Out', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.rest_days).toBe(3);
|
||||
});
|
||||
|
||||
test('returns null when no last fixture cached', async () => {
|
||||
primePlayer('Fresh', { team: 'Croatia', goals_per_90: 0.4 });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Fresh', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.rest_days).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for malformed utcDate', async () => {
|
||||
primePlayer('Edge Case', { team: 'Portugal', goals_per_90: 0.4 });
|
||||
mockCacheStore.set('soccer:lastfixture:Portugal', { utcDate: 'not-a-date' });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Edge Case', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.rest_days).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('opponent defense overlay', () => {
|
||||
test('reads team defense aggregates from cache', async () => {
|
||||
primePlayer('Forward', { team: 'England', goals_per_90: 0.6 });
|
||||
mockCacheStore.set('soccer:nextmatch:England', {
|
||||
opponent: 'Italy', venue: "Levi's Stadium", isHome: true, referee: null,
|
||||
});
|
||||
mockCacheStore.set('soccer:teamdefense:wc:Italy', {
|
||||
goals_conceded_per_game: 0.4, clean_sheet_rate: 0.55,
|
||||
defensive_rank: 3, defensive_rank_norm: 0.05, // top defense
|
||||
});
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.opp_goals_conceded_per_game).toBeCloseTo(0.4);
|
||||
expect(r.features.opp_clean_sheet_rate).toBeCloseTo(0.55);
|
||||
expect(r.features.opp_rank_stat).toBeCloseTo(0.05); // engine1 reads this
|
||||
});
|
||||
});
|
||||
|
||||
describe('tournament history overlay', () => {
|
||||
test('marks designated tournament players', async () => {
|
||||
primePlayer('Lionel Messi', { team: 'Argentina', goals_per_90: 0.8 });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.tournament_player).toBe(true);
|
||||
expect(r.features.wc_goals_career).toBeGreaterThan(0);
|
||||
});
|
||||
test('non-tournament players get false', async () => {
|
||||
primePlayer('Rookie One', { team: 'USA', goals_per_90: 0.2 });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Rookie One', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.tournament_player).toBe(false);
|
||||
expect(r.features.wc_goals_career).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shape compatibility with engine1', () => {
|
||||
test('returns the same top-level keys as the NBA path', async () => {
|
||||
primePlayer('Compat Test', { team: 'USA', goals_per_90: 0.3 });
|
||||
const r = await extractor.extractSoccerFeatures({
|
||||
player: 'Compat Test', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(Object.keys(r).sort()).toEqual(
|
||||
['consistency', 'features', 'meta', 'prop', 'trap'].sort()
|
||||
);
|
||||
// meta carries the same NBA-path field names so callers can read
|
||||
// teamAbbr / opponentAbbr / errors uniformly.
|
||||
expect(r.meta).toHaveProperty('teamAbbr');
|
||||
expect(r.meta).toHaveProperty('opponentAbbr');
|
||||
expect(r.meta).toHaveProperty('errors');
|
||||
expect(r.meta).toHaveProperty('sport', 'soccer');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
// Soccer poller — tests the tick() function (the unit of work). Run
|
||||
// loop intentionally not exercised: it's a sleep+repeat shape that
|
||||
// would only test setTimeout.
|
||||
|
||||
const mockAxiosGet = jest.fn();
|
||||
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Stub the football-data adapter so soccer poller's "non-WC league"
|
||||
// branch is exercised without hitting the real API.
|
||||
const mockFbdGetLeagueFixtures = jest.fn();
|
||||
const mockFbdGetWorldCupFixtures = jest.fn();
|
||||
jest.mock('../../src/services/adapters/footballDataAdapter', () => ({
|
||||
getLeagueFixtures: (...a) => mockFbdGetLeagueFixtures(...a),
|
||||
getWorldCupFixtures: (...a) => mockFbdGetWorldCupFixtures(...a),
|
||||
hasApiKey: () => false,
|
||||
}));
|
||||
|
||||
const soccerPoller = require('../../poller/soccer');
|
||||
|
||||
beforeEach(() => {
|
||||
mockAxiosGet.mockReset();
|
||||
mockFbdGetLeagueFixtures.mockReset();
|
||||
mockFbdGetWorldCupFixtures.mockReset();
|
||||
mockCacheSets.clear();
|
||||
});
|
||||
|
||||
describe('soccer poller', () => {
|
||||
describe('parseLeagues', () => {
|
||||
test('defaults to WC when env var unset', () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
delete process.env.SOCCER_LEAGUES;
|
||||
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC']);
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
});
|
||||
|
||||
test('parses comma-separated list, uppercases, trims', () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'wc, pl ,PD,bl1';
|
||||
expect(soccerPoller.__internals.parseLeagues()).toEqual(['WC', 'PL', 'PD', 'BL1']);
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyStatus', () => {
|
||||
const { classifyStatus } = soccerPoller.__internals;
|
||||
test.each([
|
||||
['IN_PLAY', 'live'],
|
||||
['PAUSED', 'live'],
|
||||
['LIVE', 'live'],
|
||||
['FINISHED', 'finished'],
|
||||
['FINAL', 'finished'],
|
||||
['COMPLETED', 'finished'],
|
||||
['SCHEDULED', 'scheduled'],
|
||||
['TIMED', 'scheduled'],
|
||||
['', 'scheduled'],
|
||||
[null, 'scheduled'],
|
||||
])('classifies %s → %s', (input, expected) => {
|
||||
expect(classifyStatus(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWorldCupFixtures via OSS API', () => {
|
||||
test('projects the OSS API response to the unified shape', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 1, home_team: 'England', away_team: 'Brazil',
|
||||
utc_date: '2026-06-15T20:00:00Z', status: 'SCHEDULED',
|
||||
matchday: 1, venue: 'MetLife Stadium',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(Array.isArray(fixtures)).toBe(true);
|
||||
expect(fixtures[0]).toMatchObject({
|
||||
id: 1, homeTeam: 'England', awayTeam: 'Brazil',
|
||||
venue: 'MetLife Stadium', competition: 'WC',
|
||||
});
|
||||
});
|
||||
|
||||
test('axios throw → returns null (graceful)', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(fixtures).toBeNull();
|
||||
});
|
||||
|
||||
test('handles both top-level array and {matches: [...]} envelopes', async () => {
|
||||
mockAxiosGet.mockResolvedValueOnce({ data: { matches: [{ id: 9, homeTeam: 'X', awayTeam: 'Y' }] } });
|
||||
const fixtures = await soccerPoller.__internals.fetchWorldCupFixtures();
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(fixtures[0].id).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLeagueFixtures dispatch', () => {
|
||||
test('WC prefers OSS API, falls back to football-data when OSS dies', async () => {
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS unreachable'));
|
||||
mockFbdGetWorldCupFixtures.mockResolvedValueOnce([{ id: 1, homeTeam: 'A', awayTeam: 'B', competition: 'WC' }]);
|
||||
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('WC');
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(mockFbdGetWorldCupFixtures).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('non-WC leagues use the football-data adapter', async () => {
|
||||
mockFbdGetLeagueFixtures.mockResolvedValueOnce([{ id: 7, homeTeam: 'X', awayTeam: 'Y', competition: 'PL' }]);
|
||||
const fixtures = await soccerPoller.__internals.fetchLeagueFixtures('PL');
|
||||
expect(fixtures).toHaveLength(1);
|
||||
expect(mockFbdGetLeagueFixtures).toHaveBeenCalledWith('PL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexFixturesForLeague', () => {
|
||||
test('writes per-team nextmatch + lastfixture keys', async () => {
|
||||
const inFuture = new Date(Date.now() + 5 * 86_400_000).toISOString();
|
||||
const inPast = new Date(Date.now() - 2 * 86_400_000).toISOString();
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', [
|
||||
{ homeTeam: 'England', awayTeam: 'Brazil', utcDate: inFuture, status: 'SCHEDULED', venue: 'MetLife Stadium' },
|
||||
{ homeTeam: 'USA', awayTeam: 'Mexico', utcDate: inPast, status: 'FINISHED', venue: 'AT&T Stadium', score: { fullTime: { home: 2, away: 1 } } },
|
||||
]);
|
||||
expect(counts.scheduled).toBe(1);
|
||||
expect(counts.finished).toBe(1);
|
||||
|
||||
// Future fixture → next match for both teams.
|
||||
expect(mockCacheSets.has('soccer:nextmatch:England')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:nextmatch:Brazil')).toBe(true);
|
||||
expect(mockCacheSets.get('soccer:nextmatch:England').value).toMatchObject({
|
||||
opponent: 'Brazil', isHome: true, venue: 'MetLife Stadium',
|
||||
});
|
||||
expect(mockCacheSets.get('soccer:nextmatch:Brazil').value).toMatchObject({
|
||||
opponent: 'England', isHome: false,
|
||||
});
|
||||
|
||||
// Past finished → last fixture for both teams.
|
||||
expect(mockCacheSets.has('soccer:lastfixture:USA')).toBe(true);
|
||||
expect(mockCacheSets.has('soccer:lastfixture:Mexico')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns zero counts on empty input', async () => {
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', []);
|
||||
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
|
||||
expect(mockCacheSets.size).toBe(0);
|
||||
});
|
||||
|
||||
test('returns zero counts on null input (graceful)', async () => {
|
||||
const counts = await soccerPoller.__internals.indexFixturesForLeague('WC', null);
|
||||
expect(counts).toEqual({ scheduled: 0, live: 0, finished: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
test('tick polls each configured league and reports live status', async () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'WC,PL';
|
||||
const inFuture = new Date(Date.now() + 1 * 86_400_000).toISOString();
|
||||
|
||||
// WC: OSS returns one live match.
|
||||
mockAxiosGet.mockResolvedValueOnce({
|
||||
data: [{ id: 1, home_team: 'A', away_team: 'B', utc_date: inFuture, status: 'IN_PLAY' }],
|
||||
});
|
||||
// PL: football-data adapter returns one scheduled.
|
||||
mockFbdGetLeagueFixtures.mockResolvedValueOnce([
|
||||
{ id: 2, homeTeam: 'X', awayTeam: 'Y', utcDate: inFuture, status: 'SCHEDULED' },
|
||||
]);
|
||||
|
||||
const result = await soccerPoller.tick();
|
||||
expect(result.liveSeen).toBe(true);
|
||||
expect(result.summary.some((s) => s.startsWith('WC:'))).toBe(true);
|
||||
expect(result.summary.some((s) => s.startsWith('PL:'))).toBe(true);
|
||||
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
|
||||
test('tick survives a league with no_data', async () => {
|
||||
const original = process.env.SOCCER_LEAGUES;
|
||||
process.env.SOCCER_LEAGUES = 'WC';
|
||||
mockAxiosGet.mockRejectedValueOnce(new Error('OSS down'));
|
||||
mockFbdGetWorldCupFixtures.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await soccerPoller.tick();
|
||||
expect(result.summary[0]).toMatch(/WC: no_data/);
|
||||
|
||||
if (original !== undefined) process.env.SOCCER_LEAGUES = original;
|
||||
else delete process.env.SOCCER_LEAGUES;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Soccer-branch tests for trapDetection. NBA path is covered by the
|
||||
// existing trapDetection.test.js; we only need to verify the soccer
|
||||
// signals fire on the right conditions and that the sport dispatch
|
||||
// keeps the two trap sets isolated.
|
||||
//
|
||||
// Soccer signals are synchronous and pure over `input.features` — no
|
||||
// Redis or DB mocks required.
|
||||
|
||||
const trap = require('../../src/services/intelligence/trapDetection');
|
||||
|
||||
function soccerInput(features = {}, statType = 'goals') {
|
||||
return {
|
||||
sport: 'soccer',
|
||||
statType,
|
||||
features: { stat_type: statType, ...features },
|
||||
odds: { playerLine: 0.5, consensus: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe('trapDetection — soccer branch', () => {
|
||||
describe('getTrapScore dispatches on sport', () => {
|
||||
test('soccer input runs ONLY the soccer signals (no NBA signals fire)', async () => {
|
||||
const r = await trap.getTrapScore(soccerInput({ goals_per_90: 0.5 }));
|
||||
const names = Object.keys(r.signals);
|
||||
// The NBA names should be absent; the soccer names should be present.
|
||||
expect(names).not.toContain('reverse_line_movement');
|
||||
expect(names).not.toContain('historical_hit_rate_paradox');
|
||||
expect(names).toContain('xg_regression');
|
||||
expect(names).toContain('altitude_risk');
|
||||
expect(names).toContain('rotation_risk');
|
||||
expect(names).toContain('minute_discount');
|
||||
expect(names).toContain('referee_card_bias');
|
||||
expect(names).toContain('strong_defense');
|
||||
});
|
||||
|
||||
test('nba input still runs the NBA signal set unchanged', async () => {
|
||||
const r = await trap.getTrapScore({
|
||||
sport: 'nba',
|
||||
gameId: 'gX', playerName: 'A', statType: 'points',
|
||||
odds: { playerLine: 25.5 },
|
||||
});
|
||||
const names = Object.keys(r.signals);
|
||||
expect(names).toContain('reverse_line_movement');
|
||||
expect(names).toContain('historical_hit_rate_paradox');
|
||||
expect(names).not.toContain('xg_regression');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalXgRegression', () => {
|
||||
test('fires when xg_delta > 0.3', () => {
|
||||
const result = trap.__internals.signalXgRegression(
|
||||
soccerInput({ xg_delta: 0.6 })
|
||||
);
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.explanation).toMatch(/above expected goals/);
|
||||
});
|
||||
test('does NOT fire when xg_delta is near zero', () => {
|
||||
const result = trap.__internals.signalXgRegression(
|
||||
soccerInput({ xg_delta: 0.05 })
|
||||
);
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.score).toBe(0);
|
||||
});
|
||||
test('inactive when xg_delta is null', () => {
|
||||
const result = trap.__internals.signalXgRegression(soccerInput({ xg_delta: null }));
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalAltitudeRisk', () => {
|
||||
test('fires for non-host-continent team at high altitude', () => {
|
||||
const r = trap.__internals.signalAltitudeRisk(
|
||||
soccerInput({ altitude_impact: 'high', home_continent: false, venue_altitude_ft: 7349 })
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
expect(r.explanation).toMatch(/altitude/);
|
||||
});
|
||||
test('host-continent team gets a pass (acclimated)', () => {
|
||||
const r = trap.__internals.signalAltitudeRisk(
|
||||
soccerInput({ altitude_impact: 'high', home_continent: true })
|
||||
);
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
test('moderate or no altitude → inactive', () => {
|
||||
expect(trap.__internals.signalAltitudeRisk(
|
||||
soccerInput({ altitude_impact: 'moderate', home_continent: false })
|
||||
).active).toBe(false);
|
||||
expect(trap.__internals.signalAltitudeRisk(
|
||||
soccerInput({ altitude_impact: 'none' })
|
||||
).active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalRotationRisk', () => {
|
||||
test('fires for low start_rate + short rest', () => {
|
||||
const r = trap.__internals.signalRotationRisk(
|
||||
soccerInput({ start_rate: 0.5, rest_days: 1 })
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
});
|
||||
test('does NOT fire when start_rate is high', () => {
|
||||
const r = trap.__internals.signalRotationRisk(
|
||||
soccerInput({ start_rate: 0.95, rest_days: 1 })
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBe(0);
|
||||
});
|
||||
test('does NOT fire when rest_days is sufficient', () => {
|
||||
const r = trap.__internals.signalRotationRisk(
|
||||
soccerInput({ start_rate: 0.5, rest_days: 5 })
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBe(0);
|
||||
});
|
||||
test('inactive when fields missing', () => {
|
||||
const r = trap.__internals.signalRotationRisk(soccerInput({}));
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalMinuteDiscount', () => {
|
||||
test('fires when minutes_per_game < 70', () => {
|
||||
const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 55 }));
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
});
|
||||
test('does NOT fire when minutes_per_game >= 70', () => {
|
||||
const r = trap.__internals.signalMinuteDiscount(soccerInput({ minutes_per_game: 88 }));
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalRefereeCardBias (POSITIVE)', () => {
|
||||
test('marks positive signal for card-heavy ref on a CARDS prop', () => {
|
||||
const r = trap.__internals.signalRefereeCardBias(
|
||||
soccerInput({ referee_cards_per_game: 6.2, referee_name: 'Anthony Taylor' }, 'cards')
|
||||
);
|
||||
expect(r.positive).toBe(true);
|
||||
expect(r.active).toBe(false); // explicit: positive signals do NOT count active
|
||||
expect(r.score).toBe(0);
|
||||
expect(r.explanation).toMatch(/favorable for card over/);
|
||||
});
|
||||
|
||||
test('does NOT mark positive for a non-cards stat type', () => {
|
||||
const r = trap.__internals.signalRefereeCardBias(
|
||||
soccerInput({ referee_cards_per_game: 6.2 }, 'goals')
|
||||
);
|
||||
expect(r.positive).not.toBe(true);
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
|
||||
test('composite EXCLUDES positive signals even when many fire', async () => {
|
||||
// Drop one trap + one positive — composite should reflect ONLY the trap.
|
||||
const r = await trap.getTrapScore(soccerInput(
|
||||
{
|
||||
xg_delta: 0.6, // trap fires
|
||||
referee_cards_per_game: 6.5, // positive fires
|
||||
},
|
||||
'cards', // makes the positive applicable
|
||||
));
|
||||
expect(r.active_count).toBe(1); // only the xG regression counts
|
||||
expect(r.composite).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signalStrongDefense', () => {
|
||||
test('fires for top-5 defense on goals over', () => {
|
||||
const r = trap.__internals.signalStrongDefense(
|
||||
soccerInput({ opp_defensive_rank: 3 }, 'goals')
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
});
|
||||
test('fires for top-5 defense on shots_on_target', () => {
|
||||
const r = trap.__internals.signalStrongDefense(
|
||||
soccerInput({ opp_defensive_rank: 5 }, 'shots_on_target')
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
});
|
||||
test('inactive for non-scoring stat types', () => {
|
||||
const r = trap.__internals.signalStrongDefense(
|
||||
soccerInput({ opp_defensive_rank: 3 }, 'cards')
|
||||
);
|
||||
expect(r.active).toBe(false);
|
||||
});
|
||||
test('does NOT fire when defense is mid-table', () => {
|
||||
const r = trap.__internals.signalStrongDefense(
|
||||
soccerInput({ opp_defensive_rank: 18 }, 'goals')
|
||||
);
|
||||
expect(r.active).toBe(true);
|
||||
expect(r.score).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composite scoring', () => {
|
||||
test('multiple soccer traps → composite >= 0.5 → avoid', async () => {
|
||||
const r = await trap.getTrapScore(soccerInput(
|
||||
{
|
||||
xg_delta: 0.5,
|
||||
altitude_impact: 'high',
|
||||
home_continent: false,
|
||||
start_rate: 0.5,
|
||||
rest_days: 1,
|
||||
opp_defensive_rank: 3,
|
||||
},
|
||||
'goals',
|
||||
));
|
||||
expect(r.composite).toBeGreaterThanOrEqual(0.5);
|
||||
expect(r.recommendation).toBe('avoid');
|
||||
});
|
||||
|
||||
test('no soccer traps → composite 0 → proceed', async () => {
|
||||
const r = await trap.getTrapScore(soccerInput({
|
||||
xg_delta: 0.05,
|
||||
altitude_impact: 'none',
|
||||
start_rate: 0.95,
|
||||
rest_days: 5,
|
||||
minutes_per_game: 85,
|
||||
opp_defensive_rank: 20,
|
||||
}, 'goals'));
|
||||
expect(r.composite).toBe(0);
|
||||
expect(r.recommendation).toBe('proceed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const wc = require('../../src/data/worldcup2026');
|
||||
|
||||
describe('worldcup2026 static reference data', () => {
|
||||
describe('VENUES', () => {
|
||||
test('has 16 venues (the official 2026 host venue count)', () => {
|
||||
const count = Object.keys(wc.VENUES).length;
|
||||
expect(count).toBe(16);
|
||||
});
|
||||
|
||||
test('every venue has altitude_ft, climate, country, city', () => {
|
||||
for (const [name, data] of Object.entries(wc.VENUES)) {
|
||||
expect(typeof data.altitude_ft).toBe('number');
|
||||
expect(typeof data.climate).toBe('string');
|
||||
expect(typeof data.country).toBe('string');
|
||||
expect(typeof data.city).toBe('string');
|
||||
expect(['USA', 'Canada', 'Mexico']).toContain(data.country);
|
||||
// Sanity check — no venue at impossible altitude.
|
||||
expect(data.altitude_ft).toBeGreaterThanOrEqual(0);
|
||||
expect(data.altitude_ft).toBeLessThan(10000);
|
||||
// Test the data is shaped right, not the actual venue altitudes.
|
||||
if (!name) throw new Error('empty venue name'); // touch `name`
|
||||
}
|
||||
});
|
||||
|
||||
test('Mexico City venue is the highest-altitude host site', () => {
|
||||
const altitudes = Object.values(wc.VENUES).map((v) => v.altitude_ft);
|
||||
const max = Math.max(...altitudes);
|
||||
expect(wc.VENUES['Estadio Azteca'].altitude_ft).toBe(max);
|
||||
expect(max).toBeGreaterThan(7000); // ~7,349 ft per public elevation data
|
||||
});
|
||||
});
|
||||
|
||||
describe('altitudeImpact', () => {
|
||||
test('high above 4,000 ft', () => {
|
||||
expect(wc.altitudeImpact(7349)).toBe('high');
|
||||
expect(wc.altitudeImpact(5138)).toBe('high');
|
||||
expect(wc.altitudeImpact(4001)).toBe('high');
|
||||
});
|
||||
test('moderate between 1,500 and 4,000 ft', () => {
|
||||
expect(wc.altitudeImpact(1765)).toBe('moderate');
|
||||
expect(wc.altitudeImpact(2000)).toBe('moderate');
|
||||
});
|
||||
test('none at sea level / typical US altitudes', () => {
|
||||
expect(wc.altitudeImpact(7)).toBe('none');
|
||||
expect(wc.altitudeImpact(820)).toBe('none');
|
||||
expect(wc.altitudeImpact(1499)).toBe('none');
|
||||
});
|
||||
test('returns none for nullable inputs (graceful)', () => {
|
||||
expect(wc.altitudeImpact(null)).toBe('none');
|
||||
expect(wc.altitudeImpact(undefined)).toBe('none');
|
||||
expect(wc.altitudeImpact(NaN)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHomeContinent', () => {
|
||||
test('returns true for the three 2026 hosts', () => {
|
||||
expect(wc.isHomeContinent('USA')).toBe(true);
|
||||
expect(wc.isHomeContinent('Canada')).toBe(true);
|
||||
expect(wc.isHomeContinent('Mexico')).toBe(true);
|
||||
});
|
||||
test('returns false for European squads', () => {
|
||||
expect(wc.isHomeContinent('France')).toBe(false);
|
||||
expect(wc.isHomeContinent('Brazil')).toBe(false);
|
||||
});
|
||||
test('returns false for unknown teams', () => {
|
||||
expect(wc.isHomeContinent('Atlantis')).toBe(false);
|
||||
expect(wc.isHomeContinent(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('penalty / corner / free-kick role lookups', () => {
|
||||
test('isPenaltyTaker — known taker', () => {
|
||||
expect(wc.isPenaltyTaker('Lionel Messi', 'Argentina')).toBe(true);
|
||||
expect(wc.isPenaltyTaker('Harry Kane', 'England')).toBe(true);
|
||||
});
|
||||
test('isPenaltyTaker — case-insensitive', () => {
|
||||
expect(wc.isPenaltyTaker('lionel messi', 'Argentina')).toBe(true);
|
||||
});
|
||||
test('isPenaltyTaker — wrong team returns false', () => {
|
||||
expect(wc.isPenaltyTaker('Lionel Messi', 'France')).toBe(false);
|
||||
});
|
||||
test('isPenaltyTaker — null inputs return false', () => {
|
||||
expect(wc.isPenaltyTaker(null, 'Argentina')).toBe(false);
|
||||
expect(wc.isPenaltyTaker('X', null)).toBe(false);
|
||||
});
|
||||
test('isCornerTaker — multi-name array picks up secondaries', () => {
|
||||
// England has 3+ corner takers — verify the second is found too.
|
||||
expect(wc.isCornerTaker('Phil Foden', 'England')).toBe(true);
|
||||
expect(wc.isCornerTaker('Trent Alexander-Arnold', 'England')).toBe(true);
|
||||
});
|
||||
test('isFreeKickTaker — sparse map (not every team has one)', () => {
|
||||
expect(wc.isFreeKickTaker('Lionel Messi', 'Argentina')).toBe(true);
|
||||
// Australia not in FK takers map at all
|
||||
expect(wc.isFreeKickTaker('Anyone', 'Australia')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTournamentHistory', () => {
|
||||
test('returns career WC stats for documented players', () => {
|
||||
const messi = wc.getTournamentHistory('Lionel Messi');
|
||||
expect(messi.wc_goals_career).toBeGreaterThanOrEqual(3);
|
||||
expect(messi.wc_appearances).toBeGreaterThan(0);
|
||||
});
|
||||
test('returns null for unknown player', () => {
|
||||
expect(wc.getTournamentHistory('Nobody Joe')).toBeNull();
|
||||
expect(wc.getTournamentHistory(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
test('VENUES is frozen (cannot mutate top-level)', () => {
|
||||
expect(Object.isFrozen(wc.VENUES)).toBe(true);
|
||||
});
|
||||
test('CONCACAF_TEAMS is frozen', () => {
|
||||
expect(Object.isFrozen(wc.CONCACAF_TEAMS)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user