refactor
This commit is contained in:
@@ -5,6 +5,7 @@ import type { AsyncUseCase } from '@core/shared/application';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ApproveLeagueJoinRequestUseCaseParams } from '../dto/ApproveLeagueJoinRequestUseCaseParams';
|
||||
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
|
||||
import { JoinedAt } from '../../domain/value-objects/JoinedAt';
|
||||
|
||||
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, string> {
|
||||
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
|
||||
@@ -22,7 +23,7 @@ export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeag
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
joinedAt: JoinedAt.create(new Date()),
|
||||
});
|
||||
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class CountryCode {
|
||||
export class CountryCode implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static create(value: string): CountryCode {
|
||||
@@ -18,7 +19,11 @@ export class CountryCode {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: CountryCode): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
readonly gameId: string;
|
||||
readonly constraints: GameConstraintsData;
|
||||
|
||||
private constructor(gameId: string, constraints: GameConstraintsData) {
|
||||
constructor(gameId: string, constraints: GameConstraintsData) {
|
||||
this.gameId = gameId;
|
||||
this.constraints = constraints;
|
||||
}
|
||||
@@ -105,22 +41,6 @@ export class GameConstraints implements IValueObject<GameConstraintsProps> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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) {}
|
||||
|
||||
static create(value: string): IRacingId {
|
||||
@@ -14,7 +15,11 @@ export class IRacingId {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: IRacingId): boolean {
|
||||
return this.value === other.value;
|
||||
get props(): string {
|
||||
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 {
|
||||
return this.value === other.props;
|
||||
return this.props === other.props;
|
||||
}
|
||||
|
||||
get props(): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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) {}
|
||||
|
||||
static create(value: Date): JoinedAt {
|
||||
@@ -15,7 +16,11 @@ export class JoinedAt {
|
||||
return new Date(this.value);
|
||||
}
|
||||
|
||||
equals(other: JoinedAt): boolean {
|
||||
return this.value.getTime() === other.value.getTime();
|
||||
get props(): Date {
|
||||
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.
|
||||
*/
|
||||
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
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> {
|
||||
private readonly id: string;
|
||||
readonly id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
if (!id || id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
|
||||
}
|
||||
private constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
static create(id: string): LeagueTimezone {
|
||||
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 {
|
||||
return { id: this.id };
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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(rotation: number): LiveryDecal {
|
||||
// Normalize rotation to 0-360 range
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
rotation: normalizedRotation,
|
||||
});
|
||||
}
|
||||
* Rotate decal
|
||||
*/
|
||||
rotate(rotation: number): LiveryDecal {
|
||||
// Normalize rotation to 0-360 range
|
||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||
return LiveryDecal.create({
|
||||
...this,
|
||||
rotation: normalizedRotation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS transform string for rendering
|
||||
*/
|
||||
getCssTransform(): string {
|
||||
return `rotate(${this.rotation}deg)`;
|
||||
}
|
||||
|
||||
get props(): LiveryDecalProps {
|
||||
get props(): LiveryDecalProps {
|
||||
return {
|
||||
id: this.id,
|
||||
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');
|
||||
}
|
||||
|
||||
if (amount.amount < 0) {
|
||||
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
|
||||
}
|
||||
|
||||
return new MembershipFee({ type, amount });
|
||||
}
|
||||
|
||||
@@ -62,29 +58,15 @@ export class MembershipFee implements IValueObject<MembershipFeeProps> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a recurring fee
|
||||
*/
|
||||
isRecurring(): boolean {
|
||||
return this.type === 'monthly';
|
||||
}
|
||||
* Check if this is a recurring fee
|
||||
*/
|
||||
isRecurring(): boolean {
|
||||
return this.type === 'monthly';
|
||||
}
|
||||
|
||||
equals(other: IValueObject<MembershipFeeProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
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';
|
||||
}
|
||||
}
|
||||
equals(other: IValueObject<MembershipFeeProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.type === b.type && a.amount.equals(b.amount);
|
||||
}
|
||||
}
|
||||
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
|
||||
*/
|
||||
equals(other: IValueObject<MoneyProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
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);
|
||||
}
|
||||
* Check if this money equals another
|
||||
*/
|
||||
equals(other: IValueObject<MoneyProps>): boolean {
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
return a.amount === b.amount && a.currency === b.currency;
|
||||
}
|
||||
}
|
||||
@@ -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 { IValueObject } from '@core/shared/domain';
|
||||
|
||||
@@ -9,20 +11,43 @@ export interface MonthlyRecurrencePatternProps {
|
||||
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
|
||||
readonly ordinal: 1 | 2 | 3 | 4;
|
||||
readonly weekday: Weekday;
|
||||
|
||||
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
|
||||
constructor(props: MonthlyRecurrencePatternProps);
|
||||
constructor(
|
||||
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
|
||||
weekday?: Weekday,
|
||||
) {
|
||||
if (typeof ordinalOrProps === 'object') {
|
||||
this.ordinal = ordinalOrProps.ordinal;
|
||||
this.weekday = ordinalOrProps.weekday;
|
||||
} else {
|
||||
this.ordinal = ordinalOrProps;
|
||||
this.weekday = weekday as Weekday;
|
||||
|
||||
private constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
|
||||
this.ordinal = ordinal;
|
||||
this.weekday = weekday;
|
||||
}
|
||||
|
||||
static create(ordinal: 1 | 2 | 3 | 4, weekday: Weekday): MonthlyRecurrencePattern {
|
||||
if (!ordinal || ordinal < 1 || ordinal > 4) {
|
||||
throw new RacingDomainValidationError('MonthlyRecurrencePattern ordinal must be between 1 and 4');
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export class Points {
|
||||
export class Points implements IValueObject<{ value: number }> {
|
||||
private constructor(private readonly value: number) {}
|
||||
|
||||
get props(): { value: number } {
|
||||
return { value: this.value };
|
||||
}
|
||||
|
||||
static create(value: number): Points {
|
||||
if (value < 0) {
|
||||
throw new RacingDomainValidationError('Points cannot be negative');
|
||||
@@ -14,7 +19,7 @@ export class Points {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: Points): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<{ value: number }>): boolean {
|
||||
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';
|
||||
|
||||
export interface PointsTableProps {
|
||||
pointsByPosition: Map<number, number>;
|
||||
pointsByPosition: ReadonlyMap<number, number>;
|
||||
}
|
||||
|
||||
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>) {
|
||||
if (pointsByPosition instanceof Map) {
|
||||
@@ -27,7 +27,7 @@ export class PointsTable implements IValueObject<PointsTableProps> {
|
||||
|
||||
get props(): PointsTableProps {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
// Removed getSeverityScore, getSummary, and getIncidentTypeLabel to eliminate static data in core
|
||||
|
||||
equals(other: IValueObject<IncidentRecord[]>): boolean {
|
||||
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 { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type WeeklyRecurrenceStrategy = {
|
||||
export type WeeklyRecurrenceStrategyProps = {
|
||||
kind: 'weekly';
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type EveryNWeeksRecurrenceStrategy = {
|
||||
export type EveryNWeeksRecurrenceStrategyProps = {
|
||||
kind: 'everyNWeeks';
|
||||
weekdays: WeekdaySet;
|
||||
intervalWeeks: number;
|
||||
};
|
||||
|
||||
export type MonthlyNthWeekdayRecurrenceStrategy = {
|
||||
export type MonthlyNthWeekdayRecurrenceStrategyProps = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
monthlyPattern: MonthlyRecurrencePattern;
|
||||
};
|
||||
|
||||
export type RecurrenceStrategy =
|
||||
| WeeklyRecurrenceStrategy
|
||||
| EveryNWeeksRecurrenceStrategy
|
||||
| MonthlyNthWeekdayRecurrenceStrategy;
|
||||
export type RecurrenceStrategyProps =
|
||||
| WeeklyRecurrenceStrategyProps
|
||||
| EveryNWeeksRecurrenceStrategyProps
|
||||
| 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 {
|
||||
if (weekdays.getAll().length === 0) {
|
||||
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
return new RecurrenceStrategy({
|
||||
kind: 'weekly',
|
||||
weekdays,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
@@ -43,17 +50,36 @@ export class RecurrenceStrategyFactory {
|
||||
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
return new RecurrenceStrategy({
|
||||
kind: 'everyNWeeks',
|
||||
weekdays,
|
||||
intervalWeeks,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||
return {
|
||||
return new RecurrenceStrategy({
|
||||
kind: 'monthlyNthWeekday',
|
||||
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
|
||||
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TeamCreatedAt {
|
||||
export class TeamCreatedAt implements IValueObject<Date> {
|
||||
private constructor(private readonly value: Date) {}
|
||||
|
||||
get props(): Date {
|
||||
return new Date(this.value);
|
||||
}
|
||||
|
||||
static create(value: Date): TeamCreatedAt {
|
||||
const now = new Date();
|
||||
if (value > now) {
|
||||
@@ -15,7 +20,7 @@ export class TeamCreatedAt {
|
||||
return new Date(this.value);
|
||||
}
|
||||
|
||||
equals(other: TeamCreatedAt): boolean {
|
||||
return this.value.getTime() === other.value.getTime();
|
||||
equals(other: IValueObject<Date>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TeamDescription {
|
||||
export class TeamDescription implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamDescription {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team description is required');
|
||||
@@ -14,7 +19,7 @@ export class TeamDescription {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TeamDescription): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TeamName {
|
||||
export class TeamName implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamName {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team name is required');
|
||||
@@ -14,7 +19,7 @@ export class TeamName {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TeamName): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TeamTag {
|
||||
export class TeamTag implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TeamTag {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team tag is required');
|
||||
@@ -14,7 +19,7 @@ export class TeamTag {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TeamTag): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackCountry {
|
||||
export class TrackCountry implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TrackCountry {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track country is required');
|
||||
@@ -14,7 +19,7 @@ export class TrackCountry {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackCountry): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackGameId {
|
||||
export class TrackGameId implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TrackGameId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track game ID cannot be empty');
|
||||
@@ -14,7 +19,7 @@ export class TrackGameId {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackGameId): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackId {
|
||||
export class TrackId implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TrackId {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track ID cannot be empty');
|
||||
@@ -14,7 +19,7 @@ export class TrackId {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackId): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackImageUrl {
|
||||
export class TrackImageUrl implements IValueObject<string | undefined> {
|
||||
private constructor(private readonly value: string | undefined) {}
|
||||
|
||||
get props(): string | undefined {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string | undefined): TrackImageUrl {
|
||||
// Allow undefined or valid URL, but for simplicity, just check if string is not empty if provided
|
||||
if (value !== undefined && value.trim().length === 0) {
|
||||
@@ -15,7 +20,7 @@ export class TrackImageUrl {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackImageUrl): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string | undefined>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackLength {
|
||||
export class TrackLength implements IValueObject<number> {
|
||||
private constructor(private readonly value: number) {}
|
||||
|
||||
get props(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: number): TrackLength {
|
||||
if (value <= 0) {
|
||||
throw new RacingDomainValidationError('Track length must be positive');
|
||||
@@ -14,7 +19,7 @@ export class TrackLength {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackLength): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<number>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackName {
|
||||
export class TrackName implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TrackName {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track name is required');
|
||||
@@ -14,7 +19,7 @@ export class TrackName {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackName): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackShortName {
|
||||
export class TrackShortName implements IValueObject<string> {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
get props(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: string): TrackShortName {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Track short name is required');
|
||||
@@ -14,7 +19,7 @@ export class TrackShortName {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackShortName): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<string>): boolean {
|
||||
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 type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
export class TrackTurns {
|
||||
export class TrackTurns implements IValueObject<number> {
|
||||
private constructor(private readonly value: number) {}
|
||||
|
||||
get props(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static create(value: number): TrackTurns {
|
||||
if (value < 0) {
|
||||
throw new RacingDomainValidationError('Track turns cannot be negative');
|
||||
@@ -14,7 +19,7 @@ export class TrackTurns {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
equals(other: TrackTurns): boolean {
|
||||
return this.value === other.value;
|
||||
equals(other: IValueObject<number>): boolean {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user