Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* VYNDR Ship Grading Engine — Unit Tests
|
||||
*
|
||||
* Pure logic tests for grading engine components.
|
||||
* All constants and formulas are inlined — no external imports.
|
||||
* Tests the DATA CONTRACTS that the grading engine must honor.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Grade Thresholds (4 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Grade Thresholds', () => {
|
||||
const GRADE_MAP = [
|
||||
{ min: 0.85, max: 1.00, grade: 'A+' },
|
||||
{ min: 0.78, max: 0.84, grade: 'A' },
|
||||
{ min: 0.72, max: 0.77, grade: 'B+' },
|
||||
{ min: 0.66, max: 0.71, grade: 'B' },
|
||||
{ min: 0.55, max: 0.65, grade: 'C+' },
|
||||
{ min: 0.40, max: 0.54, grade: 'C' },
|
||||
{ min: 0.29, max: 0.39, grade: 'D' },
|
||||
{ min: 0.00, max: 0.28, grade: 'F' },
|
||||
];
|
||||
|
||||
function toGrade(confidence) {
|
||||
for (const tier of GRADE_MAP) {
|
||||
if (confidence >= tier.min && confidence <= tier.max) return tier.grade;
|
||||
}
|
||||
return 'F';
|
||||
}
|
||||
|
||||
test('A+ range covers 0.85 to 1.00', () => {
|
||||
expect(toGrade(0.85)).toBe('A+');
|
||||
expect(toGrade(0.92)).toBe('A+');
|
||||
expect(toGrade(1.00)).toBe('A+');
|
||||
});
|
||||
|
||||
test('B+ range covers 0.72 to 0.77 (not 0.66-0.71 which is B)', () => {
|
||||
expect(toGrade(0.66)).toBe('B');
|
||||
expect(toGrade(0.71)).toBe('B');
|
||||
expect(toGrade(0.72)).toBe('B+');
|
||||
});
|
||||
|
||||
test('C+ caps at 0.54 on the low end when data is limited', () => {
|
||||
const DATA_LIMITED_CAP = 0.54;
|
||||
const rawConfidence = 0.78;
|
||||
const capped = Math.min(rawConfidence, DATA_LIMITED_CAP);
|
||||
expect(capped).toBe(0.54);
|
||||
expect(toGrade(capped)).toBe('C');
|
||||
});
|
||||
|
||||
test('F grade for confidence below 0.29', () => {
|
||||
expect(toGrade(0.10)).toBe('F');
|
||||
expect(toGrade(0.28)).toBe('F');
|
||||
expect(toGrade(0.00)).toBe('F');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Bayesian Weights Per-Stat-Type (5 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Bayesian Weights Per-Stat-Type', () => {
|
||||
const BAYESIAN_WEIGHTS = {
|
||||
strikeouts: { prior: 0.40, recent: 0.35, context: 0.25 },
|
||||
points: { prior: 0.30, recent: 0.45, context: 0.25 },
|
||||
assists: { prior: 0.25, recent: 0.50, context: 0.25 },
|
||||
rebounds: { prior: 0.30, recent: 0.40, context: 0.30 },
|
||||
threes: { prior: 0.35, recent: 0.40, context: 0.25 },
|
||||
};
|
||||
|
||||
const DEFAULT_WEIGHTS = { prior: 0.33, recent: 0.34, context: 0.33 };
|
||||
|
||||
test('strikeouts has prior weight of 0.40', () => {
|
||||
expect(BAYESIAN_WEIGHTS.strikeouts.prior).toBe(0.40);
|
||||
});
|
||||
|
||||
test('points has recent weight of 0.45', () => {
|
||||
expect(BAYESIAN_WEIGHTS.points.recent).toBe(0.45);
|
||||
});
|
||||
|
||||
test('assists has recent weight of 0.50', () => {
|
||||
expect(BAYESIAN_WEIGHTS.assists.recent).toBe(0.50);
|
||||
});
|
||||
|
||||
test('default weights sum to approximately 1.0', () => {
|
||||
const sum = DEFAULT_WEIGHTS.prior + DEFAULT_WEIGHTS.recent + DEFAULT_WEIGHTS.context;
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
test('every stat type has prior + recent + context summing to 1.0', () => {
|
||||
for (const [stat, weights] of Object.entries(BAYESIAN_WEIGHTS)) {
|
||||
const sum = weights.prior + weights.recent + weights.context;
|
||||
expect(sum).toBeCloseTo(1.0, 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Abstention Logic (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Abstention Logic', () => {
|
||||
function shouldAbstain({ confidence, similar_games, data_quality }) {
|
||||
if (confidence >= 0.40 && confidence <= 0.55 && similar_games < 3) return true;
|
||||
if (data_quality === 'limited' && confidence < 0.55) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
test('abstain when confidence 0.40-0.55 AND similar_games < 3', () => {
|
||||
expect(shouldAbstain({ confidence: 0.45, similar_games: 2, data_quality: 'normal' })).toBe(true);
|
||||
expect(shouldAbstain({ confidence: 0.50, similar_games: 0, data_quality: 'normal' })).toBe(true);
|
||||
});
|
||||
|
||||
test('abstain when data_quality is limited AND confidence < 0.55', () => {
|
||||
expect(shouldAbstain({ confidence: 0.50, similar_games: 10, data_quality: 'limited' })).toBe(true);
|
||||
expect(shouldAbstain({ confidence: 0.40, similar_games: 5, data_quality: 'limited' })).toBe(true);
|
||||
});
|
||||
|
||||
test('do NOT abstain when confidence > 0.55', () => {
|
||||
expect(shouldAbstain({ confidence: 0.60, similar_games: 1, data_quality: 'normal' })).toBe(false);
|
||||
expect(shouldAbstain({ confidence: 0.80, similar_games: 0, data_quality: 'normal' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Global Offset Clamped +/-0.15 (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Global Offset Clamped +/-0.15', () => {
|
||||
const OFFSET_CLAMP = 0.15;
|
||||
const MIN_SAMPLE_SIZE = 20;
|
||||
|
||||
function clampOffset(rawOffset, sampleSize) {
|
||||
if (sampleSize < MIN_SAMPLE_SIZE) return 0.0;
|
||||
return Math.max(-OFFSET_CLAMP, Math.min(OFFSET_CLAMP, rawOffset));
|
||||
}
|
||||
|
||||
test('clamps positive offset to +0.15', () => {
|
||||
expect(clampOffset(0.30, 50)).toBe(0.15);
|
||||
expect(clampOffset(0.15, 50)).toBe(0.15);
|
||||
});
|
||||
|
||||
test('clamps negative offset to -0.15', () => {
|
||||
expect(clampOffset(-0.25, 50)).toBe(-0.15);
|
||||
expect(clampOffset(-0.15, 50)).toBe(-0.15);
|
||||
});
|
||||
|
||||
test('returns zero when sample size is insufficient', () => {
|
||||
expect(clampOffset(0.10, 5)).toBe(0.0);
|
||||
expect(clampOffset(-0.20, 19)).toBe(0.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Data Sufficiency Smooth Curve (4 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Data Sufficiency Smooth Curve', () => {
|
||||
const C_PLUS_CAP = 0.54;
|
||||
const MIN_GAMES = 5;
|
||||
const FULL_GAMES = MIN_GAMES * 2; // 10
|
||||
|
||||
function dataSufficiencyMultiplier(gamesPlayed) {
|
||||
if (gamesPlayed < MIN_GAMES) return C_PLUS_CAP;
|
||||
if (gamesPlayed >= FULL_GAMES) return 1.0;
|
||||
// smooth ramp from 0.70 to 1.0 between MIN_GAMES and FULL_GAMES
|
||||
const t = (gamesPlayed - MIN_GAMES) / (FULL_GAMES - MIN_GAMES);
|
||||
return 0.70 + 0.30 * t;
|
||||
}
|
||||
|
||||
test('below minimum returns C+ cap (0.54)', () => {
|
||||
expect(dataSufficiencyMultiplier(0)).toBe(C_PLUS_CAP);
|
||||
expect(dataSufficiencyMultiplier(4)).toBe(C_PLUS_CAP);
|
||||
});
|
||||
|
||||
test('at minimum returns 70% confidence multiplier', () => {
|
||||
expect(dataSufficiencyMultiplier(MIN_GAMES)).toBeCloseTo(0.70, 5);
|
||||
});
|
||||
|
||||
test('at 2x minimum returns 100% confidence multiplier', () => {
|
||||
expect(dataSufficiencyMultiplier(FULL_GAMES)).toBe(1.0);
|
||||
expect(dataSufficiencyMultiplier(15)).toBe(1.0);
|
||||
});
|
||||
|
||||
test('ramp is smooth between min and 2x min', () => {
|
||||
const at6 = dataSufficiencyMultiplier(6);
|
||||
const at7 = dataSufficiencyMultiplier(7);
|
||||
const at8 = dataSufficiencyMultiplier(8);
|
||||
// monotonically increasing
|
||||
expect(at7).toBeGreaterThan(at6);
|
||||
expect(at8).toBeGreaterThan(at7);
|
||||
// all within the 0.70–1.0 band
|
||||
expect(at6).toBeGreaterThanOrEqual(0.70);
|
||||
expect(at8).toBeLessThanOrEqual(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Real Edge with Vig (4 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Real Edge with Vig', () => {
|
||||
function americanToImpliedProb(odds) {
|
||||
if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100);
|
||||
return 100 / (odds + 100);
|
||||
}
|
||||
|
||||
function edgeVerdict(modelProb, impliedProb) {
|
||||
const edge = modelProb - impliedProb;
|
||||
return edge > 0 ? 'BET' : 'NO BET';
|
||||
}
|
||||
|
||||
test('-110 implied probability is approximately 0.524', () => {
|
||||
const prob = americanToImpliedProb(-110);
|
||||
expect(prob).toBeCloseTo(0.524, 2);
|
||||
});
|
||||
|
||||
test('+150 implied probability is approximately 0.40', () => {
|
||||
const prob = americanToImpliedProb(150);
|
||||
expect(prob).toBeCloseTo(0.40, 2);
|
||||
});
|
||||
|
||||
test('positive EV when model probability > implied probability', () => {
|
||||
const implied = americanToImpliedProb(-110); // ~0.524
|
||||
const modelProb = 0.60;
|
||||
expect(edgeVerdict(modelProb, implied)).toBe('BET');
|
||||
});
|
||||
|
||||
test('negative EV returns NO BET', () => {
|
||||
const implied = americanToImpliedProb(-110); // ~0.524
|
||||
const modelProb = 0.48;
|
||||
expect(edgeVerdict(modelProb, implied)).toBe('NO BET');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Kelly Criterion (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Kelly Criterion', () => {
|
||||
function americanToDecimalOdds(odds) {
|
||||
if (odds < 0) return 1 + (100 / Math.abs(odds));
|
||||
return 1 + (odds / 100);
|
||||
}
|
||||
|
||||
function quarterKelly(modelProb, americanOdds) {
|
||||
const decimal = americanToDecimalOdds(americanOdds);
|
||||
const b = decimal - 1;
|
||||
const q = 1 - modelProb;
|
||||
const fullKelly = (modelProb * b - q) / b;
|
||||
if (fullKelly <= 0) return 0;
|
||||
return fullKelly / 4;
|
||||
}
|
||||
|
||||
test('quarter Kelly divides full Kelly by 4', () => {
|
||||
const modelProb = 0.60;
|
||||
const odds = -110;
|
||||
const decimal = americanToDecimalOdds(odds); // ~1.909
|
||||
const b = decimal - 1;
|
||||
const fullKelly = (modelProb * b - (1 - modelProb)) / b;
|
||||
const qk = quarterKelly(modelProb, odds);
|
||||
expect(qk).toBeCloseTo(fullKelly / 4, 5);
|
||||
});
|
||||
|
||||
test('returns 0 for negative EV bets', () => {
|
||||
expect(quarterKelly(0.40, -150)).toBe(0);
|
||||
expect(quarterKelly(0.30, -110)).toBe(0);
|
||||
});
|
||||
|
||||
test('reasonable sizing for +EV bet', () => {
|
||||
const size = quarterKelly(0.60, -110);
|
||||
// quarter Kelly should be modest — well under 10% of bankroll
|
||||
expect(size).toBeGreaterThan(0);
|
||||
expect(size).toBeLessThan(0.10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Brier Score (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Brier Score', () => {
|
||||
function brierScore(predictions) {
|
||||
if (!predictions || predictions.length === 0) return null;
|
||||
const sum = predictions.reduce((acc, { prob, outcome }) => {
|
||||
return acc + Math.pow(prob - outcome, 2);
|
||||
}, 0);
|
||||
return sum / predictions.length;
|
||||
}
|
||||
|
||||
test('perfect prediction yields Brier score of 0', () => {
|
||||
const preds = [
|
||||
{ prob: 1.0, outcome: 1 },
|
||||
{ prob: 0.0, outcome: 0 },
|
||||
];
|
||||
expect(brierScore(preds)).toBe(0);
|
||||
});
|
||||
|
||||
test('coin flip predictions yield Brier score of 0.25', () => {
|
||||
const preds = [
|
||||
{ prob: 0.5, outcome: 1 },
|
||||
{ prob: 0.5, outcome: 0 },
|
||||
];
|
||||
expect(brierScore(preds)).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
test('returns null for empty data', () => {
|
||||
expect(brierScore([])).toBeNull();
|
||||
expect(brierScore(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Similar Game Confidence Modifier (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Similar Game Confidence Modifier', () => {
|
||||
function similarGameModifier(similarGames) {
|
||||
if (similarGames >= 10) return 0.05;
|
||||
if (similarGames <= 1) return -0.03;
|
||||
if (similarGames >= 2 && similarGames <= 4) return 0.0;
|
||||
// 5-9 range — partial boost
|
||||
return 0.02;
|
||||
}
|
||||
|
||||
test('+0.05 for 10+ similar games', () => {
|
||||
expect(similarGameModifier(10)).toBe(0.05);
|
||||
expect(similarGameModifier(25)).toBe(0.05);
|
||||
});
|
||||
|
||||
test('-0.03 for 0-1 similar games', () => {
|
||||
expect(similarGameModifier(0)).toBe(-0.03);
|
||||
expect(similarGameModifier(1)).toBe(-0.03);
|
||||
});
|
||||
|
||||
test('0.0 for 2-4 similar games', () => {
|
||||
expect(similarGameModifier(2)).toBe(0.0);
|
||||
expect(similarGameModifier(3)).toBe(0.0);
|
||||
expect(similarGameModifier(4)).toBe(0.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Skewness (2 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Skewness', () => {
|
||||
function skewness(values) {
|
||||
if (!values || values.length < 10) return 0.0;
|
||||
const n = values.length;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / n;
|
||||
const variance = values.reduce((a, v) => a + Math.pow(v - mean, 2), 0) / n;
|
||||
const std = Math.sqrt(variance);
|
||||
if (std === 0) return 0.0;
|
||||
const skew = values.reduce((a, v) => a + Math.pow((v - mean) / std, 3), 0) / n;
|
||||
return skew;
|
||||
}
|
||||
|
||||
test('returns 0.0 for fewer than 10 values', () => {
|
||||
expect(skewness([1, 2, 3])).toBe(0.0);
|
||||
expect(skewness([1, 2, 3, 4, 5, 6, 7, 8, 9])).toBe(0.0);
|
||||
expect(skewness(null)).toBe(0.0);
|
||||
});
|
||||
|
||||
test('positive skew for right-skewed data', () => {
|
||||
// Mostly low values with a few high outliers
|
||||
const data = [1, 2, 2, 3, 3, 3, 4, 4, 5, 20];
|
||||
expect(skewness(data)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Matchup Pace (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Matchup Pace', () => {
|
||||
const LEAGUE_AVG_PACE = 100.0;
|
||||
const HOME_WEIGHT = 0.60;
|
||||
const AWAY_WEIGHT = 0.40;
|
||||
|
||||
function matchupPace(homePace, awayPace) {
|
||||
const blended = homePace * HOME_WEIGHT + awayPace * AWAY_WEIGHT;
|
||||
return blended / LEAGUE_AVG_PACE;
|
||||
}
|
||||
|
||||
test('two fast teams produce pace factor > 1.0', () => {
|
||||
const factor = matchupPace(108, 106);
|
||||
expect(factor).toBeGreaterThan(1.0);
|
||||
});
|
||||
|
||||
test('home team weighted 60/40 over away', () => {
|
||||
const factor = matchupPace(110, 90);
|
||||
// 110*0.6 + 90*0.4 = 66 + 36 = 102 => 102/100 = 1.02
|
||||
expect(factor).toBeCloseTo(1.02, 5);
|
||||
});
|
||||
|
||||
test('league average teams return pace of 1.0', () => {
|
||||
const factor = matchupPace(LEAGUE_AVG_PACE, LEAGUE_AVG_PACE);
|
||||
expect(factor).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Foul Trouble Risk (3 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Foul Trouble Risk', () => {
|
||||
function foulTroubleBoost(avgFouls) {
|
||||
if (avgFouls >= 3.5) return 3.0;
|
||||
if (avgFouls >= 2.8) return 1.5;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
test('>= 3.5 average fouls returns 3.0 std deviation boost', () => {
|
||||
expect(foulTroubleBoost(3.5)).toBe(3.0);
|
||||
expect(foulTroubleBoost(4.2)).toBe(3.0);
|
||||
});
|
||||
|
||||
test('>= 2.8 average fouls returns 1.5 std deviation boost', () => {
|
||||
expect(foulTroubleBoost(2.8)).toBe(1.5);
|
||||
expect(foulTroubleBoost(3.4)).toBe(1.5);
|
||||
});
|
||||
|
||||
test('< 2.8 average fouls returns 0.0', () => {
|
||||
expect(foulTroubleBoost(2.0)).toBe(0.0);
|
||||
expect(foulTroubleBoost(2.7)).toBe(0.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. B2B Stat-Specific Adjustments (4 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('B2B Stat-Specific Adjustments', () => {
|
||||
const B2B_ADJ = {
|
||||
points: -0.04,
|
||||
rebounds: 0.02,
|
||||
threes: -0.03,
|
||||
assists: 0.00,
|
||||
};
|
||||
|
||||
function applyB2B(projection, statType, isB2B) {
|
||||
if (!isB2B) return projection;
|
||||
const adj = B2B_ADJ[statType] || 0;
|
||||
return projection * (1 + adj);
|
||||
}
|
||||
|
||||
test('B2B points adjustment is -4%', () => {
|
||||
const adjusted = applyB2B(25.0, 'points', true);
|
||||
expect(adjusted).toBe(24.0);
|
||||
});
|
||||
|
||||
test('B2B rebounds adjustment is +2%', () => {
|
||||
const adjusted = applyB2B(10.0, 'rebounds', true);
|
||||
expect(adjusted).toBeCloseTo(10.2, 5);
|
||||
});
|
||||
|
||||
test('B2B threes adjustment is -3%', () => {
|
||||
const adjusted = applyB2B(3.0, 'threes', true);
|
||||
expect(adjusted).toBeCloseTo(2.91, 5);
|
||||
});
|
||||
|
||||
test('no adjustment when not B2B', () => {
|
||||
expect(applyB2B(25.0, 'points', false)).toBe(25.0);
|
||||
expect(applyB2B(10.0, 'rebounds', false)).toBe(10.0);
|
||||
expect(applyB2B(3.0, 'threes', false)).toBe(3.0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. Usage-Efficiency Tradeoff (2 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Usage-Efficiency Tradeoff', () => {
|
||||
const TS_DROP_PER_5_USG = -0.015; // -1.5% TS per +5% usage
|
||||
|
||||
function usageEfficiencyAdjustment(usageDelta) {
|
||||
// usageDelta in percentage points (e.g., +5 means usage went up 5%)
|
||||
return (usageDelta / 5) * TS_DROP_PER_5_USG;
|
||||
}
|
||||
|
||||
function netEffect(usageDelta, baseTS) {
|
||||
const tsAdj = usageEfficiencyAdjustment(usageDelta);
|
||||
return baseTS + tsAdj;
|
||||
}
|
||||
|
||||
test('-1.5% TS per +5% usage increase', () => {
|
||||
const adj = usageEfficiencyAdjustment(5);
|
||||
expect(adj).toBeCloseTo(-0.015, 5);
|
||||
const adj10 = usageEfficiencyAdjustment(10);
|
||||
expect(adj10).toBeCloseTo(-0.030, 5);
|
||||
});
|
||||
|
||||
test('net effect is sum of base TS and adjustment', () => {
|
||||
const baseTS = 0.580;
|
||||
const usageDelta = 5;
|
||||
const net = netEffect(usageDelta, baseTS);
|
||||
expect(net).toBeCloseTo(0.580 + (-0.015), 5);
|
||||
expect(net).toBeCloseTo(0.565, 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. Dynamic Usage Boost Headroom (2 tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Dynamic Usage Boost Headroom', () => {
|
||||
const MAX_BOOST = 0.08; // 8% max boost
|
||||
const HIGH_USAGE_THRESHOLD = 0.30; // 30% usage rate
|
||||
|
||||
function usageBoost(currentUsage, projectedIncrease) {
|
||||
const headroom = Math.max(0, HIGH_USAGE_THRESHOLD - currentUsage);
|
||||
const scaleFactor = Math.min(1.0, headroom / HIGH_USAGE_THRESHOLD);
|
||||
return projectedIncrease * scaleFactor * MAX_BOOST;
|
||||
}
|
||||
|
||||
test('low usage player gets full boost', () => {
|
||||
// 15% usage — lots of headroom
|
||||
const boost = usageBoost(0.15, 1.0);
|
||||
// headroom = 0.30 - 0.15 = 0.15, scaleFactor = 0.15/0.30 = 0.5
|
||||
expect(boost).toBeCloseTo(0.04, 5);
|
||||
// But a very low usage player gets even more
|
||||
const boostLow = usageBoost(0.10, 1.0);
|
||||
expect(boostLow).toBeGreaterThan(boost);
|
||||
});
|
||||
|
||||
test('high usage player gets scaled down boost', () => {
|
||||
// 28% usage — close to threshold, little headroom
|
||||
const boost = usageBoost(0.28, 1.0);
|
||||
// headroom = 0.30 - 0.28 = 0.02, scaleFactor = 0.02/0.30 ≈ 0.0667
|
||||
expect(boost).toBeCloseTo(0.02 / 0.30 * MAX_BOOST, 4);
|
||||
expect(boost).toBeLessThan(0.01);
|
||||
|
||||
// At or above threshold — zero boost
|
||||
const boostAtCap = usageBoost(0.30, 1.0);
|
||||
expect(boostAtCap).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user