wip
This commit is contained in:
@@ -4,8 +4,8 @@ import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
|
||||
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
|
||||
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
|
||||
import type { RecurrenceStrategy } from '../../domain/types/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/types/RecurrenceStrategy';
|
||||
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
import { BusinessRuleViolationError } from '../errors/RacingApplicationError';
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*
|
||||
@@ -15,6 +11,8 @@ import type { IEntity } from '@gridpilot/shared/domain';
|
||||
* - dismissed: Protest was dismissed (no action taken)
|
||||
* - withdrawn: Protesting driver withdrew the protest
|
||||
*/
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Domain Entity: Sponsor
|
||||
*/
|
||||
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
*
|
||||
* Represents a sponsor that can sponsor leagues/seasons.
|
||||
* Aggregate root for sponsor information.
|
||||
*/
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface SponsorProps {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Backwards-compat alias for legacy imports.
|
||||
*
|
||||
* New code should depend on IImageServicePort from
|
||||
* packages/racing/application/ports/IImageServicePort.
|
||||
*/
|
||||
export type { IImageServicePort as IImageService } from '../../application/ports/IImageServicePort';
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* Tests for ScheduleCalculator have been moved to:
|
||||
* tests/unit/domain/services/ScheduleCalculator.test.ts
|
||||
*
|
||||
* This file is kept as a stub to avoid placing tests under domain/services.
|
||||
*/
|
||||
describe('calculateRaceDates', () => {
|
||||
describe('with empty or invalid input', () => {
|
||||
it('should return empty array when weekdays is empty', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: [],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is 0', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 0,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is negative', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: -5,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly scheduling', () => {
|
||||
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// All dates should be Saturdays
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(6); // Saturday
|
||||
});
|
||||
// First race should be Jan 6
|
||||
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Last race should be 7 weeks later (Feb 24)
|
||||
expect(result.raceDates[7].toISOString().split('T')[0]).toBe('2024-02-24');
|
||||
});
|
||||
|
||||
it('should schedule races on multiple weekdays', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Wed', 'Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'), // Monday
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// Should alternate between Wednesday and Saturday
|
||||
result.raceDates.forEach(date => {
|
||||
const day = date.getDay();
|
||||
expect([3, 6]).toContain(day); // Wed=3, Sat=6
|
||||
});
|
||||
});
|
||||
|
||||
it('should schedule 8 races on Sundays', () => {
|
||||
// Given - January 7, 2024 is a Sunday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sun'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bi-weekly scheduling', () => {
|
||||
it('should schedule races every 2 weeks on Saturdays', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'everyNWeeks',
|
||||
rounds: 4,
|
||||
startDate: new Date('2024-01-06'),
|
||||
intervalWeeks: 2,
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(4);
|
||||
// First race Jan 6
|
||||
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Second race 2 weeks later (Jan 20)
|
||||
expect(result.raceDates[1].toISOString().split('T')[0]).toBe('2024-01-20');
|
||||
// Third race 2 weeks later (Feb 3)
|
||||
expect(result.raceDates[2].toISOString().split('T')[0]).toBe('2024-02-03');
|
||||
// Fourth race 2 weeks later (Feb 17)
|
||||
expect(result.raceDates[3].toISOString().split('T')[0]).toBe('2024-02-17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with start and end dates', () => {
|
||||
it('should evenly distribute races across the date range', () => {
|
||||
// Given - 3 month season
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-03-30'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// First race should be at or near start
|
||||
expect(result.raceDates[0].toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Races should be spread across the range, not consecutive weeks
|
||||
});
|
||||
|
||||
it('should use all available days if fewer than rounds requested', () => {
|
||||
// Given - short period with only 3 Saturdays
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 10,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-01-21'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// Only 3 Saturdays in this range: Jan 6, 13, 20
|
||||
expect(result.raceDates.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('season duration calculation', () => {
|
||||
it('should calculate correct season duration in weeks', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// 8 races, 1 week apart = 7 weeks duration
|
||||
expect(result.seasonDurationWeeks).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 duration for single race', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 1,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(1);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextWeekday', () => {
|
||||
it('should return next Saturday from a Monday', () => {
|
||||
// Given - January 1, 2024 is a Monday
|
||||
const fromDate = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
expect(result.getDay()).toBe(6);
|
||||
});
|
||||
|
||||
it('should return next occurrence when already on that weekday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const fromDate = new Date('2024-01-06');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
// Should return NEXT Saturday (7 days later), not same day
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
|
||||
});
|
||||
|
||||
it('should return next Sunday from a Friday', () => {
|
||||
// Given - January 5, 2024 is a Friday
|
||||
const fromDate = new Date('2024-01-05');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sun');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
|
||||
expect(result.getDay()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return next Wednesday from a Thursday', () => {
|
||||
// Given - January 4, 2024 is a Thursday
|
||||
const fromDate = new Date('2024-01-04');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Wed');
|
||||
|
||||
// Then
|
||||
// Next Wednesday is 6 days later
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
|
||||
expect(result.getDay()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
59
packages/racing/domain/value-objects/RecurrenceStrategy.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WeekdaySet } from './WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
|
||||
export type WeeklyRecurrenceStrategy = {
|
||||
kind: 'weekly';
|
||||
weekdays: WeekdaySet;
|
||||
};
|
||||
|
||||
export type EveryNWeeksRecurrenceStrategy = {
|
||||
kind: 'everyNWeeks';
|
||||
weekdays: WeekdaySet;
|
||||
intervalWeeks: number;
|
||||
};
|
||||
|
||||
export type MonthlyNthWeekdayRecurrenceStrategy = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
monthlyPattern: MonthlyRecurrencePattern;
|
||||
};
|
||||
|
||||
export type RecurrenceStrategy =
|
||||
| WeeklyRecurrenceStrategy
|
||||
| EveryNWeeksRecurrenceStrategy
|
||||
| MonthlyNthWeekdayRecurrenceStrategy;
|
||||
|
||||
export class RecurrenceStrategyFactory {
|
||||
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (weekdays.getAll().length === 0) {
|
||||
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays,
|
||||
};
|
||||
}
|
||||
|
||||
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
|
||||
if (!Number.isInteger(intervalWeeks) || intervalWeeks <= 0) {
|
||||
throw new RacingDomainValidationError('intervalWeeks must be a positive integer');
|
||||
}
|
||||
if (weekdays.getAll().length === 0) {
|
||||
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
weekdays,
|
||||
intervalWeeks,
|
||||
};
|
||||
}
|
||||
|
||||
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
monthlyPattern: pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Weekday } from './Weekday';
|
||||
import { weekdayToIndex } from './Weekday';
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
import { weekdayToIndex } from '../types/Weekday';
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsEntityId } from './AnalyticsEntityId';
|
||||
import { AnalyticsEntityId } from '../../../../../packages/analytics/domain/value-objects/AnalyticsEntityId';
|
||||
|
||||
describe('AnalyticsEntityId', () => {
|
||||
it('creates a valid AnalyticsEntityId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsSessionId } from './AnalyticsSessionId';
|
||||
import { AnalyticsSessionId } from '../../../../../packages/analytics/domain/value-objects/AnalyticsSessionId';
|
||||
|
||||
describe('AnalyticsSessionId', () => {
|
||||
it('creates a valid AnalyticsSessionId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageViewId } from './PageViewId';
|
||||
import { PageViewId } from '../../../../../packages/analytics/domain/value-objects/PageViewId';
|
||||
|
||||
describe('PageViewId', () => {
|
||||
it('creates a valid PageViewId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MediaUrl } from './MediaUrl';
|
||||
import { MediaUrl } from '../../../../../packages/media/domain/value-objects/MediaUrl';
|
||||
|
||||
describe('MediaUrl', () => {
|
||||
it('creates from valid http/https URLs', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NotificationId } from './NotificationId';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { NotificationId } from '../../../../../packages/notifications/domain/value-objects/NotificationId';
|
||||
import { NotificationDomainError } from '../../../../../packages/notifications/domain/errors/NotificationDomainError';
|
||||
|
||||
describe('NotificationId', () => {
|
||||
it('creates a valid NotificationId from a non-empty string', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QuietHours } from './QuietHours';
|
||||
import { QuietHours } from '../../../../../packages/notifications/domain/value-objects/QuietHours';
|
||||
|
||||
describe('QuietHours', () => {
|
||||
it('creates a valid normal-range window', () => {
|
||||
@@ -8,7 +8,7 @@ import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IR
|
||||
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import type { DriverRatingProvider } from '@gridpilot/racing/application/ports/DriverRatingProvider';
|
||||
import type { IImageService } from '@gridpilot/racing/domain/services/IImageService';
|
||||
import type { IImageServicePort } from '@gridpilot/racing/application/ports/IImageServicePort';
|
||||
import type {
|
||||
IRaceDetailPresenter,
|
||||
RaceDetailViewModel,
|
||||
@@ -336,7 +336,7 @@ class TestDriverRatingProvider implements DriverRatingProvider {
|
||||
}
|
||||
}
|
||||
|
||||
class TestImageService implements IImageService {
|
||||
class TestImageService implements IImageServicePort {
|
||||
getDriverAvatar(driverId: string): string {
|
||||
return `avatar-${driverId}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user