diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 136cd4c..8bf4250 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -304,11 +304,28 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { } if (!hit) return null; + // TEAM NAME RESOLUTION (2026-08-01). `/sports/1/players` returns + // `currentTeam: { id, link }` with NO `name`, so `hit.currentTeam?.name` was + // ALWAYS undefined and every resolve returned team: null. Measured downstream: + // `team` was null on 416/416 stored grades, which silently disarmed the S59 + // slate join invariant (it can only drop a prop when it KNOWS the player's + // team) — a guard that looked armed and could never fire. + // + // The id is present on 100% of the list, and the /teams list (already cached + // 24h) maps id -> name, so this costs no new request. + const teamId = teamConfirmed ? (hit.currentTeam?.id ?? null) : null; + let teamName = teamConfirmed ? (hit.currentTeam?.name ?? null) : null; + if (teamName == null && teamId != null) { + try { + const row = (await ensureTeams()).find((t) => t.id === teamId); + if (row && row.name) teamName = row.name; + } catch { /* honest-absent: an unresolved team stays null, never guessed */ } + } return { id: hit.id, fullName: hit.fullName ?? name, - team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null, - teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null, + team: teamName, + teamId, position: hit.primaryPosition?.abbreviation ?? null, }; } diff --git a/tests/unit/slateJoinInvariant.test.js b/tests/unit/slateJoinInvariant.test.js new file mode 100644 index 0000000..b34d1a3 --- /dev/null +++ b/tests/unit/slateJoinInvariant.test.js @@ -0,0 +1,68 @@ +'use strict'; + +/** + * S59 SLATE JOIN INVARIANT — re-armed 2026-08-01. + * + * The invariant drops a graded prop whose player's REAL team is not a + * participant in that game. It was written correctly and was silently INERT: + * `mlbStatsAdapter.searchPlayer` returned `team: null` for every player + * (`/sports/1/players` gives `currentTeam: { id, link }` with no `name`), so + * `team` was null on 416/416 stored grades and the guard could never fire. + * + * Two properties are locked here: + * 1. FAIL-SAFE — it drops ONLY on a positive not-in-game. Unknown team, or + * unknown game participants, ABSTAIN. Dropping a real prop on absent data + * would be a new fabrication, worse than the bad row it guards against. + * 2. It must key off the PLAYER'S team, never the prop's own game teams — + * comparing a game to itself always matches and would re-disarm it while + * looking armed. + */ + +const { buildPlayerStripsFromProps } = require('../../web/src/lib/slateAdapter'); + +const prop = (player, team) => ({ player, stat_type: 'hits', line: 1.5, side: 'O', team }); +const GAME = { home: 'Philadelphia Phillies', away: 'New York Mets' }; +const players = (strips) => strips.map((s) => s.player); + +describe('join invariant — fail-safe by construction', () => { + it('DROPS a prop whose player is positively not in this game', () => { + const out = buildPlayerStripsFromProps( + [prop('In Game', 'Philadelphia Phillies'), prop('Wrong Game', 'Toronto Blue Jays')], + {}, {}, Date.now(), GAME, + ); + expect(players(out)).toEqual(['In Game']); + }); + + it('ABSTAINS when the player team is unknown — never drops on absent data', () => { + const out = buildPlayerStripsFromProps( + [prop('No Team', null), prop('Empty Team', '')], + {}, {}, Date.now(), GAME, + ); + expect(players(out)).toEqual(['No Team', 'Empty Team']); + }); + + it('ABSTAINS when the GAME participants are unknown', () => { + const rows = [prop('Anyone', 'Toronto Blue Jays')]; + expect(players(buildPlayerStripsFromProps(rows, {}, {}, Date.now(), null))).toEqual(['Anyone']); + expect(players(buildPlayerStripsFromProps(rows, {}, {}, Date.now(), {}))).toEqual(['Anyone']); + }); + + it('matches on either participant, and on abbreviation or full name', () => { + const out = buildPlayerStripsFromProps( + [prop('Home Guy', 'Philadelphia Phillies'), prop('Away Guy', 'New York Mets')], + {}, {}, Date.now(), GAME, + ); + expect(players(out)).toEqual(['Home Guy', 'Away Guy']); + }); +}); + +describe('the invariant’s input is the PLAYER team, not the prop’s own game', () => { + it('a grade carrying only home_team/away_team does NOT satisfy the guard', () => { + // home_team describes the game the prop came from, so using it as the + // player's team compares the game to itself: always a match, guard dead. + const row = { player: 'X', stat_type: 'hits', line: 1.5, side: 'O', + home_team: 'Toronto Blue Jays', away_team: 'Boston Red Sox' }; + const out = buildPlayerStripsFromProps([row], {}, {}, Date.now(), GAME); + expect(players(out)).toEqual(['X']); // abstained — no player team known + }); +});