brackt/app/models/__tests__/standings-breakdown.test.ts
Claude a143df51f6
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.

The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.

A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.

Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.

The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.

Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.

Every fix is covered by a test confirmed to fail when that fix alone is
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00

214 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Regression tests for getTeamScoreBreakdown — the team detail page's points.
*
* This path used to branch only on `isBracket`, so a qualifying_points
* participant tied for a placement was awarded the FULL placement value here
* while calculateTeamScore (which writes team_standings) awarded the split. A
* team therefore read 225 on its team page and 218 in the standings.
*
* The load-bearing assertion is the last one: the breakdown's actualPoints must
* equal calculateTeamScore's totalPoints for the same fixture. Anything that
* makes these two disagree reintroduces the bug.
*/
vi.mock("~/services/ev-calculator", () => ({
calculateEV: vi.fn(),
}));
vi.mock("../participant-expected-value", () => ({
getParticipantEV: vi.fn(),
}));
import { getTeamScoreBreakdown } from "../standings";
import { calculateTeamScore } from "../scoring-calculator";
import { getParticipantEV } from "../participant-expected-value";
const SCORING = {
pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40,
pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15,
};
function makePick(
participantId: string,
name: string,
scoringPattern: string,
finalPosition: number | null,
{ sportsSeasonId = "ss-1", isPartialScore = false, pickNumber = 1 } = {}
) {
return {
pickNumber,
round: 1,
participant: {
id: participantId,
name,
sportsSeasonId,
results: finalPosition === null ? [] : [{ finalPosition, isPartialScore }],
sportsSeason: { scoringPattern, sport: { name: "Golf" } },
},
};
}
/**
* @param extraResults results for participants NOBODY drafted. They still count
* toward a tie span, which is exactly why the tie query cannot be narrowed to
* drafted participants.
*/
function makeDb(
picks: ReturnType<typeof makePick>[],
extraResults: { sportsSeasonId: string; finalPosition: number }[] = []
) {
const seasonResults = [
...picks.flatMap((pick) =>
pick.participant.results
.filter((r) => r.finalPosition !== null)
.map((r) => ({
sportsSeasonId: pick.participant.sportsSeasonId,
finalPosition: r.finalPosition,
}))
),
...extraResults,
];
return {
query: {
seasons: { findFirst: vi.fn().mockResolvedValue(SCORING) },
draftPicks: { findMany: vi.fn().mockResolvedValue(picks) },
scoringEvents: { findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }) },
seasonParticipantResults: { findMany: vi.fn().mockResolvedValue(seasonResults) },
teams: { findFirst: vi.fn().mockResolvedValue({ id: "team-1", name: "Mike" }) },
},
} as any;
}
describe("getTeamScoreBreakdown", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getParticipantEV).mockResolvedValue(null as any);
});
it("splits a tied qualifying_points placement", async () => {
// Rahm tied for 8th with an undrafted player: (15 + 0) / 2 = 7.5 → 8.
const db = makeDb(
[makePick("rahm", "Jon Rahm", "qualifying_points", 8)],
[{ sportsSeasonId: "ss-1", finalPosition: 8 }]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({
participant: expect.objectContaining({ name: "Jon Rahm" }),
finalPosition: 8,
points: 8,
});
expect(breakdown?.actualPoints).toBe(8);
});
it("awards the full placement value when untied", async () => {
const db = makeDb([makePick("rahm", "Jon Rahm", "qualifying_points", 8)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(15);
expect(breakdown?.actualPoints).toBe(15);
});
it("counts undrafted participants in the tie span", async () => {
// Three-way tie for 6th where two of the three went undrafted:
// (25 + 15 + 15) / 3 = 18.33 → 18, the rules page's published example.
const db = makeDb(
[makePick("golfer", "Tied Golfer", "qualifying_points", 6)],
[
{ sportsSeasonId: "ss-1", finalPosition: 6 },
{ sportsSeasonId: "ss-1", finalPosition: 6 },
]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(18);
});
it("splits a tied season_standings placement", async () => {
// processSeasonStandings writes the same finalPosition to every tied driver
// and leaves the split to scoring time. Two tied at 3rd share 3rd + 4th.
const db = makeDb(
[makePick("driver", "Tied Driver", "season_standings", 3)],
[{ sportsSeasonId: "ss-1", finalPosition: 3 }]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(45); // (50 + 40) / 2
});
it("splits a four-way season_standings tie across the 5-8 tier", async () => {
const db = makeDb(
[makePick("driver", "Tied Driver", "season_standings", 5)],
[
{ sportsSeasonId: "ss-1", finalPosition: 5 },
{ sportsSeasonId: "ss-1", finalPosition: 5 },
{ sportsSeasonId: "ss-1", finalPosition: 5 },
]
);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0].points).toBe(20); // (25 + 25 + 15 + 15) / 4
});
it("still averages bracket tiers", async () => {
const db = makeDb([makePick("team-a", "Team A", "playoff_bracket", 5)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
// Four QF losers share 5th-8th: (25 + 25 + 15 + 15) / 4 = 20
expect(breakdown?.picks[0].points).toBe(20);
});
it("leaves picks without a result at zero points", async () => {
const db = makeDb([makePick("pending", "Pending Golfer", "qualifying_points", null)]);
const breakdown = await getTeamScoreBreakdown("team-1", "season-1", db);
expect(breakdown?.picks[0]).toMatchObject({ points: 0, isComplete: false });
expect(breakdown?.actualPoints).toBe(0);
});
it("agrees with calculateTeamScore on the same roster", async () => {
// The reported bug in miniature: a tied golfer plus untied picks. Both
// functions read the same fixture, so any divergence is a real regression.
const roster = () => [
makePick("rahm", "Jon Rahm", "qualifying_points", 8, { pickNumber: 1 }),
makePick("scheffler", "Scottie Scheffler", "qualifying_points", 1, { pickNumber: 2 }),
makePick("team-a", "Team A", "playoff_bracket", 5, {
sportsSeasonId: "ss-2",
pickNumber: 3,
}),
makePick("driver", "Tied Driver", "season_standings", 3, {
sportsSeasonId: "ss-3",
pickNumber: 4,
}),
];
const undrafted = [
{ sportsSeasonId: "ss-1", finalPosition: 8 },
{ sportsSeasonId: "ss-3", finalPosition: 3 },
];
const breakdown = await getTeamScoreBreakdown(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
const score = await calculateTeamScore(
"team-1",
"season-1",
makeDb(roster(), undrafted)
);
expect(breakdown?.actualPoints).toBe(score.totalPoints);
// 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3)
expect(breakdown?.actualPoints).toBe(173);
});
});