import { describe, it, expect } from 'vitest'; import { Race } from '@gridpilot/racing/domain/entities/Race'; import { League } from '@gridpilot/racing/domain/entities/League'; import { Result } from '@gridpilot/racing/domain/entities/Result'; import { Penalty } from '@gridpilot/racing/domain/entities/Penalty'; import { GetRaceResultsDetailUseCase } from '@gridpilot/racing/application/use-cases/GetRaceResultsDetailUseCase'; import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase'; import type { IRaceResultsDetailPresenter, RaceResultsDetailViewModel, } from '@gridpilot/racing/application/presenters/IRaceResultsDetailPresenter'; import type { IImportRaceResultsPresenter, ImportRaceResultsSummaryViewModel, } from '@gridpilot/racing/application/presenters/IImportRaceResultsPresenter'; class FakeRaceResultsDetailPresenter implements IRaceResultsDetailPresenter { viewModel: RaceResultsDetailViewModel | null = null; present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel { this.viewModel = viewModel; return viewModel; } getViewModel(): RaceResultsDetailViewModel | null { return this.viewModel; } } class FakeImportRaceResultsPresenter implements IImportRaceResultsPresenter { viewModel: ImportRaceResultsSummaryViewModel | null = null; present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel { this.viewModel = viewModel; return viewModel; } getViewModel(): ImportRaceResultsSummaryViewModel | null { return this.viewModel; } } describe('ImportRaceResultsUseCase', () => { it('imports results and triggers standings recalculation for the league', async () => { // Given a league, a race, empty results, and a standing repository const league = League.create({ id: 'league-1', name: 'Import League', description: 'League for import tests', ownerId: 'owner-1', }); const race = Race.create({ id: 'race-1', leagueId: league.id, scheduledAt: new Date(), track: 'Import Circuit', car: 'GT3', sessionType: 'race', status: 'completed', }); const races = new Map(); races.set(race.id, race); const leagues = new Map(); leagues.set(league.id, league); const storedResults: Result[] = []; let existsByRaceIdCalled = false; const recalcCalls: string[] = []; const raceRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => races.get(id) ?? null, }; const leagueRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => leagues.get(id) ?? null, }; const resultRepository: { existsByRaceId: (raceId: string) => Promise; createMany: (results: Result[]) => Promise; } = { existsByRaceId: async (raceId: string) => { existsByRaceIdCalled = true; return storedResults.some((r) => r.raceId === raceId); }, createMany: async (results: Result[]) => { storedResults.push(...results); return results; }, }; const standingRepository: { recalculate: (leagueId: string) => Promise; } = { recalculate: async (leagueId: string) => { recalcCalls.push(leagueId); }, }; const presenter = new FakeImportRaceResultsPresenter(); const useCase = new ImportRaceResultsUseCase( raceRepository, leagueRepository, resultRepository, standingRepository, presenter, ); const importedResults = [ Result.create({ id: 'result-1', raceId: race.id, driverId: 'driver-1', position: 1, fastestLap: 90.123, incidents: 0, startPosition: 3, }), Result.create({ id: 'result-2', raceId: race.id, driverId: 'driver-2', position: 2, fastestLap: 91.456, incidents: 2, startPosition: 1, }), ]; // When executing the import await useCase.execute({ raceId: race.id, results: importedResults, }); // Then new Result entries are persisted expect(existsByRaceIdCalled).toBe(true); expect(storedResults.length).toBe(2); expect(storedResults.map((r) => r.id)).toEqual(['result-1', 'result-2']); // And standings are recalculated exactly once for the correct league expect(recalcCalls).toEqual([league.id]); // And the presenter receives a summary const viewModel = presenter.getViewModel(); expect(viewModel).not.toBeNull(); expect(viewModel!.importedCount).toBe(2); expect(viewModel!.standingsRecalculated).toBe(true); }); it('rejects import when results already exist for the race', async () => { const league = League.create({ id: 'league-2', name: 'Existing Results League', description: 'League with existing results', ownerId: 'owner-2', }); const race = Race.create({ id: 'race-2', leagueId: league.id, scheduledAt: new Date(), track: 'Existing Circuit', car: 'GT4', sessionType: 'race', status: 'completed', }); const races = new Map([[race.id, race]]); const leagues = new Map([[league.id, league]]); const storedResults: Result[] = [ Result.create({ id: 'existing', raceId: race.id, driverId: 'driver-x', position: 1, fastestLap: 90.0, incidents: 1, startPosition: 1, }), ]; const raceRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => races.get(id) ?? null, }; const leagueRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => leagues.get(id) ?? null, }; const resultRepository: { existsByRaceId: (raceId: string) => Promise; createMany: (results: Result[]) => Promise; } = { existsByRaceId: async (raceId: string) => { return storedResults.some((r) => r.raceId === raceId); }, createMany: async (_results: Result[]) => { throw new Error('Should not be called when results already exist'); }, }; const standingRepository: { recalculate: (leagueId: string) => Promise; } = { recalculate: async (_leagueId: string) => { throw new Error('Should not be called when results already exist'); }, }; const presenter = new FakeImportRaceResultsPresenter(); const useCase = new ImportRaceResultsUseCase( raceRepository, leagueRepository, resultRepository, standingRepository, presenter, ); const importedResults = [ Result.create({ id: 'new-result', raceId: race.id, driverId: 'driver-1', position: 2, fastestLap: 91.0, incidents: 0, startPosition: 2, }), ]; await expect( useCase.execute({ raceId: race.id, results: importedResults, }), ).rejects.toThrow('Results already exist for this race'); }); }); describe('GetRaceResultsDetailUseCase', () => { it('computes points system from league settings and identifies fastest lap', async () => { // Given a league with default scoring configuration and two results const league = League.create({ id: 'league-scoring', name: 'Scoring League', description: 'League with scoring settings', ownerId: 'owner-scoring', }); const race = Race.create({ id: 'race-scoring', leagueId: league.id, scheduledAt: new Date(), track: 'Scoring Circuit', car: 'Prototype', sessionType: 'race', status: 'completed', }); const driver1: { id: string; name: string; country: string } = { id: 'driver-a', name: 'Driver A', country: 'US', }; const driver2: { id: string; name: string; country: string } = { id: 'driver-b', name: 'Driver B', country: 'GB', }; const result1 = Result.create({ id: 'r1', raceId: race.id, driverId: driver1.id, position: 1, fastestLap: 90.123, incidents: 0, startPosition: 3, }); const result2 = Result.create({ id: 'r2', raceId: race.id, driverId: driver2.id, position: 2, fastestLap: 88.456, incidents: 2, startPosition: 1, }); const races = new Map([[race.id, race]]); const leagues = new Map([[league.id, league]]); const results = [result1, result2]; const drivers = [driver1, driver2]; const raceRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => races.get(id) ?? null, }; const leagueRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => leagues.get(id) ?? null, }; const resultRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async (raceId: string) => results.filter((r) => r.raceId === raceId), }; const driverRepository: { findAll: () => Promise>; } = { findAll: async () => drivers, }; const penaltyRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async () => [] as Penalty[], }; const presenter = new FakeRaceResultsDetailPresenter(); const useCase = new GetRaceResultsDetailUseCase( raceRepository, leagueRepository, resultRepository, driverRepository, penaltyRepository, presenter, ); // When executing the query await useCase.execute({ raceId: race.id }); const viewModel = presenter.getViewModel(); expect(viewModel).not.toBeNull(); // Then points system matches the default F1-style configuration expect(viewModel!.pointsSystem[1]).toBe(25); expect(viewModel!.pointsSystem[2]).toBe(18); // And fastest lap is identified correctly expect(viewModel!.fastestLapTime).toBeCloseTo(88.456, 3); }); it('builds race results view model including penalties', async () => { // Given a race with one result and one applied penalty const league = League.create({ id: 'league-penalties', name: 'Penalty League', description: 'League with penalties', ownerId: 'owner-penalties', }); const race = Race.create({ id: 'race-penalties', leagueId: league.id, scheduledAt: new Date(), track: 'Penalty Circuit', car: 'Touring', sessionType: 'race', status: 'completed', }); const driver: { id: string; name: string; country: string } = { id: 'driver-pen', name: 'Penalty Driver', country: 'DE', }; const result = Result.create({ id: 'res-pen', raceId: race.id, driverId: driver.id, position: 3, fastestLap: 95.0, incidents: 4, startPosition: 5, }); const penalty = Penalty.create({ id: 'pen-1', raceId: race.id, driverId: driver.id, type: 'points_deduction', value: 3, reason: 'Track limits', issuedBy: 'steward-1', status: 'applied', issuedAt: new Date(), }); const races = new Map([[race.id, race]]); const leagues = new Map([[league.id, league]]); const results = [result]; const drivers = [driver]; const penalties = [penalty]; const raceRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => races.get(id) ?? null, }; const leagueRepository: { findById: (id: string) => Promise; } = { findById: async (id: string) => leagues.get(id) ?? null, }; const resultRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async (raceId: string) => results.filter((r) => r.raceId === raceId), }; const driverRepository: { findAll: () => Promise>; } = { findAll: async () => drivers, }; const penaltyRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async (raceId: string) => penalties.filter((p) => p.raceId === raceId), }; const presenter = new FakeRaceResultsDetailPresenter(); const useCase = new GetRaceResultsDetailUseCase( raceRepository, leagueRepository, resultRepository, driverRepository, penaltyRepository, presenter, ); // When await useCase.execute({ raceId: race.id }); const viewModel = presenter.getViewModel(); expect(viewModel).not.toBeNull(); // Then header and league info are present expect(viewModel!.race).not.toBeNull(); expect(viewModel!.race!.id).toBe(race.id); expect(viewModel!.league).not.toBeNull(); expect(viewModel!.league!.id).toBe(league.id); // And classification and penalties match the underlying data expect(viewModel!.results.length).toBe(1); expect(viewModel!.results[0]!.id).toBe(result.id); expect(viewModel!.penalties.length).toBe(1); expect(viewModel!.penalties[0]!.driverId).toBe(driver.id); expect(viewModel!.penalties[0]!.type).toBe('points_deduction'); expect(viewModel!.penalties[0]!.value).toBe(3); }); it('presents an error when race does not exist', async () => { // Given repositories without the requested race const raceRepository: { findById: (id: string) => Promise; } = { findById: async () => null, }; const leagueRepository: { findById: (id: string) => Promise; } = { findById: async () => null, }; const resultRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async () => [] as Result[], }; const driverRepository: { findAll: () => Promise>; } = { findAll: async () => [], }; const penaltyRepository: { findByRaceId: (raceId: string) => Promise; } = { findByRaceId: async () => [] as Penalty[], }; const presenter = new FakeRaceResultsDetailPresenter(); const useCase = new GetRaceResultsDetailUseCase( raceRepository, leagueRepository, resultRepository, driverRepository, penaltyRepository, presenter, ); // When await useCase.execute({ raceId: 'missing-race' }); const viewModel = presenter.getViewModel(); expect(viewModel).not.toBeNull(); expect(viewModel!.race).toBeNull(); expect(viewModel!.error).toBe('Race not found'); }); });