brackt/app/models/__tests__/bracket-template-lookup.test.ts
Claude 7ec17e417d
Fix EV reporting 20 pts for both LLWS 5-6 and 7-8 locked tiers
After an LLWS simulation, a team locked into the 5th-6th tier and one
locked into the 7th-8th tier both showed 20 points EV. They should show
25 and 15.

The simulator and calculateEV were both right. A team locked into the
5-6 tier comes out of llws-simulator at probFifth = probSixth = 0.5, and
against DEFAULT_SCORING_RULES (100/70/50/40/25/25/15/15) that is 25 —
matching calculateBracketPoints, which already knows llws_20 splits 5-8
into two tiers. The Admin -> Expected Values page just wasn't using that
table. It hardcoded its own stale copy:

  const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;

0.5*20 + 0.5*20 = 20 for either tier.

It is not LLWS-specific. Four places carried that same stale table, and
it stayed invisible because a standard single-elimination bracket puts
all four quarterfinal losers in one tier worth avg(25,25,15,15) = 20 —
the same number. It only diverges for the templates that split 5-8
(llws_20, afl_10) and those with a distinct 3rd/4th (llws_20, fifa_48,
where 45/45 should be 50/40). Two of the four *persist* EVs computed
that way, so the wrong values reached the database:

  - expected-values.tsx      displayed EV, the total, and the sort order
  - expected-values.server   manual EV entry, written to expected_value
  - golf-skills.tsx          simulation EVs + snapshots, written
  - surface-elo.tsx          simulation EVs + snapshots, written

All four now use the shared DEFAULT_SCORING_RULES. probability-updater
had a fourth inline copy with the right values; it is folded in too so
there is one table left. The page's 340 total-EV invariant is unchanged
— both tables sum to 340.

A second path collapses the same two tiers, this time in real fantasy
points. calculateBracketPoints falls back to the flat avg([5,6,7,8])
when bracketTemplateId is null, and four call sites resolved the
template by taking an arbitrary scoringEvents row for the sports season
— unordered, and not filtered to rows that actually carry a template. A
season can own several events (a bracket plus schedule events, or a
re-created bracket beside a stale one), so a null row wins at random and
llws_20 is lost. New getBracketTemplateIdsForSportsSeasons in
models/bracket-template.ts filters to events with a template and takes
the most recent, the same rule llws-simulator uses to pick its bracket
event; standings, calculateTeamScore, calculateTeamProjectedScore and
getDraftedParticipantsWithPoints all go through it.

Tests: evFromProbs pinned to 25 / 15 / 20-for-a-single-5-8-tier and the
340 invariant; the new lookup against a mixed set of events; and two
llws-simulator tests that play out a full U.S. side so a team really is
locked into each tier and must come out at exactly 50/50 across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 16:11:59 +00:00

92 lines
3.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from "vitest";
import {
getBracketTemplateIdForSportsSeason,
getBracketTemplateIdsForSportsSeasons,
} from "../bracket-template";
/**
* A sports season can own several scoring events — a bracket plus schedule events, or a
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
* is not harmless: calculateBracketPoints falls back to the flat 5th8th average when the
* template id is null, so losing "llws_20" makes a team locked into 5th6th and one
* locked into 7th8th both score 20.
*/
/**
* Minimal db stub. Applies the same filter and ordering the real query does, so the
* assertions exercise the helper's row-picking rather than re-stating the query.
*/
function makeDb(
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
) {
const findMany = vi.fn(async () =>
rows
.filter((row) => row.bracketTemplateId !== null)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
);
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
}
describe("getBracketTemplateIdForSportsSeason", () => {
it("ignores a non-bracket event and returns the bracket event's template", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("takes the most recent bracket event when a stale one is still around", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
});
it("returns null when the season has no bracket event", async () => {
const { db } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
]);
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
});
});
describe("getBracketTemplateIdsForSportsSeasons", () => {
it("resolves each season independently in one query", async () => {
const { db, findMany } = makeDb([
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
expect(resolved.get("ss1")).toBe("llws_20");
expect(resolved.get("ss2")).toBe("afl_10");
expect(resolved.get("ss3")).toBeNull();
expect(findMany).toHaveBeenCalledTimes(1);
});
it("gives every requested season an entry so callers can cache the miss", async () => {
const { db } = makeDb([]);
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
expect([...resolved.entries()]).toEqual([
["ss1", null],
["ss2", null],
]);
});
it("does not query at all for an empty season list", async () => {
const { db, findMany } = makeDb([]);
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
expect(findMany).not.toHaveBeenCalled();
});
});