Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* 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);
});
});
+242
View File
@@ -0,0 +1,242 @@
/**
* End-to-end learning-loop test.
*
* 1. Resolve a prop (hit) through the existing /api/grading/resolve path.
* 2. Assert CLV computed and stored.
* 3. Assert accuracy tracker incremented.
* 4. Assert weight adjuster fired (or correctly skipped because the
* sample is thin).
* 5. Assert the JSONL training logger wrote a line.
*
* Stubs all upstream services so the test runs in isolation. The actual
* Supabase calls land in an in-memory mock that records every operation.
*/
process.env.VYNDR_INTERNAL_KEY = 'lf-test-key';
process.env.NODE_ENV = 'test';
const fs = require('fs');
const path = require('path');
const mockState = {
unresolved: [],
inserts: [],
updates: [],
closing: null,
resolutionCount: 100, // enough to pass weight adjuster gate
accuracyRows: new Map(),
weightHistory: [],
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({
from(table) {
const ctx = { table, filters: {}, countMode: null, head: false };
const proxy = {
select(_cols, opts) { ctx.head = !!opts?.head; ctx.countMode = opts?.count; return proxy; },
eq(col, val) { ctx.filters[col] = val; return proxy; },
is() { return Promise.resolve({ data: mockState.unresolved, error: null }); },
in() { return Promise.resolve({ error: null }); },
gte() { return Promise.resolve({ data: [], error: null }); },
not() { return proxy; },
order() { return proxy; },
limit() { return proxy; },
single() { return Promise.resolve({ data: { id: 'gid-new' }, error: null }); },
maybeSingle() {
if (ctx.table === 'closing_lines') return Promise.resolve({ data: mockState.closing, error: null });
if (ctx.table === 'grade_history') {
return Promise.resolve({
data: { id: ctx.filters.id, sport: 'nba', stat_type: 'points', line: 25.5, direction: 'over', player_id: '1', game_id: 'gm-1' },
error: null,
});
}
if (ctx.table === 'accuracy_tracking') {
const k = `${ctx.filters.sport}|${ctx.filters.grade}|${ctx.filters.period}`;
return Promise.resolve({ data: mockState.accuracyRows.get(k) || null, error: null });
}
return Promise.resolve({ data: null, error: null });
},
insert(row) { mockState.inserts.push({ table: ctx.table, row }); return Promise.resolve({ error: null }); },
update(patch) {
mockState.updates.push({ table: ctx.table, patch, filters: { ...ctx.filters } });
return {
eq() { return Promise.resolve({ error: null }); },
in() { return Promise.resolve({ error: null }); },
};
},
upsert(row) {
if (ctx.table === 'accuracy_tracking') {
mockState.accuracyRows.set(`${row.sport}|${row.grade}|${row.period}`, { ...row });
}
return Promise.resolve({ error: null });
},
then(resolve) {
if (ctx.table === 'resolution_results' && ctx.countMode === 'exact') {
return resolve({ count: mockState.resolutionCount, error: null });
}
if (ctx.table === 'engine1_weights') {
const matches = mockState.weightHistory.filter((r) => {
for (const [k, v] of Object.entries(ctx.filters)) {
if (r[k] !== v) return false;
}
return true;
}).sort((a, b) => b.version - a.version);
return resolve({ data: matches, error: null });
}
return resolve({ data: [], error: null });
},
};
return proxy;
},
}),
}));
jest.mock('../../src/services/distribution/webPush', () => ({
configured: () => false, sendPushToSport: async () => ({ ok: true, sent: 0 }),
}));
jest.mock('../../src/services/distribution/telegram', () => ({
configured: () => false, postToTelegram: async () => ({ ok: true }),
}));
jest.mock('../../src/services/distribution/discord', () => ({
webhookFor: () => null, postToDiscord: async () => ({ ok: true }),
}));
const express = require('express');
const gradingRoutes = require('../../src/routes/grading');
const fixture = JSON.parse(fs.readFileSync(
path.join(__dirname, '..', 'fixtures', 'nba-boxscore-sample.json'),
'utf8',
));
function pickAthletes(box, n) {
const out = [];
for (const team of box.boxscore.players) {
for (const a of team.statistics[0].athletes) {
out.push({ id: String(a.athlete.id), name: a.athlete.displayName, stats: a.stats });
if (out.length === n) return out;
}
}
return out;
}
function makeApp() {
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use('/api/grading', gradingRoutes);
return app;
}
function request(app, body, headers = {}) {
return new Promise((resolve, reject) => {
const http = require('http');
const server = app.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const data = JSON.stringify(body);
const req = http.request({
host: '127.0.0.1', port, path: '/api/grading/resolve', method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
server.close();
const raw = Buffer.concat(chunks).toString('utf8');
let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (err) => { server.close(); reject(err); });
req.write(data);
req.end();
});
});
}
beforeEach(() => {
mockState.unresolved = [];
mockState.inserts.length = 0;
mockState.updates.length = 0;
mockState.closing = { id: 'cl-1', pinnacle_line: 27.5 };
mockState.resolutionCount = 100;
mockState.accuracyRows.clear();
mockState.weightHistory.length = 0;
});
describe('learning loop — post-resolution side effects', () => {
test('hit on an A-grade prop drives CLV + accuracy + weights', async () => {
const [p1] = pickAthletes(fixture, 1);
const p1Points = Number(p1.stats[1]);
mockState.unresolved = [{
id: 'gh-A',
player_id: p1.id,
player_name: p1.name,
stat_type: 'points',
line: p1Points - 0.5,
direction: 'over',
grade: 'A',
sport: 'nba',
projection: p1Points,
factors: ['l5_hot_vs_line', 'home_game'],
}];
const app = makeApp();
const res = await request(app, {
gameId: '401859964',
sport: 'nba',
boxScore: fixture,
}, { 'X-VYNDR-Internal-Key': 'lf-test-key' });
expect(res.status).toBe(200);
expect(res.body.resolved).toBe(1);
// Side effects are fire-and-forget; give them a tick to settle.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setTimeout(r, 30));
// Accuracy upserted for all three periods.
const periods = Array.from(mockState.accuracyRows.keys());
expect(periods).toContain('nba|A|all_time');
expect(periods).toContain('nba|A|last_30d');
expect(periods).toContain('nba|A|last_7d');
const allTime = mockState.accuracyRows.get('nba|A|all_time');
expect(allTime.total_hit + allTime.total_miss).toBeGreaterThan(0);
// CLV: an update on grade_history with a clv field was queued by the
// computeCLV path.
const clvUpdates = mockState.updates.filter((u) => 'clv' in (u.patch || {}));
expect(clvUpdates.length).toBeGreaterThan(0);
expect(clvUpdates[0].patch.clv).toBeGreaterThan(0); // graded line was lower → positive CLV
// Weight adjuster inserts at least one engine1_weights row (factors > 0).
const weightInserts = mockState.inserts.filter((i) => i.table === 'engine1_weights');
expect(weightInserts.length).toBeGreaterThan(0);
});
test('void result records accuracy but not CLV / weights', async () => {
mockState.unresolved = [{
id: 'gh-void',
player_id: '99999',
player_name: 'Phantom',
stat_type: 'points',
line: 10.5,
direction: 'over',
grade: 'B',
sport: 'nba',
}];
const app = makeApp();
await request(app, {
gameId: '401859964',
sport: 'nba',
boxScore: fixture,
}, { 'X-VYNDR-Internal-Key': 'lf-test-key' });
await new Promise((r) => setTimeout(r, 30));
expect(mockState.accuracyRows.get('nba|B|all_time')).toBeTruthy();
// CLV + weight adjustments should not have fired on a void.
const clvUpdates = mockState.updates.filter((u) => 'clv' in (u.patch || {}));
expect(clvUpdates.length).toBe(0);
const weightInserts = mockState.inserts.filter((i) => i.table === 'engine1_weights');
expect(weightInserts.length).toBe(0);
});
});
+1 -1
View File
@@ -205,7 +205,7 @@ describe('GET /api/odds/nba', () => {
const res = await request(app).get('/api/odds/nba').expect(200);
expect(res.body.source).toBe('cache');
expect(res.headers['x-betonblk-stale']).toBe('true');
expect(res.headers['x-vyndr-stale']).toBe('true');
});
it('returns 503 when API fails and no cache', async () => {
+258
View File
@@ -0,0 +1,258 @@
/**
* Resolution route integration test — uses a real ESPN NBA box score
* fetched from
* https://site.api.espn.com/apis/site/v2/sports/basketball/nba/summary?event=401859964
*
* Workflow:
* 1. Mock supabase to return three fake unresolved props that match
* players in the real fixture.
* 2. Drive the resolution route with the real box-score JSON.
* 3. Assert the actual stats were extracted correctly and the
* hit/miss/push verdict matches what the line + direction imply.
*/
const fs = require('fs');
const path = require('path');
process.env.VYNDR_INTERNAL_KEY = 'int-test-key';
process.env.NODE_ENV = 'test';
// Capture writes the route makes for assertion.
const mockState = {
unresolved: [],
inserts: [],
updates: [],
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({
from(table) {
const ctx = { table };
const proxy = {
select() { return proxy; },
eq() { return proxy; },
in() { return Promise.resolve({ error: null }); },
is() { return Promise.resolve({ data: mockState.unresolved, error: null }); },
insert(rows) {
mockState.inserts.push({ table, rows });
return Promise.resolve({ error: null });
},
update(patch) {
mockState.updates.push({ table, patch });
// chain to .eq()/.in() — both close out the call with a resolved promise.
return {
eq() { return Promise.resolve({ error: null }); },
in() { return Promise.resolve({ error: null }); },
};
},
};
return proxy;
},
}),
}));
// Distribution services — stubbed configured=false so side effects are no-ops.
jest.mock('../../src/services/distribution/webPush', () => ({
configured: () => false,
sendPushToSport: async () => ({ ok: true, sent: 0 }),
}));
jest.mock('../../src/services/distribution/telegram', () => ({
configured: () => false,
postToTelegram: async () => ({ ok: true }),
}));
jest.mock('../../src/services/distribution/discord', () => ({
webhookFor: () => null,
postToDiscord: async () => ({ ok: true }),
}));
const express = require('express');
const gradingRoutes = require('../../src/routes/grading');
const fixture = JSON.parse(fs.readFileSync(
path.join(__dirname, '..', 'fixtures', 'nba-boxscore-sample.json'),
'utf8',
));
// Find a couple of real athletes in the fixture so we can build deterministic
// fake unresolved props. The first team's first athlete is OG Anunoby in the
// saved sample; selecting two players from the fixture removes any need to
// hand-author expected stats — we read them straight from the box.
function pickAthletes(box, n) {
const out = [];
for (const team of box.boxscore.players) {
for (const a of team.statistics[0].athletes) {
out.push({
id: String(a.athlete.id),
name: a.athlete.displayName,
stats: a.stats,
starter: !!a.starter,
});
if (out.length === n) return out;
}
}
return out;
}
const [p1, p2, p3] = pickAthletes(fixture, 3);
// labels: ['MIN','PTS','FG','3PT','FT','REB','AST','TO','STL','BLK',...]
const p1Points = Number(p1.stats[1]);
const p2Rebounds = Number(p2.stats[5]);
const p3PRA = Number(p3.stats[1]) + Number(p3.stats[5]) + Number(p3.stats[6]);
function makeApp() {
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use('/api/grading', gradingRoutes);
return app;
}
function request(app, method, url, body, headers = {}) {
return new Promise((resolve, reject) => {
const http = require('http');
const server = app.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const data = body ? JSON.stringify(body) : '';
const req = http.request({
host: '127.0.0.1',
port,
path: url,
method,
headers: { 'Content-Type': 'application/json', ...headers },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
server.close();
const raw = Buffer.concat(chunks).toString('utf8');
let parsed; try { parsed = JSON.parse(raw); } catch { parsed = raw; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on('error', (err) => { server.close(); reject(err); });
if (data) req.write(data);
req.end();
});
});
}
beforeEach(() => {
mockState.unresolved = [];
mockState.inserts = [];
mockState.updates = [];
});
describe('POST /api/grading/resolve — auth', () => {
test('rejects without key', async () => {
const app = makeApp();
const res = await request(app, 'POST', '/api/grading/resolve', { gameId: 'g', sport: 'nba' });
expect(res.status).toBe(401);
});
test('rejects from non-loopback (handled in real-world by Express trust proxy)', async () => {
// We can't easily spoof a non-loopback IP locally; auth covers the
// primary defense. Leaving this stub so the dependency on the IP check
// is visible if anyone ever rips it out.
expect(typeof gradingRoutes.__helpers.requireInternal).toBe('function');
});
});
describe('POST /api/grading/resolve — real ESPN box', () => {
test('resolves three props against the real box score, returning correct verdicts', async () => {
mockState.unresolved = [
{ id: 'gh-1', player_id: p1.id, player_name: p1.name, stat_type: 'points', line: p1Points - 0.5, direction: 'over', grade: 'A-', sport: 'nba', projection: p1Points },
{ id: 'gh-2', player_id: p2.id, player_name: p2.name, stat_type: 'rebounds', line: p2Rebounds + 0.5, direction: 'under', grade: 'B+', sport: 'nba', projection: p2Rebounds },
{ id: 'gh-3', player_id: p3.id, player_name: p3.name, stat_type: 'pts_reb_ast', line: p3PRA + 0.5, direction: 'over', grade: 'C', sport: 'nba', projection: p3PRA },
];
const app = makeApp();
const res = await request(app, 'POST', '/api/grading/resolve',
{ gameId: '401859964', sport: 'nba', boxScore: fixture },
{ 'X-VYNDR-Internal-Key': 'int-test-key' },
);
expect(res.status).toBe(200);
expect(res.body.resolved + res.body.voided).toBe(3);
// p1: line is points - 0.5, direction over → actual > line → HIT
const r1 = res.body.results.find((r) => r.id === 'gh-1');
expect(r1.actual_value).toBe(p1Points);
expect(r1.result).toBe('hit');
// p2: line is rebounds + 0.5, direction under → actual < line → HIT
const r2 = res.body.results.find((r) => r.id === 'gh-2');
expect(r2.actual_value).toBe(p2Rebounds);
expect(r2.result).toBe('hit');
// p3: combo over, line is sum + 0.5 → actual < line → MISS
const r3 = res.body.results.find((r) => r.id === 'gh-3');
expect(r3.actual_value).toBe(p3PRA);
expect(r3.result).toBe('miss');
});
test('marks DNP as void with correction_note', async () => {
mockState.unresolved = [
{ id: 'dnp', player_id: '999999999', player_name: 'Phantom Player', stat_type: 'points', line: 10.5, direction: 'over', grade: 'C', sport: 'nba' },
];
const app = makeApp();
const res = await request(app, 'POST', '/api/grading/resolve',
{ gameId: '401859964', sport: 'nba', boxScore: fixture },
{ 'X-VYNDR-Internal-Key': 'int-test-key' },
);
expect(res.status).toBe(200);
expect(res.body.voided).toBe(1);
expect(res.body.results[0].result).toBe('void');
});
test('void: true marks all unresolved props', async () => {
mockState.unresolved = [
{ id: 'vp-1', player_id: '1', player_name: 'A', stat_type: 'points', line: 10, direction: 'over', grade: 'B', sport: 'nba' },
{ id: 'vp-2', player_id: '2', player_name: 'B', stat_type: 'points', line: 12, direction: 'under', grade: 'C', sport: 'nba' },
];
const app = makeApp();
const res = await request(app, 'POST', '/api/grading/resolve',
{ gameId: 'pp-game', sport: 'nba', void: true, reason: 'postponed' },
{ 'X-VYNDR-Internal-Key': 'int-test-key' },
);
expect(res.status).toBe(200);
expect(res.body.voided).toBe(2);
});
});
describe('calculateStat unit cases', () => {
const { calculateStat } = gradingRoutes.__helpers ?? {};
const { getSportConfig } = require('../../src/config/sports');
test('NBA points (index)', () => {
expect(calculateStat([null, '25', null, null, null], 'points', getSportConfig('nba'))).toBe(25);
});
test('NBA threes_made parses makes-attempts string', () => {
expect(calculateStat([null, null, null, '3-7'], 'threes_made', getSportConfig('nba'))).toBe(3);
expect(calculateStat([null, null, null, ''], 'threes_made', getSportConfig('nba'))).toBe(0);
expect(calculateStat([null, null, null, null], 'threes_made', getSportConfig('nba'))).toBe(0);
});
test('NBA pts_reb_ast combo', () => {
const stats = [];
stats[1] = 22; stats[5] = 9; stats[6] = 5;
expect(calculateStat(stats, 'pts_reb_ast', getSportConfig('nba'))).toBe(36);
});
test('MLB totalBases (mlbField)', () => {
expect(calculateStat({ totalBases: 4 }, 'totalBases', getSportConfig('mlb'))).toBe(4);
});
test('MLB inningsPitched parses "5.1" → 5.33', () => {
expect(calculateStat({ inningsPitched: '5.1' }, 'inningsPitched', getSportConfig('mlb'))).toBeCloseTo(5.333, 2);
});
test('NFL passing_yards (category + field)', () => {
expect(calculateStat({ passing: { passingYards: 287 } }, 'passing_yards', getSportConfig('nfl'))).toBe(287);
});
test('defensive — undefined/null/empty all return 0', () => {
expect(calculateStat(undefined, 'points', getSportConfig('nba'))).toBe(0);
expect(calculateStat([], 'points', getSportConfig('nba'))).toBe(0);
expect(calculateStat({ inningsPitched: null }, 'inningsPitched', getSportConfig('mlb'))).toBe(0);
});
});