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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -216,6 +216,21 @@ function teamNorm(s) {
|
|||||||
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
|
* teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
|
||||||
* an abbr hint into a team id.
|
* 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) {
|
function teamRecordMatchesHint(team, teamHint, teams) {
|
||||||
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
|
if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
|
||||||
const tId = team.id;
|
const tId = team.id;
|
||||||
@@ -237,7 +252,7 @@ function teamRecordMatchesHint(team, teamHint, teams) {
|
|||||||
function disambiguateByHint(candidates, teamHint, teams) {
|
function disambiguateByHint(candidates, teamHint, teams) {
|
||||||
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
|
if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
|
||||||
const matches = (candidates || []).filter((p) =>
|
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;
|
return matches.length === 1 ? matches[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +303,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
|
|||||||
if (exact.length === 1) {
|
if (exact.length === 1) {
|
||||||
hit = exact[0];
|
hit = exact[0];
|
||||||
if (teamHint && hit.currentTeam) {
|
if (teamHint && hit.currentTeam) {
|
||||||
teamConfirmed = teamRecordMatchesHint(
|
const tms = await ensureTeams();
|
||||||
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
|
teamConfirmed = teamRecordMatchesHint(withTeamName(hit.currentTeam, tms), teamHint, tms);
|
||||||
}
|
}
|
||||||
} else if (exact.length >= 2) {
|
} else if (exact.length >= 2) {
|
||||||
// Namesake collision — resolve ONLY with a confident hint, else refuse.
|
// Namesake collision — resolve ONLY with a confident hint, else refuse.
|
||||||
@@ -298,8 +313,8 @@ async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
|
|||||||
} else {
|
} else {
|
||||||
hit = lastNameInitialFallback(people, targetKey);
|
hit = lastNameInitialFallback(people, targetKey);
|
||||||
if (hit && teamHint && hit.currentTeam) {
|
if (hit && teamHint && hit.currentTeam) {
|
||||||
teamConfirmed = teamRecordMatchesHint(
|
const tms = await ensureTeams();
|
||||||
{ id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, 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
|
// 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.
|
// 24h) maps id -> name, so this costs no new request.
|
||||||
const teamId = teamConfirmed ? (hit.currentTeam?.id ?? null) : null;
|
let resolvedTeam = null;
|
||||||
let teamName = teamConfirmed ? (hit.currentTeam?.name ?? null) : null;
|
if (teamConfirmed && hit.currentTeam) {
|
||||||
if (teamName == null && teamId != null) {
|
try { resolvedTeam = withTeamName(hit.currentTeam, await ensureTeams()); }
|
||||||
try {
|
catch { resolvedTeam = null; /* honest-absent: never guessed */ }
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
|
const teamId = resolvedTeam ? resolvedTeam.id : null;
|
||||||
|
const teamName = resolvedTeam ? resolvedTeam.name : null;
|
||||||
return {
|
return {
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
fullName: hit.fullName ?? name,
|
fullName: hit.fullName ?? name,
|
||||||
@@ -443,5 +457,7 @@ module.exports = {
|
|||||||
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
|
BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
|
||||||
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
|
// Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
|
||||||
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
|
canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
|
||||||
|
// 2026-08-01 — the team-name backfill the S59 invariant depends on.
|
||||||
|
withTeamName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user