Session 7e: Grade adapter, normalize consolidation, ARCH-2 banners
This commit is contained in:
@@ -237,3 +237,38 @@
|
|||||||
{"ts":"2026-06-10T06:54:15.253Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
{"ts":"2026-06-10T06:54:15.253Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
{"ts":"2026-06-10T06:54:15.298Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
{"ts":"2026-06-10T06:54:15.298Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
{"ts":"2026-06-10T06:54:15.578Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
{"ts":"2026-06-10T06:54:15.578Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.767Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.824Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.825Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.825Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.888Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T07:14:59.939Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:15:00.455Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.653Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.670Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.671Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.671Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.712Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:17:22.758Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T07:17:23.302Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.473Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.521Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.521Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.521Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.556Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.569Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:19:34.937Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.063Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.063Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.063Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.109Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.152Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.255Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T07:23:11.616Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.137Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.159Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.160Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.160Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.217Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.244Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-10T07:24:54.705Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
|||||||
+48
-20
@@ -411,24 +411,50 @@ No circular imports detected.
|
|||||||
### ARCH — Architecture
|
### ARCH — Architecture
|
||||||
|
|
||||||
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
|
- **[ARCH-1] Dual grading paths.** Severity: Medium. Status:
|
||||||
**DEFERRED in Session 7d** — the legacy `propAnalyzer → grader →
|
**PARTIAL in Session 7e** — Step 1 of the migration (the adapter)
|
||||||
UnifiedOddsProvider` chain returns a 4-letter grade + 0-100
|
is complete and tested. Steps 2-6 deferred per ESCAPE HATCH.
|
||||||
confidence + kill-conditions + reasoning.steps shape; the new
|
Step 1 ✓ — `src/utils/gradeAdapter.js` translates engine1 output
|
||||||
`engine1` returns an 11-step grade + 0-1 confidence + factors. Both
|
into the legacy shape `DemoScan` reads. 26 unit
|
||||||
shapes are consumed by different surfaces (GradeCard + DemoScan vs
|
tests covering grade collapse, confidence math,
|
||||||
the orchestrator's grade_history writer). Unification requires
|
kill-condition mapping, reasoning fallback, partial
|
||||||
frontend + backend coordination — out of scope for the audit-fix
|
input safety.
|
||||||
session. Migration plan: (1) decide which shape the UI keeps; (2)
|
Step 2-6 deferred — legacy `analyzeProp` and `engine1.gradeProp()`
|
||||||
write an adapter from the other shape; (3) rewire one route at a
|
have incompatible INPUT contracts: the legacy
|
||||||
time, starting with `/api/analyze/prop`. Session 7d added a
|
analyzer takes a raw prop and self-fetches data
|
||||||
DEPRECATED banner to `src/services/grader.js` to signal that new
|
(odds, stats, opponent, spread, kill conditions);
|
||||||
features should land in engine1.js.
|
engine1 takes a pre-computed feature vector and is
|
||||||
|
a pure grading function. To rewire any route to
|
||||||
|
engine1 we'd first need to extract an
|
||||||
|
orchestrator-lite preprocessor — that's the kind
|
||||||
|
of architectural change 7c/7d's no-restructure
|
||||||
|
rule blocks. The reasoning.summary string-level
|
||||||
|
parity is also currently lost (concrete numbers
|
||||||
|
in legacy vs abstract factor labels in engine1).
|
||||||
|
Migration roadmap (now actionable for the next session):
|
||||||
|
(a) Extract `gradingOrchestrator.gradeProp()` into a standalone
|
||||||
|
`computeFeaturesForProp({player, stat_type, line, direction})`
|
||||||
|
that lifts the player-roster + feature-fetch + trap-detect +
|
||||||
|
consistency-score logic into a reusable callable.
|
||||||
|
(b) Wrap engine1 + gradeAdapter behind a thin
|
||||||
|
`analyzeViaEngine1(prop)` helper.
|
||||||
|
(c) Rewire one route at a time — `/api/analyze/prop` first
|
||||||
|
(lowest risk: single prop, public, well-tested), then
|
||||||
|
`/api/analyze/batch`, then `/api/scan/parlay`, then
|
||||||
|
`/api/bets/*`.
|
||||||
|
(d) Remove `grader.js` + `propAnalyzer.js` + the legacy book
|
||||||
|
adapters only after every consumer has migrated and a soak
|
||||||
|
period.
|
||||||
|
Session 7d's DEPRECATED banner on `src/services/grader.js` stays
|
||||||
|
put.
|
||||||
|
|
||||||
- **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity:
|
- **[ARCH-2] Two circuit-breaker / rate-limiter modules.** Severity:
|
||||||
Low (documented in 6a). `src/services/{circuitBreaker.js,
|
Low. Status: **DOCUMENTED in Session 7e.** Both legacy modules now
|
||||||
rateLimiter.js}` (keyed registry, legacy) coexist with
|
carry banners listing their three callers:
|
||||||
`src/utils/rateLimiter.js` (factory, new). Consolidation is purely
|
- `src/services/UnifiedOddsProvider.js`
|
||||||
cosmetic — both work. Scope: half-session. Status: open.
|
- `src/services/adapters/ESPNAdapter.js`
|
||||||
|
- `src/services/adapters/PinnacleAdapter.js`
|
||||||
|
Full removal blocks on ARCH-1 Step 6 — the legacy book adapters
|
||||||
|
retire together with the legacy grading path.
|
||||||
|
|
||||||
### SEC — Security
|
### SEC — Security
|
||||||
|
|
||||||
@@ -466,10 +492,12 @@ No circular imports detected.
|
|||||||
### DUP — Duplicates
|
### DUP — Duplicates
|
||||||
|
|
||||||
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
|
- **[DUP-1] `normalizeName()` exists twice.** Severity: Low. Status:
|
||||||
**DOCUMENTED in Session 7d.** Both implementations now carry
|
**FIXED in Session 7e.** Consolidated into `src/utils/normalize.js`
|
||||||
cross-reference comments explaining the divergence (script keeps
|
with a `keepDigits` option (default false). `trapDetection.js`
|
||||||
digits for legacy roster fields; trap detector strips them). Full
|
imports the default; `scripts/populate-player-ids.js` wraps with
|
||||||
consolidation deferred until the script can drop its digit support.
|
`keepDigits: true` via `normalizeRosterName`. 9 unit tests cover
|
||||||
|
accent strip, suffix removal, digit options, idempotency, null
|
||||||
|
input.
|
||||||
|
|
||||||
- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
|
- **[DUP-2] `oddsToImplied` etc. only live in `src/utils/odds.js`.**
|
||||||
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
|
Confirmed not duplicated despite the `oddsNormalizer.js` neighbor —
|
||||||
|
|||||||
@@ -48,22 +48,11 @@ const espnSportPath = {
|
|||||||
|
|
||||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
|
||||||
// DUP-1 (Session 7c): a near-twin lives in
|
// DUP-1 (Session 7e): consolidated into src/utils/normalize.js. The
|
||||||
// src/services/intelligence/trapDetection.js. This variant KEEPS digits
|
// script keeps digits because legacy roster fields can encode jersey
|
||||||
// because some legacy roster fields encode jersey numbers in the name
|
// numbers in the name string.
|
||||||
// string; the trap detector strips them. If those legacy fields ever
|
const { normalizeName } = require('../src/utils/normalize');
|
||||||
// go away, consolidate to a shared util in src/utils/.
|
const normalizeRosterName = (name) => normalizeName(name, { keepDigits: true });
|
||||||
function normalizeName(name) {
|
|
||||||
if (!name) return '';
|
|
||||||
return name
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[̀-ͯ]/g, '') // strip accents
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '') // suffixes
|
|
||||||
.replace(/[^a-z0-9\s]/g, ' ') // punctuation (keeps digits)
|
|
||||||
.replace(/\s+/g, ' ') // collapse spaces
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJSON(url, { params } = {}) {
|
async function fetchJSON(url, { params } = {}) {
|
||||||
const res = await axios.get(url, { params, timeout: 15_000 });
|
const res = await axios.get(url, { params, timeout: 15_000 });
|
||||||
@@ -108,7 +97,7 @@ async function fetchMlbAllPlayers() {
|
|||||||
return list.map((p) => ({
|
return list.map((p) => ({
|
||||||
mlbam_id: String(p.id),
|
mlbam_id: String(p.id),
|
||||||
fullName: p.fullName,
|
fullName: p.fullName,
|
||||||
normalized: normalizeName(p.fullName),
|
normalized: normalizeRosterName(p.fullName),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +118,7 @@ async function processSport(sport, { dryRun }) {
|
|||||||
for (const p of roster) {
|
for (const p of roster) {
|
||||||
allPlayers.push({
|
allPlayers.push({
|
||||||
display_name: p.name,
|
display_name: p.name,
|
||||||
normalized_name: normalizeName(p.name),
|
normalized_name: normalizeRosterName(p.name),
|
||||||
espn_id: p.id,
|
espn_id: p.id,
|
||||||
sport,
|
sport,
|
||||||
team_abbr: team.abbreviation,
|
team_abbr: team.abbreviation,
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
|
* ARCH-2 (Session 7e): LEGACY circuit breaker. The canonical implementation
|
||||||
|
* lives in src/utils/rateLimiter.js (createCircuitBreaker factory).
|
||||||
|
* New code should import from there.
|
||||||
|
*
|
||||||
|
* Current callers of this legacy module (remove this file when the list
|
||||||
|
* empties):
|
||||||
|
* - src/services/UnifiedOddsProvider.js
|
||||||
|
* - src/services/adapters/ESPNAdapter.js
|
||||||
|
* - src/services/adapters/PinnacleAdapter.js
|
||||||
|
*
|
||||||
* Per-upstream circuit breaker.
|
* Per-upstream circuit breaker.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
|
|||||||
@@ -23,32 +23,14 @@
|
|||||||
|
|
||||||
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
|
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
|
||||||
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
const { getSupabaseServiceClient } = require('../../utils/supabase');
|
||||||
|
// DUP-1 (Session 7e): consolidated into src/utils/normalize.js.
|
||||||
|
// Trap matching strips digits — keepDigits stays at the default false.
|
||||||
|
const { normalizeName } = require('../../utils/normalize');
|
||||||
|
|
||||||
function inactive(reason) {
|
function inactive(reason) {
|
||||||
return { score: 0, active: false, explanation: reason };
|
return { score: 0, active: false, explanation: reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize player names for matching across data sources. ParlayAPI may
|
|
||||||
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
|
|
||||||
// punctuation, suffixes, and collapse whitespace so equivalence works.
|
|
||||||
//
|
|
||||||
// DUP-1 (Session 7c): a near-identical implementation lives in
|
|
||||||
// scripts/populate-player-ids.js. The script's variant keeps digits
|
|
||||||
// (some legacy roster fields encode jersey numbers); this one strips
|
|
||||||
// them because trap matches go by player name only. If the script
|
|
||||||
// stops needing digits, consolidate to a shared util.
|
|
||||||
function normalizeName(name) {
|
|
||||||
if (!name) return '';
|
|
||||||
return String(name)
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[̀-ͯ]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
|
|
||||||
.replace(/[^a-z\s]/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect whether a key teammate transitioned from OUT in the recent past
|
// Detect whether a key teammate transitioned from OUT in the recent past
|
||||||
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
|
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
|
||||||
// the trap detector — the orchestrator owns the injury-history context.
|
// the trap detector — the orchestrator owns the injury-history context.
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
|
* ARCH-2 (Session 7e): LEGACY rate limiter. The canonical implementation
|
||||||
|
* lives in src/utils/rateLimiter.js (factory pattern, paired with
|
||||||
|
* createCircuitBreaker). New code should import from there.
|
||||||
|
*
|
||||||
|
* Current callers of this legacy module (remove this file when the list
|
||||||
|
* empties):
|
||||||
|
* - src/services/UnifiedOddsProvider.js
|
||||||
|
* - src/services/adapters/ESPNAdapter.js
|
||||||
|
* - src/services/adapters/PinnacleAdapter.js
|
||||||
|
*
|
||||||
* Token bucket rate limiter, per upstream key.
|
* Token bucket rate limiter, per upstream key.
|
||||||
*
|
*
|
||||||
* Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway
|
* Each upstream (ESPN, Pinnacle, DK, etc.) gets its own bucket so a runaway
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Engine 1 (new) → legacy grader shape adapter.
|
||||||
|
*
|
||||||
|
* Engine 1 emits an 11-step grade (F..A+) + 0-1 confidence + a labelled
|
||||||
|
* factors array. The frontend (`DemoScan.tsx`, `GradeCard.tsx`) and the
|
||||||
|
* `/api/analyze`, `/api/scan`, `/api/bets` routes were built against the
|
||||||
|
* legacy `grader.js` shape:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* player, stat_type, line, direction, book,
|
||||||
|
* grade, // 'A' | 'B' | 'C' | 'D' (4-letter)
|
||||||
|
* confidence, // 0-100 integer
|
||||||
|
* edge_pct, // signed percentage
|
||||||
|
* kill_conditions_triggered: [{ code, ... }],
|
||||||
|
* reasoning: { summary: string, steps: {...} }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Future route rewires that swap `analyzeProp` for the orchestrator/
|
||||||
|
* engine1 path will pipe the result through `toLegacyShape()` so the
|
||||||
|
* frontend sees no change.
|
||||||
|
*
|
||||||
|
* NOTE — Session 7e ESCAPE HATCH: the adapter is built but not yet
|
||||||
|
* applied to a live route. Engine 1's input is a pre-computed feature
|
||||||
|
* vector; the legacy analyzer takes a raw prop and fetches its own
|
||||||
|
* data. Wiring those two together requires an orchestrator-lite
|
||||||
|
* preprocessor that doesn't exist yet. This file exists so the next
|
||||||
|
* session can drop it in once the preprocessor is in place. See
|
||||||
|
* docs/SYSTEM-MANIFEST.md §8 ARCH-1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FOUR_LETTER_MAP = Object.freeze({
|
||||||
|
'A+': 'A', 'A': 'A', 'A-': 'A',
|
||||||
|
'B+': 'B', 'B': 'B', 'B-': 'B',
|
||||||
|
'C+': 'C', 'C': 'C', 'C-': 'C',
|
||||||
|
'D': 'D',
|
||||||
|
'F': 'F', // DemoScan's ACCURACY map yields '—' for F, which is fine.
|
||||||
|
});
|
||||||
|
|
||||||
|
// Factors that, when present, suggest a kill condition in the legacy
|
||||||
|
// sense. Each maps to a stable code + human reason so the UI keeps
|
||||||
|
// rendering the same chip set it always has. Unknown factors fall
|
||||||
|
// through to a generic "signal" entry rather than disappearing.
|
||||||
|
const FACTOR_TO_KILL_CONDITION = Object.freeze({
|
||||||
|
trap_composite_high: { code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
||||||
|
l5_cold_vs_line: { code: 'COLD_L5', reason: 'Last-5 average significantly below the line.' },
|
||||||
|
l5_hot_vs_under: { code: 'COLD_L5', reason: 'Hot streak conflicts with UNDER side.' },
|
||||||
|
consistency_boom_bust: { code: 'BOOM_BUST', reason: 'Player is boom-or-bust on this stat.' },
|
||||||
|
top_opponent_defense: { code: 'TOP_DEFENSE', reason: 'Opponent ranks top-five defending this stat.' },
|
||||||
|
back_to_back: { code: 'B2B', reason: 'Back-to-back game.' },
|
||||||
|
heavy_workload_7d: { code: 'FATIGUE', reason: '4+ games in the last 7 days.' },
|
||||||
|
away_vs_top5_defense: { code: 'TOP_DEFENSE', reason: 'Away game vs a top-five defense.' },
|
||||||
|
rookie_in_playoffs: { code: 'NEW_CONTEXT', reason: 'No prior playoff experience.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function fourLetterGrade(elevenStep) {
|
||||||
|
if (typeof elevenStep !== 'string') return null;
|
||||||
|
return FOUR_LETTER_MAP[elevenStep.trim()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyConfidence(unitProb) {
|
||||||
|
// Guard nullish first — Number(null) === 0 is finite, which would
|
||||||
|
// silently produce a 0% confidence instead of "unknown".
|
||||||
|
if (unitProb == null) return null;
|
||||||
|
const n = Number(unitProb);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(n * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build kill_conditions_triggered from engine1 factors. Only factors
|
||||||
|
// that signal something the user should worry about become chips —
|
||||||
|
// positive factors (e.g. l5_hot_vs_line) don't.
|
||||||
|
function killConditionsFromFactors(factors) {
|
||||||
|
if (!Array.isArray(factors)) return [];
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const f of factors) {
|
||||||
|
const entry = FACTOR_TO_KILL_CONDITION[f];
|
||||||
|
if (entry && !seen.has(entry.code)) {
|
||||||
|
out.push({ ...entry });
|
||||||
|
seen.add(entry.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine 1 factors are abstract labels. To produce a reasoning.summary
|
||||||
|
// the legacy UI renders, we string the top-three factors together with a
|
||||||
|
// human-readable verb. Callers that have richer context (e.g. the
|
||||||
|
// orchestrator with featurePayload) should pass their own sentence in
|
||||||
|
// `summaryOverride`.
|
||||||
|
function buildReasoningSummary(engine1Result, prop, summaryOverride) {
|
||||||
|
if (summaryOverride && typeof summaryOverride === 'string') return summaryOverride;
|
||||||
|
const top = Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors.slice(0, 3) : [];
|
||||||
|
if (top.length === 0) {
|
||||||
|
return `Grade ${engine1Result?.grade ?? '—'} from Engine 1 (no surfaced factors).`;
|
||||||
|
}
|
||||||
|
const verb = engine1Result?.grade?.startsWith('A') ? 'favoring the play'
|
||||||
|
: engine1Result?.grade?.startsWith('B') ? 'leaning the play'
|
||||||
|
: engine1Result?.grade?.startsWith('C') ? 'split'
|
||||||
|
: 'against the play';
|
||||||
|
return `Engine 1 graded ${engine1Result?.grade ?? '—'} ${verb} — top factors: ${top.join(', ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// edge_pct lives in the legacy response. Engine 1 doesn't compute it.
|
||||||
|
// If the caller passes a computed value (e.g. l5_avg − line), use it;
|
||||||
|
// otherwise return 0 so the field exists and the UI doesn't blow up
|
||||||
|
// on missing data.
|
||||||
|
function legacyEdgePct(edgePctOverride) {
|
||||||
|
const n = Number(edgePctOverride);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toLegacyShape — transform an engine1 grading result into the shape
|
||||||
|
* `/api/analyze` and downstream callers historically returned.
|
||||||
|
*
|
||||||
|
* @param {Object} engine1Result — { grade, confidence, top_factors, all_factors }
|
||||||
|
* @param {Object} prop — { player, stat_type, line, direction, book, sport }
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {string} [opts.summaryOverride] — supply a fuller human sentence if available
|
||||||
|
* @param {number} [opts.edgePct] — pre-computed edge percentage
|
||||||
|
* @returns {Object} legacy-shaped result, including the `_cache: 'MISS'` field
|
||||||
|
* the PERF-1 wrapper expects callers to receive.
|
||||||
|
*/
|
||||||
|
function toLegacyShape(engine1Result, prop = {}, opts = {}) {
|
||||||
|
if (!engine1Result || typeof engine1Result !== 'object') return null;
|
||||||
|
const grade = fourLetterGrade(engine1Result.grade);
|
||||||
|
const confidence = legacyConfidence(engine1Result.confidence);
|
||||||
|
const kill = killConditionsFromFactors(engine1Result.all_factors || engine1Result.top_factors);
|
||||||
|
return {
|
||||||
|
player: prop.player ?? null,
|
||||||
|
stat_type: prop.stat_type ?? null,
|
||||||
|
line: prop.line ?? null,
|
||||||
|
direction: prop.direction ?? null,
|
||||||
|
book: prop.book ?? null,
|
||||||
|
grade,
|
||||||
|
confidence,
|
||||||
|
edge_pct: legacyEdgePct(opts.edgePct),
|
||||||
|
kill_conditions_triggered: kill,
|
||||||
|
reasoning: {
|
||||||
|
summary: buildReasoningSummary(engine1Result, prop, opts.summaryOverride),
|
||||||
|
// The legacy `steps` block carried season_avg / recent_form /
|
||||||
|
// situational / line_comparison / kill_conditions / final_grade.
|
||||||
|
// Engine 1 doesn't surface those individually, so the adapter
|
||||||
|
// emits an `engine1_factors` block as the modern equivalent.
|
||||||
|
// The frontend reads `.summary` only; this is documentation.
|
||||||
|
steps: {
|
||||||
|
engine1_factors: engine1Result.all_factors || engine1Result.top_factors || [],
|
||||||
|
final_grade: grade,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
toLegacyShape,
|
||||||
|
__internals: {
|
||||||
|
FOUR_LETTER_MAP,
|
||||||
|
FACTOR_TO_KILL_CONDITION,
|
||||||
|
fourLetterGrade,
|
||||||
|
legacyConfidence,
|
||||||
|
killConditionsFromFactors,
|
||||||
|
buildReasoningSummary,
|
||||||
|
legacyEdgePct,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Player-name normalization for cross-source matching.
|
||||||
|
*
|
||||||
|
* ParlayAPI emits "Brunson, Jalen"; ESPN emits "Jalen Brunson"; nba_api
|
||||||
|
* sometimes attaches a jersey number. Normalize aggressively so equality
|
||||||
|
* comparisons just work.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* NFD unicode → strip accents → lowercase → drop suffixes (jr/sr/ii/iii/
|
||||||
|
* iv/v) → strip punctuation → collapse whitespace → trim.
|
||||||
|
*
|
||||||
|
* `keepDigits` (default false) controls whether digits survive the
|
||||||
|
* punctuation strip. The trap detector matches names only and wants
|
||||||
|
* digits gone; the player-ID population script keeps them because some
|
||||||
|
* legacy roster fields encode jersey numbers inline.
|
||||||
|
*/
|
||||||
|
function normalizeName(name, { keepDigits = false } = {}) {
|
||||||
|
if (!name) return '';
|
||||||
|
const punctClass = keepDigits ? '[^a-z0-9\\s]' : '[^a-z\\s]';
|
||||||
|
return String(name)
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
|
||||||
|
.replace(new RegExp(punctClass, 'g'), ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeName };
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
const {
|
||||||
|
toLegacyShape,
|
||||||
|
__internals: {
|
||||||
|
FOUR_LETTER_MAP,
|
||||||
|
fourLetterGrade,
|
||||||
|
legacyConfidence,
|
||||||
|
killConditionsFromFactors,
|
||||||
|
buildReasoningSummary,
|
||||||
|
legacyEdgePct,
|
||||||
|
},
|
||||||
|
} = require('../../src/utils/gradeAdapter');
|
||||||
|
|
||||||
|
describe('gradeAdapter — fourLetterGrade', () => {
|
||||||
|
test('A-tier (A+, A, A-) all map to "A"', () => {
|
||||||
|
expect(fourLetterGrade('A+')).toBe('A');
|
||||||
|
expect(fourLetterGrade('A')).toBe('A');
|
||||||
|
expect(fourLetterGrade('A-')).toBe('A');
|
||||||
|
});
|
||||||
|
test('B-tier maps to "B"', () => {
|
||||||
|
expect(fourLetterGrade('B+')).toBe('B');
|
||||||
|
expect(fourLetterGrade('B')).toBe('B');
|
||||||
|
expect(fourLetterGrade('B-')).toBe('B');
|
||||||
|
});
|
||||||
|
test('C-tier maps to "C"', () => {
|
||||||
|
expect(fourLetterGrade('C+')).toBe('C');
|
||||||
|
expect(fourLetterGrade('C')).toBe('C');
|
||||||
|
expect(fourLetterGrade('C-')).toBe('C');
|
||||||
|
});
|
||||||
|
test('D maps to "D", F maps to "F" (DemoScan handles F → "—")', () => {
|
||||||
|
expect(fourLetterGrade('D')).toBe('D');
|
||||||
|
expect(fourLetterGrade('F')).toBe('F');
|
||||||
|
});
|
||||||
|
test('unknown / null / non-string returns null', () => {
|
||||||
|
expect(fourLetterGrade('Z')).toBeNull();
|
||||||
|
expect(fourLetterGrade(null)).toBeNull();
|
||||||
|
expect(fourLetterGrade(undefined)).toBeNull();
|
||||||
|
expect(fourLetterGrade(42)).toBeNull();
|
||||||
|
});
|
||||||
|
test('FOUR_LETTER_MAP exposes every engine1 grade', () => {
|
||||||
|
const expected = ['A+','A','A-','B+','B','B-','C+','C','C-','D','F'];
|
||||||
|
for (const g of expected) expect(FOUR_LETTER_MAP[g]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradeAdapter — legacyConfidence', () => {
|
||||||
|
test('0.0 → 0, 0.5 → 50, 1.0 → 100', () => {
|
||||||
|
expect(legacyConfidence(0.0)).toBe(0);
|
||||||
|
expect(legacyConfidence(0.5)).toBe(50);
|
||||||
|
expect(legacyConfidence(1.0)).toBe(100);
|
||||||
|
});
|
||||||
|
test('0.873 → 87 (rounded)', () => {
|
||||||
|
expect(legacyConfidence(0.873)).toBe(87);
|
||||||
|
});
|
||||||
|
test('out-of-range clamps to [0, 100]', () => {
|
||||||
|
expect(legacyConfidence(-0.5)).toBe(0);
|
||||||
|
expect(legacyConfidence(2.0)).toBe(100);
|
||||||
|
});
|
||||||
|
test('non-numeric input returns null', () => {
|
||||||
|
expect(legacyConfidence(null)).toBeNull();
|
||||||
|
expect(legacyConfidence(undefined)).toBeNull();
|
||||||
|
expect(legacyConfidence('abc')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradeAdapter — killConditionsFromFactors', () => {
|
||||||
|
test('translates known negative factors to kill condition entries', () => {
|
||||||
|
const out = killConditionsFromFactors(['trap_composite_high', 'l5_cold_vs_line']);
|
||||||
|
const codes = out.map((k) => k.code);
|
||||||
|
expect(codes).toContain('TRAP');
|
||||||
|
expect(codes).toContain('COLD_L5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('positive factors are ignored', () => {
|
||||||
|
const out = killConditionsFromFactors(['l5_hot_vs_line', 'home_game', 'rested_2plus']);
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates by code (l5_cold + l5_hot_under both → COLD_L5)', () => {
|
||||||
|
const out = killConditionsFromFactors(['l5_cold_vs_line', 'l5_hot_vs_under']);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].code).toBe('COLD_L5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty / non-array → []', () => {
|
||||||
|
expect(killConditionsFromFactors([])).toEqual([]);
|
||||||
|
expect(killConditionsFromFactors(null)).toEqual([]);
|
||||||
|
expect(killConditionsFromFactors(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradeAdapter — buildReasoningSummary', () => {
|
||||||
|
test('uses summaryOverride when provided', () => {
|
||||||
|
const out = buildReasoningSummary({ grade: 'A' }, {}, 'fancy override');
|
||||||
|
expect(out).toBe('fancy override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds a sentence from top_factors', () => {
|
||||||
|
const out = buildReasoningSummary({
|
||||||
|
grade: 'A-',
|
||||||
|
top_factors: ['l5_hot_vs_line', 'home_game', 'rested_2plus'],
|
||||||
|
}, { player: 'X' });
|
||||||
|
expect(out).toContain('A-');
|
||||||
|
expect(out).toContain('l5_hot_vs_line');
|
||||||
|
expect(out).toContain('favoring the play');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back gracefully when no factors are surfaced', () => {
|
||||||
|
const out = buildReasoningSummary({ grade: 'B' });
|
||||||
|
expect(typeof out).toBe('string');
|
||||||
|
expect(out).toContain('B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradeAdapter — legacyEdgePct', () => {
|
||||||
|
test('numeric override passes through', () => {
|
||||||
|
expect(legacyEdgePct(5.2)).toBe(5.2);
|
||||||
|
expect(legacyEdgePct(-3)).toBe(-3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric returns 0 (legacy callers expect the field present)', () => {
|
||||||
|
expect(legacyEdgePct(null)).toBe(0);
|
||||||
|
expect(legacyEdgePct(undefined)).toBe(0);
|
||||||
|
expect(legacyEdgePct('abc')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gradeAdapter — toLegacyShape (round-trip)', () => {
|
||||||
|
const engine1Result = {
|
||||||
|
grade: 'A-',
|
||||||
|
confidence: 0.78,
|
||||||
|
top_factors: ['l5_hot_vs_line', 'opp_rank_stat', 'rested_2plus'],
|
||||||
|
all_factors: ['l5_hot_vs_line', 'opp_rank_stat', 'rested_2plus', 'home_game'],
|
||||||
|
};
|
||||||
|
const prop = {
|
||||||
|
player: 'Jalen Brunson',
|
||||||
|
stat_type: 'points',
|
||||||
|
line: 26.5,
|
||||||
|
direction: 'over',
|
||||||
|
book: 'draftkings',
|
||||||
|
sport: 'nba',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('produces every field DemoScan consumes', () => {
|
||||||
|
const out = toLegacyShape(engine1Result, prop);
|
||||||
|
expect(out).toHaveProperty('grade', 'A');
|
||||||
|
expect(out).toHaveProperty('confidence', 78);
|
||||||
|
expect(out).toHaveProperty('reasoning.summary');
|
||||||
|
expect(typeof out.reasoning.summary).toBe('string');
|
||||||
|
expect(out).toHaveProperty('kill_conditions_triggered');
|
||||||
|
expect(Array.isArray(out.kill_conditions_triggered)).toBe(true);
|
||||||
|
expect(out).toHaveProperty('edge_pct');
|
||||||
|
expect(out).toHaveProperty('player', 'Jalen Brunson');
|
||||||
|
expect(out).toHaveProperty('stat_type', 'points');
|
||||||
|
expect(out).toHaveProperty('line', 26.5);
|
||||||
|
expect(out).toHaveProperty('direction', 'over');
|
||||||
|
expect(out).toHaveProperty('book', 'draftkings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edgePct override flows through', () => {
|
||||||
|
const out = toLegacyShape(engine1Result, prop, { edgePct: 4.7 });
|
||||||
|
expect(out.edge_pct).toBe(4.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summaryOverride wins over the factor-based sentence', () => {
|
||||||
|
const out = toLegacyShape(engine1Result, prop, { summaryOverride: 'Hand-written take.' });
|
||||||
|
expect(out.reasoning.summary).toBe('Hand-written take.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial engine1 input does not crash', () => {
|
||||||
|
const partial = { grade: 'B+' };
|
||||||
|
const out = toLegacyShape(partial, prop);
|
||||||
|
expect(out.grade).toBe('B');
|
||||||
|
expect(out.confidence).toBeNull();
|
||||||
|
expect(out.kill_conditions_triggered).toEqual([]);
|
||||||
|
expect(typeof out.reasoning.summary).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null engine1 input returns null', () => {
|
||||||
|
expect(toLegacyShape(null, prop)).toBeNull();
|
||||||
|
expect(toLegacyShape(undefined, prop)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('engine1_factors travel through reasoning.steps for debugging', () => {
|
||||||
|
const out = toLegacyShape(engine1Result, prop);
|
||||||
|
expect(Array.isArray(out.reasoning.steps.engine1_factors)).toBe(true);
|
||||||
|
expect(out.reasoning.steps.engine1_factors).toEqual(engine1Result.all_factors);
|
||||||
|
expect(out.reasoning.steps.final_grade).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy-shape compatibility: every key DemoScan reads is present', () => {
|
||||||
|
const out = toLegacyShape(engine1Result, prop);
|
||||||
|
// DemoScan reads: grade, confidence, reasoning.summary,
|
||||||
|
// kill_conditions_triggered (with .code on each entry),
|
||||||
|
// implied_probability (optional — adapter doesn't add it but doesn't
|
||||||
|
// need to since the field is allowed to be undefined).
|
||||||
|
expect(out.grade).toBeDefined();
|
||||||
|
expect(out.confidence).toBeDefined();
|
||||||
|
expect(out.reasoning.summary).toBeDefined();
|
||||||
|
for (const kc of out.kill_conditions_triggered) {
|
||||||
|
expect(kc.code).toBeDefined();
|
||||||
|
expect(typeof kc.code).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const { normalizeName } = require('../../src/utils/normalize');
|
||||||
|
|
||||||
|
describe('normalizeName (DUP-1)', () => {
|
||||||
|
test('basic lowercase + trim', () => {
|
||||||
|
expect(normalizeName(' Jalen Brunson ')).toBe('jalen brunson');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips accents via NFD', () => {
|
||||||
|
expect(normalizeName('Luka Dončić')).toBe('luka doncic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops suffixes jr/sr/ii/iii/iv/v', () => {
|
||||||
|
expect(normalizeName('Tim Hardaway Jr.')).toBe('tim hardaway');
|
||||||
|
expect(normalizeName('Wade Phillips Sr.')).toBe('wade phillips');
|
||||||
|
expect(normalizeName('Sammy Davis III')).toBe('sammy davis');
|
||||||
|
// VIII isn't in the suffix list (i, ii, iii, iv, v only) — it's
|
||||||
|
// realistically not a player-name suffix, so the regex leaves it.
|
||||||
|
expect(normalizeName('Henry VIII')).toBe('henry viii');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips punctuation (default: keepDigits=false drops digits too)', () => {
|
||||||
|
expect(normalizeName('A.J. Brown')).toBe('a j brown');
|
||||||
|
expect(normalizeName("Shaq O'Neal #34")).toBe('shaq o neal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keepDigits true preserves jersey numbers', () => {
|
||||||
|
expect(normalizeName("Shaq O'Neal #34", { keepDigits: true })).toBe('shaq o neal 34');
|
||||||
|
expect(normalizeName('Player 23', { keepDigits: true })).toBe('player 23');
|
||||||
|
expect(normalizeName('Player 23', { keepDigits: false })).toBe('player');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses runs of whitespace', () => {
|
||||||
|
expect(normalizeName('Spaced Out\tName')).toBe('spaced out name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null / undefined / empty input returns empty string', () => {
|
||||||
|
expect(normalizeName(null)).toBe('');
|
||||||
|
expect(normalizeName(undefined)).toBe('');
|
||||||
|
expect(normalizeName('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numeric input gets toStringed', () => {
|
||||||
|
expect(normalizeName(42, { keepDigits: true })).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent — normalize(normalize(x)) === normalize(x)', () => {
|
||||||
|
const samples = ['Luka Dončić', 'Tim Hardaway Jr.', "A.J. Brown #11"];
|
||||||
|
for (const s of samples) {
|
||||||
|
const once = normalizeName(s);
|
||||||
|
expect(normalizeName(once)).toBe(once);
|
||||||
|
}
|
||||||
|
for (const s of samples) {
|
||||||
|
const once = normalizeName(s, { keepDigits: true });
|
||||||
|
expect(normalizeName(once, { keepDigits: true })).toBe(once);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user