diff --git a/src/__tests__/unit/schedule-generator.test.ts b/src/__tests__/unit/schedule-generator.test.ts new file mode 100644 index 0000000..782a978 --- /dev/null +++ b/src/__tests__/unit/schedule-generator.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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); + }); + }); +}); diff --git a/src/lib/schedule-generator.ts b/src/lib/schedule-generator.ts new file mode 100644 index 0000000..2db7481 --- /dev/null +++ b/src/lib/schedule-generator.ts @@ -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 +}