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
178 lines
6 KiB
TypeScript
178 lines
6 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("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,
|
|
}),
|
|
];
|
|
const undrafted = [{ sportsSeasonId: "ss-1", finalPosition: 8 }];
|
|
|
|
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);
|
|
expect(breakdown?.actualPoints).toBe(128); // 8 (T8) + 100 (1st) + 20 (T5-8)
|
|
});
|
|
});
|