brackt/app/models/__tests__/team-projected-score.test.ts
Chris Parsons 7236c8aefb
Add Discord usernames, fix calculateTeamScore partial-score bug, and add admin re-score action (#160)
**Discord webhook improvements**
- Show team owner username in parentheses in standings (e.g. "1. Alpha FC (christhrowsrocks) — 150 pts")
- Show username in parentheses in match results for drafted participants
  (e.g. "Sporting (christhrowsrocks) def. Bodø/Glimt (apatel)"), omitting
  the parenthesis for the undrafted side
- Username lookup only runs after the webhook URL guard to avoid wasted DB queries
- 4 new tests covering all username display combinations

**Fix calculateTeamScore partial-score counting bug**
- isPartialScore participants were incorrectly counted in participantsCompleted
  and placementCounts, causing standings to show wrong remaining count and
  phantom placement badges (e.g. "5th×1") for still-alive bracket participants
- Floor points still flow into totalPoints (guaranteed value for ranking)
- 3 new unit tests covering partial vs finalized behavior in calculateTeamScore

**Admin Force Re-score action**
- New "Fantasy Standings" card on every sports season admin page with a
  Force Re-score button that triggers recalculateStandings on all linked
  fantasy seasons — useful after data corrections without waiting for a
  new scoring event
- Auth guard added to the action (was missing — layout loader only protects GET)
- Returns a meaningful message when no linked seasons exist
- Intent discriminant on action returns so success banners use actionData.intent
  instead of fragile message.includes() checks

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:34:09 -07:00

288 lines
11 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("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);
});
});