Files
vyndr/tests/unit/featureCache.test.js

222 lines
8.2 KiB
JavaScript

// 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);
});
});