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

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

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

View File

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

View File

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

View File

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

View File

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