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:
2026-04-02 00:57:49 -07:00
parent 3d87f3e1dc
commit df856c62df
2 changed files with 277 additions and 0 deletions
@@ -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);
});
});
});