- Added a new route for league standings: `/leagues/:leagueId/standings/:seasonId` - Implemented the `StandingsTable` component to display team standings with ranking, points, and placement breakdown. - Integrated tiebreaker logic for ranking teams based on total points and placements. - Enhanced the league home page with a link to view standings. - Updated scoring system documentation to reflect the new standings features. - Added comprehensive tests for tiebreaker logic and `StandingsTable` component. - Created shared types for standings to be used in both server and client code.
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
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
|
||
const sorted = [...teams].sort(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);
|
||
});
|
||
});
|
||
});
|