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
+131
View File
@@ -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();
});
});
+182
View File
@@ -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();
});
});
});
+13 -1
View File
@@ -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: [
+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');
});
});
});
+250
View File
@@ -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');
});
});
});
+196
View File
@@ -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;
});
});
});
+229
View File
@@ -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');
});
});
});
+118
View File
@@ -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);
});
});
});