Files
vyndr/tests/unit/playerIntelService.test.js
T
builtbykev c8fc9f577e Session 46: Grade card intel + name normalization + pitchers (2122 tests)
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>
2026-06-18 23:56:26 -04:00

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([]);
});
});