feat: add round-robin schedule generator
Implement circle-method algorithm for generating round-robin tournament schedules. Handles both even and odd team counts with bye rounds. Includes unit tests for algorithm correctness, input validation, and expected round/matchup calculations.
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Unit Tests: Round-Robin Schedule Generator
|
||||||
|
*
|
||||||
|
* Tests the correctness of the round-robin scheduling algorithm
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import {
|
||||||
|
generateRoundRobin,
|
||||||
|
validateScheduleInput,
|
||||||
|
expectedRounds,
|
||||||
|
expectedMatchups,
|
||||||
|
} from '@/lib/schedule-generator';
|
||||||
|
|
||||||
|
describe('Round-Robin Schedule Generator', () => {
|
||||||
|
describe('generateRoundRobin', () => {
|
||||||
|
test('should return empty array for fewer than 2 teams', () => {
|
||||||
|
expect(generateRoundRobin([])).toEqual([]);
|
||||||
|
expect(generateRoundRobin([1])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate correct schedule for 2 teams', () => {
|
||||||
|
const rounds = generateRoundRobin([1, 2]);
|
||||||
|
expect(rounds).toHaveLength(1);
|
||||||
|
expect(rounds[0].roundNumber).toBe(1);
|
||||||
|
expect(rounds[0].matchups).toHaveLength(1);
|
||||||
|
expect(rounds[0].matchups[0]).toEqual({ team1Id: 1, team2Id: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate N-1 rounds for N even teams', () => {
|
||||||
|
const teams = [1, 2, 3, 4];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
expect(rounds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate N rounds for N odd teams (with bye)', () => {
|
||||||
|
const teams = [1, 2, 3];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
expect(rounds).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each team plays every other team exactly once (even)', () => {
|
||||||
|
const teams = [1, 2, 3, 4];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
// Collect all pairings as sorted tuples
|
||||||
|
const pairings = new Set<string>();
|
||||||
|
for (const round of rounds) {
|
||||||
|
for (const matchup of round.matchups) {
|
||||||
|
const key = [matchup.team1Id, matchup.team2Id].sort().join('-');
|
||||||
|
pairings.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 teams = 6 unique pairings
|
||||||
|
expect(pairings.size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each team plays every other team exactly once (odd)', () => {
|
||||||
|
const teams = [1, 2, 3, 4, 5];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
const pairings = new Set<string>();
|
||||||
|
for (const round of rounds) {
|
||||||
|
for (const matchup of round.matchups) {
|
||||||
|
const key = [matchup.team1Id, matchup.team2Id].sort().join('-');
|
||||||
|
pairings.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 teams = 10 unique pairings
|
||||||
|
expect(pairings.size).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each team plays exactly once per round (even teams)', () => {
|
||||||
|
const teams = [1, 2, 3, 4, 5, 6];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
const teamsInRound = new Set<number>();
|
||||||
|
for (const matchup of round.matchups) {
|
||||||
|
teamsInRound.add(matchup.team1Id);
|
||||||
|
teamsInRound.add(matchup.team2Id);
|
||||||
|
}
|
||||||
|
// Each team appears exactly once
|
||||||
|
expect(teamsInRound.size).toBe(6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each team plays at most once per round (odd teams)', () => {
|
||||||
|
const teams = [1, 2, 3, 4, 5];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
for (const round of rounds) {
|
||||||
|
const teamsInRound = new Set<number>();
|
||||||
|
for (const matchup of round.matchups) {
|
||||||
|
teamsInRound.add(matchup.team1Id);
|
||||||
|
teamsInRound.add(matchup.team2Id);
|
||||||
|
}
|
||||||
|
// 5 teams, one has bye each round, so 4 play
|
||||||
|
expect(teamsInRound.size).toBe(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle 8 teams (typical euchre tournament)', () => {
|
||||||
|
const teams = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
expect(rounds).toHaveLength(7);
|
||||||
|
|
||||||
|
const totalMatchups = rounds.reduce((sum, r) => sum + r.matchups.length, 0);
|
||||||
|
expect(totalMatchups).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round numbers should be sequential starting from 1', () => {
|
||||||
|
const teams = [1, 2, 3, 4, 5, 6];
|
||||||
|
const rounds = generateRoundRobin(teams);
|
||||||
|
|
||||||
|
rounds.forEach((round, idx) => {
|
||||||
|
expect(round.roundNumber).toBe(idx + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateScheduleInput', () => {
|
||||||
|
test('should reject empty team list', () => {
|
||||||
|
const result = validateScheduleInput([]);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('At least 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject single team', () => {
|
||||||
|
const result = validateScheduleInput([1]);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject duplicate team IDs', () => {
|
||||||
|
const result = validateScheduleInput([1, 2, 2]);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Duplicate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid team list', () => {
|
||||||
|
const result = validateScheduleInput([1, 2, 3, 4]);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expectedRounds', () => {
|
||||||
|
test('should return 0 for fewer than 2 teams', () => {
|
||||||
|
expect(expectedRounds(0)).toBe(0);
|
||||||
|
expect(expectedRounds(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return N-1 for even N', () => {
|
||||||
|
expect(expectedRounds(4)).toBe(3);
|
||||||
|
expect(expectedRounds(6)).toBe(5);
|
||||||
|
expect(expectedRounds(8)).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return N for odd N', () => {
|
||||||
|
expect(expectedRounds(3)).toBe(3);
|
||||||
|
expect(expectedRounds(5)).toBe(5);
|
||||||
|
expect(expectedRounds(7)).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expectedMatchups', () => {
|
||||||
|
test('should return 0 for fewer than 2 teams', () => {
|
||||||
|
expect(expectedMatchups(0)).toBe(0);
|
||||||
|
expect(expectedMatchups(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return N*(N-1)/2 for N teams', () => {
|
||||||
|
expect(expectedMatchups(4)).toBe(6);
|
||||||
|
expect(expectedMatchups(6)).toBe(15);
|
||||||
|
expect(expectedMatchups(8)).toBe(28);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export interface MatchupPairing {
|
||||||
|
team1Id: number
|
||||||
|
team2Id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundSchedule {
|
||||||
|
roundNumber: number
|
||||||
|
matchups: MatchupPairing[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a round-robin schedule using the circle method.
|
||||||
|
*
|
||||||
|
* For N teams, produces N-1 rounds where each team plays every other
|
||||||
|
* team exactly once. If N is odd, a "bye" is added internally so one
|
||||||
|
* team sits out each round (the bye matchup is excluded from output).
|
||||||
|
*
|
||||||
|
* @param teamIds - Array of team IDs to schedule
|
||||||
|
* @returns Array of rounds, each containing matchup pairings
|
||||||
|
*/
|
||||||
|
export function generateRoundRobin(teamIds: number[]): RoundSchedule[] {
|
||||||
|
if (teamIds.length < 2) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use circle method: fix first team, rotate the rest
|
||||||
|
// If odd number of teams, add a sentinel for byes
|
||||||
|
const hasOddTeams = teamIds.length % 2 !== 0
|
||||||
|
const workingTeams = hasOddTeams ? [...teamIds, -1] : [...teamIds]
|
||||||
|
const n = workingTeams.length
|
||||||
|
const numRounds = n - 1
|
||||||
|
const matchupsPerRound = n / 2
|
||||||
|
|
||||||
|
const rounds: RoundSchedule[] = []
|
||||||
|
|
||||||
|
for (let round = 0; round < numRounds; round++) {
|
||||||
|
const matchups: MatchupPairing[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < matchupsPerRound; i++) {
|
||||||
|
const team1Idx = i
|
||||||
|
const team2Idx = n - 1 - i
|
||||||
|
|
||||||
|
const team1Id = workingTeams[team1Idx]
|
||||||
|
const team2Id = workingTeams[team2Idx]
|
||||||
|
|
||||||
|
// Skip bye matchups (where either team is the sentinel -1)
|
||||||
|
if (team1Id !== -1 && team2Id !== -1) {
|
||||||
|
matchups.push({ team1Id, team2Id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rounds.push({ roundNumber: round + 1, matchups })
|
||||||
|
|
||||||
|
// Rotate all teams except the first one (clockwise)
|
||||||
|
// Move last element to position 1
|
||||||
|
const last = workingTeams.pop()!
|
||||||
|
workingTeams.splice(1, 0, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a set of team IDs can be scheduled.
|
||||||
|
*/
|
||||||
|
export function validateScheduleInput(teamIds: number[]): {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (teamIds.length < 2) {
|
||||||
|
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIds = new Set(teamIds)
|
||||||
|
if (uniqueIds.size !== teamIds.length) {
|
||||||
|
return { valid: false, error: "Duplicate team IDs found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the expected number of rounds for N teams.
|
||||||
|
*/
|
||||||
|
export function expectedRounds(teamCount: number): number {
|
||||||
|
if (teamCount < 2) return 0
|
||||||
|
return teamCount % 2 === 0 ? teamCount - 1 : teamCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the expected number of total matchups for N teams.
|
||||||
|
*/
|
||||||
|
export function expectedMatchups(teamCount: number): number {
|
||||||
|
if (teamCount < 2) return 0
|
||||||
|
return (teamCount * (teamCount - 1)) / 2
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user