This commit is contained in:
2025-12-17 01:23:09 +01:00
parent f01e01e50c
commit 4d890863d3
73 changed files with 2632 additions and 3224 deletions

View File

@@ -0,0 +1,44 @@
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
minLength: 20,
maxLength: 1000,
recommendedMinLength: 50,
} as const;
export const LEAGUE_NAME_CONSTRAINTS = {
minLength: 3,
maxLength: 64,
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
forbiddenPatterns: [
/^\s/, // No leading whitespace
/\s$/, // No trailing whitespace
/\s{2,}/, // No multiple consecutive spaces
],
} as const;
export type LeagueVisibilityType = 'ranked' | 'unranked';
export interface LeagueVisibilityConstraints {
readonly minDrivers: number;
readonly isPubliclyVisible: boolean;
readonly affectsRatings: boolean;
readonly requiresApproval: boolean;
}
export const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
ranked: {
minDrivers: 10,
isPubliclyVisible: true,
affectsRatings: true,
requiresApproval: false, // Anyone can join public leagues
},
unranked: {
minDrivers: 2,
isPubliclyVisible: false,
affectsRatings: false,
requiresApproval: true, // Private leagues require invite/approval
},
};
// Export constants for validation
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;

View File

@@ -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);

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

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

View File

@@ -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
*/

View File

@@ -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;
}
}

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

View File

@@ -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 {

View File

@@ -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();
}
}

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

View File

@@ -3,7 +3,7 @@
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@core/shared/domain';

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

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

View File

@@ -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;
}

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

View File

@@ -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

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

View File

@@ -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,

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

View File

@@ -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);
}
}

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

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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;
}
}

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

View File

@@ -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,
};
}

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

View File

@@ -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];
}
}

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

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

View File

@@ -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;
}
}
}

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

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

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

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

View File

@@ -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

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

View File

@@ -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();
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

View File

@@ -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;
}
}

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

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

View File

@@ -0,0 +1,29 @@
import { RacingDomainValidationError } from '../../errors/RacingDomainError';
import type { IValueObject } from '@core/shared/domain';
export interface DriverNameProps {
value: string;
}
export class DriverName implements IValueObject<DriverNameProps> {
private constructor(private readonly value: string) {}
static create(value: string): DriverName {
if (!value || value.trim().length === 0) {
throw new RacingDomainValidationError('Driver name is required');
}
return new DriverName(value.trim());
}
toString(): string {
return this.value;
}
equals(other: IValueObject<DriverNameProps>): boolean {
return this.props.value === other.props.value;
}
get props(): DriverNameProps {
return { value: this.value };
}
}

2808
package-lock.json generated

File diff suppressed because it is too large Load Diff