396 lines
14 KiB
JavaScript
396 lines
14 KiB
JavaScript
/**
|
|
* VYNDR Ship Build — Data Source Component Tests
|
|
* Pure logic tests with inline data shapes and parsing logic.
|
|
*/
|
|
|
|
// ─── Inline Data Shapes ───────────────────────────────────────────────
|
|
|
|
const STARTING_TRUST = {
|
|
beat_writer: 'reliable',
|
|
national: 'authoritative',
|
|
aggregator: 'unverified',
|
|
insider: 'reliable',
|
|
};
|
|
|
|
const REPORTER_DATABASE = {
|
|
nba: {
|
|
shams_charania: { name: 'Shams Charania', source_type: 'insider', trust: 'reliable' },
|
|
woj: { name: 'Adrian Wojnarowski', source_type: 'national', trust: 'authoritative' },
|
|
local_beat: { name: 'Local Beat Writer', source_type: 'beat_writer', trust: 'reliable' },
|
|
},
|
|
};
|
|
|
|
const TRUST_LEVELS = ['unverified', 'reliable', 'verified', 'authoritative'];
|
|
|
|
function escalateTrust(reporter) {
|
|
const { tracked, accuracy } = reporter;
|
|
if (tracked >= 30 && accuracy >= 0.95) return { level: 'authoritative', badge: 'confirmed' };
|
|
if (tracked >= 20 && accuracy >= 0.90) return { level: 'verified', badge: 'trusted' };
|
|
return { level: reporter.trust, badge: null };
|
|
}
|
|
|
|
// ─── Tweet Parsing ────────────────────────────────────────────────────
|
|
|
|
const STATUS_PATTERNS = [
|
|
{ pattern: /will start/i, status: 'confirmed_playing', confidence: 0.85 },
|
|
{ pattern: /scratched/i, status: 'scratched', confidence: 0.90 },
|
|
{ pattern: /game[- ]time decision/i, status: 'questionable', confidence: 0.70 },
|
|
{ pattern: /\bOUT\b/, status: 'out', confidence: 0.90 },
|
|
];
|
|
|
|
const PAST_TENSE_FILTERS = [/was out/i, /yesterday/i, /last night/i];
|
|
|
|
function parseTweet(text) {
|
|
if (!text) return null;
|
|
for (const filter of PAST_TENSE_FILTERS) {
|
|
if (filter.test(text)) return null;
|
|
}
|
|
for (const { pattern, status, confidence } of STATUS_PATTERNS) {
|
|
if (pattern.test(text)) return { status, confidence };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── Odds API Parsing ─────────────────────────────────────────────────
|
|
|
|
function parseOddsOutcome(market, bookmakerKey) {
|
|
if (!market || !market.outcomes || market.outcomes.length === 0) {
|
|
return null;
|
|
}
|
|
const outcome = market.outcomes[0];
|
|
return {
|
|
player_name: outcome.name || null,
|
|
line: outcome.point != null ? outcome.point : null,
|
|
bookmaker: bookmakerKey,
|
|
};
|
|
}
|
|
|
|
// ─── Line Movement Detection ──────────────────────────────────────────
|
|
|
|
function detectLineMovement(opening, current) {
|
|
const diff = Math.abs(current - opening);
|
|
if (diff < 0.5) return null;
|
|
return {
|
|
movement: diff,
|
|
direction: current > opening ? 'up' : 'down',
|
|
flagged: true,
|
|
};
|
|
}
|
|
|
|
// ─── Weather + Dome Detection ─────────────────────────────────────────
|
|
|
|
const DOME_PARKS = ['tropicana_field', 'chase_field', 'minute_maid', 'rogers_centre', 'loanDepot_park', 'globe_life'];
|
|
|
|
function getWeatherForPark(park, conditions) {
|
|
if (DOME_PARKS.includes(park)) {
|
|
return { temperature: 72, wind: 0, humidity: 50, ball_carry_factor: 1.0 };
|
|
}
|
|
return conditions;
|
|
}
|
|
|
|
function ballCarryFactor(temperature, humidity) {
|
|
let factor = 1.0;
|
|
if (temperature > 72) factor += (temperature - 72) * 0.003;
|
|
if (temperature < 72) factor -= (72 - temperature) * 0.003;
|
|
if (humidity > 50) factor -= (humidity - 50) * 0.002;
|
|
if (humidity < 50) factor += (50 - humidity) * 0.002;
|
|
return parseFloat(factor.toFixed(4));
|
|
}
|
|
|
|
// ─── Catcher Framing ──────────────────────────────────────────────────
|
|
|
|
function clampFramingValue(raw) {
|
|
return Math.max(-0.5, Math.min(0.5, raw));
|
|
}
|
|
|
|
// ─── Umpire / Referee Minimums ────────────────────────────────────────
|
|
|
|
function getUmpireAdjustment(umpire) {
|
|
if (umpire.games < 30) return 0.0;
|
|
return umpire.k_rate_delta;
|
|
}
|
|
|
|
function getRefereeAdjustment(referee) {
|
|
if (referee.games < 30) return 0.0;
|
|
return referee.foul_rate_delta;
|
|
}
|
|
|
|
// ─── MLB Lineup Parsing ──────────────────────────────────────────────
|
|
|
|
function parseLineupEntry(raw) {
|
|
return {
|
|
player: raw.player,
|
|
batting_order: raw.batting_order,
|
|
position: raw.position,
|
|
status: raw.source === 'official_api' ? 'confirmed' : 'projected',
|
|
};
|
|
}
|
|
|
|
// ─── ABS Challenge System ─────────────────────────────────────────────
|
|
|
|
function disciplineScore(chase_rate, bb_rate) {
|
|
// chase_rate: 0-1 (lower = better), bb_rate: 0-1 (higher = better)
|
|
const raw = (1 - chase_rate) * 0.5 + bb_rate * 0.5;
|
|
return Math.max(0, Math.min(1, parseFloat(raw.toFixed(4))));
|
|
}
|
|
|
|
function absKAdjustment(discipline) {
|
|
if (discipline > 0.7) return -0.05;
|
|
return 0;
|
|
}
|
|
|
|
function framingVsDisciplined(framingValue, discipline) {
|
|
// Framing is 50% less effective against disciplined batters
|
|
if (discipline > 0.7) return framingValue * 0.5;
|
|
return framingValue;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// TESTS
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('Reporter seeding + trust', () => {
|
|
test('beat_writer starts at reliable', () => {
|
|
expect(STARTING_TRUST.beat_writer).toBe('reliable');
|
|
});
|
|
|
|
test('national starts at authoritative', () => {
|
|
expect(STARTING_TRUST.national).toBe('authoritative');
|
|
});
|
|
|
|
test('aggregator starts at unverified', () => {
|
|
expect(STARTING_TRUST.aggregator).toBe('unverified');
|
|
});
|
|
|
|
test('insider starts at reliable', () => {
|
|
expect(STARTING_TRUST.insider).toBe('reliable');
|
|
});
|
|
|
|
test('reporter_database has nba key', () => {
|
|
expect(REPORTER_DATABASE).toHaveProperty('nba');
|
|
expect(Object.keys(REPORTER_DATABASE.nba).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('reporter has source_type field', () => {
|
|
const reporter = REPORTER_DATABASE.nba.shams_charania;
|
|
expect(reporter).toHaveProperty('source_type');
|
|
expect(typeof reporter.source_type).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('Reporter trust escalation', () => {
|
|
test('promote from reliable to verified at 20+ tracked and 90%+ accuracy', () => {
|
|
const result = escalateTrust({ trust: 'reliable', tracked: 25, accuracy: 0.92 });
|
|
expect(result.level).toBe('verified');
|
|
});
|
|
|
|
test('stay at reliable if accuracy below 90%', () => {
|
|
const result = escalateTrust({ trust: 'reliable', tracked: 25, accuracy: 0.85 });
|
|
expect(result.level).toBe('reliable');
|
|
});
|
|
|
|
test('authoritative requires 30+ tracked and 95%+ accuracy', () => {
|
|
const result = escalateTrust({ trust: 'verified', tracked: 35, accuracy: 0.96 });
|
|
expect(result.level).toBe('authoritative');
|
|
});
|
|
|
|
test('badge for authoritative is confirmed', () => {
|
|
const result = escalateTrust({ trust: 'verified', tracked: 35, accuracy: 0.96 });
|
|
expect(result.badge).toBe('confirmed');
|
|
});
|
|
});
|
|
|
|
describe('Tweet parsing', () => {
|
|
test('"will start" detected as confirmed_playing', () => {
|
|
const result = parseTweet('LeBron James will start tonight');
|
|
expect(result.status).toBe('confirmed_playing');
|
|
});
|
|
|
|
test('"scratched" detected as scratched', () => {
|
|
const result = parseTweet('Giannis has been scratched from the lineup');
|
|
expect(result.status).toBe('scratched');
|
|
});
|
|
|
|
test('"game-time decision" detected as questionable', () => {
|
|
const result = parseTweet('Jaylen Brown is a game-time decision');
|
|
expect(result.status).toBe('questionable');
|
|
});
|
|
|
|
test('past tense "was out" filtered', () => {
|
|
const result = parseTweet('Curry was out for the game last week');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('"yesterday" filtered', () => {
|
|
const result = parseTweet('Player was scratched yesterday');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('"last night" filtered', () => {
|
|
const result = parseTweet('He sat out last night');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('returns null for irrelevant text', () => {
|
|
const result = parseTweet('Great weather in Boston today');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('player OUT returns confidence 0.90', () => {
|
|
const result = parseTweet('Player is OUT tonight');
|
|
expect(result.confidence).toBe(0.90);
|
|
});
|
|
});
|
|
|
|
describe('Odds API response parsing', () => {
|
|
test('extracts player_name from outcome', () => {
|
|
const market = { outcomes: [{ name: 'LeBron James', point: 25.5 }] };
|
|
const result = parseOddsOutcome(market, 'draftkings');
|
|
expect(result.player_name).toBe('LeBron James');
|
|
});
|
|
|
|
test('extracts line from point', () => {
|
|
const market = { outcomes: [{ name: 'LeBron James', point: 25.5 }] };
|
|
const result = parseOddsOutcome(market, 'draftkings');
|
|
expect(result.line).toBe(25.5);
|
|
});
|
|
|
|
test('extracts bookmaker key', () => {
|
|
const market = { outcomes: [{ name: 'LeBron James', point: 25.5 }] };
|
|
const result = parseOddsOutcome(market, 'fanduel');
|
|
expect(result.bookmaker).toBe('fanduel');
|
|
});
|
|
|
|
test('handles missing outcomes gracefully', () => {
|
|
expect(parseOddsOutcome({}, 'draftkings')).toBeNull();
|
|
expect(parseOddsOutcome({ outcomes: [] }, 'draftkings')).toBeNull();
|
|
expect(parseOddsOutcome(null, 'draftkings')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Line movement detection', () => {
|
|
test('flags movement >= 0.5', () => {
|
|
const result = detectLineMovement(24.5, 25.5);
|
|
expect(result.flagged).toBe(true);
|
|
expect(result.movement).toBe(1.0);
|
|
});
|
|
|
|
test('ignores movement < 0.5', () => {
|
|
const result = detectLineMovement(24.5, 24.8);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('direction is up when current > opening', () => {
|
|
const result = detectLineMovement(24.5, 25.5);
|
|
expect(result.direction).toBe('up');
|
|
});
|
|
});
|
|
|
|
describe('Weather dome detection', () => {
|
|
test('dome parks return temperature=72', () => {
|
|
const weather = getWeatherForPark('tropicana_field', {});
|
|
expect(weather.temperature).toBe(72);
|
|
});
|
|
|
|
test('dome parks return wind=0', () => {
|
|
const weather = getWeatherForPark('chase_field', {});
|
|
expect(weather.wind).toBe(0);
|
|
});
|
|
|
|
test('ball_carry_factor for dome is 1.0', () => {
|
|
const weather = getWeatherForPark('minute_maid', {});
|
|
expect(weather.ball_carry_factor).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
describe('Weather ball carry', () => {
|
|
test('hot weather increases carry (>72F)', () => {
|
|
const factor = ballCarryFactor(90, 50);
|
|
expect(factor).toBeGreaterThan(1.0);
|
|
});
|
|
|
|
test('humid weather decreases carry (>50%)', () => {
|
|
const factor = ballCarryFactor(72, 80);
|
|
expect(factor).toBeLessThan(1.0);
|
|
});
|
|
|
|
test('neutral at 72F/50%', () => {
|
|
const factor = ballCarryFactor(72, 50);
|
|
expect(factor).toBe(1.0);
|
|
});
|
|
});
|
|
|
|
describe('Catcher framing', () => {
|
|
test('framing value clamped at upper bound 0.5', () => {
|
|
expect(clampFramingValue(0.8)).toBe(0.5);
|
|
});
|
|
|
|
test('framing value clamped at lower bound -0.5', () => {
|
|
expect(clampFramingValue(-0.9)).toBe(-0.5);
|
|
});
|
|
});
|
|
|
|
describe('Umpire / referee minimums', () => {
|
|
test('umpire returns 0.0 below 30 games', () => {
|
|
expect(getUmpireAdjustment({ games: 15, k_rate_delta: 0.12 })).toBe(0.0);
|
|
});
|
|
|
|
test('referee returns 0.0 below 30 games', () => {
|
|
expect(getRefereeAdjustment({ games: 20, foul_rate_delta: 0.08 })).toBe(0.0);
|
|
});
|
|
|
|
test('umpire returns adjustment at 30+ games', () => {
|
|
const adj = getUmpireAdjustment({ games: 45, k_rate_delta: 0.12 });
|
|
expect(adj).toBe(0.12);
|
|
});
|
|
|
|
test('referee returns adjustment at 30+ games', () => {
|
|
const adj = getRefereeAdjustment({ games: 30, foul_rate_delta: 0.08 });
|
|
expect(adj).toBe(0.08);
|
|
});
|
|
});
|
|
|
|
describe('MLB lineup parsing', () => {
|
|
test('lineup has batting_order field', () => {
|
|
const entry = parseLineupEntry({ player: 'Mookie Betts', batting_order: 1, position: 'RF', source: 'official_api' });
|
|
expect(entry).toHaveProperty('batting_order');
|
|
expect(entry.batting_order).toBe(1);
|
|
});
|
|
|
|
test('lineup has position field', () => {
|
|
const entry = parseLineupEntry({ player: 'Freddie Freeman', batting_order: 3, position: '1B', source: 'official_api' });
|
|
expect(entry).toHaveProperty('position');
|
|
expect(entry.position).toBe('1B');
|
|
});
|
|
|
|
test('status is confirmed from official API', () => {
|
|
const entry = parseLineupEntry({ player: 'Shohei Ohtani', batting_order: 2, position: 'DH', source: 'official_api' });
|
|
expect(entry.status).toBe('confirmed');
|
|
});
|
|
});
|
|
|
|
describe('ABS challenge system', () => {
|
|
test('discipline_score from chase_rate + bb_rate is 0-1', () => {
|
|
const score = disciplineScore(0.3, 0.12);
|
|
expect(score).toBeGreaterThanOrEqual(0);
|
|
expect(score).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('elite discipline (>0.7) gets -5% K adjustment', () => {
|
|
const adj = absKAdjustment(0.8);
|
|
expect(adj).toBe(-0.05);
|
|
});
|
|
|
|
test('low discipline gets no benefit', () => {
|
|
const adj = absKAdjustment(0.4);
|
|
expect(adj).toBe(0);
|
|
});
|
|
|
|
test('framing 50% effective vs disciplined batter', () => {
|
|
const full = framingVsDisciplined(0.3, 0.5);
|
|
const reduced = framingVsDisciplined(0.3, 0.8);
|
|
expect(full).toBe(0.3);
|
|
expect(reduced).toBe(0.15);
|
|
});
|
|
});
|