Participants eliminated in a non-scoring bracket round (e.g. Round of 16 in a 16-team bracket) receive finalPosition=0 and isPartialScore=false. The previous calculateTeamScore loop only entered participantsCompleted++ when finalPosition > 0, so these participants were silently treated as still "remaining" in the standings table — causing e.g. a team with one eliminated participant to show "25 remaining" instead of "24 remaining". Fix: add an else-if branch that increments participantsCompleted for any finalized result (isPartialScore=false) regardless of finalPosition value. The condition is intentionally broader than `=== 0` to also cover the theoretical case of finalPosition=null with isPartialScore=false. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 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)
|
|
// must NOT be counted as completed or appear in placement tiebreakers.
|
|
|
|
it("partial-score participant contributes floor to totalPoints but not to placementCounts or 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(0); // no placement badge until finalized
|
|
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 only; finalized counts everywhere", async () => {
|
|
// p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++
|
|
// p2: partial pos 5 (floor 20) → totalPoints +20, no placement, 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(0);
|
|
});
|
|
});
|