Files
vyndr/tests/unit/statcastAggregate.test.js
T
builtbykev a011ae79fe Statcast: role belongs in the key (two-way players)
Found by inducing the real job on the server, not by review: the first chunk
wrote, the second failed with 'ON CONFLICT DO UPDATE command cannot affect row
a second time'. A player can legitimately appear in BOTH the batter and the
pitcher feeds — two-way players, position players who pitch, pitchers who bat —
so (sport, season, source_id) collapsed two real profiles into one key and a
single batch hit the same row twice.

Ohtani has a real batter profile and a real pitcher profile. Merging them would
invent one player out of two genuinely different sets of measurements, so role
goes in the primary key rather than one profile winning. Migration 031 applied;
conflict target updated; a two-way case is now a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 21:49:53 -04:00

215 lines
9.2 KiB
JavaScript

/* ============================================================
Session 68 — LAYER 1: mechanism-data ingestion.
Pure derivation + the honesty rules. No network, no database: the adapter's
fetch and the Supabase client are both injected.
============================================================ */
const svc = require('../../src/services/statcastAggregateService');
const adapter = require('../../src/services/adapters/statcastAdapter');
const { flipName, indexBy, indexPitchMix, BATTER_DISCIPLINE } = adapter.__internals;
/** Minimal feed indexes, shaped exactly like adapter.fetchSeason() output. */
function feeds({ batter = [], batterBB = [], pitcher = [], pitcherBB = [], mix = [] } = {}) {
return {
batterDiscipline: indexBy(batter, BATTER_DISCIPLINE),
batterBattedBall: indexBy(batterBB, adapter.__internals.BATTED_BALL),
pitcherDiscipline: indexBy(pitcher, adapter.__internals.PITCHER_DISCIPLINE),
pitcherBattedBall: indexBy(pitcherBB, adapter.__internals.BATTED_BALL),
pitchMix: indexPitchMix(mix),
counts: {},
};
}
const BELL = {
'last_name, first_name': 'Bell, Josh', player_id: '605137', pa: '387',
k_percent: '21.7', bb_percent: '7.5', whiff_percent: '24.8', swing_percent: '51',
oz_swing_percent: '30.6', barrel_batted_rate: '10.3', hard_hit_percent: '43.4',
};
const SKUBAL = {
'last_name, first_name': 'Skubal, Tarik', player_id: '669373', p_formatted_ip: '82.2',
k_percent: '30.5', whiff_percent: '32.1', groundballs_percent: '49', arm_angle: '46.9',
};
const SKUBAL_MIX = {
'last_name, first_name': 'Skubal, Tarik', pitcher_id: '669373', pitch_hand: 'L',
pitch_type: 'FF', pitch_type_name: '4-Seam Fastball', pitch_per: '0.371',
avg_speed: '96.7', pitcher_break_z_induced: '17.1', pitcher_break_x: '3.6', pitches_thrown: '463',
};
describe('adapter normalisation', () => {
it('flips Savant "Last, First" into a real name', () => {
expect(flipName('Bell, Josh')).toBe('Josh Bell');
expect(flipName('Guerrero Jr., Vladimir')).toBe('Vladimir Guerrero Jr.');
expect(flipName('Ohtani')).toBe('Ohtani');
expect(flipName('')).toBeNull();
});
it('normalises pitch usage from a FRACTION to a percentage', () => {
const [entry] = [...indexPitchMix([SKUBAL_MIX]).values()];
expect(entry.pitches[0].usage_pct).toBe(37.1); // not 0.371
expect(entry.throws).toBe('L');
});
it('a missing metric is ABSENT, never 0', () => {
const [entry] = [...indexBy([{ 'last_name, first_name': 'X, Y', player_id: '1' }], BATTER_DISCIPLINE).values()];
expect(entry.metrics.k_pct).toBeUndefined();
expect(entry.metrics.whiff_pct).toBeUndefined();
});
});
describe('buildRows — derivation', () => {
const rows = () => svc.buildRows(2026, feeds({
batter: [BELL], pitcher: [SKUBAL], mix: [SKUBAL_MIX],
}), { now: '2026-07-21T00:00:00.000Z' });
it('produces one row per player per role, keyed by the source id', () => {
const r = rows();
expect(r).toHaveLength(2);
expect(r.find((x) => x.role === 'batter').source_id).toBe(605137);
expect(r.find((x) => x.role === 'pitcher').source_id).toBe(669373);
});
it('joins to our canonical player_key', () => {
const b = rows().find((x) => x.role === 'batter');
expect(b.player_name).toBe('Josh Bell');
expect(b.player_key).toBe(require('../../src/utils/playerName').nameKey('Josh Bell'));
});
it('carries pitcher handedness from the movement feed', () => {
expect(rows().find((x) => x.role === 'pitcher').throws).toBe('L');
});
it('leaves batter handedness ABSENT (roster join, never guessed)', () => {
expect(rows().find((x) => x.role === 'batter').bats).toBeNull();
});
it('flags thin samples rather than dropping or inflating them', () => {
const thin = svc.buildRows(2026, feeds({
batter: [{ ...BELL, pa: '12' }], pitcher: [{ ...SKUBAL, p_formatted_ip: '2.1' }],
}), {});
expect(thin.every((r) => r._sufficient === false)).toBe(true);
// Thin is STORED, not dropped — "thin" and "missing" are different claims.
expect(thin).toHaveLength(2);
expect(thin[0].sample_pa).toBe(12);
});
it('a player with no sample at all is insufficient, not zero', () => {
const [r] = svc.buildRows(2026, feeds({ batter: [{ 'last_name, first_name': 'A, B', player_id: '9' }] }), {});
expect(r.sample_pa).toBeNull(); // NOT 0
expect(r._sufficient).toBe(false);
expect(r.k_pct).toBeNull();
});
it('keeps every raw metric in `metrics` so Layer 2/3 never needs a re-ingest', () => {
const b = rows().find((x) => x.role === 'batter');
expect(b.metrics.chase_pct).toBe(30.6);
expect(b.chase_pct).toBe(30.6);
});
});
describe('refreshSeason — the job', () => {
const okClient = () => {
const calls = [];
return {
calls,
from: () => ({ upsert: async (batch) => { calls.push(batch); return { error: null }; } }),
};
};
it('backfill and refresh are the SAME idempotent call', async () => {
const fetchSeason = async () => feeds({ batter: [BELL], pitcher: [SKUBAL], mix: [SKUBAL_MIX] });
const a = okClient(); const b = okClient();
const r1 = await svc.refreshSeason({ supabase: a, fetchSeason, now: '2026-07-21T00:00:00.000Z' });
const r2 = await svc.refreshSeason({ supabase: b, fetchSeason, now: '2026-07-21T00:00:00.000Z' });
expect(r1.ok).toBe(true);
expect(r1.written).toBe(2);
expect(JSON.stringify(a.calls)).toBe(JSON.stringify(b.calls)); // identical → idempotent
});
it('upserts on the natural key so a re-run never duplicates', async () => {
let opts = null;
const sb = { from: () => ({ upsert: async (_b, o) => { opts = o; return { error: null }; } }) };
await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({ batter: [BELL] }) });
expect(opts.onConflict).toBe('sport,season,source_id,role');
});
it('REFUSES to write when every feed is empty — a bad night cannot blank a good table', async () => {
const sb = okClient();
const out = await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({}) });
expect(out.ok).toBe(false);
expect(out.reason).toMatch(/refusing to write/);
expect(sb.calls).toHaveLength(0);
});
it('strips the internal flag before it reaches Postgres', async () => {
const sb = okClient();
await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({ batter: [BELL] }) });
expect(sb.calls[0][0]._sufficient).toBeUndefined();
});
it('keys two-way players by ROLE — one player, two real profiles', async () => {
// Ohtani appears in both the batter and the pitcher feeds. Without role in
// the key, one upsert batch hits the same row twice and Postgres refuses
// the whole chunk — found by inducing the real job, not by review.
const OHTANI_B = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', pa: '400' };
const OHTANI_P = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', p_formatted_ip: '40' };
const rows = svc.buildRows(2026, feeds({ batter: [OHTANI_B], pitcher: [OHTANI_P] }), {});
expect(rows).toHaveLength(2);
expect(new Set(rows.map((r) => r.source_id)).size).toBe(1);
expect(new Set(rows.map((r) => r.role))).toEqual(new Set(['batter', 'pitcher']));
});
it('reports the join rate — the honest-absent rate for the mechanism tier', async () => {
const out = await svc.refreshSeason({
supabase: okClient(),
fetchSeason: async () => feeds({ batter: [BELL, { player_id: '77', pa: '100' }] }),
});
expect(out.joined).toBe(1);
expect(out.unjoined).toBe(1); // no name → no key → stored, joins later
});
});
describe('staleness — never serve stale as fresh', () => {
it('fires past the max age', () => {
expect(svc.isStale({ updated_at: '2026-07-01T00:00:00Z', age_hours: 72 })).toBe(true);
expect(svc.isStale({ updated_at: '2026-07-20T00:00:00Z', age_hours: 6 })).toBe(false);
});
it('NEVER-BUILT is not STALE — different condition, different fix', () => {
// Paging on a fresh install teaches the operator to ignore the alarm.
expect(svc.isStale({ updated_at: null, age_hours: null })).toBe(false);
expect(svc.isStale(null)).toBe(false);
});
it('respects an explicit threshold', () => {
expect(svc.isStale({ updated_at: 'x', age_hours: 30 }, 24)).toBe(true);
expect(svc.isStale({ updated_at: 'x', age_hours: 30 }, 48)).toBe(false);
});
});
describe('scheduler + route wiring', () => {
const fs = require('fs');
const path = require('path');
const read = (r) => fs.readFileSync(path.join(__dirname, '..', '..', r), 'utf8');
it('is server-scheduled with a kill switch', () => {
const s = read('src/snapshotScheduler.js');
expect(s).toContain('STATCAST_HOUR_UTC');
expect(s).toMatch(/process\.env\.STATCAST !== '0'/);
expect(s).toContain('refreshSeason');
});
it('pages on BOTH a failed run and silent staleness', () => {
const s = read('src/snapshotScheduler.js');
expect(s).toMatch(/Statcast refresh failed/);
expect(s).toMatch(/Statcast aggregates stale/);
});
it('is induce-able on demand (never wait on the cron)', () => {
const s = read('src/routes/internal.js');
expect(s).toContain("router.post('/statcast/refresh'");
expect(s).toContain("router.get('/statcast/status'");
});
});