A review of the previous two commits found five defects in the new ledger writer and backfill, plus one pre-existing scoring bug the refactor exposed. season_standings ties were never split. processSeasonStandings deliberately writes the same finalPosition to every driver in a tied group -- its comment says "the scoring system will handle averaging" -- but no path ever did, so two drivers tied for 3rd each banked the full 50 instead of the published 45. This predates the tie-split work; the original cascade had only bracket and qualifying_points arms. Introduce usesSharedPlacementSplit as the single definition of which patterns record ties as a repeated placement, and route both calculatePickPoints and every caller-side gate through it. The caller gates matter as much as the helper: a gate left hardcoded to qualifying_points silently passes a tie count of 1, which reads as "no tie" and makes the fix inert. The ledger anchor picked the wrong event. Ordering on completedAt with no isComplete filter ranked never-completed events first, because drizzle's desc() emits a bare desc and Postgres orders DESC as NULLS FIRST. Restrict to completed events and order explicitly with NULLS LAST plus a stable tiebreak. The anchor is also no longer load-bearing for idempotence: stale event-level rows for the sports season are cleared before writing, so a re-run whose anchor moved replaces rather than duplicates. A ledger failure could abort finalization. The call sat unguarded after the season was already marked completed, so a throw in any of its queries would skip the standings recalculation and the Discord notification. Guard both call sites the way the probability refresh directly below already is. Rows could be mislabelled permanently. The backfill passed no eventName, and the upsert never rewrote scoringEventName. Derive the label from the scoring pattern inside the writer so omitting it is impossible, and refresh it on conflict so existing rows can be repaired. The backfill damaged unrelated leagues. recalculateStandings rewrites previousRank, so sweeping every season wiped rank-movement arrows league wide, including leagues holding no tie at all. Scope it to seasons drafting from a sports season that actually contains a tied placement, and correct the docblock that called it a pure recompute. Also drops the inert Number.EPSILON guard from calculateAveragedPoints (EPSILON is below the ULP for any value >= 2, and integer averages landing on .5 are exactly representable) and extracts countSharedPlacements so the ledger writer stops re-querying rows it already holds. Every fix is covered by a test confirmed to fail when that fix alone is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
269 lines
9.4 KiB
TypeScript
269 lines
9.4 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("splits earnedPoints for tied season_standings placements", async () => {
|
|
// Guards the caller-side gate: this collection loop decides which sports
|
|
// seasons get a tie count. Gating it on qualifying_points alone silently
|
|
// passes tiedParticipants: 1 here, which reads as "no tie" and awards the
|
|
// full 50 instead of the split 45.
|
|
const db = makeDb({
|
|
picks: [makePick({ id: "p-1", sportsSeasonId: "ss-1", scoringPattern: "season_standings", finalPosition: 3 })],
|
|
seasonParticipantResults: [
|
|
{ sportsSeasonId: "ss-1", finalPosition: 3 },
|
|
{ sportsSeasonId: "ss-1", finalPosition: 3 },
|
|
],
|
|
});
|
|
|
|
const result = await getDraftedParticipantsWithPoints("team-1", "season-1", db);
|
|
|
|
expect(result.get("ss-1")?.[0]).toMatchObject({
|
|
earnedPoints: 45, // (50 + 40) / 2
|
|
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();
|
|
});
|
|
});
|