import { describe, it, expect, beforeEach } from 'vitest'; import { RecalculateChampionshipStandingsUseCase } from '@gridpilot/racing/application/use-cases/RecalculateChampionshipStandingsUseCase'; import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '@gridpilot/racing/domain/repositories/ILeagueScoringConfigRepository'; import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository'; import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '@gridpilot/racing/domain/repositories/IPenaltyRepository'; import type { IChampionshipStandingRepository } from '@gridpilot/racing/domain/repositories/IChampionshipStandingRepository'; import type { Season } from '@gridpilot/racing/domain/entities/Season'; import type { LeagueScoringConfig } from '@gridpilot/racing/domain/entities/LeagueScoringConfig'; import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { Result } from '@gridpilot/racing/domain/entities/Result'; import type { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; import type { ChampionshipStanding } from '@gridpilot/racing/domain/entities/ChampionshipStanding'; import type { ChampionshipConfig } from '@gridpilot/racing/domain/types/ChampionshipConfig'; import { EventScoringService } from '@gridpilot/racing/domain/services/EventScoringService'; import { DropScoreApplier } from '@gridpilot/racing/domain/services/DropScoreApplier'; import { ChampionshipAggregator } from '@gridpilot/racing/domain/services/ChampionshipAggregator'; import { PointsTable } from '@gridpilot/racing/domain/value-objects/PointsTable'; import type { SessionType } from '@gridpilot/racing/domain/types/SessionType'; import type { BonusRule } from '@gridpilot/racing/domain/types/BonusRule'; import type { DropScorePolicy } from '@gridpilot/racing/domain/types/DropScorePolicy'; class InMemorySeasonRepository implements ISeasonRepository { private seasons: Season[] = []; async findById(id: string): Promise { return this.seasons.find((s) => s.id === id) || null; } async findByLeagueId(leagueId: string): Promise { return this.seasons.filter((s) => s.leagueId === leagueId); } seedSeason(season: Season): void { this.seasons.push(season); } } class InMemoryLeagueScoringConfigRepository implements ILeagueScoringConfigRepository { private configs: LeagueScoringConfig[] = []; async findBySeasonId(seasonId: string): Promise { return this.configs.find((c) => c.seasonId === seasonId) || null; } seedConfig(config: LeagueScoringConfig): void { this.configs.push(config); } } class InMemoryRaceRepository implements IRaceRepository { private races: Race[] = []; async findById(id: string): Promise { return this.races.find((r) => r.id === id) || null; } async findAll(): Promise { return [...this.races]; } async findByLeagueId(leagueId: string): Promise { return this.races.filter((r) => r.leagueId === leagueId); } async findUpcomingByLeagueId(): Promise { return []; } async findCompletedByLeagueId(): Promise { return []; } async findByStatus(): Promise { return []; } async findByDateRange(): Promise { return []; } async create(race: Race): Promise { this.races.push(race); return race; } async update(race: Race): Promise { const index = this.races.findIndex((r) => r.id === race.id); if (index >= 0) { this.races[index] = race; } else { this.races.push(race); } return race; } async delete(id: string): Promise { this.races = this.races.filter((r) => r.id !== id); } async exists(id: string): Promise { return this.races.some((r) => r.id === id); } seedRace(race: Race): void { this.races.push(race); } } class InMemoryResultRepository implements IResultRepository { private results: Result[] = []; async findByRaceId(raceId: string): Promise { return this.results.filter((r) => r.raceId === raceId); } seedResult(result: Result): void { this.results.push(result); } } class InMemoryPenaltyRepository implements IPenaltyRepository { private penalties: Penalty[] = []; async findByRaceId(raceId: string): Promise { return this.penalties.filter((p) => p.raceId === raceId); } async findByLeagueId(leagueId: string): Promise { return this.penalties.filter((p) => p.leagueId === leagueId); } async findByLeagueIdAndDriverId( leagueId: string, driverId: string, ): Promise { return this.penalties.filter( (p) => p.leagueId === leagueId && p.driverId === driverId, ); } async findAll(): Promise { return [...this.penalties]; } seedPenalty(penalty: Penalty): void { this.penalties.push(penalty); } } class InMemoryChampionshipStandingRepository implements IChampionshipStandingRepository { private standings: ChampionshipStanding[] = []; async findBySeasonAndChampionship( seasonId: string, championshipId: string, ): Promise { return this.standings.filter( (s) => s.seasonId === seasonId && s.championshipId === championshipId, ); } async saveAll(standings: ChampionshipStanding[]): Promise { this.standings = standings; } getAll(): ChampionshipStanding[] { return [...this.standings]; } } function makePointsTable(points: number[]): PointsTable { const byPos: Record = {}; points.forEach((p, idx) => { byPos[idx + 1] = p; }); return new PointsTable(byPos); } function makeChampionshipConfig(): ChampionshipConfig { const mainPoints = makePointsTable([25, 18, 15, 12, 10, 8, 6, 4, 2, 1]); const sprintPoints = makePointsTable([8, 7, 6, 5, 4, 3, 2, 1]); const fastestLapBonus: BonusRule = { id: 'fastest-lap-main', type: 'fastestLap', points: 1, requiresFinishInTopN: 10, }; const sessionTypes: SessionType[] = ['sprint', 'main']; const pointsTableBySessionType: Record = { sprint: sprintPoints, main: mainPoints, practice: new PointsTable({}), qualifying: new PointsTable({}), q1: new PointsTable({}), q2: new PointsTable({}), q3: new PointsTable({}), timeTrial: new PointsTable({}), }; const bonusRulesBySessionType: Record = { sprint: [], main: [fastestLapBonus], practice: [], qualifying: [], q1: [], q2: [], q3: [], timeTrial: [], }; const dropScorePolicy: DropScorePolicy = { strategy: 'bestNResults', count: 6, }; return { id: 'driver-champ', name: 'Driver Championship', type: 'driver', sessionTypes, pointsTableBySessionType, bonusRulesBySessionType, dropScorePolicy, }; } describe('RecalculateChampionshipStandingsUseCase', () => { const leagueId = 'league-1'; const seasonId = 'season-1'; const championshipId = 'driver-champ'; let seasonRepository: InMemorySeasonRepository; let leagueScoringConfigRepository: InMemoryLeagueScoringConfigRepository; let raceRepository: InMemoryRaceRepository; let resultRepository: InMemoryResultRepository; let penaltyRepository: InMemoryPenaltyRepository; let championshipStandingRepository: InMemoryChampionshipStandingRepository; let useCase: RecalculateChampionshipStandingsUseCase; beforeEach(() => { seasonRepository = new InMemorySeasonRepository(); leagueScoringConfigRepository = new InMemoryLeagueScoringConfigRepository(); raceRepository = new InMemoryRaceRepository(); resultRepository = new InMemoryResultRepository(); penaltyRepository = new InMemoryPenaltyRepository(); championshipStandingRepository = new InMemoryChampionshipStandingRepository(); const eventScoringService = new EventScoringService(); const dropScoreApplier = new DropScoreApplier(); const championshipAggregator = new ChampionshipAggregator(dropScoreApplier); useCase = new RecalculateChampionshipStandingsUseCase( seasonRepository, leagueScoringConfigRepository, raceRepository, resultRepository, penaltyRepository, championshipStandingRepository, eventScoringService, championshipAggregator, ); const season: Season = { id: seasonId, leagueId, gameId: 'iracing', name: 'Demo Season', status: 'active', year: 2025, order: 1, startDate: new Date('2025-01-01'), endDate: new Date('2025-12-31'), }; seasonRepository.seedSeason(season); const championship = makeChampionshipConfig(); const leagueScoringConfig: LeagueScoringConfig = { id: 'lsc-1', seasonId, championships: [championship], }; leagueScoringConfigRepository.seedConfig(leagueScoringConfig); const races: Race[] = [ { id: 'race-1-sprint', leagueId, scheduledAt: new Date('2025-02-01'), track: 'Track 1', car: 'Car A', sessionType: 'race', status: 'completed', }, { id: 'race-1-main', leagueId, scheduledAt: new Date('2025-02-01'), track: 'Track 1', car: 'Car A', sessionType: 'race', status: 'completed', }, { id: 'race-2-sprint', leagueId, scheduledAt: new Date('2025-03-01'), track: 'Track 2', car: 'Car A', sessionType: 'race', status: 'completed', }, { id: 'race-2-main', leagueId, scheduledAt: new Date('2025-03-01'), track: 'Track 2', car: 'Car A', sessionType: 'race', status: 'completed', }, { id: 'race-3-sprint', leagueId, scheduledAt: new Date('2025-04-01'), track: 'Track 3', car: 'Car A', sessionType: 'race', status: 'completed', }, { id: 'race-3-main', leagueId, scheduledAt: new Date('2025-04-01'), track: 'Track 3', car: 'Car A', sessionType: 'race', status: 'completed', }, ]; races.forEach((race) => raceRepository.seedRace(race)); const drivers = ['driver-1', 'driver-2', 'driver-3']; const resultsData: Array<{ raceId: string; finishingOrder: string[]; fastestLapDriverId: string; }> = [ { raceId: 'race-1-sprint', finishingOrder: ['driver-1', 'driver-2', 'driver-3'], fastestLapDriverId: 'driver-2', }, { raceId: 'race-1-main', finishingOrder: ['driver-2', 'driver-1', 'driver-3'], fastestLapDriverId: 'driver-1', }, { raceId: 'race-2-sprint', finishingOrder: ['driver-1', 'driver-3', 'driver-2'], fastestLapDriverId: 'driver-1', }, { raceId: 'race-2-main', finishingOrder: ['driver-1', 'driver-2', 'driver-3'], fastestLapDriverId: 'driver-1', }, { raceId: 'race-3-sprint', finishingOrder: ['driver-2', 'driver-1', 'driver-3'], fastestLapDriverId: 'driver-2', }, { raceId: 'race-3-main', finishingOrder: ['driver-3', 'driver-1', 'driver-2'], fastestLapDriverId: 'driver-3', }, ]; let resultIdCounter = 1; for (const raceData of resultsData) { raceData.finishingOrder.forEach((driverId, index) => { const result: Result = { id: `result-${resultIdCounter++}`, raceId: raceData.raceId, driverId, position: index + 1, fastestLap: driverId === raceData.fastestLapDriverId ? 90000 : 91000 + index * 100, incidents: 0, startPosition: index + 1, }; resultRepository.seedResult(result); }); } }); it('recalculates standings for a driver championship with sprint and main races', async () => { const dto = await useCase.execute({ seasonId, championshipId, }); expect(dto.seasonId).toBe(seasonId); expect(dto.championshipId).toBe(championshipId); expect(dto.championshipName).toBe('Driver Championship'); expect(dto.rows.length).toBeGreaterThan(0); const rows = dto.rows; const sorted = [...rows].sort((a, b) => b.totalPoints - a.totalPoints); expect(rows.map((r) => r.participant.id)).toEqual( sorted.map((r) => r.participant.id), ); const leader = rows[0]; expect(leader.resultsCounted).toBeLessThanOrEqual(6); expect(leader.resultsDropped).toBeGreaterThanOrEqual(0); }); });