brackt/app/models/__tests__/standings-breakdown.test.ts

215 lines
7.2 KiB
TypeScript
Raw Normal View History

Record final-placement points in the score-events ledger qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
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);
});
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
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
});
Record final-placement points in the score-events ledger qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
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,
}),
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
makePick("driver", "Tied Driver", "season_standings", 3, {
sportsSeasonId: "ss-3",
pickNumber: 4,
}),
];
const undrafted = [
{ sportsSeasonId: "ss-1", finalPosition: 8 },
{ sportsSeasonId: "ss-3", finalPosition: 3 },
Record final-placement points in the score-events ledger qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
];
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);
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
// 8 (golf T8) + 100 (golf 1st) + 20 (bracket T5-8) + 45 (F1 T3)
expect(breakdown?.actualPoints).toBe(173);
Record final-placement points in the score-events ledger qualifying_points (golf, tennis, CS2) and season_standings (F1) award all of their points in one step at finalization, so they produce no per-match deltas. recordMatchScoreEvents is bracket-only and fires from match processing, which meant these sports were never written to team_score_events at all and silently never appeared in Recent Scores. Add recordFinalPlacementScoreEvents, called from finalizeQualifyingPoints and processSeasonStandings. It writes one row per team at the ledger's event-level grain, carrying that team's summed award and every contributing participant, with points from calculatePickPoints so a tied golfer contributes the same split award the standings show. The row is anchored to a real scoring event rather than a null one: the event-level unique index is (teamId, seasonId, scoringEventId) and Postgres treats NULLs as distinct, so a null anchor would duplicate rows on every re-finalization instead of upserting. When no anchor can be resolved the ledger write is skipped, which leaves standings unaffected. Adds regression coverage for the two screens that had diverged — getTeamScoreBreakdown and computeCoronaStates — including an assertion that the team page's actualPoints equals calculateTeamScore's totalPoints for the same roster, and cases proving undrafted participants still count toward a tie span. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:38:26 +00:00
});
});