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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* Unit Tests: Team Generation Algorithms
|
|
*
|
|
* Tests the correctness of team generation algorithms
|
|
* for different partner rotation strategies.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
generateTeams,
|
|
generateRandomTeams,
|
|
generateEvenTeams,
|
|
generateELOBasedTeams,
|
|
calculateTeamBalance,
|
|
calculatePartnershipFrequency,
|
|
generateTeamsWithRotation,
|
|
type Player,
|
|
type Team,
|
|
type PartnerRotation,
|
|
} from '@/lib/team-generator';
|
|
|
|
// Test players with varying ELO ratings
|
|
const testPlayers: Player[] = [
|
|
{ id: 1, name: 'Alice', currentElo: 1500 },
|
|
{ id: 2, name: 'Bob', currentElo: 1200 },
|
|
{ id: 3, name: 'Charlie', currentElo: 1400 },
|
|
{ id: 4, name: 'Diana', currentElo: 1300 },
|
|
{ id: 5, name: 'Eve', currentElo: 1100 },
|
|
{ id: 6, name: 'Frank', currentElo: 1600 },
|
|
];
|
|
|
|
describe('Team Generation Algorithms', () => {
|
|
describe('generateTeams', () => {
|
|
test('should return empty teams for fewer than 2 players', () => {
|
|
const result = generateTeams([], 'none', true);
|
|
expect(result.teams).toEqual([]);
|
|
expect(result.byePlayer).toBeNull();
|
|
});
|
|
|
|
test('should generate one team for 2 players', () => {
|
|
const players = testPlayers.slice(0, 2);
|
|
const result = generateTeams(players, 'none', true);
|
|
expect(result.teams).toHaveLength(1);
|
|
expect(result.teams[0].player1Id).toBeDefined();
|
|
expect(result.teams[0].player2Id).toBeDefined();
|
|
});
|
|
|
|
test('should generate correct number of teams for even player count', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const result = generateTeams(players, 'none', true);
|
|
expect(result.teams).toHaveLength(3);
|
|
});
|
|
|
|
test('should handle odd player count with byes enabled', () => {
|
|
const players = testPlayers.slice(0, 5);
|
|
const result = generateTeams(players, 'none', true);
|
|
expect(result.teams).toHaveLength(2);
|
|
expect(result.byePlayer).not.toBeNull();
|
|
// Bye goes to highest ELO player (player 1 with Elo 1500)
|
|
expect(result.byePlayer?.id).toBe(1);
|
|
});
|
|
|
|
test('should throw error for odd player count with byes disabled', () => {
|
|
const players = testPlayers.slice(0, 5);
|
|
expect(() => generateTeams(players, 'none', false)).toThrow(
|
|
"Odd number of participants. Enable 'Allow Byes' to proceed."
|
|
);
|
|
});
|
|
|
|
test('should use different strategies correctly', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
|
|
// Test each strategy
|
|
const strategies: PartnerRotation[] = ['none', 'minimize_repeat', 'maximize_even', 'elo_based'];
|
|
|
|
for (const strategy of strategies) {
|
|
const result = generateTeams(players, strategy, true);
|
|
expect(result.teams).toHaveLength(3);
|
|
expect(result.strategy).toBe(strategy);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('generateRandomTeams', () => {
|
|
test('should generate all teams with unique players', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const teams = generateRandomTeams(players);
|
|
|
|
expect(teams).toHaveLength(3);
|
|
|
|
// Collect all player IDs
|
|
const allPlayerIds = teams.flatMap(t => [t.player1Id, t.player2Id]);
|
|
const uniqueIds = new Set(allPlayerIds);
|
|
|
|
expect(uniqueIds.size).toBe(6);
|
|
});
|
|
|
|
test('should not create duplicate teams', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const teams = generateRandomTeams(players);
|
|
|
|
// Check that no team has the same pair
|
|
const teamKeys = teams.map(t => [t.player1Id, t.player2Id].sort().join('-'));
|
|
const uniqueKeys = new Set(teamKeys);
|
|
|
|
expect(uniqueKeys.size).toBe(teams.length);
|
|
});
|
|
|
|
test('should include team names', () => {
|
|
const players = testPlayers.slice(0, 4);
|
|
const teams = generateRandomTeams(players);
|
|
|
|
for (const team of teams) {
|
|
expect(team.teamName).toContain('&');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('generateEvenTeams', () => {
|
|
test('should pair top players with bottom players', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const teams = generateEvenTeams(players);
|
|
|
|
expect(teams).toHaveLength(3);
|
|
|
|
// Check that teams are balanced
|
|
const playerMap = new Map(players.map(p => [p.id, p]));
|
|
let totalDiff = 0;
|
|
|
|
for (const team of teams) {
|
|
const player1 = playerMap.get(team.player1Id)!;
|
|
const player2 = playerMap.get(team.player2Id)!;
|
|
totalDiff += Math.abs(player1.currentElo - player2.currentElo);
|
|
}
|
|
|
|
// Average difference should be reasonable
|
|
const avgDiff = totalDiff / teams.length;
|
|
expect(avgDiff).toBeLessThan(500); // Should be reasonably balanced
|
|
});
|
|
|
|
test('should handle odd number of players', () => {
|
|
const players = testPlayers.slice(0, 5);
|
|
const teams = generateEvenTeams(players);
|
|
|
|
expect(teams).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('generateELOBasedTeams', () => {
|
|
test('should pair strongest with weakest', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const teams = generateELOBasedTeams(players);
|
|
|
|
expect(teams).toHaveLength(3);
|
|
|
|
// Check pairing pattern
|
|
const sorted = [...players].sort((a, b) => b.currentElo - a.currentElo);
|
|
|
|
for (let i = 0; i < teams.length; i++) {
|
|
const team = teams[i];
|
|
const expectedPlayer1 = sorted[i];
|
|
const expectedPlayer2 = sorted[sorted.length - 1 - i];
|
|
|
|
expect(team.player1Id).toBe(expectedPlayer1.id);
|
|
expect(team.player2Id).toBe(expectedPlayer2.id);
|
|
}
|
|
});
|
|
|
|
test('should create balanced teams', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
const teams = generateELOBasedTeams(players);
|
|
|
|
const playerMap = new Map(players.map(p => [p.id, p]));
|
|
let totalDiff = 0;
|
|
|
|
for (const team of teams) {
|
|
const player1 = playerMap.get(team.player1Id)!;
|
|
const player2 = playerMap.get(team.player2Id)!;
|
|
totalDiff += Math.abs(player1.currentElo - player2.currentElo);
|
|
}
|
|
|
|
// Average difference should be high for ELO-based pairing
|
|
const avgDiff = totalDiff / teams.length;
|
|
expect(avgDiff).toBeGreaterThan(200);
|
|
});
|
|
});
|
|
|
|
describe('calculateTeamBalance', () => {
|
|
test('should calculate balance for well-balanced teams', () => {
|
|
const teams: Team[] = [
|
|
{ player1Id: 1, player2Id: 2, teamName: 'Test' },
|
|
{ player1Id: 3, player2Id: 4, teamName: 'Test' },
|
|
];
|
|
|
|
const balance = calculateTeamBalance(teams, testPlayers);
|
|
expect(balance).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should return 0 for empty teams', () => {
|
|
const balance = calculateTeamBalance([], testPlayers);
|
|
expect(balance).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('calculatePartnershipFrequency', () => {
|
|
test('should count partnerships correctly', () => {
|
|
const team1: Team[] = [
|
|
{ player1Id: 1, player2Id: 2, teamName: 'Test' },
|
|
{ player1Id: 3, player2Id: 4, teamName: 'Test' },
|
|
];
|
|
|
|
const team2: Team[] = [
|
|
{ player1Id: 1, player2Id: 3, teamName: 'Test' },
|
|
{ player1Id: 2, player2Id: 4, teamName: 'Test' },
|
|
];
|
|
|
|
const frequency = calculatePartnershipFrequency([team1, team2], testPlayers);
|
|
|
|
expect(frequency.get('1-2')).toBe(1);
|
|
expect(frequency.get('3-4')).toBe(1);
|
|
expect(frequency.get('1-3')).toBe(1);
|
|
expect(frequency.get('2-4')).toBe(1);
|
|
});
|
|
|
|
test('should handle empty previous teams', () => {
|
|
const frequency = calculatePartnershipFrequency([], testPlayers);
|
|
expect(frequency.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('generateTeamsWithRotation', () => {
|
|
test('should minimize repeat partnerships with larger groups', () => {
|
|
// With 8+ players, it should almost always be possible to avoid repeats
|
|
// Test with 8 players to ensure reliable zero repeats
|
|
const players8 = [
|
|
...testPlayers.slice(0, 6),
|
|
{ id: 7, name: 'Grace', currentElo: 1000 },
|
|
{ id: 8, name: 'Henry', currentElo: 900 },
|
|
];
|
|
|
|
// Test multiple times to account for randomness
|
|
let totalRepeats = 0;
|
|
let totalTeams = 0;
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const firstRound = generateTeams(players8, 'none', true);
|
|
const secondRound = generateTeamsWithRotation(
|
|
players8,
|
|
[firstRound.teams],
|
|
'minimize_repeat',
|
|
true
|
|
);
|
|
|
|
const firstRoundKeys = new Set(
|
|
firstRound.teams.map(t => [t.player1Id, t.player2Id].sort().join('-'))
|
|
);
|
|
|
|
for (const team of secondRound.teams) {
|
|
totalTeams++;
|
|
const key = [team.player1Id, team.player2Id].sort().join('-');
|
|
if (firstRoundKeys.has(key)) {
|
|
totalRepeats++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// With 8 players and 10 iterations, the algorithm should achieve
|
|
// zero or very few repeats (allowing for randomness)
|
|
// 8 players = 28 possible partnerships, 4 teams per round
|
|
// So even with 2 rounds, there are plenty of options to avoid repeats
|
|
expect(totalRepeats).toBeLessThan(totalTeams * 0.2); // Less than 20% repeat rate
|
|
});
|
|
|
|
test('should handle small groups where repeats are unavoidable', () => {
|
|
// With 4 players, there are only 3 possible partnerships
|
|
// After 2 rounds, at least 1 repeat is guaranteed
|
|
const players4 = testPlayers.slice(0, 4);
|
|
|
|
const firstRound = generateTeams(players4, 'none', true);
|
|
const secondRound = generateTeamsWithRotation(
|
|
players4,
|
|
[firstRound.teams],
|
|
'minimize_repeat',
|
|
true
|
|
);
|
|
|
|
// For 4 players, we can only have 2 teams per round
|
|
// After 2 rounds, we have 4 team slots total but only 3 unique partnerships
|
|
// So at least 1 repeat is mathematically guaranteed
|
|
// The test just verifies the function runs without error
|
|
expect(firstRound.teams).toHaveLength(2);
|
|
expect(secondRound.teams).toHaveLength(2);
|
|
expect(firstRound.teams).toBeDefined();
|
|
expect(secondRound.teams).toBeDefined();
|
|
});
|
|
|
|
test('should handle multiple previous rounds', () => {
|
|
const players = testPlayers.slice(0, 6);
|
|
|
|
// Simulate 3 rounds
|
|
const previousTeams: Team[][] = [];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const result = generateTeamsWithRotation(
|
|
players,
|
|
previousTeams,
|
|
'minimize_repeat',
|
|
true
|
|
);
|
|
previousTeams.push(result.teams);
|
|
}
|
|
|
|
expect(previousTeams).toHaveLength(3);
|
|
|
|
// Each round should have 3 teams
|
|
for (const teams of previousTeams) {
|
|
expect(teams).toHaveLength(3);
|
|
}
|
|
});
|
|
});
|
|
});
|