brackt/app/models/__tests__/tiebreaker-logic.test.ts

340 lines
10 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from "vitest";
/**
* Tiebreaker Logic Tests
* Phase 4.1: Test comprehensive tiebreaker scenarios
*
* Tiebreaker order:
* 1. Total points (higher is better)
* 2. Number of 1st place finishes
* 3. Number of 2nd place finishes
* 4. Continue through all placements (3rd, 4th, ..., 8th)
*
* Note: These are unit tests that test the tiebreaker logic in isolation.
* The actual recalculateStandings function is tested through integration tests
* in actual league scenarios.
*/
/**
* Helper function to simulate compareTeamsForRanking logic
* This mirrors the private function in scoring-calculator.ts
*/
function compareTeamsForRanking(
teamA: {
totalPoints: number;
placementCounts: Record<number, number>;
},
teamB: {
totalPoints: number;
placementCounts: Record<number, number>;
}
): number {
// First compare by total points (descending)
if (teamA.totalPoints !== teamB.totalPoints) {
return teamB.totalPoints - teamA.totalPoints;
}
// If points are tied, apply tiebreaker cascade
for (let placement = 1; placement <= 8; placement++) {
const countA = teamA.placementCounts[placement] || 0;
const countB = teamB.placementCounts[placement] || 0;
if (countA !== countB) {
return countB - countA; // More of this placement is better
}
}
// Completely tied
return 0;
}
/**
* Helper function to simulate assignRanks logic
* This mirrors the private function in scoring-calculator.ts
*/
function assignRanks(
teams: Array<{
teamId: string;
totalPoints: number;
placementCounts: Record<number, number>;
}>
): Map<string, number> {
// Sort teams by ranking criteria
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
const sorted = [...teams].toSorted(compareTeamsForRanking);
const ranks = new Map<string, number>();
let currentRank = 1;
for (let i = 0; i < sorted.length; i++) {
const team = sorted[i];
// Check if this team is tied with the previous team
if (i > 0) {
const prevTeam = sorted[i - 1];
const comparison = compareTeamsForRanking(team, prevTeam);
if (comparison !== 0) {
// Not tied, advance rank
currentRank = i + 1;
}
// If comparison === 0, keep the same rank (tie)
}
ranks.set(team.teamId, currentRank);
}
return ranks;
}
describe("Tiebreaker Logic", () => {
describe("Basic Point Sorting", () => {
it("should rank teams by total points descending", () => {
const teams = [
{ teamId: "team-a", totalPoints: 100, placementCounts: {} },
{ teamId: "team-b", totalPoints: 200, placementCounts: {} },
{ teamId: "team-c", totalPoints: 150, placementCounts: {} },
];
const ranks = assignRanks(teams);
expect(ranks.get("team-b")).toBe(1); // Highest points
expect(ranks.get("team-c")).toBe(2);
expect(ranks.get("team-a")).toBe(3); // Lowest points
});
});
describe("First Place Tiebreaker", () => {
it("should break tie using 1st place count", () => {
// Both teams have 215 points total, different 1st place counts
const teams = [
{
teamId: "team-a",
totalPoints: 215,
placementCounts: { 1: 2, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 1 }, // 2 firsts
},
{
teamId: "team-b",
totalPoints: 215,
placementCounts: { 1: 1, 2: 1, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 1 }, // 1 first
},
];
const ranks = assignRanks(teams);
// Team A should rank higher due to more 1st place finishes
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
it("should handle multiple teams with same points but different 1st place counts", () => {
const teams = [
{
teamId: "team-a",
totalPoints: 215,
placementCounts: { 1: 2, 8: 1 } as Record<number, number>, // 2 firsts
},
{
teamId: "team-b",
totalPoints: 215,
placementCounts: { 1: 1, 2: 1, 8: 1 } as Record<number, number>, // 1 first
},
{
teamId: "team-c",
totalPoints: 215,
placementCounts: { 2: 2, 3: 1 } as Record<number, number>, // 0 firsts
},
];
const ranks = assignRanks(teams);
// Ranked by 1st place count
expect(ranks.get("team-a")).toBe(1); // 2 firsts
expect(ranks.get("team-b")).toBe(2); // 1 first
expect(ranks.get("team-c")).toBe(3); // 0 firsts
});
});
describe("Second Place Tiebreaker", () => {
it("should break tie using 2nd place count when 1st place count is tied", () => {
const teams = [
{
teamId: "team-a",
totalPoints: 240,
placementCounts: { 1: 1, 2: 2, 8: 1 } as Record<number, number>, // 1 first, 2 seconds
},
{
teamId: "team-b",
totalPoints: 240,
placementCounts: { 1: 1, 3: 2, 8: 1 } as Record<number, number>, // 1 first, 0 seconds
},
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1); // More 2nd places
expect(ranks.get("team-b")).toBe(2);
});
});
describe("Cascading Tiebreakers", () => {
it("should cascade through all placement counts when needed", () => {
// Tiebreaker goes deep: same points, same 1st-3rd, different 4th
const teams = [
{
teamId: "team-a",
totalPoints: 160,
placementCounts: { 2: 1, 3: 1, 4: 1 } as Record<number, number>, // 0×1st, 1×2nd, 1×3rd, 1×4th
},
{
teamId: "team-b",
totalPoints: 160,
placementCounts: { 2: 1, 3: 1, 5: 2 } as Record<number, number>, // 0×1st, 1×2nd, 1×3rd, 0×4th, 2×5th
},
];
const ranks = assignRanks(teams);
// Team A wins on 4th place count
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
});
describe("Complete Ties", () => {
it("should assign same rank to teams with identical scores and placements", () => {
const teams = [
{
teamId: "team-a",
totalPoints: 220,
placementCounts: { 1: 1, 2: 1, 3: 1 },
},
{
teamId: "team-b",
totalPoints: 220,
placementCounts: { 1: 1, 2: 1, 3: 1 },
},
];
const ranks = assignRanks(teams);
// Both should have rank 1
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(1);
});
it("should handle 3-way ties correctly", () => {
const teams = [
{ teamId: "team-a", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } },
{ teamId: "team-b", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } },
{ teamId: "team-c", totalPoints: 170, placementCounts: { 1: 1, 2: 1 } },
];
const ranks = assignRanks(teams);
// All three should have rank 1
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(1);
expect(ranks.get("team-c")).toBe(1);
});
it("should skip rank numbers after ties", () => {
// 2 teams tied for 1st, next team should be 3rd
const teams = [
{ teamId: "team-a", totalPoints: 300, placementCounts: { 1: 3 } as Record<number, number> },
{ teamId: "team-b", totalPoints: 300, placementCounts: { 1: 3 } as Record<number, number> }, // Tied
{ teamId: "team-c", totalPoints: 210, placementCounts: { 2: 3 } as Record<number, number> },
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(1);
expect(ranks.get("team-c")).toBe(3); // Skips 2
});
});
describe("Edge Cases", () => {
it("should handle season with single team", () => {
const teams = [{ teamId: "solo", totalPoints: 100, placementCounts: { 1: 1 } }];
const ranks = assignRanks(teams);
expect(ranks.size).toBe(1);
expect(ranks.get("solo")).toBe(1);
});
it("should handle teams with zero points", () => {
const teams = [
{ teamId: "team-a", totalPoints: 100, placementCounts: { 1: 1 } as Record<number, number> },
{ teamId: "team-b", totalPoints: 0, placementCounts: {} as Record<number, number> },
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
it("should handle all teams with same score", () => {
const teams = [
{ teamId: "team-a", totalPoints: 100, placementCounts: { 1: 1 } },
{ teamId: "team-b", totalPoints: 100, placementCounts: { 1: 1 } },
{ teamId: "team-c", totalPoints: 100, placementCounts: { 1: 1 } },
];
const ranks = assignRanks(teams);
// All should be rank 1
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(1);
expect(ranks.get("team-c")).toBe(1);
});
});
describe("Complex Tiebreaker Scenarios", () => {
it("should handle 8-way cascade ending in complete tie", () => {
// Two teams identical through all 8 placements
const teams = [
{
teamId: "team-a",
totalPoints: 450,
placementCounts: { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1 },
},
{
teamId: "team-b",
totalPoints: 450,
placementCounts: { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1 },
},
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(1); // Perfect tie
});
it("should break tie at 8th place (last tiebreaker)", () => {
// Identical through 1st-7th, different at 8th
const teams = [
{
teamId: "team-a",
totalPoints: 100,
placementCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 7 }, // 7×8th
},
{
teamId: "team-b",
totalPoints: 100,
placementCounts: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 6 }, // 6×8th
},
];
const ranks = assignRanks(teams);
// Team A wins with more 8th place finishes
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
});
});