Sessions 7e-7g: Grading path unified - adapter, computeFeatures, analyzeViaEngine1, all routes migrated, dead code removed
This commit is contained in:
@@ -18,216 +18,62 @@ jest.mock('../../src/utils/redis', () => ({
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Mock axios (used by both oddsService and nbaStatsClient)
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
|
||||
process.env.ODDS_API_KEY = 'test-key';
|
||||
process.env.NBA_SERVICE_URL = 'http://localhost:8000';
|
||||
// ARCH-1 (Session 7g): the route now grades through engine1 via the
|
||||
// analyzeViaEngine1 helper. We mock the helper directly because it owns
|
||||
// the upstream chain — there's no value in driving the chain through
|
||||
// the test now that the only legacy fallback path is gone.
|
||||
//
|
||||
// Every mocked return is a fully-shaped legacy response so the route
|
||||
// (which still does cache wrap + _cache: 'MISS|HIT' tagging) is the
|
||||
// only thing under test.
|
||||
const mockAnalyzeViaEngine1 = jest.fn();
|
||||
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
||||
}));
|
||||
|
||||
const app = require('../../src/app');
|
||||
|
||||
// Mock data
|
||||
const MOCK_ODDS_EVENTS = [
|
||||
{
|
||||
id: 'game-1',
|
||||
sport_key: 'basketball_nba',
|
||||
home_team: 'Denver Nuggets',
|
||||
away_team: 'Los Angeles Lakers',
|
||||
commence_time: '2026-03-21T19:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ODDS_WITH_SPREADS = {
|
||||
...MOCK_ODDS_EVENTS[0],
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
title: 'DraftKings',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
|
||||
],
|
||||
function fullShapedResponse(overrides = {}) {
|
||||
return {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
line: 26.5,
|
||||
direction: 'over',
|
||||
book: 'draftkings',
|
||||
grade: 'A',
|
||||
confidence: 78,
|
||||
edge_pct: 6.2,
|
||||
kill_conditions_triggered: [],
|
||||
reasoning: {
|
||||
summary: 'Concrete sentence about Jokic averaging 28.4 last 5.',
|
||||
steps: {
|
||||
season_avg: { value: 26.3, vs_line: -0.2, signal: null },
|
||||
recent_form: { value: 28.1, vs_line: 1.6, signal: null },
|
||||
situational: {
|
||||
home_away: { value: null, context: 'home', signal: null },
|
||||
rest_days: { value: 2, context: 'rested', signal: null },
|
||||
vs_opponent: { value: null, games: null, signal: null },
|
||||
},
|
||||
{
|
||||
key: 'spreads',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Denver Nuggets', price: -110, point: -5.5 },
|
||||
{ name: 'Los Angeles Lakers', price: -110, point: 5.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
line_comparison: { best_line: null, worst_line: null, edge_from_best: 0, signal: null },
|
||||
kill_conditions: [],
|
||||
final_grade: 'A',
|
||||
narrative: [{ step: 1, detail: 'placeholder' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
title: 'FanDuel',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:30:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_SEASON_AVG = {
|
||||
player: 'Nikola Jokic',
|
||||
player_id: 203999,
|
||||
team: 'DEN',
|
||||
season: '2025-26',
|
||||
source: 'cache',
|
||||
stats: {
|
||||
points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1,
|
||||
blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2,
|
||||
games_played: 65, minutes: 34.2,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_LAST_N = {
|
||||
player: 'Nikola Jokic',
|
||||
player_id: 203999,
|
||||
team: 'DEN',
|
||||
last_n: 10,
|
||||
source: 'cache',
|
||||
stats: {
|
||||
points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3,
|
||||
blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9,
|
||||
games_played: 10, minutes: 35.1,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_HOME_AWAY = {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
split_type: 'home_away',
|
||||
source: 'cache',
|
||||
splits: {
|
||||
home: { avg: 27.8, games: 33 },
|
||||
away: { avg: 24.9, games: 32 },
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_REST_DAYS = {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
split_type: 'rest_days',
|
||||
source: 'cache',
|
||||
splits: {
|
||||
b2b: { avg: 23.1, games: 8 },
|
||||
'1_day_rest': { avg: 26.5, games: 40 },
|
||||
'2_plus_days_rest': { avg: 28.2, games: 17 },
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_VS_TEAM = {
|
||||
player: 'Nikola Jokic',
|
||||
stat_type: 'points',
|
||||
split_type: 'vs_team',
|
||||
opponent: 'LAL',
|
||||
source: 'cache',
|
||||
splits: {
|
||||
vs_opponent: { avg: 30.5, games: 3 },
|
||||
vs_all_others: { avg: 25.8, games: 62 },
|
||||
},
|
||||
};
|
||||
|
||||
const API_HEADERS = {
|
||||
'x-requests-remaining': '488',
|
||||
'x-requests-used': '12',
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
|
||||
// Odds API: events then event odds
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes('/events') && !url.includes('/odds')) {
|
||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
||||
}
|
||||
if (url.includes('/odds')) {
|
||||
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
||||
}
|
||||
// NBA stats service calls
|
||||
if (url.includes('/stats/season-avg')) {
|
||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
||||
}
|
||||
if (url.includes('/stats/last-n')) {
|
||||
return Promise.resolve({ data: MOCK_LAST_N });
|
||||
}
|
||||
if (url.includes('/stats/splits')) {
|
||||
if (url.includes('split_type=home_away') || (arguments[1]?.params?.split_type === 'home_away')) {
|
||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
}
|
||||
if (url.includes('split_type=rest_days') || (arguments[1]?.params?.split_type === 'rest_days')) {
|
||||
return Promise.resolve({ data: MOCK_REST_DAYS });
|
||||
}
|
||||
if (url.includes('split_type=vs_team') || (arguments[1]?.params?.split_type === 'vs_team')) {
|
||||
return Promise.resolve({ data: MOCK_VS_TEAM });
|
||||
}
|
||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
// Better mock that checks params
|
||||
function setupDetailedMocks() {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
|
||||
axios.get.mockImplementation((url, config) => {
|
||||
// Odds API
|
||||
if (url.includes('the-odds-api.com') && url.includes('/events') && !url.includes('/odds')) {
|
||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
||||
}
|
||||
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
||||
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
|
||||
}
|
||||
// NBA stats service
|
||||
if (url.includes('localhost:8000/stats/season-avg')) {
|
||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
||||
}
|
||||
if (url.includes('localhost:8000/stats/last-n')) {
|
||||
return Promise.resolve({ data: MOCK_LAST_N });
|
||||
}
|
||||
if (url.includes('localhost:8000/stats/splits')) {
|
||||
const splitType = config?.params?.split_type;
|
||||
if (splitType === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
if (splitType === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
||||
if (splitType === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
}
|
||||
if (url.includes('localhost:8000/players/search')) {
|
||||
return Promise.resolve({ data: { results: [{ player_id: 203999, full_name: 'Nikola Jokic' }] } });
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
});
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockAnalyzeViaEngine1.mockReset();
|
||||
});
|
||||
|
||||
describe('POST /api/analyze/prop', () => {
|
||||
it('returns complete analysis with all fields', async () => {
|
||||
setupDetailedMocks();
|
||||
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({ confidence: 78 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
@@ -240,13 +86,14 @@ describe('POST /api/analyze/prop', () => {
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// SHAPE assertions (must survive engine swap — these are the API contract):
|
||||
expect(res.body.player).toBe('Nikola Jokic');
|
||||
expect(res.body.stat_type).toBe('points');
|
||||
expect(res.body.grade).toMatch(/^[ABCD]$/);
|
||||
expect(res.body.grade).toMatch(/^[ABCDF]$/);
|
||||
expect(typeof res.body.edge_pct).toBe('number');
|
||||
expect(typeof res.body.confidence).toBe('number');
|
||||
expect(res.body.confidence).toBeGreaterThanOrEqual(30);
|
||||
expect(res.body.confidence).toBeLessThanOrEqual(95);
|
||||
expect(res.body.confidence).toBeGreaterThanOrEqual(0);
|
||||
expect(res.body.confidence).toBeLessThanOrEqual(100);
|
||||
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
||||
expect(res.body.reasoning).toBeDefined();
|
||||
expect(res.body.reasoning.summary).toBeDefined();
|
||||
@@ -257,10 +104,16 @@ describe('POST /api/analyze/prop', () => {
|
||||
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
|
||||
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
|
||||
expect(res.body.reasoning.steps.final_grade).toBeDefined();
|
||||
// PERF-1 cache tag — every fresh call is MISS.
|
||||
expect(res.body._cache).toBe('MISS');
|
||||
});
|
||||
|
||||
it('returns grade A/B for player averaging above line with good recent form', async () => {
|
||||
setupDetailedMocks();
|
||||
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||
grade: 'A',
|
||||
confidence: 82,
|
||||
edge_pct: 8.4,
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
@@ -277,8 +130,14 @@ describe('POST /api/analyze/prop', () => {
|
||||
expect(res.body.edge_pct).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns grade D for player averaging below line', async () => {
|
||||
setupDetailedMocks();
|
||||
it('returns a low grade for player averaging below line', async () => {
|
||||
// Engine 1 collapses A+/A/A- → A and (D|F) → D|F. For a cold prop the
|
||||
// adapter produces D or F; both are valid for this assertion.
|
||||
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||
grade: 'D',
|
||||
confidence: 25,
|
||||
edge_pct: -12.1,
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
@@ -291,43 +150,20 @@ describe('POST /api/analyze/prop', () => {
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.grade).toBe('D');
|
||||
expect(['D', 'F']).toContain(res.body.grade);
|
||||
});
|
||||
|
||||
it('caps grade when kill conditions trigger (blowout spread)', async () => {
|
||||
// Override spread to be a blowout
|
||||
const bigSpreadOdds = JSON.parse(JSON.stringify(MOCK_ODDS_WITH_SPREADS));
|
||||
bigSpreadOdds.bookmakers[0].markets[1].outcomes[0].point = -15;
|
||||
bigSpreadOdds.bookmakers[0].markets[1].outcomes[1].point = 15;
|
||||
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
|
||||
axios.get.mockImplementation((url, config) => {
|
||||
if (url.includes('the-odds-api.com') && !url.includes('/odds')) {
|
||||
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
|
||||
}
|
||||
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
|
||||
return Promise.resolve({ data: bigSpreadOdds, headers: API_HEADERS });
|
||||
}
|
||||
if (url.includes('localhost:8000/stats/season-avg')) {
|
||||
return Promise.resolve({ data: MOCK_SEASON_AVG });
|
||||
}
|
||||
if (url.includes('localhost:8000/stats/last-n')) {
|
||||
return Promise.resolve({ data: MOCK_LAST_N });
|
||||
}
|
||||
if (url.includes('localhost:8000/stats/splits')) {
|
||||
const st = config?.params?.split_type;
|
||||
if (st === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
|
||||
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
|
||||
return Promise.resolve({ data: MOCK_HOME_AWAY });
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked: ${url}`));
|
||||
});
|
||||
it('surfaces kill conditions through the adapter', async () => {
|
||||
// The 7e adapter translates engine1 factors into kill_conditions
|
||||
// entries. The legacy "blowout_risk" code is gone; the new code
|
||||
// set comes from FACTOR_TO_KILL_CONDITION in gradeAdapter.js.
|
||||
mockAnalyzeViaEngine1.mockResolvedValueOnce(fullShapedResponse({
|
||||
grade: 'C',
|
||||
confidence: 35,
|
||||
kill_conditions_triggered: [
|
||||
{ code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
||||
],
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
@@ -340,9 +176,12 @@ describe('POST /api/analyze/prop', () => {
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const codes = res.body.kill_conditions_triggered.map((k) => k.code);
|
||||
expect(codes).toContain('blowout_risk');
|
||||
expect(['C', 'D']).toContain(res.body.grade);
|
||||
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
|
||||
expect(res.body.kill_conditions_triggered.length).toBeGreaterThan(0);
|
||||
const first = res.body.kill_conditions_triggered[0];
|
||||
expect(typeof first.code).toBe('string');
|
||||
expect(typeof first.reason).toBe('string');
|
||||
expect(['C', 'D', 'F']).toContain(res.body.grade);
|
||||
});
|
||||
|
||||
it('returns 400 for missing player field', async () => {
|
||||
@@ -352,6 +191,8 @@ describe('POST /api/analyze/prop', () => {
|
||||
.expect(400);
|
||||
|
||||
expect(res.body.error).toContain('player is required');
|
||||
// Validation runs before the engine — the helper must not be called.
|
||||
expect(mockAnalyzeViaEngine1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for invalid stat_type', async () => {
|
||||
@@ -375,7 +216,9 @@ describe('POST /api/analyze/prop', () => {
|
||||
|
||||
describe('POST /api/analyze/batch', () => {
|
||||
it('processes multiple props and returns array', async () => {
|
||||
setupDetailedMocks();
|
||||
mockAnalyzeViaEngine1
|
||||
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'points', line: 26.5 }))
|
||||
.mockResolvedValueOnce(fullShapedResponse({ stat_type: 'rebounds', line: 12.5, grade: 'B' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/batch')
|
||||
@@ -389,6 +232,11 @@ describe('POST /api/analyze/batch', () => {
|
||||
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
expect(res.body.results.length).toBe(2);
|
||||
for (const r of res.body.results) {
|
||||
expect(r.grade).toMatch(/^[ABCDF]$/);
|
||||
expect(typeof r.confidence).toBe('number');
|
||||
expect(r.reasoning.summary).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 for empty props array', async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// PERF-1 (Session 7d): /api/analyze caches via Redis. Second identical
|
||||
// call must hit cache (returns _cache:'HIT', does not re-invoke
|
||||
// analyzeProp). Different keys still miss.
|
||||
// call must hit cache (returns _cache:'HIT', does not re-invoke the
|
||||
// analyzer). Different keys still miss.
|
||||
// ARCH-1 (Session 7g): the analyzer target moved from propAnalyzer to
|
||||
// the unified analyzeViaEngine1. Mock target updated to follow; the
|
||||
// caching behavior under test is unchanged.
|
||||
|
||||
const mockAnalyze = jest.fn();
|
||||
jest.mock('../../src/services/propAnalyzer', () => ({
|
||||
analyzeProp: (...args) => mockAnalyze(...args),
|
||||
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||
analyzeViaEngine1: (...args) => mockAnalyze(...args),
|
||||
}));
|
||||
|
||||
const mockStore = new Map();
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
const { computeGrade } = require('../../src/services/grader');
|
||||
|
||||
function makeStepResults(overrides = {}) {
|
||||
return {
|
||||
seasonDelta: 0,
|
||||
recentDelta: 0,
|
||||
situationalDelta: 0,
|
||||
lineEdge: 0,
|
||||
killConditions: [],
|
||||
gamesPlayed: 65,
|
||||
seasonAndRecentAgree: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('grader', () => {
|
||||
describe('grade assignment', () => {
|
||||
test('composite >= 3.0 returns grade A with confidence 80-95', () => {
|
||||
// composite = (4*1 + 4*1.5 + 4*1.2 + 4*0.8) / 4.5 = 18/4.5 = 4.0
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
|
||||
}));
|
||||
expect(result.grade).toBe('A');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(80);
|
||||
expect(result.confidence).toBeLessThanOrEqual(95);
|
||||
});
|
||||
|
||||
test('composite 1.5-2.99 returns grade B with confidence 65-79', () => {
|
||||
// composite = (2*1 + 2*1.5 + 2*1.2 + 2*0.8) / 4.5 = 9/4.5 = 2.0
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
|
||||
}));
|
||||
expect(result.grade).toBe('B');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(65);
|
||||
expect(result.confidence).toBeLessThanOrEqual(79);
|
||||
});
|
||||
|
||||
test('composite 0.5-1.49 returns grade C with confidence 50-64', () => {
|
||||
// composite = (1*1 + 1*1.5 + 1*1.2 + 0*0.8) / 4.5 = 3.7/4.5 ≈ 0.82
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 1, recentDelta: 1, situationalDelta: 1, lineEdge: 0,
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(50);
|
||||
expect(result.confidence).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
test('composite < 0.5 returns grade D with confidence 30-49', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: -1, recentDelta: -1, situationalDelta: -1, lineEdge: 0,
|
||||
}));
|
||||
expect(result.grade).toBe('D');
|
||||
expect(result.confidence).toBeGreaterThanOrEqual(30);
|
||||
expect(result.confidence).toBeLessThanOrEqual(49);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill condition penalty', () => {
|
||||
test('caps grade at C and reduces confidence by 15', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
|
||||
killConditions: [{ code: 'blowout_risk' }],
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
// Original would be A (80+), minus 15 = 65+
|
||||
expect(result.confidence).toBeLessThan(85);
|
||||
});
|
||||
|
||||
test('grade B with kill condition becomes C', () => {
|
||||
const result = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
|
||||
killConditions: [{ code: 'low_minutes' }],
|
||||
}));
|
||||
expect(result.grade).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bonuses', () => {
|
||||
test('sample bonus +5 for > 50 games', () => {
|
||||
const with50 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 55 }));
|
||||
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
|
||||
expect(with50.confidence).toBe(without.confidence + 5);
|
||||
});
|
||||
|
||||
test('sample bonus +3 for > 30 games', () => {
|
||||
const with30 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 35 }));
|
||||
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
|
||||
expect(with30.confidence).toBe(without.confidence + 3);
|
||||
});
|
||||
|
||||
test('consistency bonus +5 when season and recent agree', () => {
|
||||
const agree = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: true,
|
||||
}));
|
||||
const noInfo = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
|
||||
}));
|
||||
expect(agree.confidence).toBe(noInfo.confidence + 5);
|
||||
});
|
||||
|
||||
test('consistency penalty -5 when season and recent conflict', () => {
|
||||
const conflict = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: false,
|
||||
}));
|
||||
const noInfo = computeGrade(makeStepResults({
|
||||
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
|
||||
}));
|
||||
expect(conflict.confidence).toBe(noInfo.confidence - 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user