Revive the dead probability layer + restore grade range ON MERIT
Folds re-sequenced steps 1+2 into one change (Kev's call): same bug
family — features wired to sources that return null.
THE PROBABILITY LAYER WAS DEAD IN PRODUCTION. p_win/ev_pct/kelly/
model_odds/value were absent on 0/8 live grades because
gameLogService.getGameLogs returns null for MLB by construction and
depends on the offline Python service for NBA/WNBA, so meta.gameLogs was
[] for every sport. This was the S46 bug in a second location — that fix
gave featureCache an MLB branch (why grades still worked) but never the
estimator. featureCache.getStatRows now supplies normalized rows
([{date,[statType]:v}], most-recent-first) for every sport, feeding the
estimator AND consistency AND game_count_in_7d from one fetch.
VERIFIED on real props: p_win 25/25 WNBA, 8/8 MLB (was 0).
GRADE RANGE, ON MERIT — never by rescaling (permanent founder ruling:
minting A's without new information is a relabelled B sold as an A and
corrupts an append-only ledger).
- refreshTeamStats wired into runSnapshot — it had ZERO production
callers, so opp_rank_stat was permanently null and a +/-1.0 factor
could never fire. Test-env no-op (opsNotify precedent).
- L20 made SYMMETRIC: both branches were delta +1.0, so the season
baseline could only ever ADD. No negative path was a structural reason
D was unreachable. New l20_contradicts_* carries -1.0.
- game_count_in_7d derived from real logged dates (heavy_workload_7d).
- NOT wired, deliberately, with reasons inline: teamId (no team_id
column; getFeatures reads it top-level; factor also needs a starter-id
list) and season_type (ESPN 2 = REGULAR season; threading it raw would
fire veteran_in_playoffs in July). Dead code dressed as a fix is the
thing we are removing, not adding.
CALIBRATION GUARD (found by verifying, not assuming): consistency CV is
NBA-tuned; for a Poisson-ish stat cv ~ 1/sqrt(mean), so any stat with
mean < 4 auto-classifies boom_bust. First verification run showed 8/8 MLB
props boom_bust — a blanket -1.0 that dropped the board to all-C. Floored
at CONSISTENCY_MIN_MEAN=4 -> 'unknown' below. Absent beats wrong. MLB
low-count stats therefore still get no consistency factor: honest, not
fixed. Scale-free index-of-dispersion classifier is the open follow-up.
CONFIDENCE IS NOT A PROBABILITY: payloads carry confidence_basis:
'grade_band'. Corrected mlb-grade-degradation.md — its "25/25
grade<->confidence agreement" is a TAUTOLOGY (confidence is derived FROM
the letter, so it would report 25/25 even if every grade were wrong), not
a validation. Removed dead mlbGrader.js (referenced only by its own test)
and the stale computeFeatures comment claiming a penalty that never ran.
VERIFICATION (scripts/verify-grade-range.js, real props/logs/engine):
WNBA 25 props B 68%->32%, C 32%->64%, D 0->1 (4%); 11-step spread went
from 2 steps to 5 (C/C+/B-/D). The D is earned: Angel Reese assists o2.5,
p_win 0.365. Nothing flooded — grades got HARDER. A did not emit locally
because opp_rank_stat needs the Redis cache only prod populates (local
ceiling +3.0 vs the +4.5 A needs); reachability is proven arithmetically
and locked in tests. Prod A-emission is the outstanding fingerprint.
MARKETING HOLD: "A-RATED" (AccuracyBadge, TopSignals) is unsupported
until that fingerprint. Confirmed honest fallbacks render today —
/api/ledger/accuracy has B and C buckets only, so the badge shows
"MODEL · 63% HIT" and TopSignals self-hides. Nothing fabricated ships.
Suite 276/3286 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
@@ -29,6 +29,12 @@ jest.mock('../../src/services/intelligence/featureCache', () => ({
|
||||
if (mockFeatures.throws) throw new Error('feature-fetch boom');
|
||||
return { features: mockFeatures.current, meta: {} };
|
||||
},
|
||||
// Session 63 — computeFeatures now sources normalized per-game rows here
|
||||
// (feeds consistency + the probability estimator + game_count_in_7d).
|
||||
// Routed through the SAME mockLogs fixture the old gameLogService mock used,
|
||||
// so "logs available → consistency computed" keeps its original meaning.
|
||||
getStatRows: async () => mockLogs.current || [],
|
||||
gameCountInWindow: () => null,
|
||||
}));
|
||||
|
||||
const mockTrap = { current: null, throws: false };
|
||||
|
||||
@@ -16,6 +16,10 @@ jest.mock('../../src/utils/supabase', () => ({
|
||||
jest.mock('axios');
|
||||
jest.mock('../../src/services/intelligence/featureCache', () => ({
|
||||
getFeatures: jest.fn(),
|
||||
// Session 63 — computeFeatures now sources normalized per-game rows here
|
||||
// (feeds consistency + the probability estimator + game_count_in_7d).
|
||||
getStatRows: jest.fn(async () => []),
|
||||
gameCountInWindow: jest.fn(() => null),
|
||||
}));
|
||||
jest.mock('../../src/services/intelligence/trapDetection', () => ({
|
||||
getTrapScore: jest.fn(async () => ({ composite: 0.2, signals: {}, active_count: 1, recommendation: 'caution' })),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Session 63 — grade-range restoration.
|
||||
*
|
||||
* Locks the three structural facts the S63 audit found and fixed:
|
||||
* 1. L20 has a NEGATIVE branch (there was no downside path at all).
|
||||
* 2. With the previously-starving factors alive, A and D are REACHABLE.
|
||||
* 3. `confidence` is explicitly labelled as grade-derived, not a probability.
|
||||
*
|
||||
* These are arithmetic/structural assertions on the engine, NOT a claim about
|
||||
* how often A should occur in the wild — that is the live distribution report.
|
||||
*/
|
||||
|
||||
const engine1 = require('../../src/services/intelligence/engine1');
|
||||
const { toLegacyShape } = require('../../src/utils/gradeAdapter');
|
||||
const featureCache = require('../../src/services/intelligence/featureCache');
|
||||
|
||||
const prop = (direction = 'over', line = 10) => ({ line, direction });
|
||||
|
||||
describe('L20 symmetry (the missing downside path)', () => {
|
||||
test('L20 BELOW the line now subtracts on an OVER', () => {
|
||||
const factors = engine1.__internals
|
||||
? engine1.__internals.computeFactors({ features: { l20_avg: 5 }, prop: prop('over', 10) })
|
||||
: null;
|
||||
const res = engine1.gradeProp({ features: { l20_avg: 5 }, prop: prop('over', 10) });
|
||||
// Whether or not internals are exported, the graded result must be BELOW
|
||||
// the neutral 'C' — previously l20 could only ever add.
|
||||
expect(['F', 'D', 'C-']).toContain(res.grade);
|
||||
if (factors) {
|
||||
expect(factors.find((f) => f.label === 'l20_contradicts_over').delta).toBe(-1.0);
|
||||
}
|
||||
});
|
||||
|
||||
test('L20 ABOVE the line still adds on an OVER (unchanged)', () => {
|
||||
const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('over', 10) });
|
||||
expect(['C+', 'B-', 'B']).toContain(res.grade);
|
||||
});
|
||||
|
||||
test('L20 ABOVE the line subtracts on an UNDER (mirrored)', () => {
|
||||
const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('under', 10) });
|
||||
expect(['F', 'D', 'C-']).toContain(res.grade);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A and D are reachable once the starving factors are alive', () => {
|
||||
test('A emits when the real signals stack (the merit path)', () => {
|
||||
const res = engine1.gradeProp({
|
||||
features: {
|
||||
l5_avg: 14, // +1.0 hot vs line
|
||||
l20_avg: 13, // +1.0 season confirms
|
||||
opp_rank_stat: 0.85, // +1.0 weak defense (was permanently null)
|
||||
home_away: 1.0, // +0.5
|
||||
rest_days: 3, // +0.5
|
||||
},
|
||||
consistency: { consistency: 'elite', score: 0.9 }, // +1.0 (was 'unknown')
|
||||
prop: prop('over', 10),
|
||||
});
|
||||
expect(['A-', 'A', 'A+']).toContain(res.grade);
|
||||
});
|
||||
|
||||
test('D/F emits when the real signals stack against (the merit path)', () => {
|
||||
const res = engine1.gradeProp({
|
||||
features: {
|
||||
l5_avg: 6, // -1.0 cold vs line
|
||||
l20_avg: 7, // -1.0 season contradicts (NEW branch)
|
||||
opp_rank_stat: 0.1, // -1.0 top defense
|
||||
home_away: 0.0,
|
||||
rest_days: 0, // -0.5 back-to-back
|
||||
game_count_in_7d: 5, // -0.5 heavy workload (was never populated)
|
||||
},
|
||||
consistency: { consistency: 'boom_bust' }, // -1.0
|
||||
trap: { composite: 0.8 }, // -1.0
|
||||
prop: prop('over', 10),
|
||||
});
|
||||
expect(['F', 'D']).toContain(res.grade);
|
||||
});
|
||||
|
||||
test('a neutral feature set still lands at C — no inflation', () => {
|
||||
const res = engine1.gradeProp({ features: {}, prop: prop('over', 10) });
|
||||
expect(res.grade).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidence is labelled as derived, not a probability', () => {
|
||||
test('toLegacyShape marks confidence_basis', () => {
|
||||
const out = toLegacyShape(
|
||||
{ grade: 'B', confidence: 0.63, all_factors: [] },
|
||||
{ player: 'X', stat_type: 'hits', line: 1.5, direction: 'over' },
|
||||
);
|
||||
expect(out.confidence_basis).toBe('grade_band');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameCountInWindow (powers heavy_workload_7d)', () => {
|
||||
const now = Date.UTC(2026, 6, 19);
|
||||
const day = 86_400_000;
|
||||
|
||||
test('counts only games inside the window', () => {
|
||||
const rows = [
|
||||
{ date: new Date(now - 1 * day).toISOString(), hits: 1 },
|
||||
{ date: new Date(now - 3 * day).toISOString(), hits: 2 },
|
||||
{ date: new Date(now - 20 * day).toISOString(), hits: 0 },
|
||||
];
|
||||
expect(featureCache.gameCountInWindow(rows, 7, now)).toBe(2);
|
||||
});
|
||||
|
||||
test('returns null (absent, not 0) when there are no dated rows', () => {
|
||||
expect(featureCache.gameCountInWindow([], 7, now)).toBeNull();
|
||||
expect(featureCache.gameCountInWindow([{ hits: 1 }], 7, now)).toBeNull();
|
||||
expect(featureCache.gameCountInWindow(null, 7, now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistency CV floor (Session 63 calibration guard)', () => {
|
||||
const cs = require('../../src/services/intelligence/consistencyScore');
|
||||
|
||||
test('CV is refused below the mean floor — a low-count MLB stat is NOT boom_bust', async () => {
|
||||
// Real Pete Alonso hits log: mean 0.60, cv 1.17. Pre-guard this classified
|
||||
// boom_bust and stamped -1.0 on essentially every MLB prop.
|
||||
const logs = [0, 0, 0, 1, 2, 1, 0, 1, 1, 0].map((hits) => ({ hits }));
|
||||
const res = await cs.getConsistency({ statType: 'hits', gameLogs: logs });
|
||||
expect(res.consistency).toBe('unknown');
|
||||
expect(res.reason).toBe('low_mean_cv_unreliable');
|
||||
});
|
||||
|
||||
test('CV still classifies normally above the floor (NBA-scale stat)', async () => {
|
||||
const logs = [20, 22, 19, 21, 20, 23, 18, 21, 20, 22].map((points) => ({ points }));
|
||||
const res = await cs.getConsistency({ statType: 'points', gameLogs: logs });
|
||||
expect(['elite', 'reliable']).toContain(res.consistency);
|
||||
});
|
||||
|
||||
test('cvIsMeaningful is the explicit gate', () => {
|
||||
expect(cs.cvIsMeaningful(0.6)).toBe(false);
|
||||
expect(cs.cvIsMeaningful(12)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
const { gradeMlbProp, calculateMlbEdge, isMlbStatType } = require('../../src/services/mlbGrader');
|
||||
const { evaluateMlbKillConditions, classifyLineMove, checkWeather } = require('../../src/services/mlbKillConditions');
|
||||
const { MLB_PARKS, getParkByTeam } = require('../../src/constants/mlbParks');
|
||||
|
||||
jest.mock('axios');
|
||||
const axios = require('axios');
|
||||
|
||||
describe('mlbGrader', () => {
|
||||
describe('grade thresholds', () => {
|
||||
test('Grade A when edge >= 5%', () => {
|
||||
const result = gradeMlbProp({
|
||||
player: 'Aaron Judge',
|
||||
stat_type: 'home_runs',
|
||||
line: 0.5,
|
||||
direction: 'over',
|
||||
seasonAvg: 0.7,
|
||||
recentAvg: 0.8,
|
||||
});
|
||||
expect(result.grade).toBe('A');
|
||||
expect(result.edge_pct).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('Grade B when edge 3-4%', () => {
|
||||
// seasonAvg=5.15, line=5, direction=over => seasonEdge=(5.15-5)/5*100=3%
|
||||
// recentAvg=5.2, line=5 => recentEdge=(5.2-5)/5*100=4%
|
||||
// composite = 3*0.6 + 4*0.4 = 1.8+1.6 = 3.4
|
||||
const result = gradeMlbProp({
|
||||
player: 'Test Player',
|
||||
stat_type: 'strikeouts',
|
||||
line: 5,
|
||||
direction: 'over',
|
||||
seasonAvg: 5.15,
|
||||
recentAvg: 5.2,
|
||||
});
|
||||
expect(result.grade).toBe('B');
|
||||
expect(result.edge_pct).toBeGreaterThanOrEqual(3);
|
||||
expect(result.edge_pct).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test('Grade C when edge 1-2%', () => {
|
||||
// seasonAvg=5.05, line=5, direction=over => seasonEdge=1%
|
||||
// recentAvg=5.1 => recentEdge=2%
|
||||
// composite = 1*0.6 + 2*0.4 = 0.6+0.8 = 1.4
|
||||
const result = gradeMlbProp({
|
||||
player: 'Test Player',
|
||||
stat_type: 'hits',
|
||||
line: 5,
|
||||
direction: 'over',
|
||||
seasonAvg: 5.05,
|
||||
recentAvg: 5.1,
|
||||
});
|
||||
expect(result.grade).toBe('C');
|
||||
expect(result.edge_pct).toBeGreaterThanOrEqual(1);
|
||||
expect(result.edge_pct).toBeLessThan(3);
|
||||
});
|
||||
|
||||
test('Grade D when negative edge', () => {
|
||||
const result = gradeMlbProp({
|
||||
player: 'Test Player',
|
||||
stat_type: 'hits',
|
||||
line: 2,
|
||||
direction: 'over',
|
||||
seasonAvg: 1.5,
|
||||
recentAvg: 1.3,
|
||||
});
|
||||
expect(result.grade).toBe('D');
|
||||
expect(result.edge_pct).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMlbStatType', () => {
|
||||
test('returns true for valid hitting stat', () => {
|
||||
expect(isMlbStatType('hits')).toBe(true);
|
||||
expect(isMlbStatType('home_runs')).toBe(true);
|
||||
expect(isMlbStatType('stolen_bases')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for valid pitching stat', () => {
|
||||
expect(isMlbStatType('strikeouts')).toBe(true);
|
||||
expect(isMlbStatType('earned_runs')).toBe(true);
|
||||
expect(isMlbStatType('pitches_thrown')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for invalid stat type', () => {
|
||||
expect(isMlbStatType('three_pointers')).toBe(false);
|
||||
expect(isMlbStatType('touchdowns')).toBe(false);
|
||||
expect(isMlbStatType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMlbEdge', () => {
|
||||
test('calculates positive edge for over', () => {
|
||||
const edge = calculateMlbEdge(6, 5, 'over');
|
||||
expect(edge).toBe(20);
|
||||
});
|
||||
|
||||
test('calculates positive edge for under', () => {
|
||||
const edge = calculateMlbEdge(4, 5, 'under');
|
||||
expect(edge).toBe(20);
|
||||
});
|
||||
|
||||
test('returns 0 for null inputs', () => {
|
||||
expect(calculateMlbEdge(null, 5, 'over')).toBe(0);
|
||||
expect(calculateMlbEdge(5, null, 'over')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbKillConditions', () => {
|
||||
function makeContext(overrides = {}) {
|
||||
return {
|
||||
inLineup: true,
|
||||
pitcherScratched: false,
|
||||
weather: { wind_speed: 5, wind_direction: 'OUT', temp: 75, humidity: 50 },
|
||||
platoonDelta: 5,
|
||||
paVsHandedness: 100,
|
||||
lineMovement: 0,
|
||||
hoursFromOpen: 1,
|
||||
parkFactor: 1.0,
|
||||
rainProbability: 10,
|
||||
onInjuryReport: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('LINEUP_OUT triggers when player not in lineup', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ inLineup: false }));
|
||||
expect(result.some(c => c.code === 'LINEUP_OUT')).toBe(true);
|
||||
});
|
||||
|
||||
test('PITCHER_SCRATCH triggers when pitcher scratched', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ pitcherScratched: true }));
|
||||
expect(result.some(c => c.code === 'PITCHER_SCRATCH')).toBe(true);
|
||||
});
|
||||
|
||||
test('WIND_IN triggers at 15mph+ blowing in', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({
|
||||
weather: { wind_speed: 18, wind_direction: 'IN', temp: 75, humidity: 50 },
|
||||
}));
|
||||
expect(result.some(c => c.code === 'WIND_IN')).toBe(true);
|
||||
});
|
||||
|
||||
test('PLATOON_DISADVANTAGE triggers when delta > 12%', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ platoonDelta: 15 }));
|
||||
expect(result.some(c => c.code === 'PLATOON_DISADVANTAGE')).toBe(true);
|
||||
});
|
||||
|
||||
test('SMALL_SAMPLE triggers under 50 PA', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ paVsHandedness: 30 }));
|
||||
expect(result.some(c => c.code === 'SMALL_SAMPLE')).toBe(true);
|
||||
});
|
||||
|
||||
test('LINE_MOVE_AGAINST triggers at 0.5+ movement', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ lineMovement: 0.7, hoursFromOpen: 1 }));
|
||||
expect(result.some(c => c.code === 'LINE_MOVE_AGAINST')).toBe(true);
|
||||
});
|
||||
|
||||
test('PARK_SUPPRESSOR triggers below 0.90', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ parkFactor: 0.85 }));
|
||||
expect(result.some(c => c.code === 'PARK_SUPPRESSOR')).toBe(true);
|
||||
});
|
||||
|
||||
test('WEATHER_RAIN triggers above 50% probability', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ rainProbability: 65 }));
|
||||
expect(result.some(c => c.code === 'WEATHER_RAIN')).toBe(true);
|
||||
});
|
||||
|
||||
test('INJURY_REPORT triggers when on injury report', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({ onInjuryReport: true }));
|
||||
expect(result.some(c => c.code === 'INJURY_REPORT')).toBe(true);
|
||||
});
|
||||
|
||||
test('HUMIDITY_SUPPRESSOR triggers at humidity > 80% and temp < 60F', () => {
|
||||
const result = evaluateMlbKillConditions(makeContext({
|
||||
weather: { wind_speed: 5, wind_direction: 'OUT', temp: 55, humidity: 85 },
|
||||
}));
|
||||
expect(result.some(c => c.code === 'HUMIDITY_SUPPRESSOR')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyLineMove', () => {
|
||||
test('returns sharp for movement within first 2 hours', () => {
|
||||
expect(classifyLineMove(0.7, 1)).toBe('sharp');
|
||||
expect(classifyLineMove(-0.5, 0.5)).toBe('sharp');
|
||||
});
|
||||
|
||||
test('returns public for movement after 4 hours', () => {
|
||||
expect(classifyLineMove(0.6, 5)).toBe('public');
|
||||
expect(classifyLineMove(-0.8, 6)).toBe('public');
|
||||
});
|
||||
|
||||
test('returns null for movement under 0.5', () => {
|
||||
expect(classifyLineMove(0.3, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkWeather', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('falls back to open-meteo on api.weather.gov timeout', async () => {
|
||||
// Mock weather.gov to timeout
|
||||
axios.get.mockImplementation((url) => {
|
||||
if (url.includes('weather.gov')) {
|
||||
return Promise.reject(new Error('timeout of 3000ms exceeded'));
|
||||
}
|
||||
// open-meteo fallback
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
hourly: {
|
||||
temperature_2m: Array(24).fill(72),
|
||||
relative_humidity_2m: Array(24).fill(50),
|
||||
wind_speed_10m: Array(24).fill(10),
|
||||
wind_direction_10m: Array(24).fill(180),
|
||||
precipitation_probability: Array(24).fill(20),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await checkWeather([40.8296, -73.9262], 3000);
|
||||
expect(result.wind_speed).toBe(10);
|
||||
expect(result.temp).toBe(72);
|
||||
// Verify weather.gov was attempted first
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('weather.gov'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mlbParks', () => {
|
||||
test('has exactly 30 entries', () => {
|
||||
expect(Object.keys(MLB_PARKS).length).toBe(30);
|
||||
});
|
||||
|
||||
test('getParkByTeam returns correct park for NYY', () => {
|
||||
const park = getParkByTeam('NYY');
|
||||
expect(park).not.toBeNull();
|
||||
expect(park.name).toBe('Yankee Stadium');
|
||||
expect(park.coords).toEqual([40.8296, -73.9262]);
|
||||
});
|
||||
|
||||
test('getParkByTeam returns correct park for LAD', () => {
|
||||
const park = getParkByTeam('LAD');
|
||||
expect(park.name).toBe('Dodger Stadium');
|
||||
});
|
||||
|
||||
test('getParkByTeam returns null for invalid team', () => {
|
||||
expect(getParkByTeam('XXX')).toBeNull();
|
||||
});
|
||||
|
||||
test('every park has name, coords, and team', () => {
|
||||
for (const [key, park] of Object.entries(MLB_PARKS)) {
|
||||
expect(park.name).toBeDefined();
|
||||
expect(park.coords).toHaveLength(2);
|
||||
expect(park.team).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user