feat: add recalculateAllElo function for idempotent ELO recalculation

This commit is contained in:
2026-03-30 21:28:09 -07:00
parent 04a42c903b
commit 3a78f354ad
7 changed files with 594 additions and 61 deletions
+190
View File
@@ -0,0 +1,190 @@
/**
* Unit Tests: ELO Recalculation
*
* Tests the idempotent full recalculation of ELO ratings and player statistics
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { recalculateAllElo } from '@/lib/elo-utils';
// Mock Prisma client
const mockPrisma = {
player: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue({}),
},
eloSnapshot: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue({}),
},
partnershipStat: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null), // No existing stats initially
update: vi.fn().mockResolvedValue({}),
create: vi.fn().mockResolvedValue({}),
},
match: {
findMany: vi.fn().mockResolvedValue([]),
},
};
describe('recalculateAllElo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('should reset all player stats to zero', async () => {
(mockPrisma.match.findMany as any).mockResolvedValue([]);
const result = await recalculateAllElo(mockPrisma as any);
expect(mockPrisma.player.updateMany).toHaveBeenCalledWith({
data: {
currentElo: 1000,
gamesPlayed: 0,
wins: 0,
losses: 0,
},
});
expect(result.matchesProcessed).toBe(0);
expect(result.playersUpdated).toBe(0);
expect(result.partnershipsUpdated).toBe(0);
});
test('should delete all existing elo snapshots', async () => {
(mockPrisma.match.findMany as any).mockResolvedValue([]);
await recalculateAllElo(mockPrisma as any);
expect(mockPrisma.eloSnapshot.deleteMany).toHaveBeenCalledWith({});
});
test('should delete all existing partnership stats', async () => {
(mockPrisma.match.findMany as any).mockResolvedValue([]);
await recalculateAllElo(mockPrisma as any);
expect(mockPrisma.partnershipStat.deleteMany).toHaveBeenCalledWith({});
});
test('should return correct result for empty match list', async () => {
(mockPrisma.match.findMany as any).mockResolvedValue([]);
const result = await recalculateAllElo(mockPrisma as any);
expect(result).toEqual({
matchesProcessed: 0,
playersUpdated: 0,
partnershipsUpdated: 0,
});
});
test('should process matches in chronological order', async () => {
const mockMatches = [
{
id: 1,
playedAt: new Date('2024-01-01'),
team1P1: { id: 1, name: 'Player 1' },
team1P2: { id: 2, name: 'Player 2' },
team2P1: { id: 3, name: 'Player 3' },
team2P2: { id: 4, name: 'Player 4' },
team1Score: 10,
team2Score: 5,
},
{
id: 2,
playedAt: new Date('2024-01-02'),
team1P1: { id: 1, name: 'Player 1' },
team1P2: { id: 2, name: 'Player 2' },
team2P1: { id: 5, name: 'Player 5' },
team2P2: { id: 6, name: 'Player 6' },
team1Score: 8,
team2Score: 6,
},
];
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
await recalculateAllElo(mockPrisma as any);
// Should process 2 matches
expect(mockPrisma.match.findMany).toHaveBeenCalledWith({
orderBy: { playedAt: 'asc' },
include: {
team1P1: true,
team1P2: true,
team2P1: true,
team2P2: true,
},
});
});
test('should create elo snapshots for each player in each match', async () => {
const mockMatches = [
{
id: 1,
playedAt: new Date('2024-01-01'),
team1P1: { id: 1, name: 'Player 1' },
team1P2: { id: 2, name: 'Player 2' },
team2P1: { id: 3, name: 'Player 3' },
team2P2: { id: 4, name: 'Player 4' },
team1Score: 10,
team2Score: 5,
},
];
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
await recalculateAllElo(mockPrisma as any);
// Should create 4 snapshots (one for each player)
expect(mockPrisma.eloSnapshot.create).toHaveBeenCalledTimes(4);
});
test('should update player stats after processing matches', async () => {
const mockMatches = [
{
id: 1,
playedAt: new Date('2024-01-01'),
team1P1: { id: 1, name: 'Player 1' },
team1P2: { id: 2, name: 'Player 2' },
team2P1: { id: 3, name: 'Player 3' },
team2P2: { id: 4, name: 'Player 4' },
team1Score: 10,
team2Score: 5,
},
];
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
await recalculateAllElo(mockPrisma as any);
// Should update 4 players
expect(mockPrisma.player.update).toHaveBeenCalledTimes(4);
});
test('should update partnership stats after processing matches', async () => {
const mockMatches = [
{
id: 1,
playedAt: new Date('2024-01-01'),
team1P1: { id: 1, name: 'Player 1' },
team1P2: { id: 2, name: 'Player 2' },
team2P1: { id: 3, name: 'Player 3' },
team2P2: { id: 4, name: 'Player 4' },
team1Score: 10,
team2Score: 5,
},
];
(mockPrisma.match.findMany as any).mockResolvedValue(mockMatches);
await recalculateAllElo(mockPrisma as any);
// Should check for existing partnership stats (2 calls) and create new ones (2 calls)
expect(mockPrisma.partnershipStat.findFirst).toHaveBeenCalledTimes(2);
expect(mockPrisma.partnershipStat.create).toHaveBeenCalledTimes(2);
});
});