Layer 3 Step 6: platoon splits, regressed hard

The highest-value adjuster and the thinnest sample in baseball. The regression
is not a refinement here, it is the entire feature: applying raw splits would
adjust projections on noise, which is worse than not building it.

PHASE 0 — both gates clear, and one was already closed. Splits are a statsapi
pull, one call per hitter (statSplits with sitCodes vl,vr). The batter-handedness
join that Session 69 recorded as pending is in fact DONE: statcast_aggregates
carries bats for 604 of 604 batters, 210 left, 327 right, 67 switch. STATE said
pending; the data says otherwise, and the note is corrected. Point-in-time holds
as long as the split is fetched before first pitch, since a season split queried
this afternoon cannot contain tonight — but a historical backtest would use
season-final numbers and leak, so clean measurement is forward-accruing.

THE SPINE — regressed = (PA x observed + K x prior) / (PA + K), with K = 600 PA
and the prior being the hitter's OWN blended rate rather than the league's. The
question a platoon adjustment answers is whether he is DIFFERENT against this
hand than he normally is, so his own line is the correct null and a hitter with
no evidence of a split correctly gets nothing. K is deliberately conservative:
platoon skill is famously slow to stabilise, with the half-signal point for
right-handed batters near a thousand PA.

THE MAKE-OR-BREAK TEST, both halves. A .310 average against left-handed pitching
on 30 PA gets 4.8% weight and moves the projection by 0.003 — essentially
nothing, which is the correct answer rather than a limitation. The SAME .310 on
400 PA gets 40% weight and moves it by 0.023, eight times as far. A test asserts
that ratio stays above five, so if the regression ever breaks the suite says so
instead of the projections quietly drifting onto noise.

Real data behaves exactly as the mechanism predicts and is worth recording:
Josh Bell hits .259 against lefties and .248 against righties, which looks like
a platoon split until the sample speaks — 126 PA earns 17% weight and the
adjustment lands at 1.005. Aaron Judge, 76 PA against lefties, comes out at
0.999. Neither is material. Most hitters will get nothing from this adjuster,
and that is the honest output, not a failure.

Honest-absent has five distinct routes, all returning exactly 1.0: no batter
handedness, no pitcher handedness, no splits, a stat platoon says nothing about,
and a missing side falling back to the prior rather than to zero.

INDEPENDENT of the environment. Park and weather compose into one coefficient
because they both describe the stadium; platoon describes this hitter against
this pitcher's hand, so it rides its own slot with its own label. Entangling
them would make both harder to attribute when the instrument scores them.
Directional, mirrored on the under, capped at 15%, and inverted for strikeouts
where a higher rate means a higher prop rather than a better hitter.

Tests 3729 passed / 300 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-21 01:57:33 -04:00
parent 9086951852
commit 6dd6f59481
4 changed files with 419 additions and 4 deletions
+27 -3
View File
@@ -121,7 +121,7 @@ function envNudge(env, dirSign) {
return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE); return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE);
} }
function adjust({ pWin, direction, statType, classification, environment } = {}) { function adjust({ pWin, direction, statType, classification, environment, matchup } = {}) {
const p = num(pWin); const p = num(pWin);
const identical = (reason) => ({ const identical = (reason) => ({
p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION, p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION,
@@ -130,7 +130,13 @@ function adjust({ pWin, direction, statType, classification, environment } = {})
if (p == null || p <= 0 || p >= 1) return identical('no_champion_probability'); if (p == null || p <= 0 || p >= 1) return identical('no_champion_probability');
const envPresent = num(environment && environment.multiplier) != null const envPresent = num(environment && environment.multiplier) != null
&& num(environment.multiplier) !== 1; && num(environment.multiplier) !== 1;
if ((!classification || !classification.sufficient) && !envPresent) { // MATCHUP (platoon) is deliberately its OWN signal, not folded into the
// environment coefficient: park and weather describe the stadium, platoon
// describes this hitter against this pitcher's hand. Keeping them separate is
// what lets the instrument attribute them independently.
const matchupPresent = num(matchup && matchup.multiplier) != null
&& num(matchup.multiplier) !== 1;
if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent) {
return identical('archetype_absent_or_thin'); return identical('archetype_absent_or_thin');
} }
@@ -140,7 +146,7 @@ function adjust({ pWin, direction, statType, classification, environment } = {})
const map = ((classification && classification.sufficient) const map = ((classification && classification.sufficient)
? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat] ? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]
: null) || {}; : null) || {};
if (!Object.keys(map).length && !envPresent) return identical('stat_not_mapped'); if (!Object.keys(map).length && !envPresent && !matchupPresent) return identical('stat_not_mapped');
// Direction: a trait that raises the stat raises P(over) and lowers P(under). // Direction: a trait that raises the stat raises P(over) and lowers P(under).
const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1; const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1;
@@ -148,6 +154,24 @@ function adjust({ pWin, direction, statType, classification, environment } = {})
const adjustments = []; const adjustments = [];
let total = 0; let total = 0;
// ── MATCHUP (platoon) — independent of the environment ─────────────────
const mMult = num(matchup && matchup.multiplier);
if (mMult != null && mMult !== 1) {
const n = envNudge(mMult, dirSign);
if (n) {
total += n;
adjustments.push({
axis: 'matchup', label: matchup.label || 'PLATOON',
tier: 'matchup', nudge: Math.round(n * 1000) / 1000,
multiplier: Math.round(mMult * 1000) / 1000,
batter_hand: matchup.batter_hand ?? null,
pitcher_hand: matchup.pitcher_hand ?? null,
observed_pa: matchup.observed_pa ?? null,
observed_weight: matchup.observed_weight ?? null,
});
}
}
// ── ENVIRONMENT (park now; weather composes onto it next order) ──────── // ── ENVIRONMENT (park now; weather composes onto it next order) ────────
// Applied even when no archetype axis fires: a park effect is real whether or // Applied even when no archetype axis fires: a park effect is real whether or
// not the player is distinctive. `environment` is the COMPOSED multiplier. // not the player is distinctive. `environment` is the COMPOSED multiplier.
+208
View File
@@ -0,0 +1,208 @@
'use strict';
/**
* PLATOON SPLITS (Layer 3, Step 6) — the highest-value and highest-noise signal.
*
* The market often prices a hitter's BLENDED line while tonight he faces only
* one handedness. That gap is the value. The danger is that platoon samples are
* the thinnest in baseball — a hitter may see ~130 PA vs LHP in a season — so a
* raw split is mostly noise.
*
* ── THE SPINE: REGRESS, OR THIS IS ACTIVELY HARMFUL ──────────────────────
* Applying a raw split adjusts projections on noise, which is worse than not
* building the adjuster at all. Every estimate here is shrunk toward a prior:
*
* regressed = (PA · observed + K · prior) / (PA + K)
*
* The PRIOR is the hitter's OWN overall rate, not the league's. The question a
* platoon adjustment answers is "is he DIFFERENT against this hand than he
* normally is" — so the null hypothesis is his own blended line, and a hitter
* with no evidence of a split correctly gets no adjustment.
*
* K = 600 PA (env-tunable). Platoon skill is famously slow to stabilise; the
* sabermetric literature puts the half-signal point for right-handed batters
* near 1,000 PA vs a hand. 600 is deliberately conservative in the direction of
* doing nothing:
* 30 PA → 4.8% weight on the observed split (essentially the prior)
* 130 PA → 18%
* 400 PA → 40%
* 1,000 PA → 63%
* A 30-PA .310 hitter vs LHP therefore moves the projection almost not at all,
* which is the correct answer, not a limitation.
*
* ── IT IS A MATCHUP SIGNAL, NOT AN ENVIRONMENT ───────────────────────────
* Park and weather compose into one environment coefficient. Platoon does NOT
* join them: it is a property of this hitter against this pitcher's hand, and
* entangling it with the stadium would make both harder to attribute when the
* instrument scores them.
*/
/** Shrinkage constant, in PA vs the hand. Higher = more conservative. */
const K_PA = Number(process.env.PLATOON_K_PA) || 600;
/** Below this the adjustment is reported as immaterial (it will already be
* tiny from the regression; this is the honesty label, not the mechanism). */
const MATERIAL_THRESHOLD = Number(process.env.PLATOON_MATERIAL) || 0.02;
/** Hard cap. Platoon leans; it never re-forecasts. */
const MAX_MULT = Number(process.env.PLATOON_MAX_MULT) || 0.15;
/** Stats a platoon split speaks to. Absent elsewhere — we are not inventing a
* platoon effect on a strikeout-allowed prop. */
const PLATOON_STATS = Object.freeze(new Set([
'hits', 'home_runs', 'total_bases', 'rbi', 'runs', 'doubles', 'strikeouts', 'walks',
]));
/** Which observed rate speaks to which stat. */
const STAT_RATE = Object.freeze({
hits: 'avg', doubles: 'avg', runs: 'ops', rbi: 'ops',
home_runs: 'slg', total_bases: 'slg',
strikeouts: 'k_rate', walks: 'bb_rate',
});
/** Stats where a HIGHER rate means a LOWER prop outcome. */
const INVERTED = Object.freeze(new Set(['strikeouts']));
const num = (v) => {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* regressSplit({ observedRate, observedPa, priorRate, k }) — PURE.
* The whole safety mechanism in six lines. Returns the shrunk rate plus the
* weight actually given to the observation, so callers can SEE how much of the
* estimate is evidence and how much is prior.
*/
function regressSplit({ observedRate, observedPa, priorRate, k = K_PA } = {}) {
const obs = num(observedRate);
const pa = num(observedPa);
const prior = num(priorRate);
if (prior == null) return { rate: null, weight: 0, reason: 'no prior' };
if (obs == null || pa == null || pa <= 0) {
return { rate: prior, weight: 0, reason: 'no observed split — prior stands' };
}
const weight = pa / (pa + k);
return {
rate: obs * weight + prior * (1 - weight),
weight: Math.round(weight * 1000) / 1000,
observed_pa: pa,
reason: null,
};
}
/**
* platoonEstimate({ splits, batterHand, pitcherHand, statType }) — the signal.
*
* splits: { vsL: {pa, avg, ops, slg, k_rate, bb_rate}, vsR: {…}, overall: {…} }
* Returns { multiplier, state, material, ... }. 1.0 is a true no-op and is
* reached honestly and OFTEN — most hitters do not have enough evidence of a
* split to move a projection, and saying so is the correct output.
*/
function platoonEstimate({ splits, batterHand, pitcherHand, statType } = {}) {
const stat = String(statType || '').toLowerCase();
const neutral = (state, reason, extra = {}) => ({
multiplier: 1, state, reason, material: false,
regressed_rate: null, prior_rate: null, observed_weight: 0, ...extra,
});
if (!PLATOON_STATS.has(stat)) return neutral('not_applicable', 'platoon says nothing about this stat');
// GATE: without the hitter's handedness there is no platoon question to ask.
// (statcast_aggregates.bats is 100% populated since Session 69, but a prop
// for a player we never resolved still lands here.)
if (!batterHand) return neutral('batter_hand_absent', 'no batter handedness');
const ph = String(pitcherHand || '').toUpperCase();
if (ph !== 'L' && ph !== 'R') return neutral('pitcher_hand_absent', "tonight's pitcher handedness unknown");
if (!splits || !splits.overall) return neutral('splits_absent', 'no split data for this hitter');
const side = ph === 'L' ? splits.vsL : splits.vsR;
const rateKey = STAT_RATE[stat];
const prior = num(splits.overall[rateKey]);
if (prior == null || prior === 0) return neutral('no_prior', 'hitter has no overall rate for this stat');
const reg = regressSplit({
observedRate: side ? num(side[rateKey]) : null,
observedPa: side ? num(side.pa) : null,
priorRate: prior,
});
if (reg.rate == null) return neutral('no_prior', reg.reason);
// The adjustment is the REGRESSED split relative to his own blended line.
let ratio = reg.rate / prior;
if (INVERTED.has(stat)) ratio = ratio === 0 ? 1 : 1 / ratio;
const capped = clamp(ratio - 1, -MAX_MULT, MAX_MULT);
const multiplier = Math.round((1 + capped) * 1000) / 1000;
const material = Math.abs(multiplier - 1) >= MATERIAL_THRESHOLD;
return {
multiplier,
state: 'present',
reason: null,
material,
batter_hand: batterHand,
pitcher_hand: ph,
rate_key: rateKey,
prior_rate: Math.round(prior * 10000) / 10000,
observed_rate: side ? num(side[rateKey]) : null,
observed_pa: reg.observed_pa ?? 0,
observed_weight: reg.weight, // how much is evidence vs prior
regressed_rate: Math.round(reg.rate * 10000) / 10000,
capped: capped !== ratio - 1,
};
}
/** statsapi platoon splits for one hitter. `vl`/`vr` = vs left/right pitching. */
const SPLITS_URL = (playerId, season) =>
`https://statsapi.mlb.com/api/v1/people/${playerId}/stats`
+ `?stats=statSplits&sitCodes=vl,vr&group=hitting&season=${season}`;
/**
* parseSplits(payload, overall) — PURE. statsapi → the shape platoonEstimate
* wants. A side with no rows is ABSENT, never zero-filled.
*/
function parseSplits(payload, overall = null) {
const out = { vsL: null, vsR: null, overall: overall || null };
for (const st of (payload && payload.stats) || []) {
for (const sp of st.splits || []) {
const code = (sp.split && sp.split.code) || '';
const s = sp.stat || {};
const pa = num(s.plateAppearances);
const row = {
pa,
avg: num(s.avg), ops: num(s.ops), slg: num(s.slg), obp: num(s.obp),
k_rate: pa > 0 && num(s.strikeOuts) != null ? num(s.strikeOuts) / pa : null,
bb_rate: pa > 0 && num(s.baseOnBalls) != null ? num(s.baseOnBalls) / pa : null,
};
if (code === 'vl') out.vsL = row;
if (code === 'vr') out.vsR = row;
}
}
// Overall = the two sides combined, when a blended line was not supplied.
if (!out.overall && (out.vsL || out.vsR)) {
const parts = [out.vsL, out.vsR].filter((r) => r && r.pa);
const totalPa = parts.reduce((a, r) => a + r.pa, 0);
if (totalPa > 0) {
const w = (key) => {
const vals = parts.filter((r) => num(r[key]) != null);
if (!vals.length) return null;
const d = vals.reduce((a, r) => a + r.pa, 0);
return d > 0 ? vals.reduce((a, r) => a + r[key] * r.pa, 0) / d : null;
};
out.overall = { pa: totalPa, avg: w('avg'), ops: w('ops'), slg: w('slg'), obp: w('obp'), k_rate: w('k_rate'), bb_rate: w('bb_rate') };
}
}
return out;
}
module.exports = {
platoonEstimate,
regressSplit,
parseSplits,
SPLITS_URL,
K_PA,
MATERIAL_THRESHOLD,
MAX_MULT,
PLATOON_STATS,
STAT_RATE,
};
+183
View File
@@ -0,0 +1,183 @@
/* ============================================================
Session 76 — PLATOON SPLITS. Highest value, highest noise.
The regression IS the feature: raw splits applied unregressed are harmful.
============================================================ */
const pl = require('../../src/services/platoonSplits');
const ch = require('../../src/services/challengerProjection');
/** A hitter with `pa` PA vs LHP at `avg`, 300 PA vs RHP at his overall rate. */
const mk = (pa, avg, overall = 0.250) => ({
vsL: { pa, avg, ops: avg * 2.6, slg: avg * 1.7 },
vsR: { pa: 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
overall: { pa: pa + 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
});
const est = (splits, ph = 'L', stat = 'hits', bh = 'R') =>
pl.platoonEstimate({ splits, batterHand: bh, pitcherHand: ph, statType: stat });
describe('THE MAKE-OR-BREAK TEST — thin splits must not move anything', () => {
it('a .310 split on 30 PA is regressed almost entirely away', () => {
const e = est(mk(30, 0.310));
expect(e.observed_weight).toBeLessThan(0.06); // ~4.8% evidence
expect(Math.abs(e.multiplier - 1)).toBeLessThan(0.02);
expect(e.material).toBe(false);
});
it('the SAME split on 400 PA produces a real adjustment', () => {
const e = est(mk(400, 0.310));
expect(e.observed_weight).toBeCloseTo(0.4, 1);
expect(e.multiplier).toBeGreaterThan(1.05);
expect(e.material).toBe(true);
});
it('deep sample moves the projection MANY times more than thin', () => {
const thin = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(30, 0.310)) });
const deep = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(400, 0.310)) });
// If this ratio ever collapses toward 1, the regression has broken and
// platoon is applying noise.
expect(Math.abs(deep.delta)).toBeGreaterThan(Math.abs(thin.delta) * 5);
});
it('is symmetric — a deep NEGATIVE split suppresses by the same magnitude', () => {
const up = est(mk(400, 0.310));
const down = est(mk(400, 0.190));
expect(Math.abs(up.multiplier - 1)).toBeCloseTo(Math.abs(down.multiplier - 1), 2);
expect(down.multiplier).toBeLessThan(1);
});
it('regresses toward the HITTER\'S OWN rate, not the league', () => {
// The question is "is he different vs this hand than he normally is", so a
// hitter whose split matches his overall gets NO adjustment however deep.
const e = est(mk(2000, 0.250, 0.250));
expect(e.multiplier).toBe(1);
});
});
describe('regressSplit — the mechanism', () => {
it('weight rises with PA and never reaches 1', () => {
const w = (pa) => pl.regressSplit({ observedRate: 0.3, observedPa: pa, priorRate: 0.25 }).weight;
expect(w(30)).toBeLessThan(w(130));
expect(w(130)).toBeLessThan(w(400));
expect(w(400)).toBeLessThan(w(1000));
expect(w(100000)).toBeLessThan(1);
});
it('no observation → the prior stands, untouched', () => {
const r = pl.regressSplit({ observedRate: null, observedPa: null, priorRate: 0.25 });
expect(r.rate).toBe(0.25);
expect(r.weight).toBe(0);
});
it('no prior → no estimate at all (never invents one)', () => {
expect(pl.regressSplit({ observedRate: 0.3, observedPa: 400, priorRate: null }).rate).toBeNull();
});
it('K is conservative — 600 PA of prior', () => {
expect(pl.K_PA).toBe(600);
expect(pl.regressSplit({ observedRate: 1, observedPa: 600, priorRate: 0 }).weight).toBe(0.5);
});
});
describe('honest-absent — frequent and CORRECT here', () => {
it.each([
['no batter handedness', { bh: null, ph: 'L' }, 'batter_hand_absent'],
['no pitcher handedness', { bh: 'R', ph: null }, 'pitcher_hand_absent'],
])('%s → multiplier 1', (_n, { bh, ph }, state) => {
const e = pl.platoonEstimate({ splits: mk(400, 0.310), batterHand: bh, pitcherHand: ph, statType: 'hits' });
expect(e.multiplier).toBe(1);
expect(e.state).toBe(state);
});
it('a stat platoon says nothing about → not_applicable', () => {
expect(est(mk(400, 0.310), 'L', 'stolen_bases').state).toBe('not_applicable');
});
it('no splits at all → absent, never a guess', () => {
expect(pl.platoonEstimate({ splits: null, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }).state)
.toBe('splits_absent');
});
it('a missing SIDE falls back to the prior rather than zero', () => {
const s = { vsL: null, vsR: { pa: 300, avg: 0.25 }, overall: { pa: 300, avg: 0.25 } };
const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
expect(e.multiplier).toBe(1);
expect(e.observed_weight).toBe(0);
});
});
describe('conditioned on TONIGHT\'S pitcher hand', () => {
it('uses the vs-LHP split against an LHP and the vs-RHP split against an RHP', () => {
const s = {
vsL: { pa: 400, avg: 0.310 }, vsR: { pa: 400, avg: 0.210 },
overall: { pa: 800, avg: 0.260 },
};
const vsL = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
const vsR = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'R', statType: 'hits' });
expect(vsL.multiplier).toBeGreaterThan(1);
expect(vsR.multiplier).toBeLessThan(1);
});
it('inverts for strikeouts — a higher K rate means a LOWER hit prop, not higher', () => {
const s = {
vsL: { pa: 400, k_rate: 0.35 }, vsR: { pa: 400, k_rate: 0.20 },
overall: { pa: 800, k_rate: 0.25 },
};
const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'strikeouts' });
// He strikes out MORE vs LHP → the strikeout prop over is MORE likely.
expect(e.rate_key).toBe('k_rate');
expect(e.multiplier).not.toBe(1);
});
});
describe('INDEPENDENT of the environment coefficient', () => {
it('platoon rides its own slot, labelled separately', () => {
const r = ch.adjust({
pWin: 0.5, direction: 'over', statType: 'hits',
matchup: { ...est(mk(400, 0.310)), label: 'PLATOON' },
environment: { multiplier: 1.10, label: 'PARK × WEATHER', venue: 'Coors Field' },
});
const axes = r.adjustments.map((a) => a.axis);
expect(axes).toContain('matchup');
expect(axes).toContain('environment');
// Two separate entries → the instrument can attribute them independently.
expect(r.adjustments.find((a) => a.axis === 'matchup').label).toBe('PLATOON');
});
it('mirrors on the under', () => {
const m = { ...est(mk(400, 0.310)), label: 'PLATOON' };
const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: m });
const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'hits', matchup: m });
expect(under.delta).toBeCloseTo(-over.delta, 3);
});
it('is capped — platoon leans, never re-forecasts', () => {
const absurd = est(mk(5000, 0.600));
expect(Math.abs(absurd.multiplier - 1)).toBeLessThanOrEqual(pl.MAX_MULT + 1e-9);
});
});
describe('parseSplits — statsapi shape', () => {
const payload = { stats: [{ splits: [
{ split: { code: 'vl' }, stat: { plateAppearances: 126, avg: '.259', ops: '.698', slg: '.400', strikeOuts: 30, baseOnBalls: 10 } },
{ split: { code: 'vr' }, stat: { plateAppearances: 265, avg: '.248', ops: '.755', slg: '.430', strikeOuts: 55, baseOnBalls: 20 } },
] }] };
it('parses both sides and derives rates', () => {
const s = pl.parseSplits(payload);
expect(s.vsL.pa).toBe(126);
expect(s.vsL.avg).toBeCloseTo(0.259, 3);
expect(s.vsR.k_rate).toBeCloseTo(55 / 265, 3);
});
it('builds the overall prior as a PA-weighted blend when none is supplied', () => {
const s = pl.parseSplits(payload);
expect(s.overall.pa).toBe(391);
expect(s.overall.avg).toBeGreaterThan(0.248);
expect(s.overall.avg).toBeLessThan(0.259);
});
it('an absent side stays null — never zero-filled', () => {
const s = pl.parseSplits({ stats: [{ splits: [payload.stats[0].splits[1]] }] });
expect(s.vsL).toBeNull();
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long