2025-11-13 13:24:03 -08:00
|
|
|
|
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
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const sorted = [...teams].toSorted(compareTeamsForRanking);
|
2025-11-13 13:24:03 -08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|