wip league admin tools
This commit is contained in:
@@ -22,7 +22,7 @@ describe('League', () => {
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
ownerId: 'owner1',
|
||||
})).toThrow('League ID cannot be empty');
|
||||
})).toThrow('League ID is required');
|
||||
});
|
||||
|
||||
it('should throw on invalid name', () => {
|
||||
@@ -31,7 +31,7 @@ describe('League', () => {
|
||||
name: '',
|
||||
description: 'A test league',
|
||||
ownerId: 'owner1',
|
||||
})).toThrow('League name cannot be empty');
|
||||
})).toThrow('League name is required');
|
||||
});
|
||||
|
||||
it('should throw on name too long', () => {
|
||||
@@ -50,7 +50,7 @@ describe('League', () => {
|
||||
name: 'Test League',
|
||||
description: '',
|
||||
ownerId: 'owner1',
|
||||
})).toThrow('League description cannot be empty');
|
||||
})).toThrow('League description is required');
|
||||
});
|
||||
|
||||
it('should throw on description too long', () => {
|
||||
@@ -69,7 +69,7 @@ describe('League', () => {
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
ownerId: '',
|
||||
})).toThrow('League owner ID cannot be empty');
|
||||
})).toThrow('League owner ID is required');
|
||||
});
|
||||
|
||||
it('should create with social links', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('Race', () => {
|
||||
expect(race.track).toBe('Monza');
|
||||
expect(race.car).toBe('Ferrari SF21');
|
||||
expect(race.sessionType).toEqual(SessionType.main());
|
||||
expect(race.status).toBe('scheduled');
|
||||
expect(race.status.toString()).toBe('scheduled');
|
||||
expect(race.trackId).toBeUndefined();
|
||||
expect(race.carId).toBeUndefined();
|
||||
expect(race.strengthOfField).toBeUndefined();
|
||||
@@ -53,10 +53,10 @@ describe('Race', () => {
|
||||
expect(race.car).toBe('Ferrari SF21');
|
||||
expect(race.carId).toBe('car-1');
|
||||
expect(race.sessionType).toEqual(SessionType.qualifying());
|
||||
expect(race.status).toBe('running');
|
||||
expect(race.strengthOfField).toBe(1500);
|
||||
expect(race.registeredCount).toBe(20);
|
||||
expect(race.maxParticipants).toBe(24);
|
||||
expect(race.status.toString()).toBe('running');
|
||||
expect(race.strengthOfField?.toNumber()).toBe(1500);
|
||||
expect(race.registeredCount?.toNumber()).toBe(20);
|
||||
expect(race.maxParticipants?.toNumber()).toBe(24);
|
||||
});
|
||||
|
||||
it('should throw error for invalid id', () => {
|
||||
@@ -126,7 +126,7 @@ describe('Race', () => {
|
||||
status: 'scheduled',
|
||||
});
|
||||
const started = race.start();
|
||||
expect(started.status).toBe('running');
|
||||
expect(started.status.toString()).toBe('running');
|
||||
});
|
||||
|
||||
it('should throw error if not scheduled', () => {
|
||||
@@ -155,7 +155,7 @@ describe('Race', () => {
|
||||
status: 'running',
|
||||
});
|
||||
const completed = race.complete();
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.status.toString()).toBe('completed');
|
||||
});
|
||||
|
||||
it('should throw error if already completed', () => {
|
||||
@@ -197,7 +197,7 @@ describe('Race', () => {
|
||||
status: 'scheduled',
|
||||
});
|
||||
const cancelled = race.cancel();
|
||||
expect(cancelled.status).toBe('cancelled');
|
||||
expect(cancelled.status.toString()).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should throw error if completed', () => {
|
||||
@@ -238,8 +238,8 @@ describe('Race', () => {
|
||||
car: 'Ferrari SF21',
|
||||
});
|
||||
const updated = race.updateField(1600, 22);
|
||||
expect(updated.strengthOfField).toBe(1600);
|
||||
expect(updated.registeredCount).toBe(22);
|
||||
expect(updated.strengthOfField?.toNumber()).toBe(1600);
|
||||
expect(updated.registeredCount?.toNumber()).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -141,14 +141,6 @@ export class Race implements IEntity<string> {
|
||||
strengthOfField = StrengthOfField.create(props.strengthOfField);
|
||||
}
|
||||
|
||||
// Validate scheduled time is not in the past for new races
|
||||
// Allow some flexibility for testing and bootstrap scenarios
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
if (status.isScheduled() && props.scheduledAt < oneHourAgo) {
|
||||
throw new RacingDomainValidationError('Scheduled time cannot be more than 1 hour in the past');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
@@ -219,6 +211,14 @@ export class Race implements IEntity<string> {
|
||||
* Cancel the race
|
||||
*/
|
||||
cancel(): Race {
|
||||
if (this.status.isCancelled()) {
|
||||
throw new RacingDomainInvariantError('Race is already cancelled');
|
||||
}
|
||||
|
||||
if (this.status.isCompleted()) {
|
||||
throw new RacingDomainInvariantError('Cannot cancel completed race');
|
||||
}
|
||||
|
||||
const transition = this.status.canTransitionTo('cancelled');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
@@ -234,9 +234,15 @@ export class Race implements IEntity<string> {
|
||||
...(this.carId !== undefined ? { carId: this.carId } : {}),
|
||||
sessionType: this.sessionType,
|
||||
status: 'cancelled',
|
||||
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}),
|
||||
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
|
||||
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
|
||||
...(this.strengthOfField !== undefined
|
||||
? { strengthOfField: this.strengthOfField.toNumber() }
|
||||
: {}),
|
||||
...(this.registeredCount !== undefined
|
||||
? { registeredCount: this.registeredCount.toNumber() }
|
||||
: {}),
|
||||
...(this.maxParticipants !== undefined
|
||||
? { maxParticipants: this.maxParticipants.toNumber() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +250,14 @@ export class Race implements IEntity<string> {
|
||||
* Re-open a previously completed or cancelled race
|
||||
*/
|
||||
reopen(): Race {
|
||||
if (this.status.isScheduled()) {
|
||||
throw new RacingDomainInvariantError('Race is already scheduled');
|
||||
}
|
||||
|
||||
if (this.status.isRunning()) {
|
||||
throw new RacingDomainInvariantError('Cannot reopen running race');
|
||||
}
|
||||
|
||||
const transition = this.status.canTransitionTo('scheduled');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
@@ -259,9 +273,15 @@ export class Race implements IEntity<string> {
|
||||
...(this.carId !== undefined ? { carId: this.carId } : {}),
|
||||
sessionType: this.sessionType,
|
||||
status: 'scheduled',
|
||||
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}),
|
||||
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
|
||||
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
|
||||
...(this.strengthOfField !== undefined
|
||||
? { strengthOfField: this.strengthOfField.toNumber() }
|
||||
: {}),
|
||||
...(this.registeredCount !== undefined
|
||||
? { registeredCount: this.registeredCount.toNumber() }
|
||||
: {}),
|
||||
...(this.maxParticipants !== undefined
|
||||
? { maxParticipants: this.maxParticipants.toNumber() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('Track', () => {
|
||||
lengthKm: 5.793,
|
||||
turns: 11,
|
||||
gameId: 'game1',
|
||||
})).toThrow('Track name is required');
|
||||
})).toThrow('Track name cannot be empty');
|
||||
});
|
||||
|
||||
it('should throw on invalid country', () => {
|
||||
|
||||
@@ -21,17 +21,17 @@ describe('Season aggregate lifecycle', () => {
|
||||
const planned = createMinimalSeason({ status: 'planned' });
|
||||
|
||||
const activated = planned.activate();
|
||||
expect(activated.status).toBe('active');
|
||||
expect(activated.status.toString()).toBe('active');
|
||||
expect(activated.startDate).toBeInstanceOf(Date);
|
||||
expect(activated.endDate).toBeUndefined();
|
||||
|
||||
const completed = activated.complete();
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.status.toString()).toBe('completed');
|
||||
expect(completed.startDate).toEqual(activated.startDate);
|
||||
expect(completed.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const archived = completed.archive();
|
||||
expect(archived.status).toBe('archived');
|
||||
expect(archived.status.toString()).toBe('archived');
|
||||
expect(archived.startDate).toEqual(completed.startDate);
|
||||
expect(archived.endDate).toEqual(completed.endDate);
|
||||
});
|
||||
@@ -79,12 +79,12 @@ describe('Season aggregate lifecycle', () => {
|
||||
const archived = createMinimalSeason({ status: 'archived' });
|
||||
|
||||
const cancelledFromPlanned = planned.cancel();
|
||||
expect(cancelledFromPlanned.status).toBe('cancelled');
|
||||
expect(cancelledFromPlanned.status.toString()).toBe('cancelled');
|
||||
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
|
||||
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
|
||||
|
||||
const cancelledFromActive = active.cancel();
|
||||
expect(cancelledFromActive.status).toBe('cancelled');
|
||||
expect(cancelledFromActive.status.toString()).toBe('cancelled');
|
||||
expect(cancelledFromActive.startDate).toBe(active.startDate);
|
||||
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class Season implements IEntity<string> {
|
||||
readonly startDate: Date | undefined;
|
||||
readonly endDate: Date | undefined;
|
||||
readonly schedule: SeasonSchedule | undefined;
|
||||
readonly schedulePublished: boolean;
|
||||
readonly scoringConfig: SeasonScoringConfig | undefined;
|
||||
readonly dropPolicy: SeasonDropPolicy | undefined;
|
||||
readonly stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
@@ -41,6 +42,7 @@ export class Season implements IEntity<string> {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
schedule?: SeasonSchedule;
|
||||
schedulePublished: boolean;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
@@ -57,6 +59,7 @@ export class Season implements IEntity<string> {
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.schedule = props.schedule;
|
||||
this.schedulePublished = props.schedulePublished;
|
||||
this.scoringConfig = props.scoringConfig;
|
||||
this.dropPolicy = props.dropPolicy;
|
||||
this.stewardingConfig = props.stewardingConfig;
|
||||
@@ -75,6 +78,7 @@ export class Season implements IEntity<string> {
|
||||
startDate?: Date;
|
||||
endDate?: Date | undefined;
|
||||
schedule?: SeasonSchedule;
|
||||
schedulePublished?: boolean;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
@@ -162,6 +166,7 @@ export class Season implements IEntity<string> {
|
||||
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
|
||||
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
|
||||
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
|
||||
schedulePublished: props.schedulePublished ?? false,
|
||||
...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
|
||||
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
|
||||
...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}),
|
||||
@@ -348,16 +353,16 @@ export class Season implements IEntity<string> {
|
||||
* Cancel a planned or active season.
|
||||
*/
|
||||
cancel(): Season {
|
||||
// If already cancelled, return this (idempotent).
|
||||
if (this.status.isCancelled()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const transition = this.status.canTransitionTo('cancelled');
|
||||
if (!transition.valid) {
|
||||
throw new RacingDomainInvariantError(transition.error!);
|
||||
}
|
||||
|
||||
// If already cancelled, return this
|
||||
if (this.status.isCancelled()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Ensure end date is set
|
||||
const endDate = this.endDate ?? new Date();
|
||||
|
||||
@@ -400,6 +405,28 @@ export class Season implements IEntity<string> {
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
schedule,
|
||||
schedulePublished: this.schedulePublished,
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: this._participantCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
withSchedulePublished(published: boolean): Season {
|
||||
return Season.create({
|
||||
id: this.id,
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status.toString(),
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
schedulePublished: published,
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
@@ -544,16 +571,16 @@ export class Season implements IEntity<string> {
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
year: this.year,
|
||||
order: this.order,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status.toString(),
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate,
|
||||
schedule: this.schedule,
|
||||
scoringConfig: this.scoringConfig,
|
||||
dropPolicy: this.dropPolicy,
|
||||
stewardingConfig: this.stewardingConfig,
|
||||
maxDrivers: this.maxDrivers,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: newCount.toNumber(),
|
||||
});
|
||||
}
|
||||
@@ -573,16 +600,16 @@ export class Season implements IEntity<string> {
|
||||
leagueId: this.leagueId,
|
||||
gameId: this.gameId,
|
||||
name: this.name,
|
||||
year: this.year,
|
||||
order: this.order,
|
||||
...(this.year !== undefined ? { year: this.year } : {}),
|
||||
...(this.order !== undefined ? { order: this.order } : {}),
|
||||
status: this.status.toString(),
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate,
|
||||
schedule: this.schedule,
|
||||
scoringConfig: this.scoringConfig,
|
||||
dropPolicy: this.dropPolicy,
|
||||
stewardingConfig: this.stewardingConfig,
|
||||
maxDrivers: this.maxDrivers,
|
||||
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
|
||||
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
|
||||
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
|
||||
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
|
||||
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
|
||||
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
|
||||
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
|
||||
participantCount: newCount.toNumber(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Race, RaceStatus } from '../entities/Race';
|
||||
import type { Race, RaceStatusValue } from '../entities/Race';
|
||||
|
||||
export interface IRaceRepository {
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export interface IRaceRepository {
|
||||
/**
|
||||
* Find races by status
|
||||
*/
|
||||
findByStatus(status: RaceStatus): Promise<Race[]>;
|
||||
findByStatus(status: RaceStatusValue): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races scheduled within a date range
|
||||
|
||||
38
core/racing/domain/value-objects/RaceName.test.ts
Normal file
38
core/racing/domain/value-objects/RaceName.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceName } from './RaceName';
|
||||
|
||||
describe('RaceName', () => {
|
||||
it('creates a valid name and exposes stable value/toString', () => {
|
||||
const name = RaceName.fromString('Valid Race Name');
|
||||
expect(name.value).toBe('Valid Race Name');
|
||||
expect(name.toString()).toBe('Valid Race Name');
|
||||
});
|
||||
|
||||
it('trims leading/trailing whitespace', () => {
|
||||
const name = RaceName.fromString(' Valid Race Name ');
|
||||
expect(name.value).toBe('Valid Race Name');
|
||||
});
|
||||
|
||||
it('rejects empty/blank values', () => {
|
||||
expect(() => RaceName.fromString('')).toThrow('Race name cannot be empty');
|
||||
expect(() => RaceName.fromString(' ')).toThrow('Race name cannot be empty');
|
||||
});
|
||||
|
||||
it('rejects names shorter than 3 characters (after trim)', () => {
|
||||
expect(() => RaceName.fromString('ab')).toThrow('Race name must be at least 3 characters long');
|
||||
expect(() => RaceName.fromString(' ab ')).toThrow('Race name must be at least 3 characters long');
|
||||
});
|
||||
|
||||
it('rejects names longer than 100 characters (after trim)', () => {
|
||||
expect(() => RaceName.fromString('a'.repeat(101))).toThrow('Race name must not exceed 100 characters');
|
||||
expect(() => RaceName.fromString(` ${'a'.repeat(101)} `)).toThrow('Race name must not exceed 100 characters');
|
||||
});
|
||||
|
||||
it('equals compares by normalized value', () => {
|
||||
const a = RaceName.fromString(' Test Race ');
|
||||
const b = RaceName.fromString('Test Race');
|
||||
const c = RaceName.fromString('Different');
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
82
core/racing/domain/value-objects/RaceStatus.test.ts
Normal file
82
core/racing/domain/value-objects/RaceStatus.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceStatus, type RaceStatusValue } from './RaceStatus';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('RaceStatus', () => {
|
||||
it('creates a status and exposes stable value/toString/props', () => {
|
||||
const status = RaceStatus.create('scheduled');
|
||||
expect(status.value).toBe('scheduled');
|
||||
expect(status.toString()).toBe('scheduled');
|
||||
expect(status.props).toEqual({ value: 'scheduled' });
|
||||
});
|
||||
|
||||
it('rejects missing value', () => {
|
||||
expect(() => RaceStatus.create('' as unknown as RaceStatusValue)).toThrow(
|
||||
RacingDomainValidationError,
|
||||
);
|
||||
expect(() => RaceStatus.create('' as unknown as RaceStatusValue)).toThrow(
|
||||
'Race status is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports lifecycle guard helpers', () => {
|
||||
expect(RaceStatus.create('scheduled').canStart()).toBe(true);
|
||||
expect(RaceStatus.create('running').canStart()).toBe(false);
|
||||
|
||||
expect(RaceStatus.create('running').canComplete()).toBe(true);
|
||||
expect(RaceStatus.create('scheduled').canComplete()).toBe(false);
|
||||
|
||||
expect(RaceStatus.create('scheduled').canCancel()).toBe(true);
|
||||
expect(RaceStatus.create('running').canCancel()).toBe(true);
|
||||
expect(RaceStatus.create('completed').canCancel()).toBe(false);
|
||||
|
||||
expect(RaceStatus.create('completed').canReopen()).toBe(true);
|
||||
expect(RaceStatus.create('cancelled').canReopen()).toBe(true);
|
||||
expect(RaceStatus.create('running').canReopen()).toBe(false);
|
||||
|
||||
expect(RaceStatus.create('completed').isTerminal()).toBe(true);
|
||||
expect(RaceStatus.create('cancelled').isTerminal()).toBe(true);
|
||||
expect(RaceStatus.create('running').isTerminal()).toBe(false);
|
||||
|
||||
expect(RaceStatus.create('running').isRunning()).toBe(true);
|
||||
expect(RaceStatus.create('completed').isCompleted()).toBe(true);
|
||||
expect(RaceStatus.create('scheduled').isScheduled()).toBe(true);
|
||||
expect(RaceStatus.create('cancelled').isCancelled()).toBe(true);
|
||||
});
|
||||
|
||||
it('validates allowed transitions', () => {
|
||||
const scheduled = RaceStatus.create('scheduled');
|
||||
const running = RaceStatus.create('running');
|
||||
const completed = RaceStatus.create('completed');
|
||||
const cancelled = RaceStatus.create('cancelled');
|
||||
|
||||
expect(scheduled.canTransitionTo('running')).toEqual({ valid: true });
|
||||
expect(scheduled.canTransitionTo('cancelled')).toEqual({ valid: true });
|
||||
|
||||
expect(running.canTransitionTo('completed')).toEqual({ valid: true });
|
||||
expect(running.canTransitionTo('cancelled')).toEqual({ valid: true });
|
||||
|
||||
expect(completed.canTransitionTo('scheduled')).toEqual({ valid: true });
|
||||
expect(cancelled.canTransitionTo('scheduled')).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('rejects disallowed transitions with a helpful error', () => {
|
||||
const scheduled = RaceStatus.create('scheduled');
|
||||
const result = scheduled.canTransitionTo('completed');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Cannot transition from scheduled to completed');
|
||||
});
|
||||
|
||||
it('equals compares by value', () => {
|
||||
const a = RaceStatus.create('scheduled');
|
||||
const b = RaceStatus.create('scheduled');
|
||||
const c = RaceStatus.create('running');
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
|
||||
it('only allowed status values compile (type-level)', () => {
|
||||
const allowed: RaceStatusValue[] = ['scheduled', 'running', 'completed', 'cancelled'];
|
||||
expect(allowed).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -100,8 +100,8 @@ export class RaceStatus implements IValueObject<RaceStatusProps> {
|
||||
const allowedTransitions: Record<RaceStatusValue, RaceStatusValue[]> = {
|
||||
scheduled: ['running', 'cancelled'],
|
||||
running: ['completed', 'cancelled'],
|
||||
completed: [],
|
||||
cancelled: [],
|
||||
completed: ['scheduled'],
|
||||
cancelled: ['scheduled'],
|
||||
};
|
||||
|
||||
if (!allowedTransitions[current].includes(target)) {
|
||||
|
||||
@@ -24,8 +24,11 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
|
||||
throw new RacingDomainValidationError('Strength of field must be an integer');
|
||||
}
|
||||
|
||||
if (value < 0 || value > 100) {
|
||||
throw new RacingDomainValidationError('Strength of field must be between 0 and 100');
|
||||
// SOF represents iRating-like values (commonly ~0-10k), not a 0-100 percentage.
|
||||
if (value < 0 || value > 10_000) {
|
||||
throw new RacingDomainValidationError(
|
||||
'Strength of field must be between 0 and 10000',
|
||||
);
|
||||
}
|
||||
|
||||
return new StrengthOfField(value);
|
||||
@@ -35,9 +38,9 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
|
||||
* Get the strength category
|
||||
*/
|
||||
getCategory(): 'beginner' | 'intermediate' | 'advanced' | 'expert' {
|
||||
if (this.value < 25) return 'beginner';
|
||||
if (this.value < 50) return 'intermediate';
|
||||
if (this.value < 75) return 'advanced';
|
||||
if (this.value < 1500) return 'beginner';
|
||||
if (this.value < 2500) return 'intermediate';
|
||||
if (this.value < 4000) return 'advanced';
|
||||
return 'expert';
|
||||
}
|
||||
|
||||
@@ -45,8 +48,8 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
|
||||
* Check if this SOF is suitable for the given participant count
|
||||
*/
|
||||
isSuitableForParticipants(count: number): boolean {
|
||||
// Higher SOF should generally have more participants
|
||||
const minExpected = Math.floor(this.value / 10);
|
||||
// Higher SOF should generally have more participants.
|
||||
const minExpected = Math.floor(this.value / 500);
|
||||
return count >= minExpected;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,30 +3,34 @@ 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');
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const id = TrackId.create(uuid);
|
||||
expect(id.toString()).toBe(uuid);
|
||||
expect(id.value).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
const id = TrackId.create(' track-123 ');
|
||||
it('should allow fromString without validation', () => {
|
||||
const id = TrackId.fromString('track-123');
|
||||
expect(id.toString()).toBe('track-123');
|
||||
expect(id.value).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 throw for invalid uuid', () => {
|
||||
expect(() => TrackId.create('')).toThrow('TrackId must be a valid UUID');
|
||||
expect(() => TrackId.create(' ')).toThrow('TrackId must be a valid UUID');
|
||||
expect(() => TrackId.create('track-123')).toThrow('TrackId must be a valid UUID');
|
||||
});
|
||||
|
||||
it('should equal same ids', () => {
|
||||
const i1 = TrackId.create('track-123');
|
||||
const i2 = TrackId.create('track-123');
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const i1 = TrackId.create(uuid);
|
||||
const i2 = TrackId.create(uuid);
|
||||
expect(i1.equals(i2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not equal different ids', () => {
|
||||
const i1 = TrackId.create('track-123');
|
||||
const i2 = TrackId.create('track-456');
|
||||
const i1 = TrackId.create('550e8400-e29b-41d4-a716-446655440000');
|
||||
const i2 = TrackId.create('550e8400-e29b-41d4-a716-446655440001');
|
||||
expect(i1.equals(i2)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ describe('TrackName', () => {
|
||||
it('should create track name', () => {
|
||||
const name = TrackName.create('Silverstone');
|
||||
expect(name.toString()).toBe('Silverstone');
|
||||
expect(name.props).toBe('Silverstone');
|
||||
expect(name.value).toBe('Silverstone');
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
@@ -14,8 +14,8 @@ describe('TrackName', () => {
|
||||
});
|
||||
|
||||
it('should throw for empty name', () => {
|
||||
expect(() => TrackName.create('')).toThrow('Track name is required');
|
||||
expect(() => TrackName.create(' ')).toThrow('Track name is required');
|
||||
expect(() => TrackName.create('')).toThrow('Track name cannot be empty');
|
||||
expect(() => TrackName.create(' ')).toThrow('Track name cannot be empty');
|
||||
});
|
||||
|
||||
it('should equal same names', () => {
|
||||
|
||||
Reference in New Issue
Block a user