* 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>
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].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);
|
||
});
|
||
});
|
||
});
|