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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user