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>
303 lines
12 KiB
TypeScript
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
|
|
});
|
|
});
|