website refactor
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { RaceResultsService } from './RaceResultsService';
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
|
||||
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
|
||||
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
|
||||
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '../../types/generated';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
|
||||
import { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
|
||||
import { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel';
|
||||
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '@/lib/types/generated';
|
||||
|
||||
describe('RaceResultsService', () => {
|
||||
let mockApiClient: Mocked<RacesApiClient>;
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
|
||||
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
|
||||
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
|
||||
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Define types
|
||||
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
|
||||
type ImportRaceResultsSummaryDto = {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
resultsRecorded: number;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export interface ImportResultRowDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export interface CSVRow {
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race Results Service
|
||||
*
|
||||
* Orchestrates race results operations including viewing, importing, and SOF calculations.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class RaceResultsService {
|
||||
constructor(
|
||||
private readonly apiClient: RacesApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get race results detail with view model transformation
|
||||
*/
|
||||
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
||||
const dto = await this.apiClient.getResultsDetail(raceId);
|
||||
return new RaceResultsDetailViewModel(dto, currentUserId || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race with strength of field calculation
|
||||
*/
|
||||
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
|
||||
const dto = await this.apiClient.getWithSOF(raceId);
|
||||
return new RaceWithSOFViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import race results and get summary
|
||||
*/
|
||||
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
|
||||
const dto = await this.apiClient.importResults(raceId, input);
|
||||
return new ImportRaceResultsSummaryViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV content and validate results
|
||||
* @throws Error with descriptive message if validation fails
|
||||
*/
|
||||
parseCSV(content: string): CSVRow[] {
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV file is empty or invalid');
|
||||
}
|
||||
|
||||
const headerLine = lines[0]!;
|
||||
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!header.includes(field)) {
|
||||
throw new Error(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: CSVRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
const values = line.split(',').map((v) => v.trim());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(
|
||||
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const row: Record<string, string> = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index] ?? '';
|
||||
});
|
||||
|
||||
const driverId = row['driverid'] ?? '';
|
||||
const position = parseInt(row['position'] ?? '', 10);
|
||||
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (Number.isNaN(startPosition) || startPosition < 1) {
|
||||
throw new Error(`Row ${i}: startPosition must be a positive integer`);
|
||||
}
|
||||
|
||||
rows.push({ driverId, position, fastestLap, incidents, startPosition });
|
||||
}
|
||||
|
||||
const positions = rows.map((r) => r.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
if (positions.length !== uniquePositions.size) {
|
||||
throw new Error('Duplicate positions found in CSV');
|
||||
}
|
||||
|
||||
const driverIds = rows.map((r) => r.driverId);
|
||||
const uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform parsed CSV rows into ImportResultRowDTO array
|
||||
*/
|
||||
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
|
||||
return rows.map((row) => ({
|
||||
id: uuidv4(),
|
||||
raceId,
|
||||
driverId: row.driverId,
|
||||
position: row.position,
|
||||
fastestLap: row.fastestLap,
|
||||
incidents: row.incidents,
|
||||
startPosition: row.startPosition,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV file content and transform to import results
|
||||
* @throws Error with descriptive message if parsing or validation fails
|
||||
*/
|
||||
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
|
||||
const rows = this.parseCSV(content);
|
||||
return this.transformToImportResults(rows, raceId);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceDetailEntryViewModel } from '../../view-models/RaceDetailEntryViewModel';
|
||||
import { RaceDetailUserResultViewModel } from '../../view-models/RaceDetailUserResultViewModel';
|
||||
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
||||
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
|
||||
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
|
||||
import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel';
|
||||
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
|
||||
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
|
||||
/**
|
||||
* Race Service
|
||||
*
|
||||
* Orchestrates race operations by coordinating API calls and view model creation.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class RaceService {
|
||||
constructor(
|
||||
private readonly apiClient: RacesApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get race detail with view model transformation
|
||||
*/
|
||||
async getRaceDetail(
|
||||
raceId: string,
|
||||
driverId: string
|
||||
): Promise<RaceDetailViewModel> {
|
||||
const dto = await this.apiClient.getDetail(raceId, driverId);
|
||||
return new RaceDetailViewModel(dto, driverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race details for pages/components (DTO-free shape)
|
||||
*/
|
||||
async getRaceDetails(
|
||||
raceId: string,
|
||||
driverId: string
|
||||
): Promise<RaceDetailsViewModel> {
|
||||
const dto: any = await this.apiClient.getDetail(raceId, driverId);
|
||||
|
||||
const raceDto: any = dto?.race ?? null;
|
||||
const leagueDto: any = dto?.league ?? null;
|
||||
|
||||
const registrationDto: any = dto?.registration ?? {};
|
||||
const isUserRegistered = Boolean(registrationDto.isUserRegistered ?? registrationDto.isRegistered ?? false);
|
||||
const canRegister = Boolean(registrationDto.canRegister);
|
||||
|
||||
const status = String(raceDto?.status ?? '');
|
||||
const canReopenRace = status === 'completed' || status === 'cancelled';
|
||||
|
||||
return {
|
||||
race: raceDto
|
||||
? {
|
||||
id: String(raceDto.id ?? ''),
|
||||
track: String(raceDto.track ?? ''),
|
||||
car: String(raceDto.car ?? ''),
|
||||
scheduledAt: String(raceDto.scheduledAt ?? ''),
|
||||
status,
|
||||
sessionType: String(raceDto.sessionType ?? ''),
|
||||
}
|
||||
: null,
|
||||
league: leagueDto
|
||||
? {
|
||||
id: String(leagueDto.id ?? ''),
|
||||
name: String(leagueDto.name ?? ''),
|
||||
description: leagueDto.description ?? null,
|
||||
settings: leagueDto.settings,
|
||||
}
|
||||
: null,
|
||||
entryList: (dto?.entryList ?? []).map((entry: any) => new RaceDetailEntryViewModel(entry, driverId)),
|
||||
registration: {
|
||||
canRegister,
|
||||
isUserRegistered,
|
||||
},
|
||||
userResult: dto?.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null,
|
||||
canReopenRace,
|
||||
error: dto?.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get races page data with view model transformation
|
||||
*/
|
||||
async getRacesPageData(): Promise<RacesPageViewModel> {
|
||||
const dto = await this.apiClient.getPageData();
|
||||
return new RacesPageViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get races page data filtered by league
|
||||
*/
|
||||
async getLeagueRacesPageData(leagueId: string): Promise<RacesPageViewModel> {
|
||||
const dto = await this.apiClient.getPageData(leagueId);
|
||||
return new RacesPageViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all races page data with view model transformation
|
||||
* Currently same as getRacesPageData, but can be extended for different filtering
|
||||
*/
|
||||
async getAllRacesPageData(): Promise<RacesPageViewModel> {
|
||||
const dto = await this.apiClient.getPageData();
|
||||
return new RacesPageViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total races statistics with view model transformation
|
||||
*/
|
||||
async getRacesTotal(): Promise<RaceStatsViewModel> {
|
||||
const dto: RaceStatsDTO = await this.apiClient.getTotal();
|
||||
return new RaceStatsViewModel(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register for a race
|
||||
*/
|
||||
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
|
||||
await this.apiClient.register(raceId, { raceId, leagueId, driverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw from a race
|
||||
*/
|
||||
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
|
||||
await this.apiClient.withdraw(raceId, { raceId, driverId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a race
|
||||
*/
|
||||
async cancelRace(raceId: string): Promise<void> {
|
||||
await this.apiClient.cancel(raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a race
|
||||
*/
|
||||
async completeRace(raceId: string): Promise<void> {
|
||||
await this.apiClient.complete(raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a race
|
||||
*/
|
||||
async reopenRace(raceId: string): Promise<void> {
|
||||
await this.apiClient.reopen(raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* File a protest
|
||||
*/
|
||||
async fileProtest(input: FileProtestCommandDTO): Promise<void> {
|
||||
await this.apiClient.fileProtest(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find races by league ID
|
||||
*
|
||||
* The races API does not currently expose a league-filtered listing endpoint in this build,
|
||||
* so this method deliberately signals that the operation is unavailable instead of making
|
||||
* assumptions about URL structure.
|
||||
*/
|
||||
async findByLeagueId(leagueId: string): Promise<RacesPageViewModel['races']> {
|
||||
const page = await this.getLeagueRacesPageData(leagueId);
|
||||
return page.races;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { RaceStewardingService } from './RaceStewardingService';
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
|
||||
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
|
||||
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
||||
|
||||
describe('RaceStewardingService', () => {
|
||||
let mockRacesApiClient: Mocked<RacesApiClient>;
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
|
||||
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
|
||||
|
||||
/**
|
||||
* Race Stewarding Service
|
||||
*
|
||||
* Orchestrates race stewarding operations by coordinating API calls for race details,
|
||||
* protests, and penalties, and returning a unified view model.
|
||||
*/
|
||||
export class RaceStewardingService {
|
||||
constructor(
|
||||
private readonly racesApiClient: RacesApiClient,
|
||||
private readonly protestsApiClient: ProtestsApiClient,
|
||||
private readonly penaltiesApiClient: PenaltiesApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get race stewarding data with view model transformation
|
||||
*/
|
||||
async getRaceStewardingData(raceId: string, driverId: string): Promise<RaceStewardingViewModel> {
|
||||
// Fetch all data in parallel
|
||||
const [raceDetail, protests, penalties] = await Promise.all([
|
||||
this.racesApiClient.getDetail(raceId, driverId),
|
||||
this.protestsApiClient.getRaceProtests(raceId),
|
||||
this.penaltiesApiClient.getRacePenalties(raceId),
|
||||
]);
|
||||
|
||||
// Convert API responses to match RaceStewardingViewModel expectations
|
||||
const convertedProtests = {
|
||||
protests: protests.protests.map(p => ({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
incident: {
|
||||
lap: p.lap,
|
||||
description: p.description
|
||||
},
|
||||
filedAt: p.filedAt,
|
||||
status: p.status
|
||||
})),
|
||||
driverMap: Object.entries(protests.driverMap).reduce((acc, [id, name]) => {
|
||||
acc[id] = { id, name: name as string };
|
||||
return acc;
|
||||
}, {} as Record<string, { id: string; name: string }>)
|
||||
};
|
||||
|
||||
const convertedPenalties = {
|
||||
penalties: penalties.penalties.map(p => ({
|
||||
id: p.id,
|
||||
driverId: p.driverId,
|
||||
type: p.type,
|
||||
value: p.value,
|
||||
reason: p.reason,
|
||||
notes: p.notes
|
||||
})),
|
||||
driverMap: Object.entries(penalties.driverMap).reduce((acc, [id, name]) => {
|
||||
acc[id] = { id, name: name as string };
|
||||
return acc;
|
||||
}, {} as Record<string, { id: string; name: string }>)
|
||||
};
|
||||
|
||||
return new RaceStewardingViewModel({
|
||||
raceDetail,
|
||||
protests: convertedProtests,
|
||||
penalties: convertedPenalties,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user