From c49ea2598d362484ae5e51c6956f80d51acd40ff Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 11 Dec 2025 14:39:57 +0100 Subject: [PATCH] wip --- .../application/dto/LeagueScheduleDTO.ts | 4 +- packages/racing/domain/entities/Protest.ts | 6 +- packages/racing/domain/entities/Sponsor.ts | 8 +- .../racing/domain/services/IImageService.ts | 7 - .../services/ScheduleCalculator.test.ts | 279 ------------------ .../value-objects/RecurrenceStrategy.ts | 59 ++++ .../racing/domain/value-objects/WeekdaySet.ts | 4 +- .../value-objects/AnalyticsEntityId.test.ts | 2 +- .../value-objects/AnalyticsSessionId.test.ts | 2 +- .../domain/value-objects/PageViewId.test.ts | 2 +- .../domain/value-objects/MediaUrl.test.ts | 2 +- .../value-objects/NotificationId.test.ts | 4 +- .../domain/value-objects/QuietHours.test.ts | 2 +- .../RaceDetailUseCases.test.ts | 4 +- 14 files changed, 77 insertions(+), 308 deletions(-) delete mode 100644 packages/racing/domain/services/IImageService.ts delete mode 100644 packages/racing/domain/services/ScheduleCalculator.test.ts create mode 100644 packages/racing/domain/value-objects/RecurrenceStrategy.ts rename {packages => tests/unit}/analytics/domain/value-objects/AnalyticsEntityId.test.ts (88%) rename {packages => tests/unit}/analytics/domain/value-objects/AnalyticsSessionId.test.ts (88%) rename {packages => tests/unit}/analytics/domain/value-objects/PageViewId.test.ts (89%) rename {packages => tests/unit}/media/domain/value-objects/MediaUrl.test.ts (93%) rename {packages => tests/unit}/notifications/domain/value-objects/NotificationId.test.ts (82%) rename {packages => tests/unit}/notifications/domain/value-objects/QuietHours.test.ts (93%) diff --git a/packages/racing/application/dto/LeagueScheduleDTO.ts b/packages/racing/application/dto/LeagueScheduleDTO.ts index da082173d..8dbcc656d 100644 --- a/packages/racing/application/dto/LeagueScheduleDTO.ts +++ b/packages/racing/application/dto/LeagueScheduleDTO.ts @@ -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'; diff --git a/packages/racing/domain/entities/Protest.ts b/packages/racing/domain/entities/Protest.ts index a73f59861..8f6329bbd 100644 --- a/packages/racing/domain/entities/Protest.ts +++ b/packages/racing/domain/entities/Protest.ts @@ -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'; diff --git a/packages/racing/domain/entities/Sponsor.ts b/packages/racing/domain/entities/Sponsor.ts index 44ce069b7..2212afa39 100644 --- a/packages/racing/domain/entities/Sponsor.ts +++ b/packages/racing/domain/entities/Sponsor.ts @@ -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; diff --git a/packages/racing/domain/services/IImageService.ts b/packages/racing/domain/services/IImageService.ts deleted file mode 100644 index 9e04bc1e8..000000000 --- a/packages/racing/domain/services/IImageService.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/packages/racing/domain/services/ScheduleCalculator.test.ts b/packages/racing/domain/services/ScheduleCalculator.test.ts deleted file mode 100644 index 08285744a..000000000 --- a/packages/racing/domain/services/ScheduleCalculator.test.ts +++ /dev/null @@ -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); - }); - }); -}); \ No newline at end of file diff --git a/packages/racing/domain/value-objects/RecurrenceStrategy.ts b/packages/racing/domain/value-objects/RecurrenceStrategy.ts new file mode 100644 index 000000000..6a47cfd06 --- /dev/null +++ b/packages/racing/domain/value-objects/RecurrenceStrategy.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/packages/racing/domain/value-objects/WeekdaySet.ts b/packages/racing/domain/value-objects/WeekdaySet.ts index 7ce575284..e12875428 100644 --- a/packages/racing/domain/value-objects/WeekdaySet.ts +++ b/packages/racing/domain/value-objects/WeekdaySet.ts @@ -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'; diff --git a/packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts b/tests/unit/analytics/domain/value-objects/AnalyticsEntityId.test.ts similarity index 88% rename from packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts rename to tests/unit/analytics/domain/value-objects/AnalyticsEntityId.test.ts index 449914c86..434b7f74c 100644 --- a/packages/analytics/domain/value-objects/AnalyticsEntityId.test.ts +++ b/tests/unit/analytics/domain/value-objects/AnalyticsEntityId.test.ts @@ -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', () => { diff --git a/packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts b/tests/unit/analytics/domain/value-objects/AnalyticsSessionId.test.ts similarity index 88% rename from packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts rename to tests/unit/analytics/domain/value-objects/AnalyticsSessionId.test.ts index 9877684a6..d7cad466b 100644 --- a/packages/analytics/domain/value-objects/AnalyticsSessionId.test.ts +++ b/tests/unit/analytics/domain/value-objects/AnalyticsSessionId.test.ts @@ -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', () => { diff --git a/packages/analytics/domain/value-objects/PageViewId.test.ts b/tests/unit/analytics/domain/value-objects/PageViewId.test.ts similarity index 89% rename from packages/analytics/domain/value-objects/PageViewId.test.ts rename to tests/unit/analytics/domain/value-objects/PageViewId.test.ts index 65295e5cc..6ac2da2c2 100644 --- a/packages/analytics/domain/value-objects/PageViewId.test.ts +++ b/tests/unit/analytics/domain/value-objects/PageViewId.test.ts @@ -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', () => { diff --git a/packages/media/domain/value-objects/MediaUrl.test.ts b/tests/unit/media/domain/value-objects/MediaUrl.test.ts similarity index 93% rename from packages/media/domain/value-objects/MediaUrl.test.ts rename to tests/unit/media/domain/value-objects/MediaUrl.test.ts index d78d8d04c..512a33e9a 100644 --- a/packages/media/domain/value-objects/MediaUrl.test.ts +++ b/tests/unit/media/domain/value-objects/MediaUrl.test.ts @@ -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', () => { diff --git a/packages/notifications/domain/value-objects/NotificationId.test.ts b/tests/unit/notifications/domain/value-objects/NotificationId.test.ts similarity index 82% rename from packages/notifications/domain/value-objects/NotificationId.test.ts rename to tests/unit/notifications/domain/value-objects/NotificationId.test.ts index 073cc26af..1b1252a37 100644 --- a/packages/notifications/domain/value-objects/NotificationId.test.ts +++ b/tests/unit/notifications/domain/value-objects/NotificationId.test.ts @@ -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', () => { diff --git a/packages/notifications/domain/value-objects/QuietHours.test.ts b/tests/unit/notifications/domain/value-objects/QuietHours.test.ts similarity index 93% rename from packages/notifications/domain/value-objects/QuietHours.test.ts rename to tests/unit/notifications/domain/value-objects/QuietHours.test.ts index 73cd6d03c..3e15ef15b 100644 --- a/packages/notifications/domain/value-objects/QuietHours.test.ts +++ b/tests/unit/notifications/domain/value-objects/QuietHours.test.ts @@ -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', () => { diff --git a/tests/unit/racing-application/RaceDetailUseCases.test.ts b/tests/unit/racing-application/RaceDetailUseCases.test.ts index 4ffd3f830..db8fb7dc2 100644 --- a/tests/unit/racing-application/RaceDetailUseCases.test.ts +++ b/tests/unit/racing-application/RaceDetailUseCases.test.ts @@ -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}`; }