diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 9a005f7..4f72177 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -131,6 +131,33 @@ inline with `GradeBadge`. `onPlayerClick` → `/player/:name?sport=` (`lib/playe --- +## 5b. Player name normalization (Session 46) + +`src/utils/playerName.js` (+ identical `web/src/lib/playerName.js`) is the ONE +source of truth for comparing/deduping names. `normalizeName(raw)` → +`{ display, key }`: `display` strips periods + de-dots the suffix (keeps accents ++ casing); `key` is accent-folded, lowercased, suffix-stripped for comparison. +Used by snapshot grouping, `slateAdapter` (grade index + player strips), and +`playerIntelService` so "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz +Chisholm Jr." collapse to one player. + +## 5c. MLB intel features (Session 46) + +`buildIntelFields` (grade-card STAT CONTEXT + VYNDR INTELLIGENCE) reads +`l5_avg`/`l10_avg`/`l20_avg`/`opp_rank_stat`/`rest_days` from the feature vector. +MLB game logs are now wired into `featureCache.gameLogFeatures` via +`mlbStatsAdapter.getPlayerStats` (the old Python game-log path was NBA/WNBA-only, +so MLB props had no intel). `buildIntelFields(features, { playerStats, projection })` +also accepts fallbacks so partial intel still renders. + +## 5d. MLB probable pitchers (Session 46) + +`GET /api/schedule/:sport/pitchers` (MLB only) → `{ sport, date, games: +[{ home:{team,pitcher,era}, away:{...} }] }` from `probablePitchers` / +`mlbStatsAdapter.getScheduleWithPitchers` (the ESPN schedule lacks them). The +Slate builds a team→pitcher map (`slateAdapter.buildPitcherMap` / +`pitchersForGameTeams`) and attaches `pitchers` to MLB GameCardData. + ## 6. Freshness & caching - Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 80b1cd7..ca2a953 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,9 +4,45 @@ 2026-06-18 ## Current Phase -SHIP BUILD v45.0 — Snapshot pipeline + GameCard swap + live ticker. The product -shifted: on-demand "Read" grading is RETIRED; a scheduled pipeline pre-grades the -slate, locks grades to the line, and the dashboard shows them already there. +SHIP BUILD v46.0 — Grade-card intel (MLB game logs) + player-name normalization ++ MLB starting pitchers. Three focused P1 fixes on the Session-45 snapshot model. + +## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES + +Backend 2100 → **2122 tests** (+22), 176 suites. Web build clean (exit 0). + +### Phase 1 — grade card intel (ROOT CAUSE) +The STAT CONTEXT + VYNDR INTELLIGENCE sections were empty for MLB because +`gameLogService.getGameLogs` is NBA/WNBA-only (offline Python service) — MLB +props NEVER got `l5_avg`/`l20_avg`, so `buildIntelFields` always returned `{}`. +FIX: `featureCache.gameLogFeatures` now has an MLB branch that derives +l5/l10/l20 averages from `mlbStatsAdapter.getPlayerStats` (free statsapi.mlb.com) +via the pure `mlbGameLogFeatures` + an MLB stat_type→game-log-field map. +`buildIntelFields(features, opts)` also gained `playerStats`/`projection` +fallbacks so partial intel renders (Sessions 43/44 had the wiring; the engine +just never produced the values for MLB). + +### Phase 2 — player name normalization +`src/utils/playerName.js` + `web/src/lib/playerName.js` (identical; cross-checked +by a test): `normalizeName(raw)` → `{ display, key }`. Strips periods, de-dots +suffixes, accent-folds the key. Applied in `snapshotService` (grouping/deltas), +`slateAdapter` (grade index + `buildPlayerStripsFromProps` merges variants, +displays the longest), and `playerIntelService.sanitizePlayerName`/`normName`. +"A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." now merge. + +### Phase 3 — MLB starting pitchers +The ESPN schedule lacks probable pitchers. NEW `GET /api/schedule/:sport/pitchers` +(MLB) → `probablePitchers` service wrapping `mlbStatsAdapter.getScheduleWithPitchers` ++ best-effort season ERA. The Slate fetches it, builds a team→pitcher map +(`slateAdapter.buildPitcherMap`/`pitchersForGameTeams`, matched by full name + +mascot), and attaches `pitchers` to MLB GameCardData. + Next proxy. + +### Phase 4 — verify +`computeLineDeltas` confirmed structurally sound (S45 tests); `deltas: 0` in the +audit was just the first snapshot (no previous to diff). BACKEND_HANDOFF.md +updated. + +## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE diff --git a/CLAUDE.md b/CLAUDE.md index 8ec5fee..c818066 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -450,6 +450,29 @@ snapshot, locked to the line, and read from cache. - **Env:** PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array). +## Grade Intel + Name Norm + Pitchers (Session 46 — non-obvious) +- **Grade-card intel root cause:** `buildIntelFields` reads l5_avg/l20_avg/ + opp_rank_stat/rest_days from the feature vector. `gameLogService.getGameLogs` + is NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season + averages → intel always empty. FIX: `featureCache.gameLogFeatures` has an MLB + branch using `mlbStatsAdapter.getPlayerStats` + the pure `mlbGameLogFeatures` + (MLB stat_type→game-log field via `MLB_LOG_FIELD`). If you add an MLB stat + type, add it to `MLB_LOG_FIELD` or its intel won't compute. `buildIntelFields` + also takes `{ playerStats, projection }` fallbacks — don't remove the resilience. +- **Player name normalization:** `src/utils/playerName.js` is the source of truth + (frontend copy at `web/src/lib/playerName.js` — keep them identical; a test + cross-checks). `normalizeName(raw)→{display,key}`: display strips periods + + de-dots suffix (keeps accents); key accent-folds + suffix-strips for comparison. + Used in snapshotService grouping, slateAdapter grade-index + player-strip + merge, and playerIntelService. `sanitizePlayerName` now returns the de-dotted + display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were + updated. +- **MLB pitchers:** the ESPN `/api/schedule` has NO probable pitchers. They come + from `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service → + `mlbStatsAdapter.getScheduleWithPitchers`. The Slate matches them to games by + team (full name OR mascot via `slateAdapter.buildPitcherMap`/`pitchersForGameTeams`). + ERA is best-effort (season stats per pitcher id, cached). + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/src/routes/schedule.js b/src/routes/schedule.js index f88a162..52ea631 100644 --- a/src/routes/schedule.js +++ b/src/routes/schedule.js @@ -30,6 +30,25 @@ router.use(createRateLimit({ windowMs: 60_000, max: 60 })); const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' }; +// GET /api/schedule/:sport/pitchers (Session 46) — today's MLB probable starters +// (statsapi.mlb.com; the ESPN schedule above doesn't carry them). MLB only. +router.get('/:sport/pitchers', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + if (sport !== 'mlb') { + return res.set(MISSION_HEADER).json({ sport, date: null, games: [] }); + } + const date = req.query.date || scheduleService.todayET(); + try { + const { getProbablePitchers } = require('../services/probablePitchers'); + const games = await getProbablePitchers(date); + res.set('Cache-Control', 'public, max-age=300'); + return res.set(MISSION_HEADER).json({ sport, date, games }); + } catch (err) { + console.error('[schedule/pitchers]', err.message); + return res.set(MISSION_HEADER).json({ sport, date, games: [] }); + } +}); + router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); const date = req.query.date || scheduleService.todayET(); diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 011e47b..6ba3477 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -296,18 +296,31 @@ function matchupGradeFromRank(rank) { * yield the fallback. The archetype strip lights up once the snapshot pipeline * (Session 44) feeds per-player season lines into the grade response. */ -function buildIntelFields(features = {}) { +const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v)); + +function buildIntelFields(features = {}, opts = {}) { const out = {}; const round1 = (n) => Math.round(n * 10) / 10; - if (Number.isFinite(features.l20_avg)) out.season_avg = round1(features.l20_avg); - else if (Number.isFinite(features.season_avg)) out.season_avg = round1(features.season_avg); - if (Number.isFinite(features.l10_avg)) out.last10_avg = round1(features.l10_avg); - else if (Number.isFinite(features.l5_avg)) out.last10_avg = round1(features.l5_avg); + // Resilience (Session 46): fall back to a caller-supplied playerStats bundle + // and the model projection when the feature vector is sparse. Partial intel + // beats none — we add only the fields we can actually back with a number. + const ps = opts.playerStats || {}; + const proj = Number.isFinite(opts.projection) ? opts.projection : undefined; - const form = computeFormScore(features); + const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj); + if (seasonAvg != null) out.season_avg = round1(seasonAvg); + + const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg); + if (last10 != null) out.last10_avg = round1(last10); + + let form = computeFormScore(features); + if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form); if (form != null) out.form = form; + if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`; else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`; + else if (ps.usage) out.usage = String(ps.usage); + const matchup = matchupGradeFromRank(features.opp_rank_stat); if (matchup) out.matchup_grade = matchup; if (Number.isFinite(features.rest_days)) out.rest = `${features.rest_days}d rest`; diff --git a/src/services/intelligence/featureCache.js b/src/services/intelligence/featureCache.js index 42b909c..3006224 100644 --- a/src/services/intelligence/featureCache.js +++ b/src/services/intelligence/featureCache.js @@ -76,7 +76,66 @@ function daysBetween(aIso, bIso) { return Math.floor(ms / (1000 * 60 * 60 * 24)); } +// MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row. +const MLB_LOG_FIELD = { + total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi', + runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls', + strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits', + innings_pitched: 'inningsPitched', +}; + +function mlbStatValue(statObj, statType) { + const f = MLB_LOG_FIELD[statType]; + if (!f || !statObj) return null; + const n = parseFloat(statObj[f]); + return Number.isFinite(n) ? n : null; +} + +/** + * Session 46 — derive recent/season averages from a real MLB game log + * (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without + * the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season + * per-game reference) — exactly the fields buildIntelFields consumes, which the + * NBA/WNBA-only Python game-log path never populated for MLB. + */ +function mlbGameLogFeatures(res, statType) { + if (!res || !res.found) return {}; + const out = {}; + const logs = Array.isArray(res.last10) ? res.last10 : []; + const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null); + if (vals.length) { + const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last) + const m10 = avg(vals.slice(-10)); + const s10 = stddev(vals.slice(-10)); + if (m5 != null) out.l5_avg = m5; + if (m10 != null) out.l10_avg = m10; + if (s10 != null) out.l10_stddev = s10; + } + const seasonTotal = mlbStatValue(res.season, statType); + const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched)); + if (seasonTotal != null && Number.isFinite(games) && games > 0) { + out.l20_avg = seasonTotal / games; + } else if (out.l10_avg != null) { + out.l20_avg = out.l10_avg; // baseline so form has a reference + } + return out; +} + async function gameLogFeatures(playerName, sport, statType) { + // MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python + // gameLogService only covers NBA/WNBA, so MLB props had no recent/season + // averages and the grade card's intel sections stayed empty. + if (sport === 'mlb') { + try { + const mlbStats = require('../adapters/mlbStatsAdapter'); + const res = await mlbStats.getPlayerStats(playerName); + return mlbGameLogFeatures(res, statType); + } catch (e) { + console.warn('[featureCache] MLB game-log features failed:', e.message); + return {}; + } + } + const logs = await gameLogs.getGameLogs(playerName, sport, 20); if (!logs || logs.length === 0) return {}; @@ -261,6 +320,8 @@ module.exports = { coachFeatures, lineupFeatures, statFromGameLog, + mlbGameLogFeatures, + mlbStatValue, avg, stddev, daysBetween, diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 96e8da0..00d514e 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -13,6 +13,7 @@ */ const { classify } = require('./archetypeService'); +const { normalizeName, nameKey } = require('../utils/playerName'); const toNum = (v) => { const n = parseFloat(v); @@ -31,14 +32,18 @@ const fmt3 = (v) => { function sanitizePlayerName(raw) { let decoded = String(raw == null ? '' : raw); try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ } - return decoded + const cleaned = decoded .replace(/[^\p{L}\p{N}\s.'-]/gu, '') .replace(/\s+/g, ' ') .trim() .slice(0, 60); + // Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing"). + return normalizeName(cleaned).display; } -const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, ''); +// Session 46 — match by the normalized name key so name variants resolve to the +// same player (snapshot grades, profile lookups). +const normName = (n) => nameKey(n); async function loadPlayerGrades(sport, name, cacheGetFn) { const env = await cacheGetFn(`grades:${sport}`); diff --git a/src/services/probablePitchers.js b/src/services/probablePitchers.js new file mode 100644 index 0000000..d6cc257 --- /dev/null +++ b/src/services/probablePitchers.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * probablePitchers — today's MLB probable starters (Session 46). + * + * The ESPN schedule (`scheduleService`) doesn't carry probable pitchers, so MLB + * game cards never showed them. This wraps `mlbStatsAdapter.getScheduleWithPitchers` + * (FREE statsapi.mlb.com) and best-effort enriches each starter's season ERA. + * Everything is graceful + injectable. `shapePitcherGames` is pure/unit-tested. + */ + +const mlbStats = require('./adapters/mlbStatsAdapter'); + +/** Map the adapter's schedule shape → a compact { home, away } pitcher record. */ +function shapePitcherGames(scheduleGames) { + return (scheduleGames || []) + .map((g) => ({ + home: { team: g.home?.team || null, pitcher: g.home?.probablePitcher?.name || null, pitcherId: g.home?.probablePitcher?.id || null, era: null }, + away: { team: g.away?.team || null, pitcher: g.away?.probablePitcher?.name || null, pitcherId: g.away?.probablePitcher?.id || null, era: null }, + })) + .filter((x) => x.home.pitcher || x.away.pitcher); +} + +async function getProbablePitchers(date, opts = {}) { + const adapter = opts.mlbAdapter || mlbStats; + let games; + try { + games = await adapter.getScheduleWithPitchers(date); + } catch (e) { + console.warn('[probablePitchers] schedule fetch failed:', e.message); + return []; + } + const shaped = shapePitcherGames(games); + + if (opts.withEra !== false) { + const eraLookup = opts.eraLookup || (async (id) => { + const s = await adapter.getSeasonAverages(id, undefined, 'pitching').catch(() => null); + const era = s && parseFloat(s.era); + return Number.isFinite(era) ? era : null; + }); + await Promise.all( + shaped.flatMap((g) => [g.home, g.away]).map(async (side) => { + if (side.pitcherId) { + try { side.era = await eraLookup(side.pitcherId); } catch { side.era = null; } + } + }), + ); + } + return shaped; +} + +module.exports = { getProbablePitchers, shapePitcherGames }; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 151f055..fa48fac 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -25,7 +25,10 @@ const DELTA_NOISE = 0.5; // ignore movements smaller than this const DELTA_MOVE = 1.0; // ticker MOVE threshold const STATS_CONCURRENCY = 5; -const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, ''); +const { nameKey } = require('../utils/playerName'); +// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and +// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player. +const norm = (s) => nameKey(s); const lastName = (full) => { const parts = String(full || '').trim().split(/\s+/); return parts.length > 1 ? parts[parts.length - 1] : (parts[0] || ''); diff --git a/src/utils/playerName.js b/src/utils/playerName.js new file mode 100644 index 0000000..bc038c1 --- /dev/null +++ b/src/utils/playerName.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Player-name normalization (Session 46) — the ONE source of truth for comparing + * and de-duplicating player names. PropLine sends variants ("A.J. Ewing" vs + * "AJ Ewing", "Jazz Chisholm" vs "Jazz Chisholm Jr.") as different players; this + * collapses them. + * + * normalizeName("A.J. Ewing") → { display: "AJ Ewing", key: "aj ewing" } + * normalizeName("Jazz Chisholm Jr.")→ { display: "Jazz Chisholm Jr", key: "jazz chisholm" } + * normalizeName("Jazz Chisholm") → { display: "Jazz Chisholm", key: "jazz chisholm" } + * normalizeName("Ronald Acuña Jr.") → { display: "Ronald Acuña Jr", key: "ronald acuna" } + * + * `display` keeps proper casing + accents (periods stripped, suffix de-dotted). + * `key` is accent-folded, lowercased, suffix-stripped for comparison. + * + * NOTE: an identical copy lives at web/src/lib/playerName.js for the frontend + * (the Next bundle can't import from src/). A test cross-checks they agree. + */ + +const SUFFIXES = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'v']); + +function normalizeName(raw) { + const display = String(raw == null ? '' : raw) + .replace(/\./g, '') // "A.J." → "AJ", "Jr." → "Jr" + .replace(/\s+/g, ' ') + .trim(); + const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); + const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' '); + return { display, key }; +} + +/** Comparison key only (the common case). */ +function nameKey(raw) { + return normalizeName(raw).key; +} + +module.exports = { normalizeName, nameKey, SUFFIXES }; diff --git a/tests/unit/mlbIntelFeatures.test.js b/tests/unit/mlbIntelFeatures.test.js new file mode 100644 index 0000000..cfa4e5e --- /dev/null +++ b/tests/unit/mlbIntelFeatures.test.js @@ -0,0 +1,71 @@ +// Session 46 — Phase 1: MLB game-log features (root cause of the empty grade +// card intel) + buildIntelFields resilience. + +const { __internals: fc } = require('../../src/services/intelligence/featureCache'); +const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1'); + +const judgeStats = { + found: true, group: 'hitting', + season: { totalBases: 180, homeRuns: 34, hits: 95, rbi: 87, gamesPlayed: 92 }, + last10: [ + { stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } }, + { stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } }, + { stat: { totalBases: 0, homeRuns: 0 } }, { stat: { totalBases: 5, homeRuns: 1 } }, + { stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } }, + { stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } }, + ], +}; + +describe('mlbGameLogFeatures (root-cause fix)', () => { + it('derives l5/l10/l20 averages for an MLB stat from the real game log', () => { + const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases'); + expect(f.l5_avg).toBeGreaterThan(0); + expect(f.l10_avg).toBeGreaterThan(0); + // season per-game = 180 / 92 ≈ 1.96 + expect(f.l20_avg).toBeCloseTo(180 / 92, 2); + }); + + it('returns {} for an unfound player or unmapped stat (graceful)', () => { + expect(fc.mlbGameLogFeatures({ found: false }, 'hits')).toEqual({}); + expect(fc.mlbGameLogFeatures(judgeStats, 'not_a_stat')).toEqual({}); + }); + + it('mlbStatValue maps stat_type → MLB game-log field', () => { + expect(fc.mlbStatValue({ homeRuns: 2 }, 'home_runs')).toBe(2); + expect(fc.mlbStatValue({ totalBases: 3 }, 'total_bases')).toBe(3); + expect(fc.mlbStatValue({}, 'home_runs')).toBeNull(); + }); +}); + +describe('buildIntelFields — feature-populated + resilient', () => { + it('produces season + last10 + form from MLB-derived features', () => { + const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases'); + const intel = eng.buildIntelFields(f); + expect(intel.season_avg).toBeDefined(); + expect(intel.last10_avg).toBeDefined(); + expect(intel.form).toBeDefined(); + }); + + it('falls back to playerStats when the feature vector is empty', () => { + const intel = eng.buildIntelFields({}, { playerStats: { season_avg: 26.9, last10_avg: 28.4, form: 92, usage: '31%' } }); + expect(intel.season_avg).toBe(26.9); + expect(intel.last10_avg).toBe(28.4); + expect(intel.form).toBe(92); + expect(intel.usage).toBe('31%'); + }); + + it('falls back to the model projection for season when nothing else exists', () => { + const intel = eng.buildIntelFields({}, { projection: 1.9 }); + expect(intel.season_avg).toBe(1.9); + }); + + it('still returns {} when there is genuinely nothing (backward compatible)', () => { + expect(eng.buildIntelFields({})).toEqual({}); + }); + + it('produces partial output (matchup only) when only opp rank exists', () => { + const intel = eng.buildIntelFields({ opp_rank_stat: 0.8 }); + expect(intel.matchup_grade).toBe('A'); + expect(intel.season_avg).toBeUndefined(); + }); +}); diff --git a/tests/unit/playerIntelService.test.js b/tests/unit/playerIntelService.test.js index cc75554..1632683 100644 --- a/tests/unit/playerIntelService.test.js +++ b/tests/unit/playerIntelService.test.js @@ -9,12 +9,12 @@ 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.'); + expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr'); // S46: suffix de-dotted }); it('strips injection / control characters', () => { expect(svc.sanitizePlayerName('Luka