bb6be245b7
## Summary This PR implements the tournament schedule tab functionality and fixes all remaining E2E test failures. ### Changes Included 1. **Tournament Schedule Feature** - Added tournament schedule page at `/admin/tournaments/[id]/schedule` - Implemented "Generate Schedule" button functionality - Added schedule generation logic for round-robin tournaments 2. **E2E Test Fixes** - Fixed database connection issues in production builds - Improved test reliability with better error handling and debugging - Updated test infrastructure to use environment variables instead of hardcoded values 3. **CI/CD Updates** - Added E2E test job to PR workflow - Configured tests to run against development database - Moved database password to Gitea secrets 4. **Code Quality** - Removed hardcoded passwords from codebase - Improved Prisma client configuration - Enhanced authentication and navigation components ### Test Results All 16 E2E test scenarios are now passing: - Authentication tests: ✅ - Registration tests: ✅ - Tournament schedule tests: ✅ - Player schedule tests: ✅ - Admin navigation tests: ✅ ### Database Configuration - Tests run against `euchre_camp_dev` database - Production builds use environment variables for database configuration - Database password stored in Gitea secrets as `DB_PASSWORD` ### CI Pipeline The PR workflow now includes: 1. Unit tests 2. E2E tests (using production build) 3. Version bump analysis E2E tests must pass before PR can be merged. Reviewed-on: #27 Co-authored-by: David Gwilliam <dhgwilliam@gmail.com> Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
189 lines
5.3 KiB
TypeScript
189 lines
5.3 KiB
TypeScript
export interface MatchupPairing {
|
|
player1P1Id: number
|
|
player1P2Id: number
|
|
player2P1Id: number
|
|
player2P2Id: number
|
|
}
|
|
|
|
export interface RoundSchedule {
|
|
roundNumber: number
|
|
matchups: MatchupPairing[]
|
|
}
|
|
|
|
export interface TeamPairing {
|
|
player1Id: number
|
|
player2Id: number
|
|
}
|
|
|
|
/**
|
|
* 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 teamPairings - Array of player pairings (each with 2 player IDs)
|
|
* @returns Array of rounds, each containing matchup pairings
|
|
*/
|
|
export function generateRoundRobin(
|
|
teamPairings: TeamPairing[]
|
|
): RoundSchedule[] {
|
|
if (teamPairings.length < 2) {
|
|
return []
|
|
}
|
|
|
|
// Use circle method: fix first team, rotate the rest
|
|
// If odd number of teams, add a sentinel for byes
|
|
const hasOddTeams = teamPairings.length % 2 !== 0
|
|
const workingTeams = hasOddTeams
|
|
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
|
|
: [...teamPairings]
|
|
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 team1 = workingTeams[team1Idx]
|
|
const team2 = workingTeams[team2Idx]
|
|
|
|
// Skip bye matchups (where either team is the sentinel)
|
|
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
|
|
matchups.push({
|
|
player1P1Id: team1.player1Id,
|
|
player1P2Id: team1.player2Id,
|
|
player2P1Id: team2.player1Id,
|
|
player2P2Id: team2.player2Id,
|
|
})
|
|
}
|
|
}
|
|
|
|
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 player pairings can be scheduled.
|
|
*/
|
|
export function validateScheduleInput(
|
|
teamPairings: { player1Id: number; player2Id: number }[]
|
|
): {
|
|
valid: boolean
|
|
error?: string
|
|
} {
|
|
if (teamPairings.length < 2) {
|
|
return { valid: false, error: "At least 2 teams are required to generate a schedule" }
|
|
}
|
|
|
|
// Check for duplicate teams
|
|
const teamKeys = teamPairings.map(
|
|
(t) => [t.player1Id, t.player2Id].sort().join('-')
|
|
)
|
|
const uniqueKeys = new Set(teamKeys)
|
|
if (uniqueKeys.size !== teamPairings.length) {
|
|
return { valid: false, error: "Duplicate team pairings 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
|
|
}
|
|
|
|
/**
|
|
* Generate a schedule where teams are created fresh each round.
|
|
* This ensures every player gets different partners throughout the tournament.
|
|
*
|
|
* @param players - Array of players to be paired
|
|
* @param teamCount - Number of teams (player pairs) to create per round
|
|
* @param roundCount - Number of rounds to generate
|
|
* @param generateTeamFunction - Function to generate teams for a round
|
|
* @returns Array of rounds, each containing matchups with fresh team pairings
|
|
*/
|
|
export function generateVariableRoundRobin(
|
|
players: { id: number; name: string; currentElo: number }[],
|
|
teamCount: number,
|
|
roundCount: number,
|
|
generateTeamFunction: (players: { id: number; name: string; currentElo: number }[]) => TeamPairing[]
|
|
): RoundSchedule[] {
|
|
if (players.length < 4 || teamCount < 2) {
|
|
return []
|
|
}
|
|
|
|
const rounds: RoundSchedule[] = []
|
|
|
|
for (let roundNum = 1; roundNum <= roundCount; roundNum++) {
|
|
// Generate fresh teams for this round
|
|
const teams = generateTeamFunction(players)
|
|
|
|
// Validate we have enough teams
|
|
if (teams.length < 2) {
|
|
continue
|
|
}
|
|
|
|
// Apply round-robin pairing to the fresh teams
|
|
const teamPairings = teams.map((team) => ({
|
|
player1Id: team.player1Id,
|
|
player2Id: team.player2Id,
|
|
}))
|
|
|
|
// Use circle method for this round's matchups
|
|
const hasOddTeams = teamPairings.length % 2 !== 0
|
|
const workingTeams = hasOddTeams
|
|
? [...teamPairings, { player1Id: -1, player2Id: -1 }]
|
|
: [...teamPairings]
|
|
const n = workingTeams.length
|
|
const matchupsPerRound = n / 2
|
|
const matchups: MatchupPairing[] = []
|
|
|
|
for (let i = 0; i < matchupsPerRound; i++) {
|
|
const team1Idx = i
|
|
const team2Idx = n - 1 - i
|
|
|
|
const team1 = workingTeams[team1Idx]
|
|
const team2 = workingTeams[team2Idx]
|
|
|
|
// Skip bye matchups
|
|
if (team1.player1Id !== -1 && team2.player1Id !== -1) {
|
|
matchups.push({
|
|
player1P1Id: team1.player1Id,
|
|
player1P2Id: team1.player2Id,
|
|
player2P1Id: team2.player1Id,
|
|
player2P2Id: team2.player2Id,
|
|
})
|
|
}
|
|
}
|
|
|
|
rounds.push({ roundNumber: roundNum, matchups })
|
|
}
|
|
|
|
return rounds
|
|
}
|