Merge Wave 5B (wiring/data): pitcher arsenal via Baseball Savant (D4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
// Wave 5B — PitcherArsenal component source locks. The card is CONTEXT (the
|
||||
// arsenal read), never a graded market value. Honesty invariants:
|
||||
// • SELF-HIDES (returns null) when Savant has no arsenal — no empty shell.
|
||||
// • ALL pitch data (velo / usage / whiff) is mono + tabular (brand rule).
|
||||
// • A missing velo/whiff renders "—" (absent), NEVER 0.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('PitcherArsenal component', () => {
|
||||
const src = read('components/vyndr/PitcherArsenal.tsx');
|
||||
|
||||
test('self-hides (returns null) when there are no pitches', () => {
|
||||
expect(src).toMatch(/if\s*\(\s*pitches\.length\s*===\s*0\s*\)\s*return null/);
|
||||
});
|
||||
|
||||
test('renders the arsenal strip headers (VELO / USE / WHIFF)', () => {
|
||||
expect(src).toContain('VELO');
|
||||
expect(src).toContain('USE');
|
||||
expect(src).toContain('WHIFF');
|
||||
expect(src).toContain('PITCH');
|
||||
});
|
||||
|
||||
test('pitch data is mono + tabular (brand rule: data is mono)', () => {
|
||||
expect(src).toContain('className="mono"');
|
||||
expect(src).toContain('tabular-nums');
|
||||
// the three data cells map the normalized fields
|
||||
expect(src).toContain('fmtVelo(p.velo)');
|
||||
expect(src).toContain('fmtPct(p.usagePct)');
|
||||
expect(src).toContain('fmtPct(p.whiffPct)');
|
||||
});
|
||||
|
||||
test('absent velo/whiff renders an em-dash, never 0 (absent beats zero)', () => {
|
||||
// fmtVelo / fmtPct return '—' for null|undefined
|
||||
expect(src).toMatch(/function fmtVelo[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
|
||||
expect(src).toMatch(/function fmtPct[\s\S]*?===\s*null[\s\S]*?['"]—['"]/);
|
||||
// no `?? 0` / `|| 0` coercion on the numeric fields
|
||||
expect(src).not.toMatch(/velo\s*(\?\?|\|\|)\s*0/);
|
||||
expect(src).not.toMatch(/whiffPct\s*(\?\?|\|\|)\s*0/);
|
||||
});
|
||||
|
||||
test('cites Baseball Savant as the source (honest provenance)', () => {
|
||||
expect(src).toMatch(/BASEBALL SAVANT/i);
|
||||
});
|
||||
|
||||
test('ranks the strip by the normalized fields from the adapter contract', () => {
|
||||
// consumes { found, pitches:[{ type, name, usagePct, velo, whiffPct }] }
|
||||
expect(src).toContain('fetched.found');
|
||||
expect(src).toContain('fetched.pitches');
|
||||
});
|
||||
|
||||
test('is exported from the vyndr barrel', () => {
|
||||
const barrel = read('components/vyndr/index.ts');
|
||||
expect(barrel).toContain("export { default as PitcherArsenal }");
|
||||
});
|
||||
|
||||
test('the arsenal proxy route exists (S25 rule) and forwards to the backend', () => {
|
||||
const proxy = read('app/api/stats/pitcher/[name]/arsenal/route.ts');
|
||||
expect(proxy).toContain('/api/stats/pitcher/');
|
||||
expect(proxy).toContain('/arsenal');
|
||||
expect(proxy).toContain('found: false'); // honest fallback, not an error card
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
'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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user