wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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