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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user