Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// Mocked deps for the feature cache. Every sub-service is replaced so we
|
||||
// can verify the cache OMITS features whose source returns null.
|
||||
|
||||
const mockGameLogs = { logs: null, careerPlayoff: null };
|
||||
jest.mock('../../src/services/intelligence/gameLogService', () => ({
|
||||
getGameLogs: async () => mockGameLogs.logs,
|
||||
getCareerPlayoffGames: async () => mockGameLogs.careerPlayoff,
|
||||
getWithWithoutStats: async () => null,
|
||||
}));
|
||||
|
||||
const mockTeam = { stats: null, rank: null };
|
||||
jest.mock('../../src/services/intelligence/teamStatsCache', () => ({
|
||||
getTeamStats: async () => mockTeam.stats,
|
||||
getOpponentRank: async () => mockTeam.rank,
|
||||
refreshTeamStats: async () => ({ captured: 0 }),
|
||||
}));
|
||||
|
||||
const mockRef = { impact: null };
|
||||
jest.mock('../../src/services/intelligence/refSignals', () => ({
|
||||
getRefImpact: async () => mockRef.impact,
|
||||
setRefAssignment: async () => ({ ok: true }),
|
||||
}));
|
||||
|
||||
const mockCoach = { impact: null };
|
||||
jest.mock('../../src/services/intelligence/coachSignals', () => ({
|
||||
getCoachImpact: async () => mockCoach.impact,
|
||||
getCoachProfile: async () => null,
|
||||
tenureAdjustment: () => 0.5,
|
||||
loadSeed: () => ({ coaches: [] }),
|
||||
}));
|
||||
|
||||
const mockLineup = { roleValue: 1.0 };
|
||||
jest.mock('../../src/services/intelligence/lineupSignals', () => ({
|
||||
roleValue: () => mockLineup.roleValue,
|
||||
classifyByUsage: () => 'primary_handler',
|
||||
getProjectedStarters: async () => ({ home: [], away: [] }),
|
||||
}));
|
||||
|
||||
const mockInjuries = { list: [] };
|
||||
jest.mock('../../src/services/intelligence/injuryParser', () => ({
|
||||
getTeamInjuries: async () => mockInjuries.list,
|
||||
isPlayerOut: async () => false,
|
||||
getMissingStarters: async () => [],
|
||||
}));
|
||||
|
||||
const mockLineMovement = { lm: null };
|
||||
jest.mock('../../src/services/intelligence/lineMovement', () => ({
|
||||
getLineMovement: async () => mockLineMovement.lm,
|
||||
}));
|
||||
|
||||
const mockCache = { current: new Map() };
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => mockCache.current.get(k) ?? null,
|
||||
cacheSet: async (k, v) => { mockCache.current.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCache.current.delete(k); return true; },
|
||||
}));
|
||||
|
||||
const fc = require('../../src/services/intelligence/featureCache');
|
||||
|
||||
beforeEach(() => {
|
||||
mockGameLogs.logs = null;
|
||||
mockGameLogs.careerPlayoff = null;
|
||||
mockTeam.stats = null;
|
||||
mockTeam.rank = null;
|
||||
mockRef.impact = null;
|
||||
mockCoach.impact = null;
|
||||
mockLineup.roleValue = 1.0;
|
||||
mockInjuries.list = [];
|
||||
mockLineMovement.lm = null;
|
||||
mockCache.current.clear();
|
||||
});
|
||||
|
||||
describe('feature cache — missing data omits features', () => {
|
||||
test('with no upstream data, only context + injury_severity_score (=0) populate', async () => {
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p1',
|
||||
playerName: 'P',
|
||||
statType: 'points',
|
||||
sport: 'nba',
|
||||
gameId: 'g1',
|
||||
gameContext: { home_away: 'home', rest_days: 1 },
|
||||
teamId: 18,
|
||||
});
|
||||
expect(payload.features.home_away).toBe(1.0);
|
||||
expect(payload.features.rest_days).toBe(1);
|
||||
expect(payload.features.injury_severity_score).toBe(0);
|
||||
// Missing because upstreams returned null:
|
||||
expect(payload.features.l5_avg).toBeUndefined();
|
||||
expect(payload.features.opp_rank_stat).toBeUndefined();
|
||||
expect(payload.features.ref_pace_adjustment).toBeUndefined();
|
||||
expect(payload.meta.features_missing).toContain('l5_avg');
|
||||
expect(payload.meta.features_missing).toContain('ref_pace_adjustment');
|
||||
expect(payload.meta.features_available).toContain('home_away');
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — game log features', () => {
|
||||
test('computes l5_avg, l20_avg, l10_stddev from a 10-game log', async () => {
|
||||
mockGameLogs.logs = [
|
||||
{ points: 30 }, { points: 25 }, { points: 28 }, { points: 22 }, { points: 35 },
|
||||
{ points: 20 }, { points: 18 }, { points: 26 }, { points: 31 }, { points: 24 },
|
||||
];
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p2', playerName: 'P', statType: 'points',
|
||||
sport: 'nba', gameId: 'g2', gameContext: {},
|
||||
});
|
||||
expect(payload.features.l5_avg).toBeCloseTo(28.0, 1);
|
||||
expect(payload.features.l20_avg).toBeCloseTo(25.9, 1);
|
||||
expect(payload.features.l10_stddev).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('combo stats (pts_reb_ast) sum components per game', async () => {
|
||||
mockGameLogs.logs = [
|
||||
{ points: 25, rebounds: 5, assists: 7 },
|
||||
{ points: 22, rebounds: 6, assists: 8 },
|
||||
{ points: 30, rebounds: 4, assists: 5 },
|
||||
];
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p3', playerName: 'P', statType: 'pts_reb_ast',
|
||||
sport: 'nba', gameId: 'g3', gameContext: {},
|
||||
});
|
||||
// (37 + 36 + 39) / 3 = 37.33
|
||||
expect(payload.features.l5_avg).toBeCloseTo(37.33, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — team features', () => {
|
||||
test('opp_rank_stat + pace_factor + team_pace come from team cache', async () => {
|
||||
mockTeam.stats = { pace: 102.4 };
|
||||
mockTeam.rank = 7;
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p4', playerName: 'P', statType: 'points',
|
||||
sport: 'nba', gameId: 'g4', opponentAbbr: 'BOS', gameContext: {},
|
||||
});
|
||||
expect(payload.features.opp_rank_stat).toBe(7);
|
||||
expect(payload.features.pace_factor).toBe(102.4);
|
||||
expect(payload.features.team_pace).toBe(102.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — injury features', () => {
|
||||
test('counts missing starters', async () => {
|
||||
mockInjuries.list = [
|
||||
{ playerId: '1', playerName: 'A', status: 'OUT' },
|
||||
{ playerId: '2', playerName: 'B', status: 'OUT' },
|
||||
{ playerId: '3', playerName: 'C', status: 'QUESTIONABLE' },
|
||||
];
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p5', playerName: 'P', statType: 'points',
|
||||
sport: 'nba', gameId: 'g5', teamId: 18,
|
||||
knownStarterIds: ['1', '2', '3', '4'],
|
||||
gameContext: {},
|
||||
});
|
||||
// Only OUT counts as missing starter (status=QUESTIONABLE skipped).
|
||||
expect(payload.features.injury_severity_score).toBe(2);
|
||||
expect(payload.features.teammate_absence_bump).toBeCloseTo(0.10, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — context features cover all knobs', () => {
|
||||
test('every documented context field round-trips', async () => {
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p6', playerName: 'P', statType: 'points',
|
||||
sport: 'nba', gameId: 'g6',
|
||||
gameContext: {
|
||||
home_away: 'away',
|
||||
rest_days: 0,
|
||||
game_count_in_7d: 4,
|
||||
season_type: 2,
|
||||
game_in_series: 1,
|
||||
season_phase: 0.95,
|
||||
},
|
||||
});
|
||||
const f = payload.features;
|
||||
expect(f.home_away).toBe(0.0);
|
||||
expect(f.rest_days).toBe(0);
|
||||
expect(f.game_count_in_7d).toBe(4);
|
||||
expect(f.season_type).toBe(2);
|
||||
expect(f.game_in_series).toBe(1);
|
||||
expect(f.season_phase).toBe(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — cache key reuse', () => {
|
||||
test('second call returns the cached vector', async () => {
|
||||
const args = { playerId: 'p7', playerName: 'P', statType: 'points', sport: 'nba', gameId: 'g7', gameContext: {} };
|
||||
const first = await fc.getFeatures(args);
|
||||
mockGameLogs.logs = [{ points: 99 }]; // upstream changes
|
||||
const second = await fc.getFeatures(args);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — coach + ref + lineup features', () => {
|
||||
test('coach delta + ref pace + lineup role pass through', async () => {
|
||||
mockCoach.impact = { adjusted_pace_delta: 1.4, without_primary_pace_shift: 0.5 };
|
||||
mockRef.impact = { pace_impact: 0.6, foul_adjustment: 22 };
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p8', playerName: 'P', statType: 'points', sport: 'nba',
|
||||
gameId: 'g8', teamAbbr: 'NYK',
|
||||
role: 'primary_handler',
|
||||
gameContext: {},
|
||||
});
|
||||
expect(payload.features.coach_pace_delta).toBe(1.4);
|
||||
expect(payload.features.coach_player_interaction).toBe(0.5);
|
||||
expect(payload.features.ref_pace_adjustment).toBe(0.6);
|
||||
expect(payload.features.ref_foul_adjustment).toBe(22);
|
||||
expect(payload.features.lineup_ball_handler_role).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature cache — line movement', () => {
|
||||
test('line_delta surfaces the movement value', async () => {
|
||||
mockLineMovement.lm = { movement: -0.5 };
|
||||
const payload = await fc.getFeatures({
|
||||
playerId: 'p9', playerName: 'P', statType: 'points',
|
||||
sport: 'nba', gameId: 'g9', gameContext: {},
|
||||
});
|
||||
expect(payload.features.line_delta).toBe(-0.5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user