brackt/app/models/__tests__/simulator-inputs.test.ts

115 lines
3.5 KiB
TypeScript
Raw Normal View History

import { describe, expect, it, vi, beforeEach } from "vitest";
Fix MLB standings 406 error and refactor ESPN adapter shared code (#62) ## Summary - **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS. - **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters. - **Bug fixes** found during review and applied across all affected adapters: - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0) - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison) - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`) ## Test plan - [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`) - [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406 - [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/62
2026-06-01 03:31:18 +00:00
import { getParticipantSimulatorInputs } from "../simulator";
const mockDb = vi.hoisted(() => ({
query: {
seasonParticipants: { findMany: vi.fn() },
seasonParticipantSimulatorInputs: { findMany: vi.fn() },
seasonParticipantExpectedValues: { findMany: vi.fn() },
},
}));
vi.mock("~/database/context", () => ({
database: () => mockDb,
}));
describe("simulator input model", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not treat previously generated ratings as direct rating inputs", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "direct-rating" },
{ id: "generated-rating" },
{ id: "legacy-direct-rating" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "direct-rating",
sourceOdds: 750,
sourceElo: null,
worldRanking: null,
rating: "31.2500",
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { ratingMethod: "direct" },
},
{
participantId: "generated-rating",
sourceOdds: 750,
sourceElo: null,
worldRanking: null,
rating: "35.0000",
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { ratingMethod: "sourceOdds" },
},
{
participantId: "legacy-direct-rating",
sourceOdds: null,
sourceElo: null,
worldRanking: null,
rating: "27.5000",
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: null,
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("direct-rating")?.rating).toBe(31.25);
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
});
Fix futures odds being ignored when stale Elo exists When an admin entered futures (preseason) odds for a season that already had Elo ratings stored, the simulator kept using the old Elo and silently ignored the new odds. This affected any Elo-based simulator (e.g. NHL). Root cause: resolveSourceElos() ranks a direct sourceElo above the sourceOdds -> convertFuturesToElo branch, but batchSaveFuturesOddsForSimulator() only cleared the bracket-seeding `rating`/`ratingMethod` — never the stale `sourceElo`/`sourceEloMethod`. A manually entered Elo (method "direct") is not treated as generated, so it survived and short-circuited the resolver. Fix: - batchSaveFuturesOddsForSimulator now also nulls sourceElo and strips sourceEloMethod (both the pre-update and upsert-conflict paths), so the existing futures -> Elo conversion drives the run. - resolveSourceElos' sourceOdds branch now guards for >= 2 participants (mirroring resolveRatings), so a lone-odds season falls through to the configured missing-Elo strategy instead of getting a flat ~1500. - batchSaveSourceOdds clears the legacy EV sourceElo and marks source as futures_odds so the elo-ratings page won't resurrect a stale rating. Adds unit coverage for odds-derived Elo, the single-participant guard, the post-clear regression, generated-vs-direct sourceElo suppression, and the new clearing behavior in batchSaveFuturesOddsForSimulator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNfUEd9RzD3zm84oLHBHUH
2026-06-25 17:43:31 +00:00
it("keeps direct sourceElo but suppresses generated sourceElo", async () => {
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "direct-elo" },
{ id: "generated-elo" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "direct-elo",
sourceOdds: null,
sourceElo: 1600,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "direct" },
},
{
participantId: "generated-elo",
sourceOdds: 750,
sourceElo: 1480,
worldRanking: null,
rating: null,
projectedWins: null,
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "sourceOdds" },
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
});
});