2026-03-17 14:00:32 -07:00
|
|
|
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(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
import { calculateTeamProjectedScore, calculateTeamScore } from "../scoring-calculator";
|
2026-03-17 14:00:32 -07:00
|
|
|
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 */
|
2026-05-17 21:58:53 -07:00
|
|
|
function makeDb(
|
|
|
|
|
picks: ReturnType<typeof makePick>[],
|
|
|
|
|
seasonResults = picks.flatMap((pick) =>
|
|
|
|
|
pick.participant.results
|
|
|
|
|
.filter((result) => result.finalPosition !== null)
|
|
|
|
|
.map((result) => ({
|
|
|
|
|
sportsSeasonId: pick.participant.sportsSeasonId,
|
|
|
|
|
finalPosition: result.finalPosition,
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-03-17 14:00:32 -07:00
|
|
|
return {
|
|
|
|
|
query: {
|
|
|
|
|
seasons: {
|
|
|
|
|
findFirst: vi.fn().mockResolvedValue(SCORING),
|
|
|
|
|
},
|
|
|
|
|
draftPicks: {
|
|
|
|
|
findMany: vi.fn().mockResolvedValue(picks),
|
|
|
|
|
},
|
|
|
|
|
scoringEvents: {
|
|
|
|
|
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
|
|
|
|
},
|
2026-05-17 21:58:53 -07:00
|
|
|
seasonParticipantResults: {
|
|
|
|
|
findMany: vi.fn().mockResolvedValue(seasonResults),
|
|
|
|
|
},
|
2026-03-17 14:00:32 -07:00
|
|
|
},
|
|
|
|
|
} 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
|
|
|
|
|
});
|
2026-05-17 21:58:53 -07:00
|
|
|
|
|
|
|
|
it("splits finalized QP tie points by shared fantasy slots", async () => {
|
|
|
|
|
const db = makeDb(
|
|
|
|
|
[
|
|
|
|
|
makePick("p1", "ss1", "qualifying_points", { finalPosition: 2, isPartialScore: false }),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
{ sportsSeasonId: "ss1", finalPosition: 2 },
|
|
|
|
|
{ sportsSeasonId: "ss1", finalPosition: 2 },
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await calculateTeamProjectedScore("team1", "season1", db);
|
|
|
|
|
|
|
|
|
|
expect(result.actualPoints).toBe(60); // (70 + 50) / 2
|
|
|
|
|
expect(result.projectedPoints).toBe(60);
|
|
|
|
|
});
|
2026-03-17 14:00:32 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 14:34:09 -07:00
|
|
|
describe("mixed team (calculateTeamProjectedScore)", () => {
|
2026-03-17 14:00:32 -07:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-17 14:34:09 -07:00
|
|
|
|
|
|
|
|
describe("calculateTeamScore", () => {
|
|
|
|
|
// calculateTeamScore writes totalPoints, placementCounts, and participantsCompleted
|
|
|
|
|
// to the teamStandings table. Partial-score participants (still alive in bracket)
|
2026-03-29 16:59:13 -07:00
|
|
|
// count toward placementCounts using their current position (best available signal),
|
|
|
|
|
// but must NOT be counted as completed — they're still in progress.
|
2026-03-17 14:34:09 -07:00
|
|
|
|
2026-03-29 16:59:13 -07:00
|
|
|
it("partial-score participant contributes floor to totalPoints and placementCounts, but not participantsCompleted", async () => {
|
2026-03-17 14:34:09 -07:00
|
|
|
// 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
|
2026-03-29 16:59:13 -07:00
|
|
|
expect(result.placementCounts[5]).toBe(1); // current position counts toward tiebreaker
|
2026-03-17 14:34:09 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 15:02:19 -07:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 16:59:13 -07:00
|
|
|
it("mixed team: partial counts in totalPoints and placementCounts; finalized also counts as completed", async () => {
|
2026-03-17 14:34:09 -07:00
|
|
|
// p1: finalized 1st → totalPoints +100, placementCounts[1]++, completed++
|
2026-03-29 16:59:13 -07:00
|
|
|
// p2: partial pos 5 (floor 20) → totalPoints +20, placementCounts[5]++, not completed
|
2026-03-17 14:34:09 -07:00
|
|
|
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);
|
2026-03-29 16:59:13 -07:00
|
|
|
expect(result.placementCounts[5]).toBe(1); // partial position counts toward tiebreaker
|
2026-03-17 14:34:09 -07:00
|
|
|
});
|
2026-05-17 21:58:53 -07:00
|
|
|
|
|
|
|
|
it("splits finalized QP tie points across the 8th-place cutoff", async () => {
|
|
|
|
|
const db = makeDb(
|
|
|
|
|
[
|
|
|
|
|
makePick("p1", "ss1", "qualifying_points", { finalPosition: 8, isPartialScore: false }),
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
{ sportsSeasonId: "ss1", finalPosition: 8 },
|
|
|
|
|
{ sportsSeasonId: "ss1", finalPosition: 8 },
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await calculateTeamScore("team1", "season1", db);
|
|
|
|
|
|
Unify tie-split point math across every screen
A participant tied for a scoring placement splits the combined points of
the tied positions. Four code paths computed a pick's points, each
re-implementing the same bracket/qualifying_points/default cascade, and
two of them omitted the qualifying_points arm entirely. A golfer tied for
8th was therefore worth the full 15 points on the team page and draft
board but the split 7.5 in the standings, so a team read 225 on one
screen and 218 on another.
Collapse the cascade into a single calculatePickPoints helper and route
all six call sites through it, backed by one shared getSharedPlacementCounts
loader replacing the two separate tie-count queries. Tie counts span every
participant in the sports season, not just drafted ones, since an
undrafted tie partner still halves the award.
Also round split awards to the nearest whole point in
calculateAveragedPoints. The /rules page states ties are "combined and
split equally among them, rounded to the nearest whole point", and its own
worked example rounds 18.33 down to 18, so this is nearest rather than
ceiling. Season point values are integer columns, making this averaging
the only source of fractional points; rounding here means the standings'
218 is now correct by construction rather than a display artifact, and
per-pick values visibly sum to the team total.
Existing assertions encoding the unrounded results are updated, and the
rules page's two published examples are asserted directly so the code and
the published rule cannot drift apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-07 07:29:37 +00:00
|
|
|
expect(result.totalPoints).toBe(8); // (15 + 0) / 2 = 7.5 → 8
|
2026-05-17 21:58:53 -07:00
|
|
|
expect(result.participantsCompleted).toBe(1);
|
|
|
|
|
expect(result.placementCounts[8]).toBe(1);
|
|
|
|
|
});
|
2026-03-17 14:34:09 -07:00
|
|
|
});
|