194 lines
7.2 KiB
JavaScript
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);
|
|
});
|
|
});
|