11fc5a66d2
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>
68 lines
2.8 KiB
JavaScript
68 lines
2.8 KiB
JavaScript
'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
|
|
});
|
|
});
|