fix issues in core
This commit is contained in:
@@ -156,7 +156,7 @@ describe('DriverLivery', () => {
|
||||
const updatedLivery = livery.setLeagueOverride('league-1', 'season-1', 'decal-1', 0.5, 0.5);
|
||||
|
||||
expect(updatedLivery.leagueOverrides).toHaveLength(1);
|
||||
expect(updatedLivery.leagueOverrides[0].leagueId).toBe('league-1');
|
||||
expect(updatedLivery.leagueOverrides[0]!.leagueId).toBe('league-1');
|
||||
});
|
||||
|
||||
it('should update existing override', () => {
|
||||
@@ -172,7 +172,7 @@ describe('DriverLivery', () => {
|
||||
const updatedLivery = livery.setLeagueOverride('league-1', 'season-1', 'decal-1', 0.6, 0.6);
|
||||
|
||||
expect(updatedLivery.leagueOverrides).toHaveLength(1);
|
||||
expect(updatedLivery.leagueOverrides[0].newX).toBe(0.6);
|
||||
expect(updatedLivery.leagueOverrides[0]!.newX).toBe(0.6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,192 +1,14 @@
|
||||
/**
|
||||
* Domain Entity: Penalty
|
||||
* Compatibility re-export.
|
||||
*
|
||||
* 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.
|
||||
* `Penalty` moved to `entities/penalty/Penalty` but some code still imports
|
||||
* from `entities/Penalty`. Re-exporting avoids having two distinct classes
|
||||
* (which breaks assignability due to private fields).
|
||||
*/
|
||||
|
||||
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 { Penalty } from './penalty/Penalty';
|
||||
export type { PenaltyProps } from './penalty/Penalty';
|
||||
|
||||
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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestProps } from './SponsorshipRequest';
|
||||
import { SponsorshipRequest, SponsorableEntityType } from './SponsorshipRequest';
|
||||
import { SponsorshipTier } from './season/SeasonSponsorship';
|
||||
import { Money } from '../value-objects/Money';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
SeasonDropPolicy,
|
||||
} from '@core/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
import { createMinimalSeason, createBaseSeason } from '../../../../../testing/factories/racing/SeasonFactory';
|
||||
import {
|
||||
createMinimalSeason,
|
||||
createBaseSeason,
|
||||
} from '@core/testing/factories/racing/SeasonFactory';
|
||||
|
||||
|
||||
describe('Season aggregate lifecycle', () => {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { EventScoringService } from '@core/racing/domain/services/EventScoringService';
|
||||
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import { Result } from '@core/racing/domain/entities/result/Result';
|
||||
import type { Penalty } from '@core/racing/domain/entities/Penalty';
|
||||
import type { ChampionshipType } from '@core/racing/domain/types/ChampionshipType';
|
||||
import { makeDriverRef } from '../../../testing/factories/racing/DriverRefFactory';
|
||||
import { makePointsTable } from '../../../testing/factories/racing/PointsTableFactory';
|
||||
import { makeChampionshipConfig } from '../../../testing/factories/racing/ChampionshipConfigFactory';
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* Represents a monetary amount with currency and platform fee calculation
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'GBP';
|
||||
|
||||
export const isCurrency = (value: string): value is Currency =>
|
||||
value === 'USD' || value === 'EUR' || value === 'GBP';
|
||||
|
||||
export interface MoneyProps {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
@@ -34,6 +37,7 @@ export class Money implements IValueObject<MoneyProps> {
|
||||
return new Money(amount, currency);
|
||||
}
|
||||
|
||||
// TODO i dont think platform fee must be coupled
|
||||
/**
|
||||
* Calculate platform fee (10%)
|
||||
*/
|
||||
|
||||
@@ -90,7 +90,51 @@ export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
||||
return this.incidents.length === 0;
|
||||
}
|
||||
|
||||
// Removed getSeverityScore, getSummary, and getIncidentTypeLabel to eliminate static data in core
|
||||
/**
|
||||
* Backwards-compatible helper for legacy "incident count" fields.
|
||||
* Creates `count` placeholder incidents of type `other`.
|
||||
*/
|
||||
static fromLegacyIncidentsCount(count: number): RaceIncidents {
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return new RaceIncidents();
|
||||
}
|
||||
|
||||
const incidents: IncidentRecord[] = Array.from({ length: Math.floor(count) }, (_, index) => ({
|
||||
type: 'other',
|
||||
lap: index + 1,
|
||||
penaltyPoints: 0,
|
||||
}));
|
||||
|
||||
return new RaceIncidents(incidents);
|
||||
}
|
||||
|
||||
/**
|
||||
* A coarse severity score for incidents.
|
||||
* Kept intentionally data-light: derived only from `penaltyPoints`.
|
||||
*/
|
||||
getSeverityScore(): number {
|
||||
return this.getTotalPenaltyPoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable summary without hardcoded incident labels.
|
||||
*/
|
||||
getSummary(): string {
|
||||
const total = this.getTotalCount();
|
||||
if (total === 0) return 'Clean race';
|
||||
|
||||
const countsByType = new Map<IncidentType, number>();
|
||||
for (const incident of this.incidents) {
|
||||
countsByType.set(incident.type, (countsByType.get(incident.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const typeSummary = Array.from(countsByType.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([type, n]) => `${type}:${n}`)
|
||||
.join(', ');
|
||||
|
||||
return typeSummary.length > 0 ? `${total} incidents (${typeSummary})` : `${total} incidents`;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||
const otherIncidents = other.props;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionType } from './SessionType';
|
||||
import { SessionType, type SessionTypeValue } from './SessionType';
|
||||
|
||||
describe('SessionType', () => {
|
||||
it('should create session type', () => {
|
||||
@@ -9,8 +9,8 @@ describe('SessionType', () => {
|
||||
});
|
||||
|
||||
it('should throw for invalid session type', () => {
|
||||
expect(() => new SessionType('invalid' as any)).toThrow();
|
||||
expect(() => new SessionType('' as any)).toThrow();
|
||||
expect(() => new SessionType('invalid' as unknown as SessionTypeValue)).toThrow();
|
||||
expect(() => new SessionType('' as unknown as SessionTypeValue)).toThrow();
|
||||
});
|
||||
|
||||
it('should have static factory methods', () => {
|
||||
|
||||
Reference in New Issue
Block a user