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
248 lines
8.5 KiB
TypeScript
248 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// ── DB mock ────────────────────────────────────────────────────────────────
|
|
|
|
const DEFAULT_SEASON = {
|
|
id: "season-1",
|
|
pointsFor1st: 100, pointsFor2nd: 75, pointsFor3rd: 50,
|
|
pointsFor4th: 40, pointsFor5th: 30, pointsFor6th: 20,
|
|
pointsFor7th: 10, pointsFor8th: 5,
|
|
};
|
|
|
|
type PickRow = {
|
|
participant: {
|
|
id: string;
|
|
name: string;
|
|
sportsSeasonId: string;
|
|
sportsSeason: { id: string; scoringPattern: string };
|
|
results: { finalPosition: number | null }[];
|
|
};
|
|
};
|
|
|
|
interface MakeDbOpts {
|
|
season?: typeof DEFAULT_SEASON | null;
|
|
picks?: PickRow[];
|
|
scoringEvents?: { sportsSeasonId: string; bracketTemplateId: string | null }[];
|
|
qualifyingTotals?: { participantId: string; totalQualifyingPoints: string }[];
|
|
seasonParticipantResults?: { sportsSeasonId: string; finalPosition: number | null }[];
|
|
}
|
|
|
|
function makeDb(opts: MakeDbOpts = {}) {
|
|
const {
|
|
season = DEFAULT_SEASON,
|
|
picks = [],
|
|
scoringEvents = [],
|
|
qualifyingTotals = [],
|
|
seasonParticipantResults = [],
|
|
} = opts;
|
|
|
|
return {
|
|
query: {
|
|
seasons: {
|
|
findFirst: vi.fn().mockResolvedValue(season),
|
|
},
|
|
draftPicks: {
|
|
findMany: vi.fn().mockResolvedValue(picks),
|
|
},
|
|
scoringEvents: {
|
|
findMany: vi.fn().mockResolvedValue(scoringEvents),
|
|
},
|
|
seasonParticipantQualifyingTotals: {
|
|
findMany: vi.fn().mockResolvedValue(qualifyingTotals),
|
|
},
|
|
seasonParticipantResults: {
|
|
findMany: vi.fn().mockResolvedValue(seasonParticipantResults),
|
|
},
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
import { getDraftedParticipantsWithPoints } from "../draft-pick";
|
|
|
|
function makePick(overrides: {
|
|
id?: string;
|
|
name?: string;
|
|
sportsSeasonId?: string;
|
|
scoringPattern?: string;
|
|
finalPosition?: number | null;
|
|
}): PickRow {
|
|
const {
|
|
id = "p-1",
|
|
name = "Participant One",
|
|
sportsSeasonId = "ss-1",
|
|
scoringPattern = "playoff_bracket",
|
|
finalPosition = null,
|
|
} = overrides;
|
|
return {
|
|
participant: {
|
|
id,
|
|
name,
|
|
sportsSeasonId,
|
|
sportsSeason: { id: sportsSeasonId, scoringPattern },
|
|
results: finalPosition !== null ? [{ finalPosition }] : [],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("getDraftedParticipantsWithPoints", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("returns empty map when season not found", async () => {
|
|
const db = makeDb({ season: null });
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
|
|
it("returns empty map when no picks", async () => {
|
|
const db = makeDb({ picks: [] });
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
expect(result.size).toBe(0);
|
|
});
|
|
|
|
describe("playoff_bracket pattern", () => {
|
|
it("returns earnedPoints based on finalPosition and scoring rules", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: 1 })],
|
|
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
const ssEntry = result.get("ss-1");
|
|
expect(ssEntry).toBeDefined();
|
|
expect(ssEntry?.[0]).toMatchObject({
|
|
id: "p-1",
|
|
name: "Participant One",
|
|
earnedPoints: 100, // pointsFor1st
|
|
currentQP: null,
|
|
});
|
|
});
|
|
|
|
it("returns null earnedPoints when no finalPosition yet", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
|
|
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
|
|
});
|
|
|
|
it("groups multiple picks by sportsSeasonId", async () => {
|
|
const db = makeDb({
|
|
picks: [
|
|
makePick({ id: "p-1", name: "Alice", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 1 }),
|
|
makePick({ id: "p-2", name: "Bob", sportsSeasonId: "ss-1", scoringPattern: "playoff_bracket", finalPosition: 2 }),
|
|
makePick({ id: "p-3", name: "Carol", sportsSeasonId: "ss-2", scoringPattern: "playoff_bracket", finalPosition: 1 }),
|
|
],
|
|
scoringEvents: [
|
|
{ sportsSeasonId: "ss-1", bracketTemplateId: null },
|
|
{ sportsSeasonId: "ss-2", bracketTemplateId: null },
|
|
],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")).toHaveLength(2);
|
|
expect(result.get("ss-2")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("qualifying_points pattern — active (no finalPosition)", () => {
|
|
it("returns currentQP from participantQualifyingTotals", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
|
|
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "42.5" }],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
|
earnedPoints: null,
|
|
currentQP: 42.5,
|
|
});
|
|
});
|
|
|
|
it("returns null currentQP when participant has no QP total row", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: null })],
|
|
qualifyingTotals: [],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({ earnedPoints: null, currentQP: null });
|
|
});
|
|
});
|
|
|
|
describe("qualifying_points pattern — finalized (has finalPosition)", () => {
|
|
it("returns earnedPoints via calculateFantasyPoints when finalPosition set", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: 2 })],
|
|
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "99" }],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
|
earnedPoints: 75, // pointsFor2nd
|
|
currentQP: null,
|
|
});
|
|
});
|
|
|
|
it("splits earnedPoints for tied QP placements", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "qualifying_points", finalPosition: 2 })],
|
|
qualifyingTotals: [{ participantId: "p-1", totalQualifyingPoints: "99" }],
|
|
seasonParticipantResults: [
|
|
{ sportsSeasonId: "ss-1", finalPosition: 2 },
|
|
{ sportsSeasonId: "ss-1", finalPosition: 2 },
|
|
],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
|
earnedPoints: 63, // (75 + 50) / 2 = 62.5 → 63
|
|
currentQP: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("season_standings pattern", () => {
|
|
it("returns earnedPoints via calculateFantasyPoints", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
|
earnedPoints: 50, // pointsFor3rd
|
|
currentQP: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not query scoringEvents when no playoff_bracket picks", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ scoringPattern: "season_standings", finalPosition: 1 })],
|
|
});
|
|
|
|
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(db.query.scoringEvents.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not query participantQualifyingTotals when no qualifying_points picks", async () => {
|
|
const db = makeDb({
|
|
picks: [makePick({ scoringPattern: "playoff_bracket", finalPosition: null })],
|
|
scoringEvents: [{ sportsSeasonId: "ss-1", bracketTemplateId: null }],
|
|
});
|
|
|
|
await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(db.query.seasonParticipantQualifyingTotals.findMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|