This commit is contained in:
2025-12-11 14:39:57 +01:00
parent c7e5de40d6
commit c49ea2598d
14 changed files with 77 additions and 308 deletions

View File

@@ -1,29 +0,0 @@
import { AnalyticsEntityId } from './AnalyticsEntityId';
describe('AnalyticsEntityId', () => {
it('creates a valid AnalyticsEntityId from a non-empty string', () => {
const id = AnalyticsEntityId.create('entity_123');
expect(id.value).toBe('entity_123');
});
it('trims whitespace from the raw value', () => {
const id = AnalyticsEntityId.create(' entity_456 ');
expect(id.value).toBe('entity_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => AnalyticsEntityId.create('')).toThrow(Error);
expect(() => AnalyticsEntityId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = AnalyticsEntityId.create('entity_1');
const b = AnalyticsEntityId.create('entity_1');
const c = AnalyticsEntityId.create('entity_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -1,29 +0,0 @@
import { AnalyticsSessionId } from './AnalyticsSessionId';
describe('AnalyticsSessionId', () => {
it('creates a valid AnalyticsSessionId from a non-empty string', () => {
const id = AnalyticsSessionId.create('session_123');
expect(id.value).toBe('session_123');
});
it('trims whitespace from the raw value', () => {
const id = AnalyticsSessionId.create(' session_456 ');
expect(id.value).toBe('session_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => AnalyticsSessionId.create('')).toThrow(Error);
expect(() => AnalyticsSessionId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = AnalyticsSessionId.create('session_1');
const b = AnalyticsSessionId.create('session_1');
const c = AnalyticsSessionId.create('session_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -1,29 +0,0 @@
import { PageViewId } from './PageViewId';
describe('PageViewId', () => {
it('creates a valid PageViewId from a non-empty string', () => {
const id = PageViewId.create('pv_123');
expect(id.value).toBe('pv_123');
});
it('trims whitespace from the raw value', () => {
const id = PageViewId.create(' pv_456 ');
expect(id.value).toBe('pv_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => PageViewId.create('')).toThrow(Error);
expect(() => PageViewId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = PageViewId.create('pv_1');
const b = PageViewId.create('pv_1');
const c = PageViewId.create('pv_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -1,36 +0,0 @@
import { MediaUrl } from './MediaUrl';
describe('MediaUrl', () => {
it('creates from valid http/https URLs', () => {
expect(MediaUrl.create('http://example.com').value).toBe('http://example.com');
expect(MediaUrl.create('https://example.com/path').value).toBe('https://example.com/path');
});
it('creates from data URIs', () => {
const url = 'data:image/jpeg;base64,AAA';
expect(MediaUrl.create(url).value).toBe(url);
});
it('creates from root-relative paths', () => {
expect(MediaUrl.create('/images/avatar.png').value).toBe('/images/avatar.png');
});
it('rejects empty or whitespace URLs', () => {
expect(() => MediaUrl.create('')).toThrow();
expect(() => MediaUrl.create(' ')).toThrow();
});
it('rejects unsupported schemes', () => {
expect(() => MediaUrl.create('ftp://example.com/file')).toThrow();
expect(() => MediaUrl.create('mailto:user@example.com')).toThrow();
});
it('implements value-based equality', () => {
const a = MediaUrl.create('https://example.com/a.png');
const b = MediaUrl.create('https://example.com/a.png');
const c = MediaUrl.create('https://example.com/b.png');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -1,38 +0,0 @@
import { NotificationId } from './NotificationId';
import { NotificationDomainError } from '../errors/NotificationDomainError';
describe('NotificationId', () => {
it('creates a valid NotificationId from a non-empty string', () => {
const id = NotificationId.create('noti_123');
expect(id.value).toBe('noti_123');
});
it('trims whitespace from the raw value', () => {
const id = NotificationId.create(' noti_456 ');
expect(id.value).toBe('noti_456');
});
it('throws NotificationDomainError for empty string', () => {
expect(() => NotificationId.create('')).toThrow(NotificationDomainError);
expect(() => NotificationId.create(' ')).toThrow(NotificationDomainError);
try {
NotificationId.create(' ');
} catch (error) {
if (error instanceof NotificationDomainError) {
expect(error.kind).toBe('validation');
}
}
});
it('compares equality based on underlying value', () => {
const a = NotificationId.create('noti_1');
const b = NotificationId.create('noti_1');
const c = NotificationId.create('noti_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -1,51 +0,0 @@
import { QuietHours } from './QuietHours';
describe('QuietHours', () => {
it('creates a valid normal-range window', () => {
const qh = QuietHours.create(9, 17);
expect(qh.props.startHour).toBe(9);
expect(qh.props.endHour).toBe(17);
});
it('creates a valid overnight window', () => {
const qh = QuietHours.create(22, 7);
expect(qh.props.startHour).toBe(22);
expect(qh.props.endHour).toBe(7);
});
it('throws when hours are out of range', () => {
expect(() => QuietHours.create(-1, 10)).toThrow();
expect(() => QuietHours.create(0, 24)).toThrow();
});
it('throws when start and end are equal', () => {
expect(() => QuietHours.create(10, 10)).toThrow();
});
it('detects containment for normal range', () => {
const qh = QuietHours.create(9, 17);
expect(qh.containsHour(8)).toBe(false);
expect(qh.containsHour(9)).toBe(true);
expect(qh.containsHour(12)).toBe(true);
expect(qh.containsHour(17)).toBe(false);
});
it('detects containment for overnight range', () => {
const qh = QuietHours.create(22, 7);
expect(qh.containsHour(21)).toBe(false);
expect(qh.containsHour(22)).toBe(true);
expect(qh.containsHour(23)).toBe(true);
expect(qh.containsHour(0)).toBe(true);
expect(qh.containsHour(6)).toBe(true);
expect(qh.containsHour(7)).toBe(false);
});
it('implements value-based equality', () => {
const a = QuietHours.create(22, 7);
const b = QuietHours.create(22, 7);
const c = QuietHours.create(9, 17);
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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