This commit is contained in:
2025-12-17 00:33:13 +01:00
parent 8c67081953
commit f01e01e50c
186 changed files with 9242 additions and 1342 deletions

View File

@@ -0,0 +1,45 @@
import { IncidentCount } from './IncidentCount';
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
describe('IncidentCount', () => {
describe('create', () => {
it('should create an IncidentCount with valid non-negative integer', () => {
const incidentCount = IncidentCount.create(5);
expect(incidentCount.toNumber()).toBe(5);
});
it('should create with zero', () => {
const incidentCount = IncidentCount.create(0);
expect(incidentCount.toNumber()).toBe(0);
});
it('should throw error for negative number', () => {
expect(() => IncidentCount.create(-1)).toThrow(RacingDomainValidationError);
});
it('should throw error for non-integer', () => {
expect(() => IncidentCount.create(1.5)).toThrow(RacingDomainValidationError);
});
});
describe('toNumber', () => {
it('should return the number value', () => {
const incidentCount = IncidentCount.create(3);
expect(incidentCount.toNumber()).toBe(3);
});
});
describe('equals', () => {
it('should return true for equal incident counts', () => {
const ic1 = IncidentCount.create(2);
const ic2 = IncidentCount.create(2);
expect(ic1.equals(ic2)).toBe(true);
});
it('should return false for different incident counts', () => {
const ic1 = IncidentCount.create(2);
const ic2 = IncidentCount.create(3);
expect(ic1.equals(ic2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,20 @@
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
export class IncidentCount {
private constructor(private readonly value: number) {}
static create(value: number): IncidentCount {
if (!Number.isInteger(value) || value < 0) {
throw new RacingDomainValidationError('Incident count must be a non-negative integer');
}
return new IncidentCount(value);
}
toNumber(): number {
return this.value;
}
equals(other: IncidentCount): boolean {
return this.value === other.value;
}
}

View File

@@ -0,0 +1,45 @@
import { LapTime } from './LapTime';
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
describe('LapTime', () => {
describe('create', () => {
it('should create a LapTime with valid non-negative number', () => {
const lapTime = LapTime.create(120.5);
expect(lapTime.toNumber()).toBe(120.5);
});
it('should create with zero', () => {
const lapTime = LapTime.create(0);
expect(lapTime.toNumber()).toBe(0);
});
it('should throw error for negative number', () => {
expect(() => LapTime.create(-1)).toThrow(RacingDomainValidationError);
});
it('should throw error for NaN', () => {
expect(() => LapTime.create(NaN)).toThrow(RacingDomainValidationError);
});
});
describe('toNumber', () => {
it('should return the number value', () => {
const lapTime = LapTime.create(95.2);
expect(lapTime.toNumber()).toBe(95.2);
});
});
describe('equals', () => {
it('should return true for equal lap times', () => {
const lt1 = LapTime.create(100.0);
const lt2 = LapTime.create(100.0);
expect(lt1.equals(lt2)).toBe(true);
});
it('should return false for different lap times', () => {
const lt1 = LapTime.create(100.0);
const lt2 = LapTime.create(101.0);
expect(lt1.equals(lt2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,20 @@
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
export class LapTime {
private constructor(private readonly value: number) {}
static create(value: number): LapTime {
if (typeof value !== 'number' || value < 0 || isNaN(value)) {
throw new RacingDomainValidationError('Lap time must be a non-negative number');
}
return new LapTime(value);
}
toNumber(): number {
return this.value;
}
equals(other: LapTime): boolean {
return this.value === other.value;
}
}

View File

@@ -0,0 +1,44 @@
import { Position } from './Position';
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
describe('Position', () => {
describe('create', () => {
it('should create a Position with valid positive integer', () => {
const position = Position.create(1);
expect(position.toNumber()).toBe(1);
});
it('should throw error for zero', () => {
expect(() => Position.create(0)).toThrow(RacingDomainValidationError);
});
it('should throw error for negative number', () => {
expect(() => Position.create(-1)).toThrow(RacingDomainValidationError);
});
it('should throw error for non-integer', () => {
expect(() => Position.create(1.5)).toThrow(RacingDomainValidationError);
});
});
describe('toNumber', () => {
it('should return the number value', () => {
const position = Position.create(5);
expect(position.toNumber()).toBe(5);
});
});
describe('equals', () => {
it('should return true for equal positions', () => {
const pos1 = Position.create(2);
const pos2 = Position.create(2);
expect(pos1.equals(pos2)).toBe(true);
});
it('should return false for different positions', () => {
const pos1 = Position.create(2);
const pos2 = Position.create(3);
expect(pos1.equals(pos2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,20 @@
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
export class Position {
private constructor(private readonly value: number) {}
static create(value: number): Position {
if (!Number.isInteger(value) || value <= 0) {
throw new RacingDomainValidationError('Position must be a positive integer');
}
return new Position(value);
}
toNumber(): number {
return this.value;
}
equals(other: Position): boolean {
return this.value === other.value;
}
}

View File

@@ -0,0 +1,261 @@
import { Result } from './Result';
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
import { RaceId } from '../RaceId';
import { DriverId } from '../DriverId';
import { Position } from './Position';
import { LapTime } from './LapTime';
import { IncidentCount } from './IncidentCount';
describe('Result', () => {
const validId = 'result-123';
const validRaceId = 'race-456';
const validDriverId = 'driver-789';
const validPosition = 1;
const validFastestLap = 95.5;
const validIncidents = 0;
const validStartPosition = 2;
describe('create', () => {
it('should create a Result with valid props', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
};
const result = Result.create(props);
expect(result.id).toBe(validId);
expect(result.raceId.toString()).toBe(validRaceId);
expect(result.driverId.toString()).toBe(validDriverId);
expect(result.position.toNumber()).toBe(validPosition);
expect(result.fastestLap.toNumber()).toBe(validFastestLap);
expect(result.incidents.toNumber()).toBe(validIncidents);
expect(result.startPosition.toNumber()).toBe(validStartPosition);
});
it('should throw error for empty id', () => {
const props = {
id: '',
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for empty raceId', () => {
const props = {
id: validId,
raceId: '',
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for empty driverId', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: '',
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for invalid position', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: 0,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for invalid fastestLap', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: -1,
incidents: validIncidents,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for invalid incidents', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: -1,
startPosition: validStartPosition,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
it('should throw error for invalid startPosition', () => {
const props = {
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: 0,
};
expect(() => Result.create(props)).toThrow(RacingDomainValidationError);
});
});
describe('entity properties', () => {
let result: Result;
beforeEach(() => {
result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
});
});
it('should have readonly id', () => {
expect(result.id).toBe(validId);
});
it('should have readonly raceId as RaceId', () => {
expect(result.raceId).toBeInstanceOf(RaceId);
expect(result.raceId.toString()).toBe(validRaceId);
});
it('should have readonly driverId as DriverId', () => {
expect(result.driverId).toBeInstanceOf(DriverId);
expect(result.driverId.toString()).toBe(validDriverId);
});
it('should have readonly position as Position', () => {
expect(result.position).toBeInstanceOf(Position);
expect(result.position.toNumber()).toBe(validPosition);
});
it('should have readonly fastestLap as LapTime', () => {
expect(result.fastestLap).toBeInstanceOf(LapTime);
expect(result.fastestLap.toNumber()).toBe(validFastestLap);
});
it('should have readonly incidents as IncidentCount', () => {
expect(result.incidents).toBeInstanceOf(IncidentCount);
expect(result.incidents.toNumber()).toBe(validIncidents);
});
it('should have readonly startPosition as Position', () => {
expect(result.startPosition).toBeInstanceOf(Position);
expect(result.startPosition.toNumber()).toBe(validStartPosition);
});
});
describe('domain methods', () => {
it('should calculate position change correctly', () => {
const result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: 1,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: 3,
});
expect(result.getPositionChange()).toBe(2); // 3 - 1
});
it('should return true for podium finish', () => {
const result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: 3,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
});
expect(result.isPodium()).toBe(true);
});
it('should return false for non-podium finish', () => {
const result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: 4,
fastestLap: validFastestLap,
incidents: validIncidents,
startPosition: validStartPosition,
});
expect(result.isPodium()).toBe(false);
});
it('should return true for clean race', () => {
const result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: 0,
startPosition: validStartPosition,
});
expect(result.isClean()).toBe(true);
});
it('should return false for race with incidents', () => {
const result = Result.create({
id: validId,
raceId: validRaceId,
driverId: validDriverId,
position: validPosition,
fastestLap: validFastestLap,
incidents: 1,
startPosition: validStartPosition,
});
expect(result.isClean()).toBe(false);
});
});
});

View File

@@ -0,0 +1,120 @@
/**
* Domain Entity: Result
*
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { RaceId } from '../RaceId';
import { DriverId } from '../DriverId';
import { Position } from './Position';
import { LapTime } from './LapTime';
import { IncidentCount } from './IncidentCount';
export class Result implements IEntity<string> {
readonly id: string;
readonly raceId: RaceId;
readonly driverId: DriverId;
readonly position: Position;
readonly fastestLap: LapTime;
readonly incidents: IncidentCount;
readonly startPosition: Position;
private constructor(props: {
id: string;
raceId: RaceId;
driverId: DriverId;
position: Position;
fastestLap: LapTime;
incidents: IncidentCount;
startPosition: Position;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.position = props.position;
this.fastestLap = props.fastestLap;
this.incidents = props.incidents;
this.startPosition = props.startPosition;
}
/**
* Factory method to create a new Result entity
*/
static create(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): Result {
this.validate(props);
const raceId = RaceId.create(props.raceId);
const driverId = DriverId.create(props.driverId);
const position = Position.create(props.position);
const fastestLap = LapTime.create(props.fastestLap);
const incidents = IncidentCount.create(props.incidents);
const startPosition = Position.create(props.startPosition);
return new Result({
id: props.id,
raceId,
driverId,
position,
fastestLap,
incidents,
startPosition,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
}
/**
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition.toNumber() - this.position.toNumber();
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position.toNumber() <= 3;
}
/**
* Check if driver had a clean race (0 incidents)
*/
isClean(): boolean {
return this.incidents.toNumber() === 0;
}
}