diff --git a/tests/unit/platoonPolarity.test.js b/tests/unit/platoonPolarity.test.js new file mode 100644 index 0000000..92c55ff --- /dev/null +++ b/tests/unit/platoonPolarity.test.js @@ -0,0 +1,110 @@ +/* ============================================================ + Session 78 — PLATOON POLARITY LOCK (standing regression). + + The opp_rank_stat lesson: polarity needs a PERMANENT assertion, not a + one-time eyeball. A backwards opposing-pitcher resolution OR a backwards + multiplier sign both look fine on a near-neutral hitter — so this pins BOTH + on a real, materially-split hitter (Yordan Alvarez, LHB, 2026), with frozen + numbers so it can never silently drift. + + If this test ever fails, platoon has been broken in one of two ways and the + Session-77 wiring can no longer be trusted — do not "fix the test", find the + regression. + ============================================================ */ + +const pl = require('../../src/services/platoonSplits'); +const ec = require('../../src/services/environmentContext'); + +// Yordan Alvarez, real 2026 splits — deep both hands, textbook LHB platoon. +// vs RHP he slugs far above his overall (advantage); vs LHP below (disadvantage). +const ALVAREZ = { + vsL: { pa: 115, slg: 0.529, avg: 0.250 }, + vsR: { pa: 327, slg: 0.695, avg: 0.290 }, + overall: { pa: 442, slg: 0.652, avg: 0.278 }, +}; + +describe('DIRECTION — signed against the real split AND platoon theory', () => { + const est = (ph) => pl.platoonEstimate({ splits: ALVAREZ, batterHand: 'L', pitcherHand: ph, statType: 'total_bases' }); + + it('produces a MATERIAL multiplier both ways (fixture can reveal direction)', () => { + // A neutral subject would reproduce the inconclusive Session-77 test. + expect(est('L').material).toBe(true); + expect(est('R').material).toBe(true); + }); + + it('vs RHP nudges UP — favorable split (opposite hand) → over more likely', () => { + // .695 slg vs RHP > .652 overall → favorable → UP. Backwards would be < 1. + expect(est('R').multiplier).toBeGreaterThan(1); + }); + + it('vs LHP nudges DOWN — unfavorable split (same hand)', () => { + // .529 slg vs LHP < .652 overall → unfavorable → DOWN. Backwards would be > 1. + expect(est('L').multiplier).toBeLessThan(1); + }); + + it('the two hands move OPPOSITE directions', () => { + const l = est('L').multiplier; + const r = est('R').multiplier; + expect((l < 1)).not.toBe((r < 1)); + }); + + it('BOTH-BACKWARDS is ruled out — a mirrored-but-wrong sign fails here', () => { + // If the whole thing were inverted, RHP would be < 1 and LHP > 1. Assert the + // exact expected side, not merely "opposite". + expect(est('R').multiplier).toBeGreaterThan(1); // must be the RHP side that's up + expect(est('L').multiplier).toBeLessThan(1); // and the LHP side that's down + }); + + it('a REVERSE-split hitter follows his real numbers, not the platoon prior', () => { + // Brandon Nimmo (LHB) hits BETTER vs LHP than RHP — unusual. The estimate + // must follow HIS data (up vs LHP), proving the sign is data-driven and not + // a hardcoded LHB-vs-RHP assumption. + const nimmo = { vsL: { pa: 104, slg: 0.510 }, vsR: { pa: 301, slg: 0.405 }, overall: { pa: 405, slg: 0.432 } }; + const vsL = pl.platoonEstimate({ splits: nimmo, batterHand: 'L', pitcherHand: 'L', statType: 'total_bases' }); + const vsR = pl.platoonEstimate({ splits: nimmo, batterHand: 'L', pitcherHand: 'R', statType: 'total_bases' }); + expect(vsL.multiplier).toBeGreaterThan(1); // favorable vs LHP per HIS split + expect(vsR.multiplier).toBeLessThan(1); + }); +}); + +describe('RESOLUTION — the OPPOSING team\'s pitcher, never the hitter\'s own', () => { + // A backwards resolution runs platoon on the wrong hand — invisible on a + // neutral hitter, so it is pinned independently of direction. + const SCHEDULE = { games: [ + { homeTeam: { name: 'Cleveland Guardians' }, awayTeam: { name: 'Minnesota Twins' }, gameTime: '2026-07-21T22:40Z', venue: 'Progressive Field' }, + ] }; + const PITCHERS = { games: [ + { home: { team: 'Cleveland Guardians', pitcherId: 800048 }, away: { team: 'Minnesota Twins', pitcherId: 696070 } }, + ] }; + const PEOPLE = { people: [ + { id: 800048, pitchHand: { code: 'L' } }, // CLE's own SP + { id: 696070, pitchHand: { code: 'R' } }, // MIN's own SP + ] }; + const fetchJson = async (url) => { + if (url.includes('/pitchers')) return PITCHERS; + if (url.includes('personIds=')) return PEOPLE; + if (url.includes('open-meteo')) return { hourly: { time: [], temperature_2m: [] } }; + if (url.includes('statSplits')) return { stats: [] }; + return SCHEDULE; + }; + + it('a HOME hitter faces the AWAY team\'s pitcher hand', async () => { + const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); + // CLE hitter should resolve to MIN's pitcher (696070, R), NOT CLE's own (800048, L). + const oppPid = ctx._internals.oppPitcherByTeam.get('CLE'); + expect(oppPid).toBe(696070); + expect(ctx._internals.handById.get(oppPid)).toBe('R'); + }); + + it('an AWAY hitter faces the HOME team\'s pitcher hand', async () => { + const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); + const oppPid = ctx._internals.oppPitcherByTeam.get('MIN'); + expect(oppPid).toBe(800048); // CLE's SP, not MIN's own + expect(ctx._internals.handById.get(oppPid)).toBe('L'); + }); + + it('the two teams get DIFFERENT pitchers — never the same (own) arm', async () => { + const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE }); + expect(ctx._internals.oppPitcherByTeam.get('CLE')).not.toBe(ctx._internals.oppPitcherByTeam.get('MIN')); + }); +});