'use strict'; /** * The three proven hits factors, on the serving path at last. * * What these lock: the SIGN each factor moves the forecast, and that every * unreadable case leaves it completely alone. A factor that nudges toward a * default on missing input is fabricating a read from an absence. */ const hf = require('../../src/services/model/hitsFactors'); const pullGround = { pull_gb: 0.45, straight_gb: 0.10, oppo_gb: 0.05, pull_air: 0.20, straight_air: 0.12, oppo_air: 0.08 }; const pos = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { oaa: v, fielders: 2 }])); const side = (avg, pa) => ({ pa, atBats: Math.round(pa * 0.9), hits: Math.round(pa * 0.9 * avg) }); const bigSplit = { vl: side(0.284, 183), vr: side(0.221, 291) }; describe('each factor moves the forecast in the direction it proved', () => { it('TOUGH spray defence lowers the hit forecast', () => { const eliteLeft = pos({ '3B': 12, SS: 10, '1B': 0, '2B': 0, LF: 0, CF: 0, RF: 0 }); const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'R', positionOaa: eliteLeft }); expect(out.p_adjusted).toBeLessThan(0.6); expect(out.applied.map((a) => a.factor)).toContain('defense_by_direction'); }); it('a CONTACT-ALLOWING pitcher raises it; a bat-misser lowers it', () => { const soft = hf.adjustProbability(0.6, { pitcherHardHit: 0.46 }); const tough = hf.adjustProbability(0.6, { pitcherHardHit: 0.31 }); expect(soft.p_adjusted).toBeGreaterThan(0.6); expect(tough.p_adjusted).toBeLessThan(0.6); }); it('a PLATOON disadvantage lowers it, the edge raises it', () => { const edge = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'L' }); const wrongSide = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'R' }); expect(edge.p_adjusted).toBeGreaterThan(0.6); expect(wrongSide.p_adjusted).toBeLessThan(0.6); }); }); describe('unreadable means UNTOUCHED, never nudged to a default', () => { it('a SWITCH hitter gets no spray adjustment', () => { const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'S', positionOaa: pos({ '3B': 12, SS: 10 }) }); expect(out.applied.find((a) => a.factor === 'defense_by_direction')).toBeUndefined(); expect(out.skipped.map((s) => s.factor)).toContain('defense_by_direction'); }); it('a THIN platoon split is refused, not shrunk toward league', () => { const thin = { vl: side(0.350, 25), vr: side(0.250, 400) }; const out = hf.adjustProbability(0.6, { platoonSplits: thin, bats: 'R', throws: 'L' }); expect(out.applied.find((a) => a.factor === 'platoon_severity')).toBeUndefined(); }); it('no pitcher profile contributes nothing at all', () => { expect(hf.pitcherContactMultiplier(null)).toBeNull(); const out = hf.adjustProbability(0.6, { pitcherHardHit: null }); expect(out.p_adjusted).toBe(0.6); }); it('with NOTHING readable the forecast is returned exactly', () => { const out = hf.adjustProbability(0.6, {}); expect(out.p_adjusted).toBe(0.6); expect(out.multiplier).toBe(1); expect(out.factors_fired).toBe(0); }); it('a null forecast stays null — no factor invents one', () => { expect(hf.adjustProbability(null, { pitcherHardHit: 0.46 }).p_adjusted).toBeNull(); }); }); describe('composition', () => { it('three factors compound, and the stack is bounded', () => { const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'R', positionOaa: pos({ '3B': -12, SS: -12, '1B': -12, '2B': -12, LF: -12, CF: -12, RF: -12 }), pitcherHardHit: 0.46, platoonSplits: bigSplit, throws: 'L', }); expect(out.factors_fired).toBe(3); expect(out.multiplier).toBeLessThanOrEqual(1 + hf.COMBINED_MAX); expect(out.multiplier).toBeGreaterThanOrEqual(1 - hf.COMBINED_MAX); }); it('partial readability applies only what is readable', () => { const out = hf.adjustProbability(0.6, { pitcherHardHit: 0.46, bats: 'S', spray: pullGround, positionOaa: pos({ '3B': 5 }) }); expect(out.factors_fired).toBe(1); expect(out.applied[0].factor).toBe('pitcher_contact_profile'); }); });