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

161 lines
5.2 KiB
TypeScript
Raw Permalink 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();
});
Make MLB projected wins actually drive the simulation Entering projected wins for an in-progress MLB season did not behave as expected: the entered numbers came back changed, and the simulation appeared to ignore them in favour of whatever Elo was already stored. Four separate defects were involved. Projections are now stored and shown verbatim. The Elo Ratings page never kept the number typed into it — the field was a display derived from Elo, so a pasted 95 rendered as 95.1 the moment it was applied (wins to Elo rounds to an integer Elo) and drifted again after each run, because a run re-resolves that Elo through the input policy. The loader now reads back the stored projection and the paste flow keeps the pasted value as-is; the derived round-trip survives only as a prefill for seasons that have never had a projection saved. A stale Elo no longer silently outranks a projection. baseEloPriority takes the first available base source, and the simulator page's bulk CSV wrote projectedWins without stamping metadata.sourceEloMethod, so the non-destructive upsert left the old Elo in place as a trusted direct value and it won the race — the projection was stored and then ignored on every run. The CSV path now stamps the flag like the Elo Ratings page does, the metadata upsert merges rather than replaces so a flag-only write keeps unrelated keys, and Base Elo Source is editable per season for the case where a genuine hand-entered Elo should still lose to projections. Projected wins now act as a projected final total. The value was baked into a flat season-long rate (projectedWins / 162) applied to every remaining game, so a team at 60-50 projected for 95 finished around 90.5 and the projection was never reached mid-season. seedingWinRateFor spreads the difference over the games still to play, which is a no-op pre-season where the two rates coincide; projectedWinsWeight blends it back toward the Elo-implied rate. Playoff-parity compression is restored for Elo-rated teams. eloToRDif scaled by RDIF_DIVISOR, making it the exact algebraic inverse of winRateFromRDif, so any team with an Elo skipped the compression every hardcoded-rdif team gets: a 95-win projection became RDif +686 and played playoff games at .586 instead of the documented ~.517. It now scales by SEEDING_RDIF_SCALE, landing at ~+140 alongside the Dodgers' hardcoded +137. Also fixes the preview table's "missing a required input" marker, which flagged every projection-configured participant because a generated Elo or rating is deliberately hidden from getParticipantSimulatorInputs. It now consults the resolved values, so it agrees with readiness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 05:31:15 +00:00
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
// This is what stops a stale Elo from winning the baseEloPriority race. A row
// carrying projectedWins and a projectedWins method flag must surface with a
// null sourceElo, so resolveSourceElos falls through to the projection rather
// than reusing an Elo that was itself derived from an older projection.
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
{ id: "projected" },
{ id: "hand-entered" },
]);
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
{
participantId: "projected",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: { sourceEloMethod: "projectedWins" },
},
{
participantId: "hand-entered",
sourceOdds: null,
sourceElo: 1561,
worldRanking: null,
rating: null,
projectedWins: "95.00",
projectedTablePoints: null,
seed: null,
region: null,
metadata: {},
},
]);
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
const inputs = await getParticipantSimulatorInputs("season-1");
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
// No flag means the admin entered that Elo themselves — it is trusted as direct.
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
});
});