refactor
This commit is contained in:
231
core/racing/domain/entities/penalty/Penalty.test.ts
Normal file
231
core/racing/domain/entities/penalty/Penalty.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Penalty } from './Penalty';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('Penalty', () => {
|
||||
describe('create', () => {
|
||||
it('should create a penalty with required fields', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
|
||||
expect(penalty.id).toBe('penalty-1');
|
||||
expect(penalty.leagueId).toBe('league-1');
|
||||
expect(penalty.raceId).toBe('race-1');
|
||||
expect(penalty.driverId).toBe('driver-1');
|
||||
expect(penalty.type).toBe('time_penalty');
|
||||
expect(penalty.value).toBe(5);
|
||||
expect(penalty.reason).toBe('Speeding');
|
||||
expect(penalty.issuedBy).toBe('steward-1');
|
||||
expect(penalty.status).toBe('pending');
|
||||
expect(penalty.appliedAt).toBeUndefined();
|
||||
expect(penalty.notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a penalty with all fields', () => {
|
||||
const issuedAt = new Date('2023-01-01');
|
||||
const appliedAt = new Date('2023-01-02');
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'disqualification',
|
||||
reason: 'Unsafe driving',
|
||||
protestId: 'protest-1',
|
||||
issuedBy: 'steward-1',
|
||||
status: 'applied',
|
||||
issuedAt,
|
||||
appliedAt,
|
||||
notes: 'Applied after review',
|
||||
});
|
||||
|
||||
expect(penalty.id).toBe('penalty-1');
|
||||
expect(penalty.type).toBe('disqualification');
|
||||
expect(penalty.value).toBeUndefined();
|
||||
expect(penalty.protestId).toBe('protest-1');
|
||||
expect(penalty.status).toBe('applied');
|
||||
expect(penalty.issuedAt).toEqual(issuedAt);
|
||||
expect(penalty.appliedAt).toEqual(appliedAt);
|
||||
expect(penalty.notes).toBe('Applied after review');
|
||||
});
|
||||
|
||||
it('should throw error for invalid id', () => {
|
||||
expect(() => Penalty.create({
|
||||
id: '',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
})).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for invalid type', () => {
|
||||
expect(() => Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'invalid_type',
|
||||
value: 5,
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
})).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for missing value on time_penalty', () => {
|
||||
expect(() => Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
})).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for zero value', () => {
|
||||
expect(() => Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 0,
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
})).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPending', () => {
|
||||
it('should return true for pending status', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
expect(penalty.isPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for applied status', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
status: 'applied',
|
||||
});
|
||||
expect(penalty.isPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsApplied', () => {
|
||||
it('should mark penalty as applied', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
const applied = penalty.markAsApplied('Applied successfully');
|
||||
expect(applied.status).toBe('applied');
|
||||
expect(applied.appliedAt).toBeInstanceOf(Date);
|
||||
expect(applied.notes).toBe('Applied successfully');
|
||||
});
|
||||
|
||||
it('should throw error if already applied', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
status: 'applied',
|
||||
});
|
||||
expect(() => penalty.markAsApplied()).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overturn', () => {
|
||||
it('should overturn the penalty', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
const overturned = penalty.overturn('Appealed successfully');
|
||||
expect(overturned.status).toBe('overturned');
|
||||
expect(overturned.notes).toBe('Appealed successfully');
|
||||
});
|
||||
|
||||
it('should throw error if already overturned', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'warning',
|
||||
reason: 'Warning',
|
||||
issuedBy: 'steward-1',
|
||||
status: 'overturned',
|
||||
});
|
||||
expect(() => penalty.overturn('Reason')).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
it('should return description for time_penalty', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Speeding',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
expect(penalty.getDescription()).toBe('+5s time penalty');
|
||||
});
|
||||
|
||||
it('should return description for disqualification', () => {
|
||||
const penalty = Penalty.create({
|
||||
id: 'penalty-1',
|
||||
leagueId: 'league-1',
|
||||
raceId: 'race-1',
|
||||
driverId: 'driver-1',
|
||||
type: 'disqualification',
|
||||
reason: 'Unsafe',
|
||||
issuedBy: 'steward-1',
|
||||
});
|
||||
expect(penalty.getDescription()).toBe('Disqualified from race');
|
||||
});
|
||||
});
|
||||
});
|
||||
187
core/racing/domain/entities/penalty/Penalty.ts
Normal file
187
core/racing/domain/entities/penalty/Penalty.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Domain Entity: Penalty
|
||||
*
|
||||
* Represents a penalty applied to a driver for an incident during a race.
|
||||
* Penalties can be applied as a result of an upheld protest or directly by stewards.
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError';
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { PenaltyId } from './PenaltyId';
|
||||
import { LeagueId } from '../LeagueId';
|
||||
import { RaceId } from '../RaceId';
|
||||
import { DriverId } from '../DriverId';
|
||||
import { PenaltyType } from './PenaltyType';
|
||||
import { PenaltyValue } from './PenaltyValue';
|
||||
import { PenaltyReason } from './PenaltyReason';
|
||||
import { ProtestId } from '../ProtestId';
|
||||
import { StewardId } from '../StewardId';
|
||||
import { PenaltyStatus } from './PenaltyStatus';
|
||||
import { IssuedAt } from '../IssuedAt';
|
||||
import { AppliedAt } from '../AppliedAt';
|
||||
import { PenaltyNotes } from './PenaltyNotes';
|
||||
|
||||
export interface PenaltyProps {
|
||||
id: PenaltyId;
|
||||
leagueId: LeagueId;
|
||||
raceId: RaceId;
|
||||
/** The driver receiving the penalty */
|
||||
driverId: DriverId;
|
||||
/** Type of penalty */
|
||||
type: PenaltyType;
|
||||
/** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */
|
||||
value?: PenaltyValue;
|
||||
/** Reason for the penalty */
|
||||
reason: PenaltyReason;
|
||||
/** ID of the protest that led to this penalty (if applicable) */
|
||||
protestId?: ProtestId;
|
||||
/** ID of the steward who issued the penalty */
|
||||
issuedBy: StewardId;
|
||||
/** Current status of the penalty */
|
||||
status: PenaltyStatus;
|
||||
/** Timestamp when the penalty was issued */
|
||||
issuedAt: IssuedAt;
|
||||
/** Timestamp when the penalty was applied to results */
|
||||
appliedAt?: AppliedAt;
|
||||
/** Notes about the penalty application */
|
||||
notes?: PenaltyNotes;
|
||||
}
|
||||
|
||||
export class Penalty implements IEntity<string> {
|
||||
private constructor(private readonly props: PenaltyProps) {}
|
||||
|
||||
static create(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
type: string;
|
||||
value?: number;
|
||||
reason: string;
|
||||
protestId?: string;
|
||||
issuedBy: string;
|
||||
status?: string;
|
||||
issuedAt?: Date;
|
||||
appliedAt?: Date;
|
||||
notes?: string;
|
||||
}): Penalty {
|
||||
if (!props.id) throw new RacingDomainValidationError('Penalty ID is required');
|
||||
if (!props.leagueId) throw new RacingDomainValidationError('League ID is required');
|
||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
||||
if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required');
|
||||
if (!props.type) throw new RacingDomainValidationError('Penalty type is required');
|
||||
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
|
||||
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
|
||||
|
||||
// Validate value based on type
|
||||
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
|
||||
if (props.value === undefined || props.value <= 0) {
|
||||
throw new RacingDomainValidationError(`${props.type} requires a positive value`);
|
||||
}
|
||||
}
|
||||
|
||||
const penaltyProps: PenaltyProps = {
|
||||
id: PenaltyId.create(props.id),
|
||||
leagueId: LeagueId.create(props.leagueId),
|
||||
raceId: RaceId.create(props.raceId),
|
||||
driverId: DriverId.create(props.driverId),
|
||||
type: PenaltyType.create(props.type),
|
||||
reason: PenaltyReason.create(props.reason),
|
||||
issuedBy: StewardId.create(props.issuedBy),
|
||||
status: PenaltyStatus.create(props.status || 'pending'),
|
||||
issuedAt: IssuedAt.create(props.issuedAt || new Date()),
|
||||
...(props.value !== undefined && { value: PenaltyValue.create(props.value) }),
|
||||
...(props.protestId !== undefined && { protestId: ProtestId.create(props.protestId) }),
|
||||
...(props.appliedAt !== undefined && { appliedAt: AppliedAt.create(props.appliedAt) }),
|
||||
...(props.notes !== undefined && { notes: PenaltyNotes.create(props.notes) }),
|
||||
};
|
||||
|
||||
return new Penalty(penaltyProps);
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id.toString(); }
|
||||
get leagueId(): string { return this.props.leagueId.toString(); }
|
||||
get raceId(): string { return this.props.raceId.toString(); }
|
||||
get driverId(): string { return this.props.driverId.toString(); }
|
||||
get type(): string { return this.props.type.toString(); }
|
||||
get value(): number | undefined { return this.props.value?.toNumber(); }
|
||||
get reason(): string { return this.props.reason.toString(); }
|
||||
get protestId(): string | undefined { return this.props.protestId?.toString(); }
|
||||
get issuedBy(): string { return this.props.issuedBy.toString(); }
|
||||
get status(): string { return this.props.status.toString(); }
|
||||
get issuedAt(): Date { return this.props.issuedAt.toDate(); }
|
||||
get appliedAt(): Date | undefined { return this.props.appliedAt?.toDate(); }
|
||||
get notes(): string | undefined { return this.props.notes?.toString(); }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status.toString() === 'pending';
|
||||
}
|
||||
|
||||
isApplied(): boolean {
|
||||
return this.props.status.toString() === 'applied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark penalty as applied (after recalculating results)
|
||||
*/
|
||||
markAsApplied(notes?: string): Penalty {
|
||||
if (this.isApplied()) {
|
||||
throw new RacingDomainInvariantError('Penalty is already applied');
|
||||
}
|
||||
if (this.props.status.toString() === 'overturned') {
|
||||
throw new RacingDomainInvariantError('Cannot apply an overturned penalty');
|
||||
}
|
||||
const base: PenaltyProps = {
|
||||
...this.props,
|
||||
status: PenaltyStatus.create('applied'),
|
||||
appliedAt: AppliedAt.create(new Date()),
|
||||
};
|
||||
|
||||
const next: PenaltyProps =
|
||||
notes !== undefined ? { ...base, notes: PenaltyNotes.create(notes) } : base;
|
||||
|
||||
return new Penalty(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overturn the penalty (e.g., after successful appeal)
|
||||
*/
|
||||
overturn(reason: string): Penalty {
|
||||
if (this.props.status.toString() === 'overturned') {
|
||||
throw new RacingDomainInvariantError('Penalty is already overturned');
|
||||
}
|
||||
return new Penalty({
|
||||
...this.props,
|
||||
status: PenaltyStatus.create('overturned'),
|
||||
notes: PenaltyNotes.create(reason),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of the penalty
|
||||
*/
|
||||
getDescription(): string {
|
||||
switch (this.props.type.toString()) {
|
||||
case 'time_penalty':
|
||||
return `+${this.props.value?.toNumber()}s time penalty`;
|
||||
case 'grid_penalty':
|
||||
return `${this.props.value?.toNumber()} place grid penalty (next race)`;
|
||||
case 'points_deduction':
|
||||
return `${this.props.value?.toNumber()} championship points deducted`;
|
||||
case 'disqualification':
|
||||
return 'Disqualified from race';
|
||||
case 'warning':
|
||||
return 'Official warning';
|
||||
case 'license_points':
|
||||
return `${this.props.value?.toNumber()} license penalty points`;
|
||||
case 'probation':
|
||||
return 'Probationary period';
|
||||
case 'fine':
|
||||
return `${this.props.value?.toNumber()} points fine`;
|
||||
case 'race_ban':
|
||||
return `${this.props.value?.toNumber()} race suspension`;
|
||||
default:
|
||||
return 'Penalty';
|
||||
}
|
||||
}
|
||||
}
|
||||
38
core/racing/domain/entities/penalty/PenaltyId.test.ts
Normal file
38
core/racing/domain/entities/penalty/PenaltyId.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PenaltyId } from './PenaltyId';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyId', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyId with valid value', () => {
|
||||
const id = PenaltyId.create('penalty-123');
|
||||
expect(id.toString()).toBe('penalty-123');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const id = PenaltyId.create(' penalty-123 ');
|
||||
expect(id.toString()).toBe('penalty-123');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => PenaltyId.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for whitespace only', () => {
|
||||
expect(() => PenaltyId.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal ids', () => {
|
||||
const id1 = PenaltyId.create('penalty-123');
|
||||
const id2 = PenaltyId.create('penalty-123');
|
||||
expect(id1.equals(id2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different ids', () => {
|
||||
const id1 = PenaltyId.create('penalty-123');
|
||||
const id2 = PenaltyId.create('penalty-456');
|
||||
expect(id1.equals(id2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/penalty/PenaltyId.ts
Normal file
20
core/racing/domain/entities/penalty/PenaltyId.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export class PenaltyId {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static create(value: string): PenaltyId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Penalty ID cannot be empty');
|
||||
}
|
||||
return new PenaltyId(value.trim());
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyId): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
38
core/racing/domain/entities/penalty/PenaltyNotes.test.ts
Normal file
38
core/racing/domain/entities/penalty/PenaltyNotes.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PenaltyNotes } from './PenaltyNotes';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyNotes', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyNotes with valid value', () => {
|
||||
const notes = PenaltyNotes.create('Additional notes');
|
||||
expect(notes.toString()).toBe('Additional notes');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const notes = PenaltyNotes.create(' Additional notes ');
|
||||
expect(notes.toString()).toBe('Additional notes');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => PenaltyNotes.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for whitespace only', () => {
|
||||
expect(() => PenaltyNotes.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal notes', () => {
|
||||
const notes1 = PenaltyNotes.create('Note');
|
||||
const notes2 = PenaltyNotes.create('Note');
|
||||
expect(notes1.equals(notes2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different notes', () => {
|
||||
const notes1 = PenaltyNotes.create('Note1');
|
||||
const notes2 = PenaltyNotes.create('Note2');
|
||||
expect(notes1.equals(notes2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
core/racing/domain/entities/penalty/PenaltyNotes.ts
Normal file
21
core/racing/domain/entities/penalty/PenaltyNotes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export class PenaltyNotes {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static create(value: string): PenaltyNotes {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new RacingDomainValidationError('Penalty notes cannot be empty');
|
||||
}
|
||||
return new PenaltyNotes(trimmed);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyNotes): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
38
core/racing/domain/entities/penalty/PenaltyReason.test.ts
Normal file
38
core/racing/domain/entities/penalty/PenaltyReason.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PenaltyReason } from './PenaltyReason';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyReason', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyReason with valid value', () => {
|
||||
const reason = PenaltyReason.create('Speeding in pit lane');
|
||||
expect(reason.toString()).toBe('Speeding in pit lane');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const reason = PenaltyReason.create(' Speeding in pit lane ');
|
||||
expect(reason.toString()).toBe('Speeding in pit lane');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => PenaltyReason.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for whitespace only', () => {
|
||||
expect(() => PenaltyReason.create(' ')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal reasons', () => {
|
||||
const reason1 = PenaltyReason.create('Speeding');
|
||||
const reason2 = PenaltyReason.create('Speeding');
|
||||
expect(reason1.equals(reason2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different reasons', () => {
|
||||
const reason1 = PenaltyReason.create('Speeding');
|
||||
const reason2 = PenaltyReason.create('Cutting');
|
||||
expect(reason1.equals(reason2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
core/racing/domain/entities/penalty/PenaltyReason.ts
Normal file
21
core/racing/domain/entities/penalty/PenaltyReason.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export class PenaltyReason {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static create(value: string): PenaltyReason {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new RacingDomainValidationError('Penalty reason cannot be empty');
|
||||
}
|
||||
return new PenaltyReason(trimmed);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyReason): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
42
core/racing/domain/entities/penalty/PenaltyStatus.test.ts
Normal file
42
core/racing/domain/entities/penalty/PenaltyStatus.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { PenaltyStatus } from './PenaltyStatus';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyStatus', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyStatus with valid value', () => {
|
||||
const status = PenaltyStatus.create('pending');
|
||||
expect(status.toString()).toBe('pending');
|
||||
});
|
||||
|
||||
it('should create all valid statuses', () => {
|
||||
const validStatuses = ['pending', 'applied', 'appealed', 'overturned'];
|
||||
|
||||
validStatuses.forEach(statusValue => {
|
||||
const status = PenaltyStatus.create(statusValue);
|
||||
expect(status.toString()).toBe(statusValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid status', () => {
|
||||
expect(() => PenaltyStatus.create('invalid_status')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => PenaltyStatus.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal statuses', () => {
|
||||
const status1 = PenaltyStatus.create('pending');
|
||||
const status2 = PenaltyStatus.create('pending');
|
||||
expect(status1.equals(status2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different statuses', () => {
|
||||
const status1 = PenaltyStatus.create('pending');
|
||||
const status2 = PenaltyStatus.create('applied');
|
||||
expect(status1.equals(status2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
core/racing/domain/entities/penalty/PenaltyStatus.ts
Normal file
25
core/racing/domain/entities/penalty/PenaltyStatus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export type PenaltyStatusValue = 'pending' | 'applied' | 'appealed' | 'overturned';
|
||||
|
||||
export class PenaltyStatus {
|
||||
private constructor(private readonly value: PenaltyStatusValue) {}
|
||||
|
||||
static create(value: string): PenaltyStatus {
|
||||
const validStatuses: PenaltyStatusValue[] = ['pending', 'applied', 'appealed', 'overturned'];
|
||||
|
||||
if (!validStatuses.includes(value as PenaltyStatusValue)) {
|
||||
throw new RacingDomainValidationError(`Invalid penalty status: ${value}`);
|
||||
}
|
||||
|
||||
return new PenaltyStatus(value as PenaltyStatusValue);
|
||||
}
|
||||
|
||||
toString(): PenaltyStatusValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyStatus): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
52
core/racing/domain/entities/penalty/PenaltyType.test.ts
Normal file
52
core/racing/domain/entities/penalty/PenaltyType.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { PenaltyType } from './PenaltyType';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyType', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyType with valid value', () => {
|
||||
const type = PenaltyType.create('time_penalty');
|
||||
expect(type.toString()).toBe('time_penalty');
|
||||
});
|
||||
|
||||
it('should create all valid types', () => {
|
||||
const validTypes = [
|
||||
'time_penalty',
|
||||
'grid_penalty',
|
||||
'points_deduction',
|
||||
'disqualification',
|
||||
'warning',
|
||||
'license_points',
|
||||
'probation',
|
||||
'fine',
|
||||
'race_ban',
|
||||
];
|
||||
|
||||
validTypes.forEach(typeValue => {
|
||||
const type = PenaltyType.create(typeValue);
|
||||
expect(type.toString()).toBe(typeValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid type', () => {
|
||||
expect(() => PenaltyType.create('invalid_type')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
expect(() => PenaltyType.create('')).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal types', () => {
|
||||
const type1 = PenaltyType.create('time_penalty');
|
||||
const type2 = PenaltyType.create('time_penalty');
|
||||
expect(type1.equals(type2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different types', () => {
|
||||
const type1 = PenaltyType.create('time_penalty');
|
||||
const type2 = PenaltyType.create('grid_penalty');
|
||||
expect(type1.equals(type2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
core/racing/domain/entities/penalty/PenaltyType.ts
Normal file
44
core/racing/domain/entities/penalty/PenaltyType.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export type PenaltyTypeValue =
|
||||
| 'time_penalty'
|
||||
| 'grid_penalty'
|
||||
| 'points_deduction'
|
||||
| 'disqualification'
|
||||
| 'warning'
|
||||
| 'license_points'
|
||||
| 'probation'
|
||||
| 'fine'
|
||||
| 'race_ban';
|
||||
|
||||
export class PenaltyType {
|
||||
private constructor(private readonly value: PenaltyTypeValue) {}
|
||||
|
||||
static create(value: string): PenaltyType {
|
||||
const validTypes: PenaltyTypeValue[] = [
|
||||
'time_penalty',
|
||||
'grid_penalty',
|
||||
'points_deduction',
|
||||
'disqualification',
|
||||
'warning',
|
||||
'license_points',
|
||||
'probation',
|
||||
'fine',
|
||||
'race_ban',
|
||||
];
|
||||
|
||||
if (!validTypes.includes(value as PenaltyTypeValue)) {
|
||||
throw new RacingDomainValidationError(`Invalid penalty type: ${value}`);
|
||||
}
|
||||
|
||||
return new PenaltyType(value as PenaltyTypeValue);
|
||||
}
|
||||
|
||||
toString(): PenaltyTypeValue {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyType): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
37
core/racing/domain/entities/penalty/PenaltyValue.test.ts
Normal file
37
core/racing/domain/entities/penalty/PenaltyValue.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PenaltyValue } from './PenaltyValue';
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
describe('PenaltyValue', () => {
|
||||
describe('create', () => {
|
||||
it('should create a PenaltyValue with positive integer', () => {
|
||||
const value = PenaltyValue.create(5);
|
||||
expect(value.toNumber()).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw error for zero', () => {
|
||||
expect(() => PenaltyValue.create(0)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for negative number', () => {
|
||||
expect(() => PenaltyValue.create(-1)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw error for non-integer', () => {
|
||||
expect(() => PenaltyValue.create(5.5)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for equal values', () => {
|
||||
const value1 = PenaltyValue.create(5);
|
||||
const value2 = PenaltyValue.create(5);
|
||||
expect(value1.equals(value2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different values', () => {
|
||||
const value1 = PenaltyValue.create(5);
|
||||
const value2 = PenaltyValue.create(10);
|
||||
expect(value1.equals(value2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/penalty/PenaltyValue.ts
Normal file
20
core/racing/domain/entities/penalty/PenaltyValue.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||
|
||||
export class PenaltyValue {
|
||||
private constructor(private readonly value: number) {}
|
||||
|
||||
static create(value: number): PenaltyValue {
|
||||
if (value <= 0 || !Number.isInteger(value)) {
|
||||
throw new RacingDomainValidationError('Penalty value must be a positive integer');
|
||||
}
|
||||
return new PenaltyValue(value);
|
||||
}
|
||||
|
||||
toNumber(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: PenaltyValue): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user