Files
vyndr/tests/unit/savantAdapter.test.js
T
builtbykev 11fc5a66d2 Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast)
D4 — build the FREE Baseball Savant adapter for pitch-level identity
(mix / velo / usage% / whiff%), the missing layer statsapi doesn't carry.

- savantAdapter.getPitcherArsenal(id|name) — normalizes two public Savant
  CSV leaderboards (csv=true, NO parsing dependency): pitch-arsenal-stats
  (usage% + whiff% + K%) + pitch-arsenals avg_speed (velo). League-wide,
  cached 24h + in-memory mirror, indexed by MLBAM id. Defensive: null on any
  unrecognized shape; a missing velo/whiff is ABSENT (null), never 0.
  Injectable (fetchImpl/statsCsv/veloCsv/resolveId) → tests hit no network.
  Live endpoints VERIFIED (200, exact columns) from the sandbox.
- GET /api/stats/pitcher/:name/arsenal (stats.js) + Next proxy. MLB-only;
  an error/miss returns { found:false } so the card self-hides honestly.
- PitcherArsenal.tsx (+ barrel) — the mockup's PITCHER IDENTITY strip:
  pitch mix % + velo + whiff%, mono/tabular, ranked by usage, sharpest-whiff
  pitch highlighted green. Self-hides (heading included) when arsenal absent.
  Mounted on the MLB player profile (a pitcher surface). Context, not a
  graded market value.
- Tests: savantAdapter (fake CSV → ranked arsenal; unknown shape/blank cells
  → absent not 0; name→id resolve) + PitcherArsenal source locks (self-hide,
  mono/tabular, em-dash-not-zero). +2 suites / +17 tests (3012 → 3029).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:02:16 -04:00

101 lines
4.5 KiB
JavaScript

'use strict';
const savant = require('../../src/services/adapters/savantAdapter');
// Fixture CSVs shaped like the real Baseball Savant leaderboard exports.
// NOTE the first header column is a QUOTED field containing a comma
// (`"last_name, first_name"`) — the parser must be quote-aware.
const STATS_CSV = [
'"last_name, first_name",player_id,team_name_alt,pitch_type,pitch_name,run_value_per_100,pitches,pitch_usage,pa,whiff_percent,k_percent',
'"Skenes, Paul",694973,PIT,FF,4-Seam Fastball,1.2,500,32.0,300,26.0,30.0',
'"Skenes, Paul",694973,PIT,SL,Slider,2.1,350,22.0,200,41.0,38.0',
'"Skenes, Paul",694973,PIT,FS,Splitter,1.8,300,24.0,180,38.0,",', // trailing malformed cell → whiff null-ish; still parses
'"Skenes, Paul",694973,PIT,CU,Curveball,0.5,120,14.0,90,33.0,20.0',
'"Other, Guy",111111,LAD,CH,Changeup,0.1,50,,40,,', // usage/whiff BLANK → must be null, never 0
].join('\n');
// WIDE velo leaderboard — one row per pitcher, `{abbr}_avg_speed` columns.
const VELO_CSV = [
'"last_name, first_name",pitcher,team,ff_avg_speed,sl_avg_speed,fs_avg_speed,cu_avg_speed',
'"Skenes, Paul",694973,PIT,99.1,87.0,94.2,82.5',
].join('\n');
describe('savantAdapter.getPitcherArsenal', () => {
test('normalizes a fake Savant CSV payload into a ranked arsenal (usage desc)', async () => {
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV, veloCsv: VELO_CSV });
expect(out.found).toBe(true);
expect(out.playerId).toBe(694973);
expect(out.source).toBe('baseball_savant');
expect(Array.isArray(out.pitches)).toBe(true);
// ranked by usage: FF(32) > FS(24) > SL(22) > CU(14)
expect(out.pitches.map((p) => p.type)).toEqual(['FF', 'FS', 'SL', 'CU']);
const ff = out.pitches[0];
expect(ff.usagePct).toBe(32.0);
expect(ff.velo).toBe(99.1); // merged from the WIDE velo CSV by pitch abbr
expect(ff.whiffPct).toBe(26.0);
const sl = out.pitches.find((p) => p.type === 'SL');
expect(sl.velo).toBe(87.0);
expect(sl.whiffPct).toBe(41.0);
});
test('missing velo is ABSENT (null), never 0 — velo CSV omitted entirely', async () => {
const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV /* no veloCsv */ });
expect(out.found).toBe(true);
for (const p of out.pitches) expect(p.velo).toBeNull();
});
test('blank whiff/usage cells parse to null, not 0 (absent beats zero)', async () => {
const out = await savant.getPitcherArsenal(111111, { statsCsv: STATS_CSV });
expect(out.found).toBe(true);
expect(out.pitches).toHaveLength(1);
expect(out.pitches[0].usagePct).toBeNull();
expect(out.pitches[0].whiffPct).toBeNull();
expect(out.pitches[0].velo).toBeNull();
});
test('unrecognized shape → { found:false } (defensive parsing)', async () => {
const junk = 'totally,unrelated,columns\n1,2,3';
const out = await savant.getPitcherArsenal(694973, { statsCsv: junk });
expect(out.found).toBe(false);
const empty = await savant.getPitcherArsenal(694973, { statsCsv: '' });
expect(empty.found).toBe(false);
});
test('a pitcher with no rows in the feed → { found:false }', async () => {
const out = await savant.getPitcherArsenal(999999, { statsCsv: STATS_CSV });
expect(out.found).toBe(false);
});
test('resolves a NAME → id via injected resolver, then returns arsenal', async () => {
const out = await savant.getPitcherArsenal('Paul Skenes', {
statsCsv: STATS_CSV,
veloCsv: VELO_CSV,
resolveId: async (n) => (/skenes/i.test(n) ? 694973 : null),
});
expect(out.found).toBe(true);
expect(out.playerId).toBe(694973);
});
test('unresolvable name / empty input → { found:false } (never throws)', async () => {
const noId = await savant.getPitcherArsenal('Nobody Here', { statsCsv: STATS_CSV, resolveId: async () => null });
expect(noId.found).toBe(false);
const blank = await savant.getPitcherArsenal('', { statsCsv: STATS_CSV });
expect(blank.found).toBe(false);
});
test('quote-aware CSV parser keeps a comma inside a quoted field', () => {
const rows = savant.__internals.parseCsv('"last, first",id\n"Skenes, Paul",694973');
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('694973');
expect(rows[0]['last, first']).toBe('Skenes, Paul');
});
test('pctOrNull scales a fraction but leaves whole percents alone; null stays null', () => {
const { pctOrNull } = savant.__internals;
expect(pctOrNull('0.324')).toBe(32.4);
expect(pctOrNull('32.4')).toBe(32.4);
expect(pctOrNull('')).toBeNull();
expect(pctOrNull(null)).toBeNull();
});
});