c8fc9f577e
Three focused P1 fixes on the Session-45 snapshot model.
- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
(pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
the key. Applied in snapshotService grouping, slateAdapter grade index +
player-strip merge (variants collapse, longest name shown), and
playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
Slate fetches it, builds a team->pitcher map (full name + mascot match),
attaches pitchers to MLB GameCardData. + Next proxy.
Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
4.0 KiB
JavaScript
92 lines
4.0 KiB
JavaScript
// Session 42 — player intelligence aggregation. cacheGet is injected so these
|
|
// run pure (no Redis, no HTTP, no rate limiter).
|
|
|
|
const svc = require('../../src/services/playerIntelService');
|
|
|
|
const cacheWith = (envelope) => async (key) => (key.startsWith('grades:') ? envelope : null);
|
|
|
|
describe('sanitizePlayerName', () => {
|
|
it('decodes URL encoding and keeps name punctuation', () => {
|
|
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
|
|
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
|
|
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr'); // S46: suffix de-dotted
|
|
});
|
|
|
|
it('strips injection / control characters', () => {
|
|
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
|
|
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('aetcpasswd'); // S46: periods stripped
|
|
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
|
|
});
|
|
|
|
it('handles malformed percent-encoding without throwing', () => {
|
|
expect(() => svc.sanitizePlayerName('%E0%A4%A')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getPlayerIntel', () => {
|
|
it('returns archetype + intelligence + props, found=true when player has grades', async () => {
|
|
const envelope = {
|
|
grades: [
|
|
{ player: 'Austin Riley', team: 'ATL', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'B+', confidence: 71 },
|
|
{ player: 'Austin Riley', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', confidence: 60 },
|
|
{ player: 'Someone Else', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 80 },
|
|
],
|
|
};
|
|
const r = await svc.getPlayerIntel('Austin Riley', 'mlb', {
|
|
cacheGet: cacheWith(envelope),
|
|
stats: { avg: 0.282, hr: 18, rbi: 54, ops: 0.845, k_rate: 26 },
|
|
});
|
|
expect(r.player).toBe('Austin Riley');
|
|
expect(r.sport).toBe('mlb');
|
|
expect(r.team).toBe('ATL');
|
|
expect(r.found).toBe(true);
|
|
expect(r.archetype.primary).toBeTruthy();
|
|
expect(r.activeProps).toHaveLength(2); // only Riley's two props
|
|
expect(r.activeProps[0]).toMatchObject({ stat: 'total_bases', side: 'O', grade: 'B+' });
|
|
expect(r.propDNA.reliable.length + r.propDNA.volatile.length).toBeGreaterThan(0);
|
|
expect(r.education.length).toBeGreaterThan(0);
|
|
expect(r.intel.find((m) => m.label === 'FORM')).toBeTruthy();
|
|
});
|
|
|
|
it('degrades gracefully when the grades cache is cold (found=false, no crash)', async () => {
|
|
const r = await svc.getPlayerIntel('Nobody Special', 'nba', { cacheGet: async () => null });
|
|
expect(r.found).toBe(false);
|
|
expect(r.activeProps).toEqual([]);
|
|
expect(r.archetype.primary).toBeTruthy(); // fallback archetype
|
|
expect(Array.isArray(r.intel)).toBe(true);
|
|
});
|
|
|
|
it('survives a throwing cache (returns a valid payload)', async () => {
|
|
const r = await svc.getPlayerIntel('X', 'nba', { cacheGet: async () => { throw new Error('redis down'); } });
|
|
expect(r.activeProps).toEqual([]);
|
|
expect(r.player).toBe('X');
|
|
});
|
|
});
|
|
|
|
describe('getLeaders', () => {
|
|
const envelope = {
|
|
grades: [
|
|
{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 88 },
|
|
{ player: 'B', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 72 },
|
|
{ player: 'C', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 95 },
|
|
],
|
|
};
|
|
|
|
it('returns top props by confidence', async () => {
|
|
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), limit: 2 });
|
|
expect(r).toHaveLength(2);
|
|
expect(r[0].player).toBe('C'); // highest confidence
|
|
expect(r[0].confidence).toBe(95);
|
|
});
|
|
|
|
it('filters to a single stat when given', async () => {
|
|
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), stat: 'hits' });
|
|
expect(r).toHaveLength(2);
|
|
expect(r.every((x) => x.stat === 'hits')).toBe(true);
|
|
});
|
|
|
|
it('returns [] on a cold cache', async () => {
|
|
expect(await svc.getLeaders('nba', { cacheGet: async () => null })).toEqual([]);
|
|
});
|
|
});
|