brackt/app/models/__tests__/tiebreaker-logic.test.ts
Chris Parsons e2b178221a
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

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