refactor
This commit is contained in:
44
adapters/bootstrap/LeagueConstraints.ts
Normal file
44
adapters/bootstrap/LeagueConstraints.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 1000,
|
||||||
|
recommendedMinLength: 50,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LEAGUE_NAME_CONSTRAINTS = {
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 64,
|
||||||
|
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
|
||||||
|
forbiddenPatterns: [
|
||||||
|
/^\s/, // No leading whitespace
|
||||||
|
/\s$/, // No trailing whitespace
|
||||||
|
/\s{2,}/, // No multiple consecutive spaces
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LeagueVisibilityType = 'ranked' | 'unranked';
|
||||||
|
|
||||||
|
export interface LeagueVisibilityConstraints {
|
||||||
|
readonly minDrivers: number;
|
||||||
|
readonly isPubliclyVisible: boolean;
|
||||||
|
readonly affectsRatings: boolean;
|
||||||
|
readonly requiresApproval: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
|
||||||
|
ranked: {
|
||||||
|
minDrivers: 10,
|
||||||
|
isPubliclyVisible: true,
|
||||||
|
affectsRatings: true,
|
||||||
|
requiresApproval: false, // Anyone can join public leagues
|
||||||
|
},
|
||||||
|
unranked: {
|
||||||
|
minDrivers: 2,
|
||||||
|
isPubliclyVisible: false,
|
||||||
|
affectsRatings: false,
|
||||||
|
requiresApproval: true, // Private leagues require invite/approval
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export constants for validation
|
||||||
|
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
|
||||||
|
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;
|
||||||
@@ -5,6 +5,7 @@ import type { AsyncUseCase } from '@core/shared/application';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { ApproveLeagueJoinRequestUseCaseParams } from '../dto/ApproveLeagueJoinRequestUseCaseParams';
|
import type { ApproveLeagueJoinRequestUseCaseParams } from '../dto/ApproveLeagueJoinRequestUseCaseParams';
|
||||||
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
|
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
|
||||||
|
import { JoinedAt } from '../../domain/value-objects/JoinedAt';
|
||||||
|
|
||||||
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, string> {
|
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, string> {
|
||||||
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
|
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
|
||||||
@@ -22,7 +23,7 @@ export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeag
|
|||||||
driverId: request.driverId,
|
driverId: request.driverId,
|
||||||
role: 'member',
|
role: 'member',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
joinedAt: new Date(),
|
joinedAt: JoinedAt.create(new Date()),
|
||||||
});
|
});
|
||||||
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
|
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
|
||||||
return Result.ok(dto);
|
return Result.ok(dto);
|
||||||
|
|||||||
34
core/racing/domain/value-objects/CarId.test.ts
Normal file
34
core/racing/domain/value-objects/CarId.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CarId } from './CarId';
|
||||||
|
|
||||||
|
describe('CarId', () => {
|
||||||
|
it('should create a car id', () => {
|
||||||
|
const id = CarId.create('car1');
|
||||||
|
expect(id.toString()).toBe('car1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty id', () => {
|
||||||
|
expect(() => CarId.create('')).toThrow('Car ID cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on whitespace id', () => {
|
||||||
|
expect(() => CarId.create(' ')).toThrow('Car ID cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const id = CarId.create(' car1 ');
|
||||||
|
expect(id.toString()).toBe('car1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same id', () => {
|
||||||
|
const id1 = CarId.create('car1');
|
||||||
|
const id2 = CarId.create('car1');
|
||||||
|
expect(id1.equals(id2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different id', () => {
|
||||||
|
const id1 = CarId.create('car1');
|
||||||
|
const id2 = CarId.create('car2');
|
||||||
|
expect(id1.equals(id2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class CountryCode {
|
export class CountryCode implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
static create(value: string): CountryCode {
|
static create(value: string): CountryCode {
|
||||||
@@ -18,7 +19,11 @@ export class CountryCode {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: CountryCode): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
141
core/racing/domain/value-objects/DecalOverride.test.ts
Normal file
141
core/racing/domain/value-objects/DecalOverride.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DecalOverride } from './DecalOverride';
|
||||||
|
|
||||||
|
describe('DecalOverride', () => {
|
||||||
|
it('should create a decal override', () => {
|
||||||
|
const override = DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
expect(override.props).toEqual({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty leagueId', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: '',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride leagueId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty seasonId', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: '',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride seasonId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty decalId', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: '',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride decalId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on newX less than 0', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: -0.1,
|
||||||
|
newY: 0.3,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride newX must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on newX greater than 1', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 1.1,
|
||||||
|
newY: 0.3,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride newX must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on newY less than 0', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: -0.1,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride newY must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on newY greater than 1', () => {
|
||||||
|
expect(() =>
|
||||||
|
DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 1.1,
|
||||||
|
})
|
||||||
|
).toThrow('DecalOverride newY must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same override', () => {
|
||||||
|
const o1 = DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
const o2 = DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
expect(o1.equals(o2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different override', () => {
|
||||||
|
const o1 = DecalOverride.create({
|
||||||
|
leagueId: 'league1',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
const o2 = DecalOverride.create({
|
||||||
|
leagueId: 'league2',
|
||||||
|
seasonId: 'season1',
|
||||||
|
decalId: 'decal1',
|
||||||
|
newX: 0.5,
|
||||||
|
newY: 0.3,
|
||||||
|
});
|
||||||
|
expect(o1.equals(o2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
|
||||||
|
|
||||||
export class DriverBio {
|
|
||||||
private constructor(private readonly value: string) {}
|
|
||||||
|
|
||||||
static create(value: string): DriverBio {
|
|
||||||
if (value.length > 500) {
|
|
||||||
throw new RacingDomainValidationError('Driver bio cannot exceed 500 characters');
|
|
||||||
}
|
|
||||||
return new DriverBio(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: DriverBio): boolean {
|
|
||||||
return this.value === other.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
|
||||||
import type { IValueObject } from '@core/shared/domain';
|
|
||||||
|
|
||||||
export class DriverId implements IValueObject<string> {
|
|
||||||
private constructor(private readonly value: string) {}
|
|
||||||
|
|
||||||
static create(value: string): DriverId {
|
|
||||||
if (!value || value.trim().length === 0) {
|
|
||||||
throw new RacingDomainValidationError('Driver ID cannot be empty');
|
|
||||||
}
|
|
||||||
return new DriverId(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: IValueObject<string>): boolean {
|
|
||||||
return this.value === other.props;
|
|
||||||
}
|
|
||||||
|
|
||||||
get props(): string {
|
|
||||||
return this.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
|
||||||
|
|
||||||
export class DriverName {
|
|
||||||
private constructor(private readonly value: string) {}
|
|
||||||
|
|
||||||
static create(value: string): DriverName {
|
|
||||||
if (!value || value.trim().length === 0) {
|
|
||||||
throw new RacingDomainValidationError('Driver name is required');
|
|
||||||
}
|
|
||||||
return new DriverName(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: DriverName): boolean {
|
|
||||||
return this.value === other.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
core/racing/domain/value-objects/GameConstraints.test.ts
Normal file
46
core/racing/domain/value-objects/GameConstraints.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GameConstraints } from './GameConstraints';
|
||||||
|
|
||||||
|
const sampleConstraints = {
|
||||||
|
maxDrivers: 64,
|
||||||
|
maxTeams: 32,
|
||||||
|
defaultMaxDrivers: 24,
|
||||||
|
minDrivers: 2,
|
||||||
|
supportsTeams: true,
|
||||||
|
supportsMultiClass: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GameConstraints', () => {
|
||||||
|
it('should create', () => {
|
||||||
|
const gc = new GameConstraints('iracing', sampleConstraints);
|
||||||
|
expect(gc.gameId).toBe('iracing');
|
||||||
|
expect(gc.maxDrivers).toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate driver count', () => {
|
||||||
|
const gc = new GameConstraints('iracing', sampleConstraints);
|
||||||
|
expect(gc.validateDriverCount(10)).toEqual({ valid: true });
|
||||||
|
expect(gc.validateDriverCount(1)).toEqual({ valid: false, error: 'Minimum 2 drivers required' });
|
||||||
|
expect(gc.validateDriverCount(100)).toEqual({ valid: false, error: 'Maximum 64 drivers allowed for IRACING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate team count', () => {
|
||||||
|
const gc = new GameConstraints('iracing', sampleConstraints);
|
||||||
|
expect(gc.validateTeamCount(10)).toEqual({ valid: true });
|
||||||
|
expect(gc.validateTeamCount(100)).toEqual({ valid: false, error: 'Maximum 32 teams allowed for IRACING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not support teams if false', () => {
|
||||||
|
const noTeams = { ...sampleConstraints, supportsTeams: false };
|
||||||
|
const gc = new GameConstraints('acc', noTeams);
|
||||||
|
expect(gc.validateTeamCount(1)).toEqual({ valid: false, error: 'ACC does not support team-based leagues' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const gc1 = new GameConstraints('iracing', sampleConstraints);
|
||||||
|
const gc2 = new GameConstraints('iracing', sampleConstraints);
|
||||||
|
const gc3 = new GameConstraints('acc', sampleConstraints);
|
||||||
|
expect(gc1.equals(gc2)).toBe(true);
|
||||||
|
expect(gc1.equals(gc3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,75 +21,11 @@ export interface GameConstraintsProps {
|
|||||||
constraints: GameConstraintsData;
|
constraints: GameConstraintsData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Game-specific constraints for popular sim racing games
|
|
||||||
*/
|
|
||||||
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
|
|
||||||
iracing: {
|
|
||||||
maxDrivers: 64,
|
|
||||||
maxTeams: 32,
|
|
||||||
defaultMaxDrivers: 24,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: true,
|
|
||||||
},
|
|
||||||
acc: {
|
|
||||||
maxDrivers: 30,
|
|
||||||
maxTeams: 15,
|
|
||||||
defaultMaxDrivers: 24,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: false,
|
|
||||||
},
|
|
||||||
rf2: {
|
|
||||||
maxDrivers: 64,
|
|
||||||
maxTeams: 32,
|
|
||||||
defaultMaxDrivers: 24,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: true,
|
|
||||||
},
|
|
||||||
ams2: {
|
|
||||||
maxDrivers: 32,
|
|
||||||
maxTeams: 16,
|
|
||||||
defaultMaxDrivers: 20,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: true,
|
|
||||||
},
|
|
||||||
lmu: {
|
|
||||||
maxDrivers: 32,
|
|
||||||
maxTeams: 16,
|
|
||||||
defaultMaxDrivers: 24,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: true,
|
|
||||||
},
|
|
||||||
// Default for unknown games
|
|
||||||
default: {
|
|
||||||
maxDrivers: 32,
|
|
||||||
maxTeams: 16,
|
|
||||||
defaultMaxDrivers: 20,
|
|
||||||
minDrivers: 2,
|
|
||||||
supportsTeams: true,
|
|
||||||
supportsMultiClass: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getConstraintsForId(gameId: string): GameConstraintsData {
|
|
||||||
const lower = gameId.toLowerCase();
|
|
||||||
const fromMap = GAME_CONSTRAINTS[lower];
|
|
||||||
if (fromMap) {
|
|
||||||
return fromMap;
|
|
||||||
}
|
|
||||||
return GAME_CONSTRAINTS.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GameConstraints implements IValueObject<GameConstraintsProps> {
|
export class GameConstraints implements IValueObject<GameConstraintsProps> {
|
||||||
readonly gameId: string;
|
readonly gameId: string;
|
||||||
readonly constraints: GameConstraintsData;
|
readonly constraints: GameConstraintsData;
|
||||||
|
|
||||||
private constructor(gameId: string, constraints: GameConstraintsData) {
|
constructor(gameId: string, constraints: GameConstraintsData) {
|
||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.constraints = constraints;
|
this.constraints = constraints;
|
||||||
}
|
}
|
||||||
@@ -105,22 +41,6 @@ export class GameConstraints implements IValueObject<GameConstraintsProps> {
|
|||||||
return this.props.gameId === other.props.gameId;
|
return this.props.gameId === other.props.gameId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get constraints for a specific game
|
|
||||||
*/
|
|
||||||
static forGame(gameId: string): GameConstraints {
|
|
||||||
const constraints = getConstraintsForId(gameId);
|
|
||||||
const lowerId = gameId.toLowerCase();
|
|
||||||
return new GameConstraints(lowerId, constraints);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all supported game IDs
|
|
||||||
*/
|
|
||||||
static getSupportedGames(): string[] {
|
|
||||||
return Object.keys(GAME_CONSTRAINTS).filter(id => id !== 'default');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum drivers allowed for this game
|
* Maximum drivers allowed for this game
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class IRacingId {
|
export class IRacingId implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
static create(value: string): IRacingId {
|
static create(value: string): IRacingId {
|
||||||
@@ -14,7 +15,11 @@ export class IRacingId {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: IRacingId): boolean {
|
get props(): string {
|
||||||
return this.value === other.value;
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<string>): boolean {
|
||||||
|
return this.props === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
34
core/racing/domain/value-objects/ImageUrl.test.ts
Normal file
34
core/racing/domain/value-objects/ImageUrl.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ImageUrl } from './ImageUrl';
|
||||||
|
|
||||||
|
describe('ImageUrl', () => {
|
||||||
|
it('should create valid url', () => {
|
||||||
|
const url = ImageUrl.create('https://example.com/image.jpg');
|
||||||
|
expect(url.toString()).toBe('https://example.com/image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on empty', () => {
|
||||||
|
expect(() => ImageUrl.create('')).toThrow('Image URL cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid url', () => {
|
||||||
|
expect(() => ImageUrl.create('not-a-url')).toThrow('Invalid image URL format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim', () => {
|
||||||
|
const url = ImageUrl.create(' https://example.com ');
|
||||||
|
expect(url.toString()).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same', () => {
|
||||||
|
const u1 = ImageUrl.create('https://example.com');
|
||||||
|
const u2 = ImageUrl.create('https://example.com');
|
||||||
|
expect(u1.equals(u2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different', () => {
|
||||||
|
const u1 = ImageUrl.create('https://example.com/1');
|
||||||
|
const u2 = ImageUrl.create('https://example.com/2');
|
||||||
|
expect(u1.equals(u2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ export class ImageUrl implements IValueObject<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
equals(other: IValueObject<string>): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.props;
|
return this.props === other.props;
|
||||||
}
|
}
|
||||||
|
|
||||||
get props(): string {
|
get props(): string {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class JoinedAt {
|
export class JoinedAt implements IValueObject<Date> {
|
||||||
private constructor(private readonly value: Date) {}
|
private constructor(private readonly value: Date) {}
|
||||||
|
|
||||||
static create(value: Date): JoinedAt {
|
static create(value: Date): JoinedAt {
|
||||||
@@ -15,7 +16,11 @@ export class JoinedAt {
|
|||||||
return new Date(this.value);
|
return new Date(this.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: JoinedAt): boolean {
|
get props(): Date {
|
||||||
return this.value.getTime() === other.value.getTime();
|
return new Date(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<Date>): boolean {
|
||||||
|
return this.props.getTime() === other.props.getTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
61
core/racing/domain/value-objects/LeagueDescription.test.ts
Normal file
61
core/racing/domain/value-objects/LeagueDescription.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeagueDescription } from './LeagueDescription';
|
||||||
|
|
||||||
|
describe('LeagueDescription', () => {
|
||||||
|
it('should create valid description', () => {
|
||||||
|
const desc = LeagueDescription.create('This is a valid league description with enough characters.');
|
||||||
|
expect(desc.value).toBe('This is a valid league description with enough characters.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const desc = LeagueDescription.create(' This is a valid description with enough characters to pass validation. ');
|
||||||
|
expect(desc.value).toBe('This is a valid description with enough characters to pass validation.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate minimum length', () => {
|
||||||
|
expect(() => LeagueDescription.create('Short')).toThrow('Description must be at least 20 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate maximum length', () => {
|
||||||
|
const longDesc = 'a'.repeat(1001);
|
||||||
|
expect(() => LeagueDescription.create(longDesc)).toThrow('Description must be 1000 characters or less');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required', () => {
|
||||||
|
expect(() => LeagueDescription.create('')).toThrow('Description is required');
|
||||||
|
expect(() => LeagueDescription.create(' ')).toThrow('Description is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check recommended length', () => {
|
||||||
|
expect(LeagueDescription.isRecommendedLength('Short desc')).toBe(false);
|
||||||
|
expect(LeagueDescription.isRecommendedLength('This is a longer description that meets the recommended length.')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate without creating', () => {
|
||||||
|
expect(LeagueDescription.validate('Valid')).toEqual({ valid: false, error: 'Description must be at least 20 characters — tell drivers what makes your league special' });
|
||||||
|
expect(LeagueDescription.validate('This is a valid description.')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tryCreate', () => {
|
||||||
|
expect(LeagueDescription.tryCreate('This is a valid description with enough characters.')).toBeInstanceOf(LeagueDescription);
|
||||||
|
expect(LeagueDescription.tryCreate('Short')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const desc = LeagueDescription.create('This is a test description with enough characters.');
|
||||||
|
expect(desc.props).toEqual({ value: 'This is a test description with enough characters.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toString', () => {
|
||||||
|
const desc = LeagueDescription.create('This is a test description.');
|
||||||
|
expect(desc.toString()).toBe('This is a test description.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const desc1 = LeagueDescription.create('This is a test description.');
|
||||||
|
const desc2 = LeagueDescription.create('This is a test description.');
|
||||||
|
const desc3 = LeagueDescription.create('This is a different description with enough characters.');
|
||||||
|
expect(desc1.equals(desc2)).toBe(true);
|
||||||
|
expect(desc1.equals(desc3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Represents a valid league description with validation rules.
|
* Represents a valid league description with validation rules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
import type { IValueObject } from '@core/shared/domain';
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
|
|||||||
65
core/racing/domain/value-objects/LeagueName.test.ts
Normal file
65
core/racing/domain/value-objects/LeagueName.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeagueName } from './LeagueName';
|
||||||
|
|
||||||
|
describe('LeagueName', () => {
|
||||||
|
it('should create valid name', () => {
|
||||||
|
const name = LeagueName.create('Valid League Name');
|
||||||
|
expect(name.value).toBe('Valid League Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const name = LeagueName.create('Valid Name');
|
||||||
|
expect(name.value).toBe('Valid Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate minimum length', () => {
|
||||||
|
expect(() => LeagueName.create('AB')).toThrow('League name must be at least 3 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate maximum length', () => {
|
||||||
|
const longName = 'a'.repeat(65);
|
||||||
|
expect(() => LeagueName.create(longName)).toThrow('League name must be 64 characters or less');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required', () => {
|
||||||
|
expect(() => LeagueName.create('')).toThrow('League name is required');
|
||||||
|
expect(() => LeagueName.create(' ')).toThrow('League name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate pattern', () => {
|
||||||
|
expect(() => LeagueName.create('_league')).toThrow('League name must start with a letter or number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate forbidden patterns', () => {
|
||||||
|
expect(() => LeagueName.create(' League ')).toThrow('League name cannot have leading/trailing spaces or multiple consecutive spaces');
|
||||||
|
expect(() => LeagueName.create('League Name')).toThrow('League name cannot have leading/trailing spaces or multiple consecutive spaces');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate without creating', () => {
|
||||||
|
expect(LeagueName.validate('AB')).toEqual({ valid: false, error: 'League name must be at least 3 characters' });
|
||||||
|
expect(LeagueName.validate('Valid Name')).toEqual({ valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tryCreate', () => {
|
||||||
|
expect(LeagueName.tryCreate('Valid')).toBeInstanceOf(LeagueName);
|
||||||
|
expect(LeagueName.tryCreate('AB')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const name = LeagueName.create('Test');
|
||||||
|
expect(name.props).toEqual({ value: 'Test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toString', () => {
|
||||||
|
const name = LeagueName.create('Test');
|
||||||
|
expect(name.toString()).toBe('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const name1 = LeagueName.create('Test');
|
||||||
|
const name2 = LeagueName.create('Test');
|
||||||
|
const name3 = LeagueName.create('Different');
|
||||||
|
expect(name1.equals(name2)).toBe(true);
|
||||||
|
expect(name1.equals(name3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
core/racing/domain/value-objects/LeagueTimezone.test.ts
Normal file
37
core/racing/domain/value-objects/LeagueTimezone.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeagueTimezone } from './LeagueTimezone';
|
||||||
|
|
||||||
|
describe('LeagueTimezone', () => {
|
||||||
|
it('should create valid timezone', () => {
|
||||||
|
const tz = LeagueTimezone.create('America/New_York');
|
||||||
|
expect(tz.id).toBe('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const tz = LeagueTimezone.create(' America/New_York ');
|
||||||
|
expect(tz.id).toBe('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate non-empty', () => {
|
||||||
|
expect(() => LeagueTimezone.create('')).toThrow('LeagueTimezone id must be a non-empty string');
|
||||||
|
expect(() => LeagueTimezone.create(' ')).toThrow('LeagueTimezone id must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const tz = LeagueTimezone.create('UTC');
|
||||||
|
expect(tz.props).toEqual({ id: 'UTC' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toString', () => {
|
||||||
|
const tz = LeagueTimezone.create('UTC');
|
||||||
|
expect(tz.toString()).toBe('UTC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const tz1 = LeagueTimezone.create('UTC');
|
||||||
|
const tz2 = LeagueTimezone.create('UTC');
|
||||||
|
const tz3 = LeagueTimezone.create('EST');
|
||||||
|
expect(tz1.equals(tz2)).toBe(true);
|
||||||
|
expect(tz1.equals(tz3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,23 +6,27 @@ export interface LeagueTimezoneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
|
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
|
||||||
private readonly id: string;
|
readonly id: string;
|
||||||
|
|
||||||
constructor(id: string) {
|
private constructor(id: string) {
|
||||||
if (!id || id.trim().length === 0) {
|
|
||||||
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
|
|
||||||
}
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
getId(): string {
|
static create(id: string): LeagueTimezone {
|
||||||
return this.id;
|
if (!id || id.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
|
||||||
|
}
|
||||||
|
return new LeagueTimezone(id.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
get props(): LeagueTimezoneProps {
|
get props(): LeagueTimezoneProps {
|
||||||
return { id: this.id };
|
return { id: this.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
|
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
|
||||||
return this.props.id === other.props.id;
|
return this.props.id === other.props.id;
|
||||||
}
|
}
|
||||||
|
|||||||
61
core/racing/domain/value-objects/LeagueVisibility.test.ts
Normal file
61
core/racing/domain/value-objects/LeagueVisibility.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LeagueVisibility } from './LeagueVisibility';
|
||||||
|
|
||||||
|
describe('LeagueVisibility', () => {
|
||||||
|
it('should create ranked', () => {
|
||||||
|
const vis = LeagueVisibility.ranked();
|
||||||
|
expect(vis.type).toBe('ranked');
|
||||||
|
expect(vis.isRanked()).toBe(true);
|
||||||
|
expect(vis.isUnranked()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create unranked', () => {
|
||||||
|
const vis = LeagueVisibility.unranked();
|
||||||
|
expect(vis.type).toBe('unranked');
|
||||||
|
expect(vis.isRanked()).toBe(false);
|
||||||
|
expect(vis.isUnranked()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fromString', () => {
|
||||||
|
expect(LeagueVisibility.fromString('ranked').type).toBe('ranked');
|
||||||
|
expect(LeagueVisibility.fromString('unranked').type).toBe('unranked');
|
||||||
|
expect(LeagueVisibility.fromString('public').type).toBe('ranked');
|
||||||
|
expect(LeagueVisibility.fromString('private').type).toBe('unranked');
|
||||||
|
expect(() => LeagueVisibility.fromString('invalid')).toThrow('Invalid league visibility: invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate driver count', () => {
|
||||||
|
const ranked = LeagueVisibility.ranked();
|
||||||
|
expect(ranked.validateDriverCount(15)).toEqual({ valid: true });
|
||||||
|
expect(ranked.validateDriverCount(5)).toEqual({ valid: false, error: 'Ranked leagues require at least 10 drivers' });
|
||||||
|
|
||||||
|
const unranked = LeagueVisibility.unranked();
|
||||||
|
expect(unranked.validateDriverCount(5)).toEqual({ valid: true });
|
||||||
|
expect(unranked.validateDriverCount(1)).toEqual({ valid: false, error: 'Unranked leagues require at least 2 drivers' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const vis = LeagueVisibility.ranked();
|
||||||
|
expect(vis.props).toEqual({ type: 'ranked' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toString', () => {
|
||||||
|
const vis = LeagueVisibility.ranked();
|
||||||
|
expect(vis.toString()).toBe('ranked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toLegacyString', () => {
|
||||||
|
const ranked = LeagueVisibility.ranked();
|
||||||
|
const unranked = LeagueVisibility.unranked();
|
||||||
|
expect(ranked.toLegacyString()).toBe('public');
|
||||||
|
expect(unranked.toLegacyString()).toBe('private');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const vis1 = LeagueVisibility.ranked();
|
||||||
|
const vis2 = LeagueVisibility.ranked();
|
||||||
|
const vis3 = LeagueVisibility.unranked();
|
||||||
|
expect(vis1.equals(vis2)).toBe(true);
|
||||||
|
expect(vis1.equals(vis3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,21 +96,6 @@ export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
|
|||||||
return this.type === 'unranked';
|
return this.type === 'unranked';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Human-readable label for UI display
|
|
||||||
*/
|
|
||||||
getLabel(): string {
|
|
||||||
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Short description for UI tooltips
|
|
||||||
*/
|
|
||||||
getDescription(): string {
|
|
||||||
return this.type === 'ranked'
|
|
||||||
? 'Competitive league visible to everyone. Results affect driver ratings.'
|
|
||||||
: 'Private league for friends. Results do not affect ratings.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert to string for serialization
|
* Convert to string for serialization
|
||||||
|
|||||||
344
core/racing/domain/value-objects/LiveryDecal.test.ts
Normal file
344
core/racing/domain/value-objects/LiveryDecal.test.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { LiveryDecal } from './LiveryDecal';
|
||||||
|
|
||||||
|
describe('LiveryDecal', () => {
|
||||||
|
it('should create valid decal', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
expect(decal.id).toBe('test-id');
|
||||||
|
expect(decal.imageUrl).toBe('http://example.com/image.png');
|
||||||
|
expect(decal.x).toBe(0.5);
|
||||||
|
expect(decal.y).toBe(0.5);
|
||||||
|
expect(decal.width).toBe(0.2);
|
||||||
|
expect(decal.height).toBe(0.1);
|
||||||
|
expect(decal.rotation).toBe(0);
|
||||||
|
expect(decal.zIndex).toBe(1);
|
||||||
|
expect(decal.type).toBe('sponsor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create with custom rotation', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
rotation: 45,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'user',
|
||||||
|
});
|
||||||
|
expect(decal.rotation).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate id required', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: '',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal ID is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate imageUrl required', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: '',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal imageUrl is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate x coordinate range', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: -0.1,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal x coordinate must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate y coordinate range', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 1.1,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal y coordinate must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate width range', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal width must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate height range', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 1.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal height must be between 0 and 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate zIndex non-negative integer', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: -1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal zIndex must be a non-negative integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate rotation range', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
rotation: 361,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal rotation must be between 0 and 360 degrees');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate type required', () => {
|
||||||
|
expect(() => LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: '' as 'sponsor',
|
||||||
|
})).toThrow('LiveryDecal type is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move to new position', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const moved = decal.moveTo(0.3, 0.7);
|
||||||
|
expect(moved.x).toBe(0.3);
|
||||||
|
expect(moved.y).toBe(0.7);
|
||||||
|
expect(moved.id).toBe(decal.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resize', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const resized = decal.resize(0.4, 0.2);
|
||||||
|
expect(resized.width).toBe(0.4);
|
||||||
|
expect(resized.height).toBe(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set zIndex', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const updated = decal.setZIndex(5);
|
||||||
|
expect(updated.zIndex).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rotate', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const rotated = decal.rotate(90);
|
||||||
|
expect(rotated.rotation).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize rotation', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const rotated = decal.rotate(450);
|
||||||
|
expect(rotated.rotation).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check overlaps', () => {
|
||||||
|
const decal1 = LiveryDecal.create({
|
||||||
|
id: '1',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.3,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const decal2 = LiveryDecal.create({
|
||||||
|
id: '2',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.3,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
expect(decal1.overlapsWith(decal2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not overlap when separate', () => {
|
||||||
|
const decal1 = LiveryDecal.create({
|
||||||
|
id: '1',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.2,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const decal2 = LiveryDecal.create({
|
||||||
|
id: '2',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.2,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
expect(decal1.overlapsWith(decal2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const decal = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
expect(decal.props).toEqual({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
rotation: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const decal1 = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const decal2 = LiveryDecal.create({
|
||||||
|
id: 'test-id',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
const decal3 = LiveryDecal.create({
|
||||||
|
id: 'different',
|
||||||
|
imageUrl: 'http://example.com/image.png',
|
||||||
|
x: 0.5,
|
||||||
|
y: 0.5,
|
||||||
|
width: 0.2,
|
||||||
|
height: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
type: 'sponsor',
|
||||||
|
});
|
||||||
|
expect(decal1.equals(decal2)).toBe(true);
|
||||||
|
expect(decal1.equals(decal3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,25 +123,18 @@ export class LiveryDecal implements IValueObject<LiveryDecalProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotate decal
|
* Rotate decal
|
||||||
*/
|
*/
|
||||||
rotate(rotation: number): LiveryDecal {
|
rotate(rotation: number): LiveryDecal {
|
||||||
// Normalize rotation to 0-360 range
|
// Normalize rotation to 0-360 range
|
||||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||||
return LiveryDecal.create({
|
return LiveryDecal.create({
|
||||||
...this,
|
...this,
|
||||||
rotation: normalizedRotation,
|
rotation: normalizedRotation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
get props(): LiveryDecalProps {
|
||||||
* Get CSS transform string for rendering
|
|
||||||
*/
|
|
||||||
getCssTransform(): string {
|
|
||||||
return `rotate(${this.rotation}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get props(): LiveryDecalProps {
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
imageUrl: this.imageUrl,
|
imageUrl: this.imageUrl,
|
||||||
|
|||||||
73
core/racing/domain/value-objects/MembershipFee.test.ts
Normal file
73
core/racing/domain/value-objects/MembershipFee.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MembershipFee } from './MembershipFee';
|
||||||
|
import { Money } from './Money';
|
||||||
|
|
||||||
|
describe('MembershipFee', () => {
|
||||||
|
it('should create valid membership fee', () => {
|
||||||
|
const amount = Money.create(1000, 'USD');
|
||||||
|
const fee = MembershipFee.create('season', amount);
|
||||||
|
expect(fee.type).toBe('season');
|
||||||
|
expect(fee.amount).toBe(amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate type required', () => {
|
||||||
|
const amount = Money.create(1000, 'USD');
|
||||||
|
expect(() => MembershipFee.create('' as 'season', amount)).toThrow('MembershipFee type is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate amount required', () => {
|
||||||
|
expect(() => MembershipFee.create('season', null as unknown as Money)).toThrow('MembershipFee amount is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate amount not negative', () => {
|
||||||
|
expect(() => Money.create(-100, 'USD')).toThrow('Money amount cannot be negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get platform fee', () => {
|
||||||
|
const amount = Money.create(1000, 'USD'); // $10.00
|
||||||
|
const fee = MembershipFee.create('season', amount);
|
||||||
|
const platformFee = fee.getPlatformFee();
|
||||||
|
expect(platformFee.amount).toBe(100); // $1.00
|
||||||
|
expect(platformFee.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get net amount', () => {
|
||||||
|
const amount = Money.create(1000, 'USD'); // $10.00
|
||||||
|
const fee = MembershipFee.create('season', amount);
|
||||||
|
const netAmount = fee.getNetAmount();
|
||||||
|
expect(netAmount.amount).toBe(900); // $9.00
|
||||||
|
expect(netAmount.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check if recurring', () => {
|
||||||
|
const amount = Money.create(1000, 'USD');
|
||||||
|
const seasonFee = MembershipFee.create('season', amount);
|
||||||
|
const monthlyFee = MembershipFee.create('monthly', amount);
|
||||||
|
const perRaceFee = MembershipFee.create('per_race', amount);
|
||||||
|
expect(seasonFee.isRecurring()).toBe(false);
|
||||||
|
expect(monthlyFee.isRecurring()).toBe(true);
|
||||||
|
expect(perRaceFee.isRecurring()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const amount = Money.create(1000, 'USD');
|
||||||
|
const fee = MembershipFee.create('season', amount);
|
||||||
|
expect(fee.props).toEqual({
|
||||||
|
type: 'season',
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const amount1 = Money.create(1000, 'USD');
|
||||||
|
const amount2 = Money.create(1000, 'USD');
|
||||||
|
const amount3 = Money.create(2000, 'USD');
|
||||||
|
const fee1 = MembershipFee.create('season', amount1);
|
||||||
|
const fee2 = MembershipFee.create('season', amount2);
|
||||||
|
const fee3 = MembershipFee.create('monthly', amount1);
|
||||||
|
const fee4 = MembershipFee.create('season', amount3);
|
||||||
|
expect(fee1.equals(fee2)).toBe(true);
|
||||||
|
expect(fee1.equals(fee3)).toBe(false);
|
||||||
|
expect(fee1.equals(fee4)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,10 +33,6 @@ export class MembershipFee implements IValueObject<MembershipFeeProps> {
|
|||||||
throw new RacingDomainValidationError('MembershipFee amount is required');
|
throw new RacingDomainValidationError('MembershipFee amount is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount.amount < 0) {
|
|
||||||
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new MembershipFee({ type, amount });
|
return new MembershipFee({ type, amount });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,29 +58,15 @@ export class MembershipFee implements IValueObject<MembershipFeeProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this is a recurring fee
|
* Check if this is a recurring fee
|
||||||
*/
|
*/
|
||||||
isRecurring(): boolean {
|
isRecurring(): boolean {
|
||||||
return this.type === 'monthly';
|
return this.type === 'monthly';
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: IValueObject<MembershipFeeProps>): boolean {
|
equals(other: IValueObject<MembershipFeeProps>): boolean {
|
||||||
const a = this.props;
|
const a = this.props;
|
||||||
const b = other.props;
|
const b = other.props;
|
||||||
return a.type === b.type && a.amount.equals(b.amount);
|
return a.type === b.type && a.amount.equals(b.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for fee type
|
|
||||||
*/
|
|
||||||
getDisplayName(): string {
|
|
||||||
switch (this.type) {
|
|
||||||
case 'season':
|
|
||||||
return 'Season Fee';
|
|
||||||
case 'monthly':
|
|
||||||
return 'Monthly Subscription';
|
|
||||||
case 'per_race':
|
|
||||||
return 'Per-Race Fee';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
103
core/racing/domain/value-objects/Money.test.ts
Normal file
103
core/racing/domain/value-objects/Money.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Money } from './Money';
|
||||||
|
|
||||||
|
describe('Money', () => {
|
||||||
|
it('should create valid money', () => {
|
||||||
|
const money = Money.create(1000, 'USD');
|
||||||
|
expect(money.amount).toBe(1000);
|
||||||
|
expect(money.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to USD', () => {
|
||||||
|
const money = Money.create(500);
|
||||||
|
expect(money.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate amount not negative', () => {
|
||||||
|
expect(() => Money.create(-100, 'USD')).toThrow('Money amount cannot be negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate amount finite', () => {
|
||||||
|
expect(() => Money.create(Infinity, 'USD')).toThrow('Money amount must be a finite number');
|
||||||
|
expect(() => Money.create(NaN, 'USD')).toThrow('Money amount must be a finite number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate platform fee', () => {
|
||||||
|
const money = Money.create(1000, 'USD'); // $10.00
|
||||||
|
const fee = money.calculatePlatformFee();
|
||||||
|
expect(fee.amount).toBe(100); // $1.00
|
||||||
|
expect(fee.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate net amount', () => {
|
||||||
|
const money = Money.create(1000, 'USD'); // $10.00
|
||||||
|
const net = money.calculateNetAmount();
|
||||||
|
expect(net.amount).toBe(900); // $9.00
|
||||||
|
expect(net.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add money', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'USD');
|
||||||
|
const sum = money1.add(money2);
|
||||||
|
expect(sum.amount).toBe(800);
|
||||||
|
expect(sum.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add different currencies', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'EUR');
|
||||||
|
expect(() => money1.add(money2)).toThrow('Cannot add money with different currencies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subtract money', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'USD');
|
||||||
|
const diff = money1.subtract(money2);
|
||||||
|
expect(diff.amount).toBe(200);
|
||||||
|
expect(diff.currency).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not subtract different currencies', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'EUR');
|
||||||
|
expect(() => money1.subtract(money2)).toThrow('Cannot subtract money with different currencies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not subtract to negative', () => {
|
||||||
|
const money1 = Money.create(300, 'USD');
|
||||||
|
const money2 = Money.create(500, 'USD');
|
||||||
|
expect(() => money1.subtract(money2)).toThrow('Subtraction would result in negative amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check greater than', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'USD');
|
||||||
|
expect(money1.isGreaterThan(money2)).toBe(true);
|
||||||
|
expect(money2.isGreaterThan(money1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not compare different currencies', () => {
|
||||||
|
const money1 = Money.create(500, 'USD');
|
||||||
|
const money2 = Money.create(300, 'EUR');
|
||||||
|
expect(() => money1.isGreaterThan(money2)).toThrow('Cannot compare money with different currencies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const money = Money.create(1000, 'USD');
|
||||||
|
expect(money.props).toEqual({
|
||||||
|
amount: 1000,
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const money1 = Money.create(1000, 'USD');
|
||||||
|
const money2 = Money.create(1000, 'USD');
|
||||||
|
const money3 = Money.create(500, 'USD');
|
||||||
|
const money4 = Money.create(1000, 'EUR');
|
||||||
|
expect(money1.equals(money2)).toBe(true);
|
||||||
|
expect(money1.equals(money3)).toBe(false);
|
||||||
|
expect(money1.equals(money4)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,24 +92,11 @@ export class Money implements IValueObject<MoneyProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this money equals another
|
* Check if this money equals another
|
||||||
*/
|
*/
|
||||||
equals(other: IValueObject<MoneyProps>): boolean {
|
equals(other: IValueObject<MoneyProps>): boolean {
|
||||||
const a = this.props;
|
const a = this.props;
|
||||||
const b = other.props;
|
const b = other.props;
|
||||||
return a.amount === b.amount && a.currency === b.currency;
|
return a.amount === b.amount && a.currency === b.currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format money for display
|
|
||||||
*/
|
|
||||||
format(): string {
|
|
||||||
const formatter = new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: this.currency,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
return formatter.format(this.amount / 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||||
|
|
||||||
|
describe('MonthlyRecurrencePattern', () => {
|
||||||
|
it('should create valid pattern', () => {
|
||||||
|
const pattern = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
expect(pattern.ordinal).toBe(1);
|
||||||
|
expect(pattern.weekday).toBe('Mon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate ordinal range', () => {
|
||||||
|
expect(() => MonthlyRecurrencePattern.create(0 as 1, 'Mon')).toThrow('MonthlyRecurrencePattern ordinal must be between 1 and 4');
|
||||||
|
expect(() => MonthlyRecurrencePattern.create(5 as 1, 'Mon')).toThrow('MonthlyRecurrencePattern ordinal must be between 1 and 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate weekday', () => {
|
||||||
|
expect(() => MonthlyRecurrencePattern.create(1, 'Invalid' as 'Mon')).toThrow('MonthlyRecurrencePattern weekday must be a valid weekday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get ordinal suffix', () => {
|
||||||
|
const first = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
const second = MonthlyRecurrencePattern.create(2, 'Tue');
|
||||||
|
const third = MonthlyRecurrencePattern.create(3, 'Wed');
|
||||||
|
const fourth = MonthlyRecurrencePattern.create(4, 'Thu');
|
||||||
|
expect(first.getOrdinalSuffix()).toBe('1st');
|
||||||
|
expect(second.getOrdinalSuffix()).toBe('2nd');
|
||||||
|
expect(third.getOrdinalSuffix()).toBe('3rd');
|
||||||
|
expect(fourth.getOrdinalSuffix()).toBe('4th');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get description', () => {
|
||||||
|
const pattern = MonthlyRecurrencePattern.create(2, 'Wed');
|
||||||
|
expect(pattern.getDescription()).toBe('2nd Wed of the month');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have props', () => {
|
||||||
|
const pattern = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
expect(pattern.props).toEqual({
|
||||||
|
ordinal: 1,
|
||||||
|
weekday: 'Mon',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals', () => {
|
||||||
|
const pattern1 = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
const pattern2 = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
const pattern3 = MonthlyRecurrencePattern.create(2, 'Mon');
|
||||||
|
const pattern4 = MonthlyRecurrencePattern.create(1, 'Tue');
|
||||||
|
expect(pattern1.equals(pattern2)).toBe(true);
|
||||||
|
expect(pattern1.equals(pattern3)).toBe(false);
|
||||||
|
expect(pattern1.equals(pattern4)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import { ALL_WEEKDAYS } from '../types/Weekday';
|
||||||
import type { Weekday } from '../types/Weekday';
|
import type { Weekday } from '../types/Weekday';
|
||||||
import type { IValueObject } from '@core/shared/domain';
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
@@ -9,20 +11,43 @@ export interface MonthlyRecurrencePatternProps {
|
|||||||
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
|
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
|
||||||
readonly ordinal: 1 | 2 | 3 | 4;
|
readonly ordinal: 1 | 2 | 3 | 4;
|
||||||
readonly weekday: Weekday;
|
readonly weekday: Weekday;
|
||||||
|
|
||||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
|
private constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
|
||||||
constructor(props: MonthlyRecurrencePatternProps);
|
this.ordinal = ordinal;
|
||||||
constructor(
|
this.weekday = weekday;
|
||||||
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
|
}
|
||||||
weekday?: Weekday,
|
|
||||||
) {
|
static create(ordinal: 1 | 2 | 3 | 4, weekday: Weekday): MonthlyRecurrencePattern {
|
||||||
if (typeof ordinalOrProps === 'object') {
|
if (!ordinal || ordinal < 1 || ordinal > 4) {
|
||||||
this.ordinal = ordinalOrProps.ordinal;
|
throw new RacingDomainValidationError('MonthlyRecurrencePattern ordinal must be between 1 and 4');
|
||||||
this.weekday = ordinalOrProps.weekday;
|
|
||||||
} else {
|
|
||||||
this.ordinal = ordinalOrProps;
|
|
||||||
this.weekday = weekday as Weekday;
|
|
||||||
}
|
}
|
||||||
|
if (!weekday || !ALL_WEEKDAYS.includes(weekday)) {
|
||||||
|
throw new RacingDomainValidationError('MonthlyRecurrencePattern weekday must be a valid weekday');
|
||||||
|
}
|
||||||
|
return new MonthlyRecurrencePattern(ordinal, weekday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ordinal suffix (1st, 2nd, 3rd, 4th)
|
||||||
|
*/
|
||||||
|
getOrdinalSuffix(): string {
|
||||||
|
switch (this.ordinal) {
|
||||||
|
case 1:
|
||||||
|
return '1st';
|
||||||
|
case 2:
|
||||||
|
return '2nd';
|
||||||
|
case 3:
|
||||||
|
return '3rd';
|
||||||
|
case 4:
|
||||||
|
return '4th';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get description of the pattern
|
||||||
|
*/
|
||||||
|
getDescription(): string {
|
||||||
|
return `${this.getOrdinalSuffix()} ${this.weekday} of the month`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get props(): MonthlyRecurrencePatternProps {
|
get props(): MonthlyRecurrencePatternProps {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
|
||||||
export class Points {
|
export class Points implements IValueObject<{ value: number }> {
|
||||||
private constructor(private readonly value: number) {}
|
private constructor(private readonly value: number) {}
|
||||||
|
|
||||||
|
get props(): { value: number } {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: number): Points {
|
static create(value: number): Points {
|
||||||
if (value < 0) {
|
if (value < 0) {
|
||||||
throw new RacingDomainValidationError('Points cannot be negative');
|
throw new RacingDomainValidationError('Points cannot be negative');
|
||||||
@@ -14,7 +19,7 @@ export class Points {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: Points): boolean {
|
equals(other: IValueObject<{ value: number }>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
core/racing/domain/value-objects/PointsTable.test.ts
Normal file
45
core/racing/domain/value-objects/PointsTable.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PointsTable } from './PointsTable';
|
||||||
|
|
||||||
|
describe('PointsTable', () => {
|
||||||
|
it('should create points table from record', () => {
|
||||||
|
const table = new PointsTable({ 1: 25, 2: 18, 3: 15 });
|
||||||
|
expect(table.getPointsForPosition(1)).toBe(25);
|
||||||
|
expect(table.getPointsForPosition(2)).toBe(18);
|
||||||
|
expect(table.getPointsForPosition(3)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create points table from map', () => {
|
||||||
|
const map = new Map([[1, 25], [2, 18]]);
|
||||||
|
const table = new PointsTable(map);
|
||||||
|
expect(table.getPointsForPosition(1)).toBe(25);
|
||||||
|
expect(table.getPointsForPosition(2)).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for invalid positions', () => {
|
||||||
|
const table = new PointsTable({ 1: 25 });
|
||||||
|
expect(table.getPointsForPosition(0)).toBe(0);
|
||||||
|
expect(table.getPointsForPosition(-1)).toBe(0);
|
||||||
|
expect(table.getPointsForPosition(1.5)).toBe(0);
|
||||||
|
expect(table.getPointsForPosition(2)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should equal same points table', () => {
|
||||||
|
const t1 = new PointsTable({ 1: 25, 2: 18 });
|
||||||
|
const t2 = new PointsTable({ 1: 25, 2: 18 });
|
||||||
|
expect(t1.equals(t2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different points table', () => {
|
||||||
|
const t1 = new PointsTable({ 1: 25, 2: 18 });
|
||||||
|
const t2 = new PointsTable({ 1: 25, 2: 19 });
|
||||||
|
expect(t1.equals(t2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal table with different size', () => {
|
||||||
|
const t1 = new PointsTable({ 1: 25 });
|
||||||
|
const t2 = new PointsTable({ 1: 25, 2: 18 });
|
||||||
|
expect(t1.equals(t2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { IValueObject } from '@core/shared/domain';
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export interface PointsTableProps {
|
export interface PointsTableProps {
|
||||||
pointsByPosition: Map<number, number>;
|
pointsByPosition: ReadonlyMap<number, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PointsTable implements IValueObject<PointsTableProps> {
|
export class PointsTable implements IValueObject<PointsTableProps> {
|
||||||
private readonly pointsByPosition: Map<number, number>;
|
private readonly pointsByPosition: ReadonlyMap<number, number>;
|
||||||
|
|
||||||
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
|
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
|
||||||
if (pointsByPosition instanceof Map) {
|
if (pointsByPosition instanceof Map) {
|
||||||
@@ -27,7 +27,7 @@ export class PointsTable implements IValueObject<PointsTableProps> {
|
|||||||
|
|
||||||
get props(): PointsTableProps {
|
get props(): PointsTableProps {
|
||||||
return {
|
return {
|
||||||
pointsByPosition: new Map(this.pointsByPosition),
|
pointsByPosition: this.pointsByPosition,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
core/racing/domain/value-objects/RaceIncidents.test.ts
Normal file
102
core/racing/domain/value-objects/RaceIncidents.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RaceIncidents, type IncidentRecord } from './RaceIncidents';
|
||||||
|
|
||||||
|
describe('RaceIncidents', () => {
|
||||||
|
const sampleIncident: IncidentRecord = {
|
||||||
|
type: 'contact',
|
||||||
|
lap: 5,
|
||||||
|
description: 'Minor contact',
|
||||||
|
penaltyPoints: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const anotherIncident: IncidentRecord = {
|
||||||
|
type: 'track_limits',
|
||||||
|
lap: 10,
|
||||||
|
penaltyPoints: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create empty incidents', () => {
|
||||||
|
const incidents = new RaceIncidents();
|
||||||
|
expect(incidents.getTotalCount()).toBe(0);
|
||||||
|
expect(incidents.isClean()).toBe(true);
|
||||||
|
expect(incidents.hasIncidents()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create incidents from array', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident]);
|
||||||
|
expect(incidents.getTotalCount()).toBe(1);
|
||||||
|
expect(incidents.isClean()).toBe(false);
|
||||||
|
expect(incidents.hasIncidents()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add incident immutably', () => {
|
||||||
|
const original = new RaceIncidents([sampleIncident]);
|
||||||
|
const updated = original.addIncident(anotherIncident);
|
||||||
|
expect(original.getTotalCount()).toBe(1);
|
||||||
|
expect(updated.getTotalCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all incidents', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident, anotherIncident]);
|
||||||
|
const all = incidents.getAllIncidents();
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
expect(all).toEqual([sampleIncident, anotherIncident]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get total count', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident, anotherIncident]);
|
||||||
|
expect(incidents.getTotalCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get total penalty points', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident, anotherIncident]);
|
||||||
|
expect(incidents.getTotalPenaltyPoints()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get incidents by type', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident, anotherIncident]);
|
||||||
|
const contacts = incidents.getIncidentsByType('contact');
|
||||||
|
expect(contacts).toHaveLength(1);
|
||||||
|
expect(contacts[0]).toEqual(sampleIncident);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check has incidents', () => {
|
||||||
|
const empty = new RaceIncidents();
|
||||||
|
const withIncidents = new RaceIncidents([sampleIncident]);
|
||||||
|
expect(empty.hasIncidents()).toBe(false);
|
||||||
|
expect(withIncidents.hasIncidents()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check is clean', () => {
|
||||||
|
const empty = new RaceIncidents();
|
||||||
|
const withIncidents = new RaceIncidents([sampleIncident]);
|
||||||
|
expect(empty.isClean()).toBe(true);
|
||||||
|
expect(withIncidents.isClean()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same incidents', () => {
|
||||||
|
const i1 = new RaceIncidents([sampleIncident]);
|
||||||
|
const i2 = new RaceIncidents([sampleIncident]);
|
||||||
|
expect(i1.equals(i2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different incidents', () => {
|
||||||
|
const i1 = new RaceIncidents([sampleIncident]);
|
||||||
|
const i2 = new RaceIncidents([anotherIncident]);
|
||||||
|
expect(i1.equals(i2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different length', () => {
|
||||||
|
const i1 = new RaceIncidents([sampleIncident]);
|
||||||
|
const i2 = new RaceIncidents([sampleIncident, anotherIncident]);
|
||||||
|
expect(i1.equals(i2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return props as copy', () => {
|
||||||
|
const incidents = new RaceIncidents([sampleIncident]);
|
||||||
|
const props = incidents.props;
|
||||||
|
expect(props).toEqual([sampleIncident]);
|
||||||
|
props.push(anotherIncident);
|
||||||
|
expect(incidents.getTotalCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,70 +90,7 @@ export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
|||||||
return this.incidents.length === 0;
|
return this.incidents.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Removed getSeverityScore, getSummary, and getIncidentTypeLabel to eliminate static data in core
|
||||||
* Get incident severity score (0-100, higher = more severe)
|
|
||||||
*/
|
|
||||||
getSeverityScore(): number {
|
|
||||||
if (this.incidents.length === 0) return 0;
|
|
||||||
|
|
||||||
const severityWeights: Record<IncidentType, number> = {
|
|
||||||
track_limits: 10,
|
|
||||||
contact: 20,
|
|
||||||
unsafe_rejoin: 25,
|
|
||||||
aggressive_driving: 15,
|
|
||||||
false_start: 30,
|
|
||||||
collision: 40,
|
|
||||||
spin: 35,
|
|
||||||
mechanical: 5, // Lower weight as it's not driver error
|
|
||||||
other: 15,
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalSeverity = this.incidents.reduce((total, incident) => {
|
|
||||||
return total + severityWeights[incident.type];
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
|
|
||||||
return Math.min(100, totalSeverity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get human-readable incident summary
|
|
||||||
*/
|
|
||||||
getSummary(): string {
|
|
||||||
if (this.incidents.length === 0) {
|
|
||||||
return 'Clean race';
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeCounts = this.incidents.reduce((counts, incident) => {
|
|
||||||
counts[incident.type] = (counts[incident.type] || 0) + 1;
|
|
||||||
return counts;
|
|
||||||
}, {} as Record<IncidentType, number>);
|
|
||||||
|
|
||||||
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
|
|
||||||
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
|
|
||||||
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
|
|
||||||
});
|
|
||||||
|
|
||||||
return summaryParts.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get human-readable label for incident type
|
|
||||||
*/
|
|
||||||
private getIncidentTypeLabel(type: IncidentType): string {
|
|
||||||
const labels: Record<IncidentType, string> = {
|
|
||||||
track_limits: 'Track Limits',
|
|
||||||
contact: 'Contact',
|
|
||||||
unsafe_rejoin: 'Unsafe Rejoin',
|
|
||||||
aggressive_driving: 'Aggressive Driving',
|
|
||||||
false_start: 'False Start',
|
|
||||||
collision: 'Collision',
|
|
||||||
spin: 'Spin',
|
|
||||||
mechanical: 'Mechanical',
|
|
||||||
other: 'Other',
|
|
||||||
};
|
|
||||||
return labels[type];
|
|
||||||
}
|
|
||||||
|
|
||||||
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||||
const otherIncidents = other.props;
|
const otherIncidents = other.props;
|
||||||
@@ -174,66 +111,4 @@ export class RaceIncidents implements IValueObject<IncidentRecord[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create RaceIncidents from legacy incidents count
|
|
||||||
*/
|
|
||||||
static fromLegacyIncidentsCount(count: number): RaceIncidents {
|
|
||||||
if (count === 0) {
|
|
||||||
return new RaceIncidents();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distribute legacy incidents across different types based on probability
|
|
||||||
const incidents: IncidentRecord[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const type = RaceIncidents.getRandomIncidentType();
|
|
||||||
incidents.push({
|
|
||||||
type,
|
|
||||||
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
|
|
||||||
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RaceIncidents(incidents);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get random incident type for legacy data conversion
|
|
||||||
*/
|
|
||||||
private static getRandomIncidentType(): IncidentType {
|
|
||||||
const types: IncidentType[] = [
|
|
||||||
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
|
|
||||||
'collision', 'spin', 'other'
|
|
||||||
];
|
|
||||||
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
|
|
||||||
|
|
||||||
const random = Math.random();
|
|
||||||
let cumulativeWeight = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < types.length; i++) {
|
|
||||||
cumulativeWeight += weights[i];
|
|
||||||
if (random <= cumulativeWeight) {
|
|
||||||
return types[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default penalty points for incident type
|
|
||||||
*/
|
|
||||||
private static getDefaultPenaltyPoints(type: IncidentType): number {
|
|
||||||
const penalties: Record<IncidentType, number> = {
|
|
||||||
track_limits: 0, // Usually just a warning
|
|
||||||
contact: 2,
|
|
||||||
unsafe_rejoin: 3,
|
|
||||||
aggressive_driving: 2,
|
|
||||||
false_start: 5,
|
|
||||||
collision: 5,
|
|
||||||
spin: 0, // Usually no penalty if no contact
|
|
||||||
mechanical: 0,
|
|
||||||
other: 2,
|
|
||||||
};
|
|
||||||
return penalties[type];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
65
core/racing/domain/value-objects/RaceTimeOfDay.test.ts
Normal file
65
core/racing/domain/value-objects/RaceTimeOfDay.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||||
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
|
||||||
|
describe('RaceTimeOfDay', () => {
|
||||||
|
it('should create valid time', () => {
|
||||||
|
const time = new RaceTimeOfDay(12, 30);
|
||||||
|
expect(time.hour).toBe(12);
|
||||||
|
expect(time.minute).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid hour', () => {
|
||||||
|
expect(() => new RaceTimeOfDay(24, 0)).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => new RaceTimeOfDay(-1, 0)).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => new RaceTimeOfDay(12.5, 0)).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid minute', () => {
|
||||||
|
expect(() => new RaceTimeOfDay(12, 60)).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => new RaceTimeOfDay(12, -1)).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => new RaceTimeOfDay(12, 30.5)).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create from valid string', () => {
|
||||||
|
const time = RaceTimeOfDay.fromString('14:45');
|
||||||
|
expect(time.hour).toBe(14);
|
||||||
|
expect(time.minute).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid string format', () => {
|
||||||
|
expect(() => RaceTimeOfDay.fromString('14:45:00')).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => RaceTimeOfDay.fromString('1445')).toThrow(RacingDomainValidationError);
|
||||||
|
expect(() => RaceTimeOfDay.fromString('14-45')).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid hour in string', () => {
|
||||||
|
expect(() => RaceTimeOfDay.fromString('25:00')).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid minute in string', () => {
|
||||||
|
expect(() => RaceTimeOfDay.fromString('12:60')).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert to string', () => {
|
||||||
|
const time = new RaceTimeOfDay(9, 5);
|
||||||
|
expect(time.toString()).toBe('09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same time', () => {
|
||||||
|
const t1 = new RaceTimeOfDay(10, 20);
|
||||||
|
const t2 = new RaceTimeOfDay(10, 20);
|
||||||
|
expect(t1.equals(t2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different time', () => {
|
||||||
|
const t1 = new RaceTimeOfDay(10, 20);
|
||||||
|
const t2 = new RaceTimeOfDay(10, 21);
|
||||||
|
expect(t1.equals(t2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return props', () => {
|
||||||
|
const time = new RaceTimeOfDay(15, 30);
|
||||||
|
expect(time.props).toEqual({ hour: 15, minute: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
107
core/racing/domain/value-objects/RecurrenceStrategy.test.ts
Normal file
107
core/racing/domain/value-objects/RecurrenceStrategy.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RecurrenceStrategy } from './RecurrenceStrategy';
|
||||||
|
import { WeekdaySet } from './WeekdaySet';
|
||||||
|
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||||
|
|
||||||
|
describe('RecurrenceStrategy', () => {
|
||||||
|
describe('weekly', () => {
|
||||||
|
it('should create weekly recurrence', () => {
|
||||||
|
const weekdays = WeekdaySet.fromArray(['Mon', 'Wed', 'Fri']);
|
||||||
|
const strategy = RecurrenceStrategy.weekly(weekdays);
|
||||||
|
expect(strategy.props.kind).toBe('weekly');
|
||||||
|
expect((strategy.props as { kind: 'weekly'; weekdays: WeekdaySet }).weekdays).toBe(weekdays);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if no weekdays', () => {
|
||||||
|
expect(() => WeekdaySet.fromArray([])).toThrow('WeekdaySet requires at least one weekday');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('everyNWeeks', () => {
|
||||||
|
it('should create everyNWeeks recurrence', () => {
|
||||||
|
const weekdays = WeekdaySet.fromArray(['Tue', 'Thu']);
|
||||||
|
const strategy = RecurrenceStrategy.everyNWeeks(2, weekdays);
|
||||||
|
expect(strategy.props.kind).toBe('everyNWeeks');
|
||||||
|
const props = strategy.props as { kind: 'everyNWeeks'; intervalWeeks: number; weekdays: WeekdaySet };
|
||||||
|
expect(props.intervalWeeks).toBe(2);
|
||||||
|
expect(props.weekdays).toBe(weekdays);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if intervalWeeks not positive integer', () => {
|
||||||
|
const weekdays = WeekdaySet.fromArray(['Mon']);
|
||||||
|
expect(() => RecurrenceStrategy.everyNWeeks(0, weekdays)).toThrow('intervalWeeks must be a positive integer');
|
||||||
|
expect(() => RecurrenceStrategy.everyNWeeks(-1, weekdays)).toThrow('intervalWeeks must be a positive integer');
|
||||||
|
expect(() => RecurrenceStrategy.everyNWeeks(1.5, weekdays)).toThrow('intervalWeeks must be a positive integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if no weekdays', () => {
|
||||||
|
expect(() => WeekdaySet.fromArray([])).toThrow('WeekdaySet requires at least one weekday');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('monthlyNthWeekday', () => {
|
||||||
|
it('should create monthlyNthWeekday recurrence', () => {
|
||||||
|
const pattern = MonthlyRecurrencePattern.create(1, 'Mon');
|
||||||
|
const strategy = RecurrenceStrategy.monthlyNthWeekday(pattern);
|
||||||
|
expect(strategy.props.kind).toBe('monthlyNthWeekday');
|
||||||
|
expect((strategy.props as { kind: 'monthlyNthWeekday'; monthlyPattern: MonthlyRecurrencePattern }).monthlyPattern).toBe(pattern);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should equal same weekly strategies', () => {
|
||||||
|
const weekdays1 = WeekdaySet.fromArray(['Mon', 'Wed']);
|
||||||
|
const weekdays2 = WeekdaySet.fromArray(['Mon', 'Wed']);
|
||||||
|
const s1 = RecurrenceStrategy.weekly(weekdays1);
|
||||||
|
const s2 = RecurrenceStrategy.weekly(weekdays2);
|
||||||
|
expect(s1.equals(s2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different weekly strategies', () => {
|
||||||
|
const weekdays1 = WeekdaySet.fromArray(['Mon']);
|
||||||
|
const weekdays2 = WeekdaySet.fromArray(['Tue']);
|
||||||
|
const s1 = RecurrenceStrategy.weekly(weekdays1);
|
||||||
|
const s2 = RecurrenceStrategy.weekly(weekdays2);
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same everyNWeeks strategies', () => {
|
||||||
|
const weekdays1 = WeekdaySet.fromArray(['Fri']);
|
||||||
|
const weekdays2 = WeekdaySet.fromArray(['Fri']);
|
||||||
|
const s1 = RecurrenceStrategy.everyNWeeks(3, weekdays1);
|
||||||
|
const s2 = RecurrenceStrategy.everyNWeeks(3, weekdays2);
|
||||||
|
expect(s1.equals(s2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different everyNWeeks strategies', () => {
|
||||||
|
const weekdays = WeekdaySet.fromArray(['Sat']);
|
||||||
|
const s1 = RecurrenceStrategy.everyNWeeks(2, weekdays);
|
||||||
|
const s2 = RecurrenceStrategy.everyNWeeks(4, weekdays);
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same monthlyNthWeekday strategies', () => {
|
||||||
|
const pattern1 = MonthlyRecurrencePattern.create(2, 'Wed');
|
||||||
|
const pattern2 = MonthlyRecurrencePattern.create(2, 'Wed');
|
||||||
|
const s1 = RecurrenceStrategy.monthlyNthWeekday(pattern1);
|
||||||
|
const s2 = RecurrenceStrategy.monthlyNthWeekday(pattern2);
|
||||||
|
expect(s1.equals(s2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different monthlyNthWeekday strategies', () => {
|
||||||
|
const pattern1 = MonthlyRecurrencePattern.create(1, 'Thu');
|
||||||
|
const pattern2 = MonthlyRecurrencePattern.create(3, 'Thu');
|
||||||
|
const s1 = RecurrenceStrategy.monthlyNthWeekday(pattern1);
|
||||||
|
const s2 = RecurrenceStrategy.monthlyNthWeekday(pattern2);
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different kinds', () => {
|
||||||
|
const weekdays = WeekdaySet.fromArray(['Sun']);
|
||||||
|
const pattern = MonthlyRecurrencePattern.create(4, 'Sun');
|
||||||
|
const s1 = RecurrenceStrategy.weekly(weekdays);
|
||||||
|
const s2 = RecurrenceStrategy.monthlyNthWeekday(pattern);
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,45 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
import { WeekdaySet } from './WeekdaySet';
|
import { WeekdaySet } from './WeekdaySet';
|
||||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
|
||||||
export type WeeklyRecurrenceStrategy = {
|
export type WeeklyRecurrenceStrategyProps = {
|
||||||
kind: 'weekly';
|
kind: 'weekly';
|
||||||
weekdays: WeekdaySet;
|
weekdays: WeekdaySet;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EveryNWeeksRecurrenceStrategy = {
|
export type EveryNWeeksRecurrenceStrategyProps = {
|
||||||
kind: 'everyNWeeks';
|
kind: 'everyNWeeks';
|
||||||
weekdays: WeekdaySet;
|
weekdays: WeekdaySet;
|
||||||
intervalWeeks: number;
|
intervalWeeks: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MonthlyNthWeekdayRecurrenceStrategy = {
|
export type MonthlyNthWeekdayRecurrenceStrategyProps = {
|
||||||
kind: 'monthlyNthWeekday';
|
kind: 'monthlyNthWeekday';
|
||||||
monthlyPattern: MonthlyRecurrencePattern;
|
monthlyPattern: MonthlyRecurrencePattern;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurrenceStrategy =
|
export type RecurrenceStrategyProps =
|
||||||
| WeeklyRecurrenceStrategy
|
| WeeklyRecurrenceStrategyProps
|
||||||
| EveryNWeeksRecurrenceStrategy
|
| EveryNWeeksRecurrenceStrategyProps
|
||||||
| MonthlyNthWeekdayRecurrenceStrategy;
|
| MonthlyNthWeekdayRecurrenceStrategyProps;
|
||||||
|
|
||||||
|
export class RecurrenceStrategy implements IValueObject<RecurrenceStrategyProps> {
|
||||||
|
private constructor(private readonly strategy: RecurrenceStrategyProps) {}
|
||||||
|
|
||||||
|
get props(): RecurrenceStrategyProps {
|
||||||
|
return this.strategy;
|
||||||
|
}
|
||||||
|
|
||||||
export class RecurrenceStrategyFactory {
|
|
||||||
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
||||||
if (weekdays.getAll().length === 0) {
|
if (weekdays.getAll().length === 0) {
|
||||||
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
|
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new RecurrenceStrategy({
|
||||||
kind: 'weekly',
|
kind: 'weekly',
|
||||||
weekdays,
|
weekdays,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||||
@@ -43,17 +50,36 @@ export class RecurrenceStrategyFactory {
|
|||||||
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
|
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new RecurrenceStrategy({
|
||||||
kind: 'everyNWeeks',
|
kind: 'everyNWeeks',
|
||||||
weekdays,
|
weekdays,
|
||||||
intervalWeeks,
|
intervalWeeks,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||||
return {
|
return new RecurrenceStrategy({
|
||||||
kind: 'monthlyNthWeekday',
|
kind: 'monthlyNthWeekday',
|
||||||
monthlyPattern: pattern,
|
monthlyPattern: pattern,
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RecurrenceStrategyProps>): boolean {
|
||||||
|
const otherProps = other.props;
|
||||||
|
if (this.strategy.kind !== otherProps.kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
switch (this.strategy.kind) {
|
||||||
|
case 'weekly':
|
||||||
|
return this.strategy.weekdays.equals((otherProps as WeeklyRecurrenceStrategyProps).weekdays);
|
||||||
|
case 'everyNWeeks':
|
||||||
|
const everyN = otherProps as EveryNWeeksRecurrenceStrategyProps;
|
||||||
|
return this.strategy.intervalWeeks === everyN.intervalWeeks && this.strategy.weekdays.equals(everyN.weekdays);
|
||||||
|
case 'monthlyNthWeekday':
|
||||||
|
const monthly = otherProps as MonthlyNthWeekdayRecurrenceStrategyProps;
|
||||||
|
return this.strategy.monthlyPattern.equals(monthly.monthlyPattern);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
65
core/racing/domain/value-objects/ScheduledRaceSlot.test.ts
Normal file
65
core/racing/domain/value-objects/ScheduledRaceSlot.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ScheduledRaceSlot } from './ScheduledRaceSlot';
|
||||||
|
import { LeagueTimezone } from './LeagueTimezone';
|
||||||
|
|
||||||
|
describe('ScheduledRaceSlot', () => {
|
||||||
|
it('should create scheduled race slot', () => {
|
||||||
|
const timezone = LeagueTimezone.create('America/New_York');
|
||||||
|
const scheduledAt = new Date('2023-10-01T12:00:00Z');
|
||||||
|
const slot = new ScheduledRaceSlot({
|
||||||
|
roundNumber: 1,
|
||||||
|
scheduledAt,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
expect(slot.roundNumber).toBe(1);
|
||||||
|
expect(slot.scheduledAt).toBe(scheduledAt);
|
||||||
|
expect(slot.timezone).toBe(timezone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if roundNumber not positive integer', () => {
|
||||||
|
const timezone = LeagueTimezone.create('UTC');
|
||||||
|
const scheduledAt = new Date();
|
||||||
|
expect(() => new ScheduledRaceSlot({ roundNumber: 0, scheduledAt, timezone })).toThrow('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||||
|
expect(() => new ScheduledRaceSlot({ roundNumber: -1, scheduledAt, timezone })).toThrow('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||||
|
expect(() => new ScheduledRaceSlot({ roundNumber: 1.5, scheduledAt, timezone })).toThrow('ScheduledRaceSlot.roundNumber must be a positive integer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if scheduledAt not valid Date', () => {
|
||||||
|
const timezone = LeagueTimezone.create('UTC');
|
||||||
|
expect(() => new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: new Date('invalid'), timezone })).toThrow('ScheduledRaceSlot.scheduledAt must be a valid Date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same slots', () => {
|
||||||
|
const timezone1 = LeagueTimezone.create('Europe/London');
|
||||||
|
const timezone2 = LeagueTimezone.create('Europe/London');
|
||||||
|
const date1 = new Date('2023-05-15T10:00:00Z');
|
||||||
|
const date2 = new Date('2023-05-15T10:00:00Z');
|
||||||
|
const s1 = new ScheduledRaceSlot({ roundNumber: 2, scheduledAt: date1, timezone: timezone1 });
|
||||||
|
const s2 = new ScheduledRaceSlot({ roundNumber: 2, scheduledAt: date2, timezone: timezone2 });
|
||||||
|
expect(s1.equals(s2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different round numbers', () => {
|
||||||
|
const timezone = LeagueTimezone.create('UTC');
|
||||||
|
const date = new Date();
|
||||||
|
const s1 = new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: date, timezone });
|
||||||
|
const s2 = new ScheduledRaceSlot({ roundNumber: 2, scheduledAt: date, timezone });
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different dates', () => {
|
||||||
|
const timezone = LeagueTimezone.create('UTC');
|
||||||
|
const s1 = new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: new Date('2023-01-01'), timezone });
|
||||||
|
const s2 = new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: new Date('2023-01-02'), timezone });
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different timezones', () => {
|
||||||
|
const timezone1 = LeagueTimezone.create('UTC');
|
||||||
|
const timezone2 = LeagueTimezone.create('EST');
|
||||||
|
const date = new Date();
|
||||||
|
const s1 = new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: date, timezone: timezone1 });
|
||||||
|
const s2 = new ScheduledRaceSlot({ roundNumber: 1, scheduledAt: date, timezone: timezone2 });
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
core/racing/domain/value-objects/SeasonSchedule.test.ts
Normal file
112
core/racing/domain/value-objects/SeasonSchedule.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RacingDomainValidationError,
|
||||||
|
} from '../errors/RacingDomainError';
|
||||||
|
|
||||||
|
import { SeasonSchedule } from './SeasonSchedule';
|
||||||
|
import { RaceTimeOfDay } from './RaceTimeOfDay';
|
||||||
|
import { LeagueTimezone } from './LeagueTimezone';
|
||||||
|
import { RecurrenceStrategyFactory } from './RecurrenceStrategy';
|
||||||
|
import { WeekdaySet } from './WeekdaySet';
|
||||||
|
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||||
|
|
||||||
|
describe('SeasonSchedule', () => {
|
||||||
|
const timeOfDay = RaceTimeOfDay.fromString('20:00');
|
||||||
|
const timezone = LeagueTimezone.create('Europe/Berlin');
|
||||||
|
const startDate = new Date('2024-01-01');
|
||||||
|
|
||||||
|
it('creates a valid schedule with weekly recurrence', () => {
|
||||||
|
const recurrence = RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(['Mon', 'Wed', 'Fri']));
|
||||||
|
const schedule = new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence,
|
||||||
|
plannedRounds: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(schedule.startDate.getFullYear()).toBe(2024);
|
||||||
|
expect(schedule.startDate.getMonth()).toBe(0); // January is 0
|
||||||
|
expect(schedule.startDate.getDate()).toBe(1);
|
||||||
|
expect(schedule.startDate.getHours()).toBe(0);
|
||||||
|
expect(schedule.startDate.getMinutes()).toBe(0);
|
||||||
|
expect(schedule.startDate.getSeconds()).toBe(0);
|
||||||
|
expect(schedule.timeOfDay.equals(timeOfDay)).toBe(true);
|
||||||
|
expect(schedule.timezone.equals(timezone)).toBe(true);
|
||||||
|
expect(schedule.recurrence).toEqual(recurrence);
|
||||||
|
expect(schedule.plannedRounds).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when startDate is invalid', () => {
|
||||||
|
const recurrence = RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(['Mon']));
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new SeasonSchedule({
|
||||||
|
startDate: new Date('invalid'),
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence,
|
||||||
|
plannedRounds: 10,
|
||||||
|
}),
|
||||||
|
).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when plannedRounds is not positive integer', () => {
|
||||||
|
const recurrence = RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(['Mon']));
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence,
|
||||||
|
plannedRounds: 0,
|
||||||
|
}),
|
||||||
|
).toThrow(RacingDomainValidationError);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence,
|
||||||
|
plannedRounds: -1,
|
||||||
|
}),
|
||||||
|
).toThrow(RacingDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('equals compares all props', () => {
|
||||||
|
const recurrence1 = RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(['Mon', 'Wed']));
|
||||||
|
const recurrence2 = RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(['Mon', 'Wed']));
|
||||||
|
const recurrence3 = RecurrenceStrategyFactory.monthlyNthWeekday(MonthlyRecurrencePattern.create(1, 'Mon'));
|
||||||
|
|
||||||
|
const a = new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence: recurrence1,
|
||||||
|
plannedRounds: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const b = new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence: recurrence2,
|
||||||
|
plannedRounds: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = new SeasonSchedule({
|
||||||
|
startDate,
|
||||||
|
timeOfDay,
|
||||||
|
timezone,
|
||||||
|
recurrence: recurrence3,
|
||||||
|
plannedRounds: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a.equals(b)).toBe(true);
|
||||||
|
expect(a.equals(c)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
86
core/racing/domain/value-objects/SessionType.test.ts
Normal file
86
core/racing/domain/value-objects/SessionType.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SessionType } from './SessionType';
|
||||||
|
|
||||||
|
describe('SessionType', () => {
|
||||||
|
it('should create session type', () => {
|
||||||
|
const session = new SessionType('practice');
|
||||||
|
expect(session.value).toBe('practice');
|
||||||
|
expect(session.props).toBe('practice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid session type', () => {
|
||||||
|
expect(() => new SessionType('invalid' as any)).toThrow();
|
||||||
|
expect(() => new SessionType('' as any)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have static factory methods', () => {
|
||||||
|
expect(SessionType.practice().value).toBe('practice');
|
||||||
|
expect(SessionType.qualifying().value).toBe('qualifying');
|
||||||
|
expect(SessionType.sprint().value).toBe('sprint');
|
||||||
|
expect(SessionType.main().value).toBe('main');
|
||||||
|
expect(SessionType.timeTrial().value).toBe('timeTrial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same session types', () => {
|
||||||
|
const s1 = new SessionType('main');
|
||||||
|
const s2 = new SessionType('main');
|
||||||
|
expect(s1.equals(s2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different session types', () => {
|
||||||
|
const s1 = new SessionType('practice');
|
||||||
|
const s2 = new SessionType('qualifying');
|
||||||
|
expect(s1.equals(s2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countsForPoints', () => {
|
||||||
|
it('should return true for main and sprint', () => {
|
||||||
|
expect(SessionType.main().countsForPoints()).toBe(true);
|
||||||
|
expect(SessionType.sprint().countsForPoints()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for others', () => {
|
||||||
|
expect(SessionType.practice().countsForPoints()).toBe(false);
|
||||||
|
expect(SessionType.qualifying().countsForPoints()).toBe(false);
|
||||||
|
expect(SessionType.timeTrial().countsForPoints()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('determinesGrid', () => {
|
||||||
|
it('should return true for qualifying and q sessions', () => {
|
||||||
|
expect(SessionType.qualifying().determinesGrid()).toBe(true);
|
||||||
|
expect(new SessionType('q1').determinesGrid()).toBe(true);
|
||||||
|
expect(new SessionType('q2').determinesGrid()).toBe(true);
|
||||||
|
expect(new SessionType('q3').determinesGrid()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for others', () => {
|
||||||
|
expect(SessionType.practice().determinesGrid()).toBe(false);
|
||||||
|
expect(SessionType.sprint().determinesGrid()).toBe(false);
|
||||||
|
expect(SessionType.main().determinesGrid()).toBe(false);
|
||||||
|
expect(SessionType.timeTrial().determinesGrid()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDisplayName', () => {
|
||||||
|
it('should return correct display names', () => {
|
||||||
|
expect(SessionType.practice().getDisplayName()).toBe('Practice');
|
||||||
|
expect(SessionType.qualifying().getDisplayName()).toBe('Qualifying');
|
||||||
|
expect(new SessionType('q1').getDisplayName()).toBe('Q1');
|
||||||
|
expect(SessionType.sprint().getDisplayName()).toBe('Sprint Race');
|
||||||
|
expect(SessionType.main().getDisplayName()).toBe('Main Race');
|
||||||
|
expect(SessionType.timeTrial().getDisplayName()).toBe('Time Trial');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShortName', () => {
|
||||||
|
it('should return correct short names', () => {
|
||||||
|
expect(SessionType.practice().getShortName()).toBe('P');
|
||||||
|
expect(SessionType.qualifying().getShortName()).toBe('Q');
|
||||||
|
expect(new SessionType('q1').getShortName()).toBe('Q1');
|
||||||
|
expect(SessionType.sprint().getShortName()).toBe('SPR');
|
||||||
|
expect(SessionType.main().getShortName()).toBe('RACE');
|
||||||
|
expect(SessionType.timeTrial().getShortName()).toBe('TT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
121
core/racing/domain/value-objects/SponsorshipPricing.test.ts
Normal file
121
core/racing/domain/value-objects/SponsorshipPricing.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SponsorshipPricing } from './SponsorshipPricing';
|
||||||
|
import { Money } from './Money';
|
||||||
|
|
||||||
|
describe('SponsorshipPricing', () => {
|
||||||
|
const mainSlot = {
|
||||||
|
tier: 'main' as const,
|
||||||
|
price: Money.create(100, 'USD'),
|
||||||
|
benefits: ['Logo placement'],
|
||||||
|
available: true,
|
||||||
|
maxSlots: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondarySlot = {
|
||||||
|
tier: 'secondary' as const,
|
||||||
|
price: Money.create(50, 'USD'),
|
||||||
|
benefits: ['Minor placement'],
|
||||||
|
available: true,
|
||||||
|
maxSlots: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should create sponsorship pricing', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({
|
||||||
|
mainSlot,
|
||||||
|
acceptingApplications: true,
|
||||||
|
});
|
||||||
|
expect(pricing.mainSlot).toEqual(mainSlot);
|
||||||
|
expect(pricing.acceptingApplications).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create with defaults', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
expect(pricing.acceptingApplications).toBe(true);
|
||||||
|
expect(pricing.mainSlot).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same pricings', () => {
|
||||||
|
const p1 = SponsorshipPricing.create({ mainSlot, acceptingApplications: true });
|
||||||
|
const p2 = SponsorshipPricing.create({ mainSlot, acceptingApplications: true });
|
||||||
|
expect(p1.equals(p2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different pricings', () => {
|
||||||
|
const p1 = SponsorshipPricing.create({ mainSlot, acceptingApplications: true });
|
||||||
|
const p2 = SponsorshipPricing.create({ mainSlot, acceptingApplications: false });
|
||||||
|
expect(p1.equals(p2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSlotAvailable', () => {
|
||||||
|
it('should return availability for main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({ mainSlot });
|
||||||
|
expect(pricing.isSlotAvailable('main')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return availability for secondary slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({ secondarySlots: secondarySlot });
|
||||||
|
expect(pricing.isSlotAvailable('secondary')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for unavailable main slot', () => {
|
||||||
|
const unavailableMain = { ...mainSlot, available: false };
|
||||||
|
const pricing = SponsorshipPricing.create({ mainSlot: unavailableMain });
|
||||||
|
expect(pricing.isSlotAvailable('main')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for undefined main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
expect(pricing.isSlotAvailable('main')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPrice', () => {
|
||||||
|
it('should return price for main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({ mainSlot });
|
||||||
|
expect(pricing.getPrice('main')).toEqual(mainSlot.price);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for undefined main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
expect(pricing.getPrice('main')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBenefits', () => {
|
||||||
|
it('should return benefits for main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({ mainSlot });
|
||||||
|
expect(pricing.getBenefits('main')).toEqual(mainSlot.benefits);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for undefined main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
expect(pricing.getBenefits('main')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateMainSlot', () => {
|
||||||
|
it('should update main slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
const updated = pricing.updateMainSlot({ price: Money.create(200, 'USD') });
|
||||||
|
expect(updated.mainSlot?.price).toEqual(Money.create(200, 'USD'));
|
||||||
|
expect(updated.mainSlot?.tier).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateSecondarySlot', () => {
|
||||||
|
it('should update secondary slot', () => {
|
||||||
|
const pricing = SponsorshipPricing.create();
|
||||||
|
const updated = pricing.updateSecondarySlot({ price: Money.create(75, 'USD') });
|
||||||
|
expect(updated.secondarySlots?.price).toEqual(Money.create(75, 'USD'));
|
||||||
|
expect(updated.secondarySlots?.tier).toBe('secondary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAcceptingApplications', () => {
|
||||||
|
it('should set accepting applications', () => {
|
||||||
|
const pricing = SponsorshipPricing.create({ acceptingApplications: true });
|
||||||
|
const updated = pricing.setAcceptingApplications(false);
|
||||||
|
expect(updated.acceptingApplications).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,83 +92,6 @@ export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create default pricing for a driver
|
|
||||||
*/
|
|
||||||
static defaultDriver(): SponsorshipPricing {
|
|
||||||
return new SponsorshipPricing({
|
|
||||||
mainSlot: {
|
|
||||||
tier: 'main',
|
|
||||||
price: Money.create(200, 'USD'),
|
|
||||||
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 1,
|
|
||||||
},
|
|
||||||
acceptingApplications: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create default pricing for a team
|
|
||||||
*/
|
|
||||||
static defaultTeam(): SponsorshipPricing {
|
|
||||||
return new SponsorshipPricing({
|
|
||||||
mainSlot: {
|
|
||||||
tier: 'main',
|
|
||||||
price: Money.create(500, 'USD'),
|
|
||||||
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 1,
|
|
||||||
},
|
|
||||||
secondarySlots: {
|
|
||||||
tier: 'secondary',
|
|
||||||
price: Money.create(250, 'USD'),
|
|
||||||
benefits: ['Team page logo', 'Minor livery placement'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 2,
|
|
||||||
},
|
|
||||||
acceptingApplications: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create default pricing for a race
|
|
||||||
*/
|
|
||||||
static defaultRace(): SponsorshipPricing {
|
|
||||||
return new SponsorshipPricing({
|
|
||||||
mainSlot: {
|
|
||||||
tier: 'main',
|
|
||||||
price: Money.create(300, 'USD'),
|
|
||||||
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 1,
|
|
||||||
},
|
|
||||||
acceptingApplications: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create default pricing for a league/season
|
|
||||||
*/
|
|
||||||
static defaultLeague(): SponsorshipPricing {
|
|
||||||
return new SponsorshipPricing({
|
|
||||||
mainSlot: {
|
|
||||||
tier: 'main',
|
|
||||||
price: Money.create(800, 'USD'),
|
|
||||||
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 1,
|
|
||||||
},
|
|
||||||
secondarySlots: {
|
|
||||||
tier: 'secondary',
|
|
||||||
price: Money.create(250, 'USD'),
|
|
||||||
benefits: ['Side logo placement', 'League page listing'],
|
|
||||||
available: true,
|
|
||||||
maxSlots: 2,
|
|
||||||
},
|
|
||||||
acceptingApplications: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific tier is available
|
* Check if a specific tier is available
|
||||||
|
|||||||
31
core/racing/domain/value-objects/TeamCreatedAt.test.ts
Normal file
31
core/racing/domain/value-objects/TeamCreatedAt.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TeamCreatedAt } from './TeamCreatedAt';
|
||||||
|
|
||||||
|
describe('TeamCreatedAt', () => {
|
||||||
|
it('should create team created at', () => {
|
||||||
|
const date = new Date('2023-01-01');
|
||||||
|
const created = TeamCreatedAt.create(date);
|
||||||
|
expect(created.toDate()).toEqual(date);
|
||||||
|
expect(created.props).toEqual(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for future date', () => {
|
||||||
|
const future = new Date();
|
||||||
|
future.setFullYear(future.getFullYear() + 1);
|
||||||
|
expect(() => TeamCreatedAt.create(future)).toThrow('Created date cannot be in the future');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same dates', () => {
|
||||||
|
const date1 = new Date('2023-01-01');
|
||||||
|
const date2 = new Date('2023-01-01');
|
||||||
|
const c1 = TeamCreatedAt.create(date1);
|
||||||
|
const c2 = TeamCreatedAt.create(date2);
|
||||||
|
expect(c1.equals(c2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different dates', () => {
|
||||||
|
const c1 = TeamCreatedAt.create(new Date('2023-01-01'));
|
||||||
|
const c2 = TeamCreatedAt.create(new Date('2023-01-02'));
|
||||||
|
expect(c1.equals(c2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TeamCreatedAt {
|
export class TeamCreatedAt implements IValueObject<Date> {
|
||||||
private constructor(private readonly value: Date) {}
|
private constructor(private readonly value: Date) {}
|
||||||
|
|
||||||
|
get props(): Date {
|
||||||
|
return new Date(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: Date): TeamCreatedAt {
|
static create(value: Date): TeamCreatedAt {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (value > now) {
|
if (value > now) {
|
||||||
@@ -15,7 +20,7 @@ export class TeamCreatedAt {
|
|||||||
return new Date(this.value);
|
return new Date(this.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TeamCreatedAt): boolean {
|
equals(other: IValueObject<Date>): boolean {
|
||||||
return this.value.getTime() === other.value.getTime();
|
return this.value.getTime() === other.props.getTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TeamDescription.test.ts
Normal file
32
core/racing/domain/value-objects/TeamDescription.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TeamDescription } from './TeamDescription';
|
||||||
|
|
||||||
|
describe('TeamDescription', () => {
|
||||||
|
it('should create team description', () => {
|
||||||
|
const desc = TeamDescription.create('A great team');
|
||||||
|
expect(desc.toString()).toBe('A great team');
|
||||||
|
expect(desc.props).toBe('A great team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const desc = TeamDescription.create(' Description ');
|
||||||
|
expect(desc.toString()).toBe('Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty description', () => {
|
||||||
|
expect(() => TeamDescription.create('')).toThrow('Team description is required');
|
||||||
|
expect(() => TeamDescription.create(' ')).toThrow('Team description is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same descriptions', () => {
|
||||||
|
const d1 = TeamDescription.create('Desc A');
|
||||||
|
const d2 = TeamDescription.create('Desc A');
|
||||||
|
expect(d1.equals(d2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different descriptions', () => {
|
||||||
|
const d1 = TeamDescription.create('Desc A');
|
||||||
|
const d2 = TeamDescription.create('Desc B');
|
||||||
|
expect(d1.equals(d2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TeamDescription {
|
export class TeamDescription implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TeamDescription {
|
static create(value: string): TeamDescription {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Team description is required');
|
throw new RacingDomainValidationError('Team description is required');
|
||||||
@@ -14,7 +19,7 @@ export class TeamDescription {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TeamDescription): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TeamName.test.ts
Normal file
32
core/racing/domain/value-objects/TeamName.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TeamName } from './TeamName';
|
||||||
|
|
||||||
|
describe('TeamName', () => {
|
||||||
|
it('should create team name', () => {
|
||||||
|
const name = TeamName.create('Test Team');
|
||||||
|
expect(name.toString()).toBe('Test Team');
|
||||||
|
expect(name.props).toBe('Test Team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const name = TeamName.create(' Test Team ');
|
||||||
|
expect(name.toString()).toBe('Test Team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty name', () => {
|
||||||
|
expect(() => TeamName.create('')).toThrow('Team name is required');
|
||||||
|
expect(() => TeamName.create(' ')).toThrow('Team name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same names', () => {
|
||||||
|
const n1 = TeamName.create('Team A');
|
||||||
|
const n2 = TeamName.create('Team A');
|
||||||
|
expect(n1.equals(n2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different names', () => {
|
||||||
|
const n1 = TeamName.create('Team A');
|
||||||
|
const n2 = TeamName.create('Team B');
|
||||||
|
expect(n1.equals(n2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TeamName {
|
export class TeamName implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TeamName {
|
static create(value: string): TeamName {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Team name is required');
|
throw new RacingDomainValidationError('Team name is required');
|
||||||
@@ -14,7 +19,7 @@ export class TeamName {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TeamName): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TeamTag.test.ts
Normal file
32
core/racing/domain/value-objects/TeamTag.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TeamTag } from './TeamTag';
|
||||||
|
|
||||||
|
describe('TeamTag', () => {
|
||||||
|
it('should create team tag', () => {
|
||||||
|
const tag = TeamTag.create('TAG123');
|
||||||
|
expect(tag.toString()).toBe('TAG123');
|
||||||
|
expect(tag.props).toBe('TAG123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const tag = TeamTag.create(' TAG ');
|
||||||
|
expect(tag.toString()).toBe('TAG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty tag', () => {
|
||||||
|
expect(() => TeamTag.create('')).toThrow('Team tag is required');
|
||||||
|
expect(() => TeamTag.create(' ')).toThrow('Team tag is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same tags', () => {
|
||||||
|
const t1 = TeamTag.create('TAG1');
|
||||||
|
const t2 = TeamTag.create('TAG1');
|
||||||
|
expect(t1.equals(t2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different tags', () => {
|
||||||
|
const t1 = TeamTag.create('TAG1');
|
||||||
|
const t2 = TeamTag.create('TAG2');
|
||||||
|
expect(t1.equals(t2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TeamTag {
|
export class TeamTag implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TeamTag {
|
static create(value: string): TeamTag {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Team tag is required');
|
throw new RacingDomainValidationError('Team tag is required');
|
||||||
@@ -14,7 +19,7 @@ export class TeamTag {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TeamTag): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TrackCountry.test.ts
Normal file
32
core/racing/domain/value-objects/TrackCountry.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackCountry } from './TrackCountry';
|
||||||
|
|
||||||
|
describe('TrackCountry', () => {
|
||||||
|
it('should create track country', () => {
|
||||||
|
const country = TrackCountry.create('USA');
|
||||||
|
expect(country.toString()).toBe('USA');
|
||||||
|
expect(country.props).toBe('USA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const country = TrackCountry.create(' USA ');
|
||||||
|
expect(country.toString()).toBe('USA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty country', () => {
|
||||||
|
expect(() => TrackCountry.create('')).toThrow('Track country is required');
|
||||||
|
expect(() => TrackCountry.create(' ')).toThrow('Track country is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same countries', () => {
|
||||||
|
const c1 = TrackCountry.create('USA');
|
||||||
|
const c2 = TrackCountry.create('USA');
|
||||||
|
expect(c1.equals(c2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different countries', () => {
|
||||||
|
const c1 = TrackCountry.create('USA');
|
||||||
|
const c2 = TrackCountry.create('UK');
|
||||||
|
expect(c1.equals(c2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackCountry {
|
export class TrackCountry implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TrackCountry {
|
static create(value: string): TrackCountry {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track country is required');
|
throw new RacingDomainValidationError('Track country is required');
|
||||||
@@ -14,7 +19,7 @@ export class TrackCountry {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackCountry): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TrackGameId.test.ts
Normal file
32
core/racing/domain/value-objects/TrackGameId.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackGameId } from './TrackGameId';
|
||||||
|
|
||||||
|
describe('TrackGameId', () => {
|
||||||
|
it('should create track game id', () => {
|
||||||
|
const id = TrackGameId.create('123');
|
||||||
|
expect(id.toString()).toBe('123');
|
||||||
|
expect(id.props).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const id = TrackGameId.create(' 123 ');
|
||||||
|
expect(id.toString()).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty id', () => {
|
||||||
|
expect(() => TrackGameId.create('')).toThrow('Track game ID cannot be empty');
|
||||||
|
expect(() => TrackGameId.create(' ')).toThrow('Track game ID cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same ids', () => {
|
||||||
|
const i1 = TrackGameId.create('123');
|
||||||
|
const i2 = TrackGameId.create('123');
|
||||||
|
expect(i1.equals(i2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different ids', () => {
|
||||||
|
const i1 = TrackGameId.create('123');
|
||||||
|
const i2 = TrackGameId.create('456');
|
||||||
|
expect(i1.equals(i2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackGameId {
|
export class TrackGameId implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TrackGameId {
|
static create(value: string): TrackGameId {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track game ID cannot be empty');
|
throw new RacingDomainValidationError('Track game ID cannot be empty');
|
||||||
@@ -14,7 +19,7 @@ export class TrackGameId {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackGameId): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TrackId.test.ts
Normal file
32
core/racing/domain/value-objects/TrackId.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackId } from './TrackId';
|
||||||
|
|
||||||
|
describe('TrackId', () => {
|
||||||
|
it('should create track id', () => {
|
||||||
|
const id = TrackId.create('track-123');
|
||||||
|
expect(id.toString()).toBe('track-123');
|
||||||
|
expect(id.props).toBe('track-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const id = TrackId.create(' track-123 ');
|
||||||
|
expect(id.toString()).toBe('track-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty id', () => {
|
||||||
|
expect(() => TrackId.create('')).toThrow('Track ID cannot be empty');
|
||||||
|
expect(() => TrackId.create(' ')).toThrow('Track ID cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same ids', () => {
|
||||||
|
const i1 = TrackId.create('track-123');
|
||||||
|
const i2 = TrackId.create('track-123');
|
||||||
|
expect(i1.equals(i2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different ids', () => {
|
||||||
|
const i1 = TrackId.create('track-123');
|
||||||
|
const i2 = TrackId.create('track-456');
|
||||||
|
expect(i1.equals(i2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackId {
|
export class TrackId implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TrackId {
|
static create(value: string): TrackId {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track ID cannot be empty');
|
throw new RacingDomainValidationError('Track ID cannot be empty');
|
||||||
@@ -14,7 +19,7 @@ export class TrackId {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackId): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
core/racing/domain/value-objects/TrackImageUrl.test.ts
Normal file
45
core/racing/domain/value-objects/TrackImageUrl.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackImageUrl } from './TrackImageUrl';
|
||||||
|
|
||||||
|
describe('TrackImageUrl', () => {
|
||||||
|
it('should create track image url', () => {
|
||||||
|
const url = TrackImageUrl.create('http://example.com/image.jpg');
|
||||||
|
expect(url.toString()).toBe('http://example.com/image.jpg');
|
||||||
|
expect(url.props).toBe('http://example.com/image.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow undefined', () => {
|
||||||
|
const url = TrackImageUrl.create(undefined);
|
||||||
|
expect(url.toString()).toBeUndefined();
|
||||||
|
expect(url.props).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty string', () => {
|
||||||
|
expect(() => TrackImageUrl.create('')).toThrow('Track image URL cannot be empty string');
|
||||||
|
expect(() => TrackImageUrl.create(' ')).toThrow('Track image URL cannot be empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same urls', () => {
|
||||||
|
const u1 = TrackImageUrl.create('http://example.com/image.jpg');
|
||||||
|
const u2 = TrackImageUrl.create('http://example.com/image.jpg');
|
||||||
|
expect(u1.equals(u2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal undefined', () => {
|
||||||
|
const u1 = TrackImageUrl.create(undefined);
|
||||||
|
const u2 = TrackImageUrl.create(undefined);
|
||||||
|
expect(u1.equals(u2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different urls', () => {
|
||||||
|
const u1 = TrackImageUrl.create('http://example.com/image1.jpg');
|
||||||
|
const u2 = TrackImageUrl.create('http://example.com/image2.jpg');
|
||||||
|
expect(u1.equals(u2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal url and undefined', () => {
|
||||||
|
const u1 = TrackImageUrl.create('http://example.com/image.jpg');
|
||||||
|
const u2 = TrackImageUrl.create(undefined);
|
||||||
|
expect(u1.equals(u2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackImageUrl {
|
export class TrackImageUrl implements IValueObject<string | undefined> {
|
||||||
private constructor(private readonly value: string | undefined) {}
|
private constructor(private readonly value: string | undefined) {}
|
||||||
|
|
||||||
|
get props(): string | undefined {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string | undefined): TrackImageUrl {
|
static create(value: string | undefined): TrackImageUrl {
|
||||||
// Allow undefined or valid URL, but for simplicity, just check if string is not empty if provided
|
// Allow undefined or valid URL, but for simplicity, just check if string is not empty if provided
|
||||||
if (value !== undefined && value.trim().length === 0) {
|
if (value !== undefined && value.trim().length === 0) {
|
||||||
@@ -15,7 +20,7 @@ export class TrackImageUrl {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackImageUrl): boolean {
|
equals(other: IValueObject<string | undefined>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
27
core/racing/domain/value-objects/TrackLength.test.ts
Normal file
27
core/racing/domain/value-objects/TrackLength.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackLength } from './TrackLength';
|
||||||
|
|
||||||
|
describe('TrackLength', () => {
|
||||||
|
it('should create track length', () => {
|
||||||
|
const length = TrackLength.create(1000);
|
||||||
|
expect(length.toNumber()).toBe(1000);
|
||||||
|
expect(length.props).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for non-positive length', () => {
|
||||||
|
expect(() => TrackLength.create(0)).toThrow('Track length must be positive');
|
||||||
|
expect(() => TrackLength.create(-1)).toThrow('Track length must be positive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same lengths', () => {
|
||||||
|
const l1 = TrackLength.create(1000);
|
||||||
|
const l2 = TrackLength.create(1000);
|
||||||
|
expect(l1.equals(l2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different lengths', () => {
|
||||||
|
const l1 = TrackLength.create(1000);
|
||||||
|
const l2 = TrackLength.create(2000);
|
||||||
|
expect(l1.equals(l2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackLength {
|
export class TrackLength implements IValueObject<number> {
|
||||||
private constructor(private readonly value: number) {}
|
private constructor(private readonly value: number) {}
|
||||||
|
|
||||||
|
get props(): number {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: number): TrackLength {
|
static create(value: number): TrackLength {
|
||||||
if (value <= 0) {
|
if (value <= 0) {
|
||||||
throw new RacingDomainValidationError('Track length must be positive');
|
throw new RacingDomainValidationError('Track length must be positive');
|
||||||
@@ -14,7 +19,7 @@ export class TrackLength {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackLength): boolean {
|
equals(other: IValueObject<number>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TrackName.test.ts
Normal file
32
core/racing/domain/value-objects/TrackName.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackName } from './TrackName';
|
||||||
|
|
||||||
|
describe('TrackName', () => {
|
||||||
|
it('should create track name', () => {
|
||||||
|
const name = TrackName.create('Silverstone');
|
||||||
|
expect(name.toString()).toBe('Silverstone');
|
||||||
|
expect(name.props).toBe('Silverstone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const name = TrackName.create(' Silverstone ');
|
||||||
|
expect(name.toString()).toBe('Silverstone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty name', () => {
|
||||||
|
expect(() => TrackName.create('')).toThrow('Track name is required');
|
||||||
|
expect(() => TrackName.create(' ')).toThrow('Track name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same names', () => {
|
||||||
|
const n1 = TrackName.create('Silverstone');
|
||||||
|
const n2 = TrackName.create('Silverstone');
|
||||||
|
expect(n1.equals(n2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different names', () => {
|
||||||
|
const n1 = TrackName.create('Silverstone');
|
||||||
|
const n2 = TrackName.create('Monza');
|
||||||
|
expect(n1.equals(n2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackName {
|
export class TrackName implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TrackName {
|
static create(value: string): TrackName {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track name is required');
|
throw new RacingDomainValidationError('Track name is required');
|
||||||
@@ -14,7 +19,7 @@ export class TrackName {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackName): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
32
core/racing/domain/value-objects/TrackShortName.test.ts
Normal file
32
core/racing/domain/value-objects/TrackShortName.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackShortName } from './TrackShortName';
|
||||||
|
|
||||||
|
describe('TrackShortName', () => {
|
||||||
|
it('should create track short name', () => {
|
||||||
|
const name = TrackShortName.create('SIL');
|
||||||
|
expect(name.toString()).toBe('SIL');
|
||||||
|
expect(name.props).toBe('SIL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const name = TrackShortName.create(' SIL ');
|
||||||
|
expect(name.toString()).toBe('SIL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty name', () => {
|
||||||
|
expect(() => TrackShortName.create('')).toThrow('Track short name is required');
|
||||||
|
expect(() => TrackShortName.create(' ')).toThrow('Track short name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same names', () => {
|
||||||
|
const n1 = TrackShortName.create('SIL');
|
||||||
|
const n2 = TrackShortName.create('SIL');
|
||||||
|
expect(n1.equals(n2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different names', () => {
|
||||||
|
const n1 = TrackShortName.create('SIL');
|
||||||
|
const n2 = TrackShortName.create('MON');
|
||||||
|
expect(n1.equals(n2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackShortName {
|
export class TrackShortName implements IValueObject<string> {
|
||||||
private constructor(private readonly value: string) {}
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
get props(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: string): TrackShortName {
|
static create(value: string): TrackShortName {
|
||||||
if (!value || value.trim().length === 0) {
|
if (!value || value.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track short name is required');
|
throw new RacingDomainValidationError('Track short name is required');
|
||||||
@@ -14,7 +19,7 @@ export class TrackShortName {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackShortName): boolean {
|
equals(other: IValueObject<string>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
31
core/racing/domain/value-objects/TrackTurns.test.ts
Normal file
31
core/racing/domain/value-objects/TrackTurns.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TrackTurns } from './TrackTurns';
|
||||||
|
|
||||||
|
describe('TrackTurns', () => {
|
||||||
|
it('should create track turns', () => {
|
||||||
|
const turns = TrackTurns.create(10);
|
||||||
|
expect(turns.toNumber()).toBe(10);
|
||||||
|
expect(turns.props).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow zero turns', () => {
|
||||||
|
const turns = TrackTurns.create(0);
|
||||||
|
expect(turns.toNumber()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for negative turns', () => {
|
||||||
|
expect(() => TrackTurns.create(-1)).toThrow('Track turns cannot be negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should equal same turns', () => {
|
||||||
|
const t1 = TrackTurns.create(10);
|
||||||
|
const t2 = TrackTurns.create(10);
|
||||||
|
expect(t1.equals(t2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not equal different turns', () => {
|
||||||
|
const t1 = TrackTurns.create(10);
|
||||||
|
const t2 = TrackTurns.create(20);
|
||||||
|
expect(t1.equals(t2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
export class TrackTurns {
|
export class TrackTurns implements IValueObject<number> {
|
||||||
private constructor(private readonly value: number) {}
|
private constructor(private readonly value: number) {}
|
||||||
|
|
||||||
|
get props(): number {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
static create(value: number): TrackTurns {
|
static create(value: number): TrackTurns {
|
||||||
if (value < 0) {
|
if (value < 0) {
|
||||||
throw new RacingDomainValidationError('Track turns cannot be negative');
|
throw new RacingDomainValidationError('Track turns cannot be negative');
|
||||||
@@ -14,7 +19,7 @@ export class TrackTurns {
|
|||||||
return this.value;
|
return this.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: TrackTurns): boolean {
|
equals(other: IValueObject<number>): boolean {
|
||||||
return this.value === other.value;
|
return this.value === other.props;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
29
core/racing/domain/value-objects/driver/DriverBio.ts
Normal file
29
core/racing/domain/value-objects/driver/DriverBio.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
|
export interface DriverBioProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DriverBio implements IValueObject<DriverBioProps> {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static create(value: string): DriverBio {
|
||||||
|
if (value.length > 500) {
|
||||||
|
throw new RacingDomainValidationError('Driver bio cannot exceed 500 characters');
|
||||||
|
}
|
||||||
|
return new DriverBio(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<DriverBioProps>): boolean {
|
||||||
|
return this.props.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): DriverBioProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
core/racing/domain/value-objects/driver/DriverId.ts
Normal file
29
core/racing/domain/value-objects/driver/DriverId.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
|
export interface DriverIdProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DriverId implements IValueObject<DriverIdProps> {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static create(value: string): DriverId {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Driver ID is required');
|
||||||
|
}
|
||||||
|
return new DriverId(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<DriverIdProps>): boolean {
|
||||||
|
return this.props.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): DriverIdProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
core/racing/domain/value-objects/driver/DriverName.ts
Normal file
29
core/racing/domain/value-objects/driver/DriverName.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
|
||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
|
||||||
|
export interface DriverNameProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DriverName implements IValueObject<DriverNameProps> {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static create(value: string): DriverName {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new RacingDomainValidationError('Driver name is required');
|
||||||
|
}
|
||||||
|
return new DriverName(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<DriverNameProps>): boolean {
|
||||||
|
return this.props.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): DriverNameProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
2808
package-lock.json
generated
2808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user