Files
vyndr/tests/integration/gradingPipeline.test.js

194 lines
7.2 KiB
JavaScript

/**
* Grading pipeline end-to-end integration.
*
* 1. Mock SharpAPI returning 3 player props (one A-tier shaped, one
* neutral, one trap-heavy that should downgrade).
* 2. Mock feature cache returning features.
* 3. Mock trap detection — return high composite for the trap prop.
* 4. Run the orchestrator.
* 5. Assert: all 3 props get a grade_history insert.
* 6. Assert: A-tier prop queued for Engine 2, D/C-tier not queued.
* 7. Assert: trap composite > 0.5 downgrades the trap prop's grade.
*/
process.env.ENGINE2_ENABLED = 'true';
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args), post: jest.fn() }));
const mockGetPlayerProps = jest.fn();
jest.mock('../../src/services/adapters/sharpApiAdapter', () => ({
configured: () => true,
getPlayerProps: (...args) => mockGetPlayerProps(...args),
}));
jest.mock('../../src/services/adapters/openRouterAdapter', () => ({
configured: () => false, analyze: async () => null, getUsage: () => ({ requestsToday: 0 }),
}));
// Feature vectors per player — vary to drive different grades.
const mockFeatures = { current: new Map() };
jest.mock('../../src/services/intelligence/featureCache', () => ({
getFeatures: async ({ playerName }) => {
const f = mockFeatures.current.get(playerName) || {};
return { features: f, meta: { features_available: Object.keys(f), features_missing: [] } };
},
}));
const mockTrapScores = { current: new Map() };
jest.mock('../../src/services/intelligence/trapDetection', () => ({
getTrapScore: async ({ playerName }) => mockTrapScores.current.get(playerName) || {
composite: 0, signals: {}, active_count: 0, recommendation: 'proceed',
},
normalizeName: (n) => n,
detectReturningTeammate: () => null,
}));
jest.mock('../../src/services/intelligence/consistencyScore', () => ({
getConsistency: async () => ({ consistency: 'reliable', cv: 0.18, score: 0.7, games: 20 }),
}));
jest.mock('../../src/services/intelligence/gameLogService', () => ({
getGameLogs: async () => [
{ points: 30 }, { points: 28 }, { points: 32 }, { points: 27 }, { points: 31 },
],
getCareerPlayoffGames: async () => 50,
getWithWithoutStats: async () => null,
}));
const mockInserts = [];
const mockUpdates = [];
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({
from(table) {
const proxy = {
insert(row) {
mockInserts.push({ table, row });
return {
select() {
return { single: () => Promise.resolve({ data: { id: `gid-${mockInserts.length}` }, error: null }) };
},
};
},
update(patch) {
mockUpdates.push({ table, patch });
return { eq() { return Promise.resolve({ error: null }); } };
},
};
return proxy;
},
}),
}));
const orchestrator = require('../../src/services/intelligence/gradingOrchestrator');
const engine2 = require('../../src/services/intelligence/engine2');
beforeEach(() => {
mockAxiosGet.mockReset();
mockGetPlayerProps.mockReset();
mockInserts.length = 0;
mockUpdates.length = 0;
mockFeatures.current.clear();
mockTrapScores.current.clear();
engine2.clearQueue();
});
describe('grading pipeline — end-to-end through orchestrator', () => {
test('three props grade through, A-tier queued, trap-heavy downgraded', async () => {
// ESPN scoreboard with one game.
mockAxiosGet.mockResolvedValue({
status: 200,
data: {
events: [{
id: 'ev-mix',
date: '2026-06-08T23:30Z',
status: { type: { state: 'pre' } },
competitions: [{
competitors: [
{ homeAway: 'home', id: '1', team: { abbreviation: 'NYK', displayName: 'Knicks' } },
{ homeAway: 'away', id: '2', team: { abbreviation: 'BOS', displayName: 'Celtics' } },
],
}],
}],
},
});
// Three props:
// A-tier: hot recent form, weak D, rested, home — should grade A or A-
// neutral: average everything — should land C-ish
// trap-heavy: hot recent form BUT trap composite 0.7 → downgrade
mockGetPlayerProps.mockResolvedValue([
{ player: 'A-Player', team: 'NYK', statType: 'points', line: 25.5, direction: 'over' },
{ player: 'C-Player', team: 'NYK', statType: 'points', line: 25.5, direction: 'over' },
{ player: 'Trap-Player', team: 'NYK', statType: 'points', line: 25.5, direction: 'over' },
]);
mockFeatures.current.set('A-Player', {
l5_avg: 32, l20_avg: 28, opp_rank_stat: 0.85, home_away: 1.0, rest_days: 2,
});
mockFeatures.current.set('C-Player', { l5_avg: 25, l20_avg: 25.5 });
mockFeatures.current.set('Trap-Player', {
l5_avg: 32, l20_avg: 28, opp_rank_stat: 0.85, home_away: 1.0, rest_days: 2,
});
// Only the trap-heavy prop has a high trap composite.
mockTrapScores.current.set('Trap-Player', { composite: 0.7, signals: {}, active_count: 4, recommendation: 'avoid' });
const summary = await orchestrator.runPipeline('nba', { skipEngine2: true });
expect(summary.props_graded).toBe(3);
expect(mockInserts.length).toBe(3);
// All inserts went to grade_history.
expect(mockInserts.every((i) => i.table === 'grade_history')).toBe(true);
// factors column is stored as an array.
for (const i of mockInserts) {
expect(Array.isArray(i.row.factors) || i.row.factors === null).toBe(true);
}
// A-Player grade is in A range; Trap-Player is downgraded BELOW A.
const aRow = mockInserts.find((i) => i.row.player_name === 'A-Player');
const cRow = mockInserts.find((i) => i.row.player_name === 'C-Player');
const trapRow = mockInserts.find((i) => i.row.player_name === 'Trap-Player');
const GRADE_ORDER = ['F','D','C-','C','C+','B-','B','B+','A-','A','A+'];
const idx = (g) => GRADE_ORDER.indexOf(g);
expect(idx(aRow.row.grade)).toBeGreaterThan(idx('B+'));
expect(idx(trapRow.row.grade)).toBeLessThan(idx(aRow.row.grade));
expect(idx(cRow.row.grade)).toBeLessThan(idx(aRow.row.grade));
// Engine 2 queue: A-Player qualifies (A range); Trap-Player downgraded
// out of A/B range so should not be queued; C-Player C-tier excluded.
const queued = engine2.getQueueSize();
expect(queued).toBeGreaterThanOrEqual(1);
});
test('p_over and factors get persisted on the row', async () => {
mockAxiosGet.mockResolvedValue({
status: 200,
data: {
events: [{
id: 'ev-store',
competitions: [{ competitors: [
{ homeAway: 'home', id: '1', team: { abbreviation: 'NYK' } },
{ homeAway: 'away', id: '2', team: { abbreviation: 'BOS' } },
] }],
}],
},
});
mockGetPlayerProps.mockResolvedValue([
{ player: 'P', team: 'NYK', statType: 'points', line: 25.5, direction: 'over' },
]);
mockFeatures.current.set('P', {
l5_avg: 30, l20_avg: 28, opp_rank_stat: 0.85, home_away: 1.0,
});
await orchestrator.runPipeline('nba', { skipEngine2: true });
const row = mockInserts[0].row;
expect(row.factors).not.toBeNull();
expect(Array.isArray(row.factors)).toBe(true);
expect(row.factors.length).toBeGreaterThan(0);
expect(Number.isFinite(row.p_over)).toBe(true);
});
});