brackt/app/models/__tests__/tiebreaker-logic.test.ts
Chris Parsons 0385ca220c feat: add standings page and enhance standings functionality
- 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.
2025-11-13 13:24:03 -08:00

339 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});
});