nextjs-rewrite (#5)

Reviewed-on: #5
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-03-30 02:30:13 +00:00
committed by david
parent 1a9b3496e1
commit 123df671f5
160 changed files with 19293 additions and 1180 deletions
+227
View File
@@ -0,0 +1,227 @@
/**
* Unit Tests: Elo Rating System
*
* Tests the mathematical correctness of Elo calculations and rating updates
*/
import { describe, test, expect } from 'vitest';
import { calculateEloChange, calculateExpectedScore, calculateTeamElo } from '@/lib/elo-utils';
describe('Elo Rating System', () => {
const K_FACTOR = 32;
describe('calculateExpectedScore', () => {
test('should calculate expected score for equal ratings', () => {
const ratingA = 1500;
const ratingB = 1500;
const expected = calculateExpectedScore(ratingA, ratingB);
// Equal ratings should result in 0.5 expected score
expect(expected).toBeCloseTo(0.5, 4);
});
test('should calculate expected score when A is stronger', () => {
const ratingA = 1600;
const ratingB = 1500;
const expected = calculateExpectedScore(ratingA, ratingB);
// Higher rated player should have higher expected score
expect(expected).toBeGreaterThan(0.5);
expect(expected).toBeLessThan(1);
});
test('should calculate expected score when B is stronger', () => {
const ratingA = 1500;
const ratingB = 1600;
const expected = calculateExpectedScore(ratingA, ratingB);
// Lower rated player should have lower expected score
expect(expected).toBeLessThan(0.5);
expect(expected).toBeGreaterThan(0);
});
test('should handle large rating differences', () => {
const ratingA = 2000;
const ratingB = 1000;
const expected = calculateExpectedScore(ratingA, ratingB);
// Much higher rated player should have very high expected score
expect(expected).toBeGreaterThan(0.95);
});
});
describe('calculateEloChange', () => {
test('should return positive change for win against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 1; // Win
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeGreaterThan(0);
// For equal opponents, expected = 0.5, so change = K_FACTOR * (1 - 0.5) = K_FACTOR / 2
expect(change).toBeCloseTo(K_FACTOR / 2, 0);
});
test('should return negative change for loss against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 0; // Loss
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeLessThan(0);
// For equal opponents, expected = 0.5, so change = K_FACTOR * (0 - 0.5) = -K_FACTOR / 2
expect(change).toBeCloseTo(-K_FACTOR / 2, 0);
});
test('should return zero change for draw against equal opponent', () => {
const rating = 1500;
const opponentRating = 1500;
const actualScore = 0.5; // Draw
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
expect(change).toBeCloseTo(0, 1);
});
test('should return larger positive change for win against stronger opponent', () => {
const rating = 1500;
const opponentRating = 1800; // Stronger
const actualScore = 1; // Win (upset)
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
// Should gain more than K_FACTOR / 2 for upset win (since expected score is very low)
expect(change).toBeGreaterThan(K_FACTOR / 2);
});
test('should return larger negative change for loss against weaker opponent', () => {
const rating = 1800;
const opponentRating = 1500; // Weaker
const actualScore = 0; // Loss (upset)
const change = calculateEloChange(rating, opponentRating, actualScore, K_FACTOR);
// Should lose more than K_FACTOR / 2 for upset loss (since expected score is very high)
expect(change).toBeLessThan(-K_FACTOR / 2);
});
});
describe('calculateTeamElo', () => {
test('should average team member ratings', () => {
const player1Rating = 1600;
const player2Rating = 1400;
const teamRating = calculateTeamElo(player1Rating, player2Rating);
expect(teamRating).toBe(1500);
});
test('should handle different team compositions', () => {
const team1 = calculateTeamElo(1500, 1500);
const team2 = calculateTeamElo(1400, 1600);
const team3 = calculateTeamElo(1200, 1800);
// All should average to 1500
expect(team1).toBe(1500);
expect(team2).toBe(1500);
expect(team3).toBe(1500);
});
});
describe('Elo Change Distribution', () => {
test('should conserve total rating points in system', () => {
const player1Rating = 1500;
const player2Rating = 1500;
const player3Rating = 1500;
const player4Rating = 1500;
// Simulate a match where team 1 wins
const team1Rating = calculateTeamElo(player1Rating, player2Rating);
const team2Rating = calculateTeamElo(player3Rating, player4Rating);
const team1Expected = calculateExpectedScore(team1Rating, team2Rating);
const team2Expected = calculateExpectedScore(team2Rating, team1Rating);
// Team 1 wins (score = 1, team 2 score = 0)
const team1Change = calculateEloChange(team1Rating, team2Rating, 1, K_FACTOR);
const team2Change = calculateEloChange(team2Rating, team1Rating, 0, K_FACTOR);
// Split changes between team members
const player1Change = team1Change / 2;
const player2Change = team1Change / 2;
const player3Change = team2Change / 2;
const player4Change = team2Change / 2;
// Total change should be zero (conservation of points)
const totalChange = player1Change + player2Change + player3Change + player4Change;
expect(totalChange).toBeCloseTo(0, 10);
});
test('should calculate correct ratings after match', () => {
const p1Initial = 1500;
const p2Initial = 1500;
const p3Initial = 1500;
const p4Initial = 1500;
// Team 1 wins
const team1Rating = calculateTeamElo(p1Initial, p2Initial);
const team2Rating = calculateTeamElo(p3Initial, p4Initial);
const team1Change = calculateEloChange(team1Rating, team2Rating, 1, K_FACTOR);
const team2Change = calculateEloChange(team2Rating, team1Rating, 0, K_FACTOR);
const p1Change = team1Change / 2;
const p2Change = team1Change / 2;
const p3Change = team2Change / 2;
const p4Change = team2Change / 2;
// After match
const p1Final = p1Initial + p1Change;
const p2Final = p2Initial + p2Change;
const p3Final = p3Initial + p3Change;
const p4Final = p4Initial + p4Change;
// Winners should have higher ratings
expect(p1Final).toBeGreaterThan(p1Initial);
expect(p2Final).toBeGreaterThan(p2Initial);
// Losers should have lower ratings
expect(p3Final).toBeLessThan(p3Initial);
expect(p4Final).toBeLessThan(p4Initial);
// All final ratings should still be close to 1500
expect(p1Final).toBeGreaterThan(1480);
expect(p1Final).toBeLessThan(1520);
});
});
describe('Rating Improvement Over Time', () => {
test('should reflect consistent winning with rating increase', () => {
let rating = 1500;
const opponentRating = 1500;
// Simulate 10 consecutive wins
for (let i = 0; i < 10; i++) {
rating += calculateEloChange(rating, opponentRating, 1, K_FACTOR);
}
// Rating should have increased significantly
expect(rating).toBeGreaterThan(1600);
});
test('should reflect consistent losing with rating decrease', () => {
let rating = 1500;
const opponentRating = 1500;
// Simulate 10 consecutive losses
for (let i = 0; i < 10; i++) {
rating += calculateEloChange(rating, opponentRating, 0, K_FACTOR);
}
// Rating should have decreased significantly
expect(rating).toBeLessThan(1400);
});
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Unit Tests: Permissions
*
* Tests the permission system for tournament management
*/
import { describe, test, expect, vi } from 'vitest';
import { hasRole, canManageTournament, canCreateTournaments } from '@/lib/permissions';
import { getSession } from '@/lib/auth-simple';
import { prisma } from '@/lib/prisma';
// Helper to create mock user
const createMockUser = (id: string, email: string, role: string) => ({
id,
email,
role,
emailVerified: false,
name: null,
image: null,
playerId: null,
createdAt: new Date(),
updatedAt: new Date(),
});
// Mock the getSession and prisma functions
vi.mock('@/lib/auth-simple', () => ({
getSession: vi.fn(),
}));
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
event: {
findUnique: vi.fn(),
},
},
}));
describe('Permissions', () => {
describe('hasRole', () => {
test('should allow club_admin to access tournament_admin resources', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'club_admin') as any
);
const result = await hasRole('tournament_admin');
expect(result.allowed).toBe(true);
});
test('should deny player from accessing tournament_admin resources', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: '1', email: 'test@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('1', 'test@example.com', 'player') as any
);
const result = await hasRole('tournament_admin');
expect(result.allowed).toBe(false);
});
test('should deny unauthenticated user', async () => {
vi.mocked(getSession).mockResolvedValue(null);
const result = await hasRole('club_admin');
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Not authenticated');
});
});
describe('canManageTournament', () => {
test('should allow club_admin to manage any tournament', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin') as any
);
const result = await canManageTournament(999);
expect(result.allowed).toBe(true);
});
test('should deny player from managing tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player') as any
);
const result = await canManageTournament(999);
expect(result.allowed).toBe(false);
expect(result.reason).toContain('Insufficient permissions');
});
});
describe('canCreateTournaments', () => {
test('should allow tournament_admin to create tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'tournament_admin') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(true);
});
test('should allow club_admin to create tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'admin-1', email: 'admin@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('admin-1', 'admin@example.com', 'club_admin') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(true);
});
test('should deny player from creating tournaments', async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: 'player-1', email: 'player@example.com' },
session: { token: 'test', expiresAt: new Date() }
});
vi.mocked(prisma.user.findUnique).mockResolvedValue(
createMockUser('player-1', 'player@example.com', 'player') as any
);
const result = await canCreateTournaments();
expect(result.allowed).toBe(false);
});
});
});