brackt/app/models/__tests__/tiebreaker-logic.test.ts
Chris Parsons c604dc9bc5
Fix tiebreaker incorrectly separating tied teams with pending participants, fixes #244 (#245)
Placement counts (used to break points ties) previously only included
fully-finalized participants. A team whose events all resolved first would
accumulate real placement counts while tied teams with pending events had
zeros, causing the tie to be broken in favour of whichever team finished
scoring first rather than on merit.

Fix: count a participant's current position toward the tiebreaker regardless
of isPartialScore. Their position is the best available signal; if it changes,
recalculateStandings reruns and ranks update naturally.

Also removes a redundant bounds check (finalPosition > 0 was already
asserted by the outer if-condition).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:59:13 -07:00

374 lines
12 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("Pending Participants (issue #244)", () => {
// calculateTeamScore now includes partial-score participants' current positions
// in placementCounts. These tests verify the tiebreaker works correctly when
// pending positions are already reflected in the counts passed in.
it("should rank teams tied on points equally when their current positions are also tied", () => {
// All six teams currently sit at 1st — their pending events haven't separated them yet.
// placementCounts reflect the current (partial) position, so all have { 1: 1 }.
const teams = [
{ teamId: "allen", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
{ teamId: "ender", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
{ teamId: "zed", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
];
const ranks = assignRanks(teams);
expect(ranks.get("allen")).toBe(1);
expect(ranks.get("ender")).toBe(1);
expect(ranks.get("zed")).toBe(1);
});
it("should break the tie using current position when one team is ahead in standings", () => {
// Two teams tied on points; one's pending participant is currently 1st, the other 2nd.
const teams = [
{ teamId: "team-a", totalPoints: 20, placementCounts: { 1: 1 } as Record<number, number> },
{ teamId: "team-b", totalPoints: 20, placementCounts: { 2: 1 } as Record<number, number> },
];
const ranks = assignRanks(teams);
expect(ranks.get("team-a")).toBe(1);
expect(ranks.get("team-b")).toBe(2);
});
});
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);
});
});
});