brackt/app/models/__tests__/team-projected-score.test.ts
Chris Parsons c604dc9bc5
Fix tiebreaker incorrectly separating tied teams with pending participants, fixes #244 (#245)
Placement counts (used to break points ties) previously only included
fully-finalized participants. A team whose events all resolved first would
accumulate real placement counts while tied teams with pending events had
zeros, causing the tie to be broken in favour of whichever team finished
scoring first rather than on merit.

Fix: count a participant's current position toward the tiebreaker regardless
of isPartialScore. Their position is the best available signal; if it changes,
recalculateStandings reruns and ranks update naturally.

Also removes a redundant bounds check (finalPosition > 0 was already
asserted by the outer if-condition).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:59:13 -07:00

303 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Tests for calculateTeamProjectedScore
*
* Key behaviours under test:
* - Finalized bracket participants use calculateBracketPoints (averaged), not raw points
* - Partial-score participants (still alive) contribute floor points to actualPoints
* and their incremental EV (EV - floor) to projectedPoints
* - EV < floor is clamped to 0 extra EV (Math.max guard)
* - Non-bracket finalized participants use calculateFantasyPoints (raw)
* - Pending participants (no result) contribute full EV to projectedPoints
*/
vi.mock("~/services/ev-calculator", () => ({
calculateEV: vi.fn(),
}));
vi.mock("../participant-expected-value", () => ({
getParticipantEV: vi.fn(),
}));
import { calculateTeamProjectedScore, calculateTeamScore } from "../scoring-calculator";
import { calculateEV } from "~/services/ev-calculator";
import { getParticipantEV } from "../participant-expected-value";
// Standard scoring rules used across tests
const SCORING = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
// Derived bracket averages for assertions
const AVG_5_TO_8 = (25 + 25 + 15 + 15) / 4; // 20
const AVG_3_TO_4 = (50 + 40) / 2; // 45
/** Build a minimal pick object matching what the query returns */
function makePick(
participantId: string,
sportsSeasonId: string,
scoringPattern: string,
result: { finalPosition: number | null; isPartialScore: boolean } | null
) {
return {
pickNumber: 1,
round: 1,
participant: {
id: participantId,
sportsSeasonId,
results: result ? [result] : [],
sportsSeason: { scoringPattern },
},
};
}
/** Build a mock DB that returns the given picks and no bracketTemplateId */
function makeDb(picks: ReturnType<typeof makePick>[]) {
return {
query: {
seasons: {
findFirst: vi.fn().mockResolvedValue(SCORING),
},
draftPicks: {
findMany: vi.fn().mockResolvedValue(picks),
},
scoringEvents: {
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
},
},
} as any;
}
const DUMMY_EV_ROW = {
probFirst: "0.1",
probSecond: "0.1",
probThird: "0.1",
probFourth: "0.1",
probFifth: "0.1",
probSixth: "0.1",
probSeventh: "0.2",
probEighth: "0.2",
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
describe("calculateTeamProjectedScore", () => {
describe("finalized bracket participant", () => {
it("uses bracket-averaged points, not raw position value", async () => {
// QF loser: finalPosition=5, isPartialScore=false
// calculateBracketPoints(5) = avg(5,6,7,8) = 20, NOT raw pointsFor5th = 25
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(null);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_5_TO_8); // 20, not 25
expect(result.projectedPoints).toBe(AVG_5_TO_8);
expect(result.participantsFinished).toBe(1);
expect(result.participantsRemaining).toBe(0);
});
it("averages 3rd/4th for bracket semi-final losers", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: false }),
]);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_3_TO_4); // 45
expect(result.projectedPoints).toBe(AVG_3_TO_4);
});
it("uses raw points for non-bracket (e.g. season_standings) finalized participant", async () => {
// Non-bracket 3rd place should use pointsFor3rd = 50, not averaged
const db = makeDb([
makePick("p1", "ss1", "season_standings", { finalPosition: 3, isPartialScore: false }),
]);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(50); // raw pointsFor3rd
});
});
describe("partial-score bracket participant (still alive)", () => {
it("floor counts as actual; incremental EV above floor counts toward projected", async () => {
// Won QF: finalPosition=3 (floor = avg[3,4] = 45), isPartialScore=true
// EV = 60 → incremental = 60 - 45 = 15 → projected = 45 + 15 = 60
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 3, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(60);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_3_TO_4); // 45 floor
expect(result.projectedPoints).toBe(60); // 45 + (60 - 45)
// Partial participants are NOT counted as finished — they're still competing
expect(result.participantsFinished).toBe(0);
expect(result.participantsRemaining).toBe(1);
});
it("clamps EV contribution to 0 when EV drops below floor (Math.max guard)", async () => {
// Won SF: finalPosition=2 (floor = 70), isPartialScore=true
// EV = 50 (below floor) → incremental = max(0, 50-70) = 0 → projected = 70
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 2, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(50); // EV < floor
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(70); // floor = 2nd place = 70
expect(result.projectedPoints).toBe(70); // no negative EV contribution
});
it("partial-score floor for non-scoring round (pos 5) contributes 20 pts", async () => {
// Won Round of 16 (non-scoring): finalPosition=5, isPartialScore=true → floor=20
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(35);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(AVG_5_TO_8); // 20
expect(result.projectedPoints).toBe(35); // 20 + (35 - 20)
});
});
describe("pending participant (no result)", () => {
it("contributes 0 to actualPoints and full EV to projectedPoints", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV).mockResolvedValue(DUMMY_EV_ROW);
vi.mocked(calculateEV).mockReturnValue(30);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(0);
expect(result.projectedPoints).toBe(30);
expect(result.participantsFinished).toBe(0);
expect(result.participantsRemaining).toBe(1);
});
it("contributes 0 projected when no EV data exists", async () => {
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV).mockResolvedValue(null);
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(0);
expect(result.projectedPoints).toBe(0);
});
});
describe("mixed team (calculateTeamProjectedScore)", () => {
it("correctly combines finalized, partial, and pending participants", async () => {
// Pick 1: bracket finalized 1st place → actual=100, no EV
// Pick 2: bracket partial pos 5 (floor=20) → actual=20, EV=35 → +15 projected
// Pick 3: pending → actual=0, EV=25 → +25 projected
// Total: actual=120, projected=120+15+25=160
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
makePick("p3", "ss1", "playoff_bracket", null),
]);
vi.mocked(getParticipantEV)
.mockResolvedValueOnce(DUMMY_EV_ROW) // p2 — partial
.mockResolvedValueOnce(DUMMY_EV_ROW); // p3 — pending
vi.mocked(calculateEV)
.mockReturnValueOnce(35) // p2 EV
.mockReturnValueOnce(25); // p3 EV
const result = await calculateTeamProjectedScore("team1", "season1", db);
expect(result.actualPoints).toBe(120); // 100 + 20
expect(result.projectedPoints).toBe(160); // 120 + 15 + 25
expect(result.participantsFinished).toBe(1);
expect(result.participantsRemaining).toBe(2); // partial + pending
});
});
});
describe("calculateTeamScore", () => {
// calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted
// to the teamStandings table. Partial-score participants (still alive in bracket)
// count toward placementCounts using their current position (best available signal),
// but must NOT be counted as completed — they're still in progress.
it("partial-score participant contributes floor to totalPoints and placementCounts, but not participantsCompleted", async () => {
// QF survivor: finalPosition=5 (floor=20), isPartialScore=true
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 floor in score
expect(result.participantsCompleted).toBe(0); // still alive → not completed
expect(result.placementCounts[5]).toBe(1); // current position counts toward tiebreaker
expect(result.participantsTotal).toBe(1);
});
it("finalized bracket participant counts toward placementCounts and participantsCompleted", async () => {
// QF loser: finalPosition=5, isPartialScore=false
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: false }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(AVG_5_TO_8); // 20 bracket-averaged
expect(result.participantsCompleted).toBe(1);
expect(result.placementCounts[5]).toBe(1);
});
it("eliminated in non-scoring round (finalPosition=0) counts as completed", async () => {
// Round of 16 loser: 0 pts, fully eliminated (isPartialScore=false)
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 0, isPartialScore: false }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(0); // no points for pre-bracket elimination
expect(result.participantsCompleted).toBe(1); // done — should not count as remaining
expect(result.participantsTotal).toBe(1);
expect(result.placementCounts[1]).toBe(0); // no placement badge
});
it("mixed team: partial counts in totalPoints and placementCounts; finalized also counts as completed", async () => {
// p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++
// p2: partial pos 5 (floor 20) → totalPoints +20, placementCounts[5]++, not completed
const db = makeDb([
makePick("p1", "ss1", "playoff_bracket", { finalPosition: 1, isPartialScore: false }),
makePick("p2", "ss1", "playoff_bracket", { finalPosition: 5, isPartialScore: true }),
]);
const result = await calculateTeamScore("team1", "season1", db);
expect(result.totalPoints).toBe(100 + AVG_5_TO_8); // 120
expect(result.participantsCompleted).toBe(1);
expect(result.participantsTotal).toBe(2);
expect(result.placementCounts[1]).toBe(1);
expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
});
});