This commit is contained in:
2025-12-17 12:52:20 +01:00
parent 07dfefebe4
commit 1ea9c9649f
3 changed files with 427 additions and 1 deletions

View File

@@ -7,10 +7,10 @@
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { LeagueId } from './LeagueId';
import { DriverId } from '../value-objects/DriverId';
import { MembershipRole, MembershipRoleValue } from './MembershipRole';
import { MembershipStatus, MembershipStatusValue } from './MembershipStatus';
import { JoinedAt } from '../value-objects/JoinedAt';
import { DriverId } from './DriverId';
export interface LeagueMembershipProps {
id?: string;

View 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');
});
});
});

View File

@@ -0,0 +1,195 @@
/**
* 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 './penalty/PenaltyId';
import { LeagueId } from './LeagueId';
import { RaceId } from './RaceId';
import { DriverId } from './DriverId';
import { PenaltyType } from './penalty/PenaltyType';
import { PenaltyValue } from './penalty/PenaltyValue';
import { PenaltyReason } from './penalty/PenaltyReason';
import { ProtestId } from './ProtestId';
import { StewardId } from './StewardId';
import { PenaltyStatus } from './penalty/PenaltyStatus';
import { IssuedAt } from './IssuedAt';
import { AppliedAt } from './AppliedAt';
import { PenaltyNotes } from './penalty/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';
}
}
}
// Export types for external use
export { PenaltyType } from './penalty/PenaltyType';
export { PenaltyStatus } from './penalty/PenaltyStatus';
export { PenaltyValue } from './penalty/PenaltyValue';
export { PenaltyReason } from './penalty/PenaltyReason';
export { PenaltyNotes } from './penalty/PenaltyNotes';
export { PenaltyId } from './penalty/PenaltyId';