From 9fc17a468970d609ce30fffd290f6352ba99173d Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 1 Aug 2026 23:34:16 -0400 Subject: [PATCH] Fix the team resolve properly: backfill the name BEFORE confirmation My first attempt did not work in prod -- team stayed 0/323 after deploy. I resolved the team name AFTER the hint-confirmation check, but the check itself reads hit.currentTeam.name, which is undefined because /sports/1/players returns { id, link }. With a FULL-NAME hint (what snapshotService passes) neither branch of teamRecordMatchesHint could match: the name branch had no name, and the abbr branch cannot resolve a full name to an abbr. Confirmation failed, the team was nulled, and my later backfill ran on an already-null value. withTeamName() now backfills the name from the cached /teams list BEFORE any comparison, and is used at all three confirmation sites plus the return. Verified against the live API on all four cases: no hint, FULL-NAME hint, abbr hint -> "Philadelphia Phillies"; WRONG hint -> null. That last case matters most: a wrong hint must still REFUSE. The confirmation exists so a namesake collision cannot tag a player to a team he is not on, which would fabricate opponents downstream. Making the match succeed must not make it succeed wrongly, and a test locks it. Gates: 4,087 tests / 327 suites green; next build exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc --- src/services/adapters/mlbStatsAdapter.js | 40 +++++++++++++++++------- tests/unit/slateJoinInvariant.test.js | 34 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 8bf4250..81cf4a9 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -216,6 +216,21 @@ function teamNorm(s) { * teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn * an abbr hint into a team id. */ +/** + * `/sports/1/players` returns `currentTeam: { id, link }` with NO `name`, so a + * raw currentTeam cannot be matched against a FULL-NAME hint and cannot be + * reported as a team. Backfill the name from the cached `/teams` list (id is + * present on 100% of the player list) BEFORE any comparison — doing it after + * the confirmation check leaves the check failing and nulls the team anyway, + * which is exactly how `team` ended up null on 416/416 graded rows. + */ +function withTeamName(currentTeam, teams) { + if (!currentTeam || currentTeam.id == null) return null; + if (currentTeam.name) return { id: currentTeam.id, name: currentTeam.name }; + const row = (teams || []).find((t) => t.id === currentTeam.id); + return { id: currentTeam.id, name: (row && row.name) || null }; +} + function teamRecordMatchesHint(team, teamHint, teams) { if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false; const tId = team.id; @@ -237,7 +252,7 @@ function teamRecordMatchesHint(team, teamHint, teams) { function disambiguateByHint(candidates, teamHint, teams) { if (!Array.isArray(teamHint) || teamHint.length === 0) return null; const matches = (candidates || []).filter((p) => - p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams)); + p.currentTeam && teamRecordMatchesHint(withTeamName(p.currentTeam, teams), teamHint, teams)); return matches.length === 1 ? matches[0] : null; } @@ -288,8 +303,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { if (exact.length === 1) { hit = exact[0]; if (teamHint && hit.currentTeam) { - teamConfirmed = teamRecordMatchesHint( - { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); + const tms = await ensureTeams(); + teamConfirmed = teamRecordMatchesHint(withTeamName(hit.currentTeam, tms), teamHint, tms); } } else if (exact.length >= 2) { // Namesake collision — resolve ONLY with a confident hint, else refuse. @@ -298,8 +313,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { } else { hit = lastNameInitialFallback(people, targetKey); if (hit && teamHint && hit.currentTeam) { - teamConfirmed = teamRecordMatchesHint( - { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); + const tms = await ensureTeams(); + teamConfirmed = teamRecordMatchesHint(withTeamName(hit.currentTeam, tms), teamHint, tms); } } @@ -313,14 +328,13 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { // // 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 */ } + let resolvedTeam = null; + if (teamConfirmed && hit.currentTeam) { + try { resolvedTeam = withTeamName(hit.currentTeam, await ensureTeams()); } + catch { resolvedTeam = null; /* honest-absent: never guessed */ } } + const teamId = resolvedTeam ? resolvedTeam.id : null; + const teamName = resolvedTeam ? resolvedTeam.name : null; return { id: hit.id, fullName: hit.fullName ?? name, @@ -443,5 +457,7 @@ module.exports = { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, // Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures). canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback, + // 2026-08-01 — the team-name backfill the S59 invariant depends on. + withTeamName, }, }; diff --git a/tests/unit/slateJoinInvariant.test.js b/tests/unit/slateJoinInvariant.test.js index b34d1a3..972961a 100644 --- a/tests/unit/slateJoinInvariant.test.js +++ b/tests/unit/slateJoinInvariant.test.js @@ -66,3 +66,37 @@ describe('the invariant’s input is the PLAYER team, not the prop’s own game' expect(players(out)).toEqual(['X']); // abstained — no player team known }); }); + +describe('mlbStatsAdapter — the team the invariant depends on', () => { + const { __internals } = require('../../src/services/adapters/mlbStatsAdapter'); + const { withTeamName, teamRecordMatchesHint } = __internals || {}; + const TEAMS = [{ id: 143, abbr: 'PHI', name: 'Philadelphia Phillies' }, + { id: 141, abbr: 'TOR', name: 'Toronto Blue Jays' }]; + + it('backfills the team NAME from the id — the list returns { id, link } only', () => { + expect(withTeamName({ id: 143, link: '/api/v1/teams/143' }, TEAMS)) + .toEqual({ id: 143, name: 'Philadelphia Phillies' }); + }); + + it('returns null for an absent team rather than an empty shell', () => { + expect(withTeamName(null, TEAMS)).toBeNull(); + expect(withTeamName({ link: '/x' }, TEAMS)).toBeNull(); + }); + + it('leaves the name null when the id is unknown — never guessed', () => { + expect(withTeamName({ id: 999 }, TEAMS)).toEqual({ id: 999, name: null }); + }); + + it('a FULL-NAME hint now matches — this is what was silently failing', () => { + // Before the backfill, name was undefined, so a full-name hint could match + // neither branch: confirmation failed and the team was nulled. + const t = withTeamName({ id: 143 }, TEAMS); + expect(teamRecordMatchesHint(t, ['Philadelphia Phillies', 'New York Mets'], TEAMS)).toBe(true); + expect(teamRecordMatchesHint(t, ['PHI'], TEAMS)).toBe(true); + }); + + it('a WRONG hint still REFUSES — the anti-fabrication guard is preserved', () => { + const t = withTeamName({ id: 143 }, TEAMS); + expect(teamRecordMatchesHint(t, ['Toronto Blue Jays'], TEAMS)).toBe(false); + }); +});