diff --git a/src/modules/ad/core/application/queries/match/completer/journey.completer.ts b/src/modules/ad/core/application/queries/match/completer/journey.completer.ts index ac4ae5f..b50ebf5 100644 --- a/src/modules/ad/core/application/queries/match/completer/journey.completer.ts +++ b/src/modules/ad/core/application/queries/match/completer/journey.completer.ts @@ -5,5 +5,7 @@ export class JourneyCompleter extends Completer { complete = async ( candidates: CandidateEntity[], ): Promise => - candidates.map((candidate: CandidateEntity) => candidate.createJourney()); + candidates.map((candidate: CandidateEntity) => + candidate.createJourneys(this.query.fromDate, this.query.toDate), + ); } diff --git a/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts b/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts index 618a5dc..6baa3b4 100644 --- a/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts +++ b/src/modules/ad/core/application/queries/match/passenger-oriented-algorithm.ts @@ -8,6 +8,7 @@ import { RouteCompleter, RouteCompleterType, } from './completer/route.completer'; +import { JourneyCompleter } from './completer/journey.completer'; export class PassengerOrientedAlgorithm extends Algorithm { constructor( @@ -21,6 +22,7 @@ export class PassengerOrientedAlgorithm extends Algorithm { new RouteCompleter(query, RouteCompleterType.BASIC), new PassengerOrientedGeoFilter(query), new RouteCompleter(query, RouteCompleterType.DETAILED), + new JourneyCompleter(query), ]; } } diff --git a/src/modules/ad/core/domain/calendar-tools.service.ts b/src/modules/ad/core/domain/calendar-tools.service.ts new file mode 100644 index 0000000..b0d2b4f --- /dev/null +++ b/src/modules/ad/core/domain/calendar-tools.service.ts @@ -0,0 +1,80 @@ +import { ExceptionBase } from '@mobicoop/ddd-library'; + +export class CalendarTools { + /** + * Returns the first date corresponding to a week day (0 based monday) + * within a date range + */ + static firstDate = ( + weekDay: number, + lowerDate: string, + higherDate: string, + ): Date => { + if (weekDay < 0 || weekDay > 6) + throw new CalendarToolsException( + new Error('weekDay must be between 0 and 6'), + ); + const lowerDateAsDate: Date = new Date(lowerDate); + const higherDateAsDate: Date = new Date(higherDate); + if (lowerDateAsDate.getDay() == weekDay) return lowerDateAsDate; + const nextDate: Date = new Date(lowerDateAsDate); + nextDate.setDate( + lowerDateAsDate.getDate() + (7 - (lowerDateAsDate.getDay() - weekDay)), + ); + if (lowerDateAsDate.getDay() < weekDay) { + nextDate.setMonth(lowerDateAsDate.getMonth()); + nextDate.setFullYear(lowerDateAsDate.getFullYear()); + nextDate.setDate( + lowerDateAsDate.getDate() + (weekDay - lowerDateAsDate.getDay()), + ); + } + if (nextDate <= higherDateAsDate) return nextDate; + throw new CalendarToolsException( + new Error('no available day for the given date range'), + ); + }; + + /** + * Returns the last date corresponding to a week day (0 based monday) + * within a date range + */ + static lastDate = ( + weekDay: number, + lowerDate: string, + higherDate: string, + ): Date => { + if (weekDay < 0 || weekDay > 6) + throw new CalendarToolsException( + new Error('weekDay must be between 0 and 6'), + ); + const lowerDateAsDate: Date = new Date(lowerDate); + const higherDateAsDate: Date = new Date(higherDate); + if (higherDateAsDate.getDay() == weekDay) return higherDateAsDate; + const previousDate: Date = new Date(higherDateAsDate); + previousDate.setDate( + higherDateAsDate.getDate() - (higherDateAsDate.getDay() - weekDay), + ); + if (higherDateAsDate.getDay() < weekDay) { + previousDate.setMonth(higherDateAsDate.getMonth()); + previousDate.setFullYear(higherDateAsDate.getFullYear()); + previousDate.setDate( + higherDateAsDate.getDate() - + (7 + (higherDateAsDate.getDay() - weekDay)), + ); + } + if (previousDate >= lowerDateAsDate) return previousDate; + throw new CalendarToolsException( + new Error('no available day for the given date range'), + ); + }; +} + +export class CalendarToolsException extends ExceptionBase { + static readonly message = 'Calendar tools error'; + + public readonly code = 'CALENDAR.TOOLS'; + + constructor(cause?: Error, metadata?: unknown) { + super(CalendarToolsException.message, cause, metadata); + } +} diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts index 7e4b1ad..4bd0143 100644 --- a/src/modules/ad/core/domain/candidate.entity.ts +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -2,6 +2,10 @@ import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; import { CandidateProps, CreateCandidateProps } from './candidate.types'; import { CarpoolStepProps } from './value-objects/carpool-step.value-object'; import { StepProps } from './value-objects/step.value-object'; +import { ScheduleItem } from './value-objects/schedule-item.value-object'; +import { Journey } from './value-objects/journey.value-object'; +import { CalendarTools } from './calendar-tools.service'; +import { JourneyItem } from './value-objects/journey-item.value-object'; export class CandidateEntity extends AggregateRoot { protected readonly _id: AggregateID; @@ -30,7 +34,23 @@ export class CandidateEntity extends AggregateRoot { isDetourValid = (): boolean => this._validateDistanceDetour() && this._validateDurationDetour(); - createJourney = (): CandidateEntity => this; + createJourneys = (fromDate: string, toDate: string): CandidateEntity => { + this.props.driverJourneys = this.props.driverSchedule + .map((driverScheduleItem: ScheduleItem) => + this._createJourney(fromDate, toDate, driverScheduleItem), + ) + .filter( + (journey: Journey | undefined) => journey !== undefined, + ) as Journey[]; + this.props.passengerJourneys = this.props.passengerSchedule + .map((passengerScheduleItem: ScheduleItem) => + this._createJourney(fromDate, toDate, passengerScheduleItem), + ) + .filter( + (journey: Journey | undefined) => journey !== undefined, + ) as Journey[]; + return this; + }; private _validateDurationDetour = (): boolean => this.props.duration @@ -46,6 +66,22 @@ export class CandidateEntity extends AggregateRoot { (1 + this.props.spacetimeDetourRatio.maxDistanceDetourRatio) : false; + private _createJourney = ( + fromDate: string, + toDate: string, + scheduleItem: ScheduleItem, + ): Journey | undefined => + new Journey({ + day: scheduleItem.day, + firstDate: CalendarTools.firstDate(scheduleItem.day, fromDate, toDate), + lastDate: CalendarTools.lastDate(scheduleItem.day, fromDate, toDate), + journeyItems: this._createJourneyItems(scheduleItem), + }); + + private _createJourneyItems = ( + scheduleItem: ScheduleItem, + ): JourneyItem[] => []; + validate(): void { // entity business rules validation to protect it's invariant before saving entity to a database } diff --git a/src/modules/ad/core/domain/candidate.types.ts b/src/modules/ad/core/domain/candidate.types.ts index 3a68d06..07b5d27 100644 --- a/src/modules/ad/core/domain/candidate.types.ts +++ b/src/modules/ad/core/domain/candidate.types.ts @@ -18,7 +18,8 @@ export interface CandidateProps { steps?: StepProps[]; driverSchedule: ScheduleItemProps[]; passengerSchedule: ScheduleItemProps[]; - journeys?: JourneyProps[]; + driverJourneys?: JourneyProps[]; + passengerJourneys?: JourneyProps[]; spacetimeDetourRatio: SpacetimeDetourRatio; } diff --git a/src/modules/ad/core/domain/carpool-path-creator.service.ts b/src/modules/ad/core/domain/carpool-path-creator.service.ts index 43f61dd..68a0a51 100644 --- a/src/modules/ad/core/domain/carpool-path-creator.service.ts +++ b/src/modules/ad/core/domain/carpool-path-creator.service.ts @@ -86,7 +86,7 @@ export class CarpoolPathCreator { }); if ( this.driverWaypoints.filter((driverWaypoint: Point) => - passengerWaypoint.isSame(driverWaypoint), + passengerWaypoint.equals(driverWaypoint), ).length == 0 ) { carpoolStep.actors.push( @@ -233,7 +233,7 @@ export class CarpoolPathCreator { const uniquePoints: Point[] = []; carpoolSteps.forEach((carpoolStep: CarpoolStep) => { if ( - uniquePoints.find((point: Point) => point.isSame(carpoolStep.point)) === + uniquePoints.find((point: Point) => point.equals(carpoolStep.point)) === undefined ) uniquePoints.push( @@ -249,7 +249,7 @@ export class CarpoolPathCreator { point, actors: carpoolSteps .filter((carpoolStep: CarpoolStep) => - carpoolStep.point.isSame(point), + carpoolStep.point.equals(point), ) .map((carpoolStep: CarpoolStep) => carpoolStep.actors) .flat(), diff --git a/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts b/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts index ec38609..9702dcb 100644 --- a/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/actor-time.value-object.ts @@ -9,24 +9,15 @@ import { Actor, ActorProps } from './actor.value-object'; * */ export interface ActorTimeProps extends ActorProps { - time: string; - minTime: string; - maxTime: string; + firstDatetime: Date; + firstMinDatetime: Date; + firstMaxDatetime: Date; + lastDatetime: Date; + lastMinDatetime: Date; + lastMaxDatetime: Date; } export class ActorTime extends ValueObject { - get time(): string { - return this.props.time; - } - - get minTime(): string { - return this.props.minTime; - } - - get maxTime(): string { - return this.props.maxTime; - } - get role(): Role { return this.props.role; } @@ -35,23 +26,59 @@ export class ActorTime extends ValueObject { return this.props.target; } + get firstDatetime(): Date { + return this.props.firstDatetime; + } + + get firstMinDatetime(): Date { + return this.props.firstMinDatetime; + } + + get firstMaxDatetime(): Date { + return this.props.firstMaxDatetime; + } + + get lastDatetime(): Date { + return this.props.lastDatetime; + } + + get lastMinDatetime(): Date { + return this.props.lastMinDatetime; + } + + get lastMaxDatetime(): Date { + return this.props.lastMaxDatetime; + } + protected validate(props: ActorTimeProps): void { // validate actor props new Actor({ role: props.role, target: props.target, }); - this._validateTime(props.time, 'time'); - this._validateTime(props.minTime, 'minTime'); - this._validateTime(props.maxTime, 'maxTime'); - } - - private _validateTime(time: string, property: string): void { - if (time.split(':').length != 2) - throw new ArgumentInvalidException(`${property} is invalid`); - if (parseInt(time.split(':')[0]) < 0 || parseInt(time.split(':')[0]) > 23) - throw new ArgumentInvalidException(`${property} is invalid`); - if (parseInt(time.split(':')[1]) < 0 || parseInt(time.split(':')[1]) > 59) - throw new ArgumentInvalidException(`${property} is invalid`); + if (props.firstDatetime.getDay() != props.lastDatetime.getDay()) + throw new ArgumentInvalidException( + 'firstDatetime week day must be equal to lastDatetime week day', + ); + if (props.firstDatetime > props.lastDatetime) + throw new ArgumentInvalidException( + 'firstDatetime must be before or equal to lastDatetime', + ); + if (props.firstMinDatetime > props.firstDatetime) + throw new ArgumentInvalidException( + 'firstMinDatetime must be before or equal to firstDatetime', + ); + if (props.firstDatetime > props.firstMaxDatetime) + throw new ArgumentInvalidException( + 'firstDatetime must be before or equal to firstMaxDatetime', + ); + if (props.lastMinDatetime > props.lastDatetime) + throw new ArgumentInvalidException( + 'lastMinDatetime must be before or equal to lastDatetime', + ); + if (props.lastDatetime > props.lastMaxDatetime) + throw new ArgumentInvalidException( + 'lastDatetime must be before or equal to lastMaxDatetime', + ); } } diff --git a/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts b/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts new file mode 100644 index 0000000..4f1a9d4 --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/journey-item.value-object.ts @@ -0,0 +1,51 @@ +import { + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; +import { ActorTime } from './actor-time.value-object'; +import { Step, StepProps } from './step.value-object'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface JourneyItemProps extends StepProps { + actorTimes: ActorTime[]; +} + +export class JourneyItem extends ValueObject { + get duration(): number { + return this.props.duration; + } + + get distance(): number | undefined { + return this.props.distance; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get actorTimes(): ActorTime[] { + return this.props.actorTimes; + } + + protected validate(props: JourneyItemProps): void { + // validate step props + new Step({ + lon: props.lon, + lat: props.lat, + distance: props.distance, + duration: props.duration, + }); + if (props.actorTimes.length == 0) + throw new ArgumentOutOfRangeException( + 'at least one actorTime is required', + ); + } +} diff --git a/src/modules/ad/core/domain/value-objects/journey.value-object.ts b/src/modules/ad/core/domain/value-objects/journey.value-object.ts index 76699d0..fb7f421 100644 --- a/src/modules/ad/core/domain/value-objects/journey.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/journey.value-object.ts @@ -3,19 +3,18 @@ import { ArgumentOutOfRangeException, ValueObject, } from '@mobicoop/ddd-library'; -import { ScheduleItem, ScheduleItemProps } from './schedule-item.value-object'; -import { ActorTime } from './actor-time.value-object'; -import { Actor } from './actor.value-object'; +import { JourneyItem } from './journey-item.value-object'; /** Note: * Value Objects with multiple properties can contain * other Value Objects inside if needed. * */ -export interface JourneyProps extends ScheduleItemProps { +export interface JourneyProps { firstDate: Date; lastDate: Date; - actorTimes: ActorTime[]; + day: number; + journeyItems: JourneyItem[]; } export class Journey extends ValueObject { @@ -27,41 +26,26 @@ export class Journey extends ValueObject { return this.props.lastDate; } - get actorTimes(): ActorTime[] { - return this.props.actorTimes; - } - get day(): number { return this.props.day; } - get time(): string { - return this.props.time; - } - - get margin(): number { - return this.props.margin; + get journeyItems(): JourneyItem[] { + return this.props.journeyItems; } protected validate(props: JourneyProps): void { - // validate scheduleItem props - new ScheduleItem({ - day: props.day, - time: props.time, - margin: props.margin, - }); - // validate actor times - props.actorTimes.forEach((actorTime: ActorTime) => { - new Actor({ - role: actorTime.role, - target: actorTime.target, - }); - }); + if (props.day < 0 || props.day > 6) + throw new ArgumentOutOfRangeException('day must be between 0 and 6'); + if (props.firstDate.getDay() != props.lastDate.getDay()) + throw new ArgumentInvalidException( + 'firstDate week day must be equal to lastDate week day', + ); if (props.firstDate > props.lastDate) throw new ArgumentInvalidException('firstDate must be before lastDate'); - if (props.actorTimes.length < 4) - throw new ArgumentOutOfRangeException( - 'at least 4 actorTimes are required', + if (props.journeyItems.length < 2) + throw new ArgumentInvalidException( + 'at least 2 journey items are required', ); } } diff --git a/src/modules/ad/core/domain/value-objects/point.value-object.ts b/src/modules/ad/core/domain/value-objects/point.value-object.ts index 54a2677..2047ead 100644 --- a/src/modules/ad/core/domain/value-objects/point.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/point.value-object.ts @@ -22,9 +22,6 @@ export class Point extends ValueObject { return this.props.lat; } - isSame = (point: this): boolean => - point.lon == this.lon && point.lat == this.lat; - protected validate(props: PointProps): void { if (props.lon > 180 || props.lon < -180) throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); diff --git a/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts b/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts index e3cdaaf..3d7ed4e 100644 --- a/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/actor-time.value-object.spec.ts @@ -1,3 +1,4 @@ +import { ArgumentInvalidException } from '@mobicoop/ddd-library'; import { Role } from '@modules/ad/core/domain/ad.types'; import { Target } from '@modules/ad/core/domain/candidate.types'; import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object'; @@ -7,43 +8,100 @@ describe('Actor time value object', () => { const actorTimeVO = new ActorTime({ role: Role.DRIVER, target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07:15', + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), }); expect(actorTimeVO.role).toBe(Role.DRIVER); expect(actorTimeVO.target).toBe(Target.START); - expect(actorTimeVO.time).toBe('07:00'); - expect(actorTimeVO.minTime).toBe('06:45'); - expect(actorTimeVO.maxTime).toBe('07:15'); + expect(actorTimeVO.firstDatetime.getHours()).toBe(7); + expect(actorTimeVO.firstMinDatetime.getMinutes()).toBe(45); + expect(actorTimeVO.firstMaxDatetime.getMinutes()).toBe(15); + expect(actorTimeVO.lastDatetime.getHours()).toBe(7); + expect(actorTimeVO.lastMinDatetime.getMinutes()).toBe(45); + expect(actorTimeVO.lastMaxDatetime.getMinutes()).toBe(15); }); - it('should throw an error if a time is invalid', () => { - expect(() => { - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '27:00', - minTime: '06:45', - maxTime: '07:15', - }); - }).toThrow(); - expect(() => { - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:95', - maxTime: '07:15', - }); - }).toThrow(); - expect(() => { - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07', - }); - }).toThrow(); + it('should throw an error if dates are inconsistent', () => { + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 07:05'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 06:55'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 07:05'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 06:35'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2024-08-30 07:00'), + firstMinDatetime: new Date('2024-08-30 06:45'), + firstMaxDatetime: new Date('2024-08-30 07:15'), + lastDatetime: new Date('2023-09-01 07:00'), + lastMinDatetime: new Date('2023-09-01 06:45'), + lastMaxDatetime: new Date('2023-09-01 07:15'), + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-31 07:00'), + lastMinDatetime: new Date('2024-08-31 06:45'), + lastMaxDatetime: new Date('2024-08-31 06:35'), + }), + ).toThrow(ArgumentInvalidException); }); }); diff --git a/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts new file mode 100644 index 0000000..a31ff87 --- /dev/null +++ b/src/modules/ad/tests/unit/core/calendar-tools.service.spec.ts @@ -0,0 +1,84 @@ +import { + CalendarTools, + CalendarToolsException, +} from '@modules/ad/core/domain/calendar-tools.service'; + +describe('Calendar tools service', () => { + describe('First date', () => { + it('should return the first date for a given week day within a date range', () => { + const firstDate: Date = CalendarTools.firstDate( + 1, + '2023-08-31', + '2023-09-07', + ); + expect(firstDate.getDay()).toBe(1); + expect(firstDate.getDate()).toBe(4); + expect(firstDate.getMonth()).toBe(8); + const secondDate: Date = CalendarTools.firstDate( + 5, + '2023-08-31', + '2023-09-07', + ); + expect(secondDate.getDay()).toBe(5); + expect(secondDate.getDate()).toBe(1); + expect(secondDate.getMonth()).toBe(8); + const thirdDate: Date = CalendarTools.firstDate( + 4, + '2023-08-31', + '2023-09-07', + ); + expect(thirdDate.getDay()).toBe(4); + expect(thirdDate.getDate()).toBe(31); + expect(thirdDate.getMonth()).toBe(7); + }); + it('should throw an exception if a given week day is not within a date range', () => { + expect(() => { + CalendarTools.firstDate(1, '2023-09-05', '2023-09-07'); + }).toThrow(CalendarToolsException); + }); + it('should throw an exception if a given week day is invalid', () => { + expect(() => { + CalendarTools.firstDate(8, '2023-09-05', '2023-09-07'); + }).toThrow(CalendarToolsException); + }); + }); + + describe('Second date', () => { + it('should return the last date for a given week day within a date range', () => { + const firstDate: Date = CalendarTools.lastDate( + 0, + '2023-09-30', + '2024-09-30', + ); + expect(firstDate.getDay()).toBe(0); + expect(firstDate.getDate()).toBe(29); + expect(firstDate.getMonth()).toBe(8); + const secondDate: Date = CalendarTools.lastDate( + 5, + '2023-09-30', + '2024-09-30', + ); + expect(secondDate.getDay()).toBe(5); + expect(secondDate.getDate()).toBe(27); + expect(secondDate.getMonth()).toBe(8); + const thirdDate: Date = CalendarTools.lastDate( + 1, + '2023-09-30', + '2024-09-30', + ); + expect(thirdDate.getDay()).toBe(1); + expect(thirdDate.getDate()).toBe(30); + expect(thirdDate.getMonth()).toBe(8); + }); + it('should throw an exception if a given week day is not within a date range', () => { + expect(() => { + CalendarTools.lastDate(2, '2024-09-27', '2024-09-30'); + }).toThrow(CalendarToolsException); + }); + it('should throw an exception if a given week day is invalid', () => { + expect(() => { + CalendarTools.lastDate(8, '2023-09-30', '2024-09-30'); + }).toThrow(CalendarToolsException); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts b/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts new file mode 100644 index 0000000..c4461c1 --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey-item.value-object.spec.ts @@ -0,0 +1,45 @@ +import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library'; +import { Role } from '@modules/ad/core/domain/ad.types'; +import { Target } from '@modules/ad/core/domain/candidate.types'; +import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object'; +import { JourneyItem } from '@modules/ad/core/domain/value-objects/journey-item.value-object'; + +describe('Journey item value object', () => { + it('should create a journey item value object', () => { + const journeyItemVO: JourneyItem = new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 1545, + distance: 48754, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }); + expect(journeyItemVO.duration).toBe(1545); + expect(journeyItemVO.distance).toBe(48754); + expect(journeyItemVO.lon).toBe(6.17651); + expect(journeyItemVO.lat).toBe(48.689445); + expect(journeyItemVO.actorTimes[0].firstMaxDatetime.getMinutes()).toBe(15); + }); + it('should throw an error if actorTimes is too short', () => { + expect( + () => + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 1545, + distance: 48754, + actorTimes: [], + }), + ).toThrow(ArgumentOutOfRangeException); + }); +}); diff --git a/src/modules/ad/tests/unit/core/journey.completer.spec.ts b/src/modules/ad/tests/unit/core/journey.completer.spec.ts index 5adbeaa..de6dfd9 100644 --- a/src/modules/ad/tests/unit/core/journey.completer.spec.ts +++ b/src/modules/ad/tests/unit/core/journey.completer.spec.ts @@ -90,14 +90,14 @@ const candidate: CandidateEntity = CandidateEntity.create({ driverDuration: 13548, driverSchedule: [ { - day: 0, + day: 1, time: '07:00', margin: 900, }, ], passengerSchedule: [ { - day: 0, + day: 1, time: '07:10', margin: 900, }, @@ -140,6 +140,7 @@ const candidate: CandidateEntity = CandidateEntity.create({ ], }, ]); +candidate.createJourneys = jest.fn().mockImplementation(() => candidate); describe('Journey completer', () => { it('should complete candidates with their journey', async () => { diff --git a/src/modules/ad/tests/unit/core/journey.value-object.spec.ts b/src/modules/ad/tests/unit/core/journey.value-object.spec.ts index 5f8ec1e..3d321e5 100644 --- a/src/modules/ad/tests/unit/core/journey.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/journey.value-object.spec.ts @@ -5,161 +5,453 @@ import { import { Role } from '@modules/ad/core/domain/ad.types'; import { Target } from '@modules/ad/core/domain/candidate.types'; import { ActorTime } from '@modules/ad/core/domain/value-objects/actor-time.value-object'; +import { JourneyItem } from '@modules/ad/core/domain/value-objects/journey-item.value-object'; import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; describe('Journey value object', () => { it('should create a journey value object', () => { const journeyVO = new Journey({ - firstDate: new Date('2023-09-20'), - lastDate: new Date('2024-09-20'), - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07:15', + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-30'), + day: 5, + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.START, - time: '07:10', - minTime: '06:55', - maxTime: '07:25', + new JourneyItem({ + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:35'), + firstMinDatetime: new Date('2023-09-01 07:20'), + firstMaxDatetime: new Date('2023-09-01 07:50'), + lastDatetime: new Date('2024-08-30 07:35'), + lastMinDatetime: new Date('2024-08-30 07:20'), + lastMaxDatetime: new Date('2024-08-30 07:50'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:32'), + firstMinDatetime: new Date('2023-09-01 07:17'), + firstMaxDatetime: new Date('2023-09-01 07:47'), + lastDatetime: new Date('2024-08-30 07:32'), + lastMinDatetime: new Date('2024-08-30 07:17'), + lastMaxDatetime: new Date('2024-08-30 07:47'), + }), + ], }), - new ActorTime({ - role: Role.DRIVER, - target: Target.FINISH, - time: '08:30', - minTime: '08:15', - maxTime: '08:45', + new JourneyItem({ + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 08:04'), + firstMinDatetime: new Date('2023-09-01 07:51'), + firstMaxDatetime: new Date('2023-09-01 08:19'), + lastDatetime: new Date('2024-08-30 08:04'), + lastMinDatetime: new Date('2024-08-30 07:51'), + lastMaxDatetime: new Date('2024-08-30 08:19'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:01'), + firstMinDatetime: new Date('2023-09-01 07:46'), + firstMaxDatetime: new Date('2023-09-01 08:16'), + lastDatetime: new Date('2024-08-30 08:01'), + lastMinDatetime: new Date('2024-08-30 07:46'), + lastMaxDatetime: new Date('2024-08-30 08:16'), + }), + ], }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.FINISH, - time: '08:40', - minTime: '08:25', - maxTime: '08:55', + new JourneyItem({ + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:23'), + firstMinDatetime: new Date('2023-09-01 08:08'), + firstMaxDatetime: new Date('2023-09-01 08:38'), + lastDatetime: new Date('2024-08-30 08:23'), + lastMinDatetime: new Date('2024-08-30 08:08'), + lastMaxDatetime: new Date('2024-08-30 08:38'), + }), + ], }), ], - day: 0, - time: '07:00', - margin: 900, }); - expect(journeyVO.day).toBe(0); - expect(journeyVO.time).toBe('07:00'); - expect(journeyVO.margin).toBe(900); - expect(journeyVO.actorTimes).toHaveLength(4); - expect(journeyVO.firstDate.getDate()).toBe(20); - expect(journeyVO.lastDate.getMonth()).toBe(8); + expect(journeyVO.day).toBe(5); + expect(journeyVO.journeyItems).toHaveLength(4); + expect(journeyVO.firstDate.getDate()).toBe(1); + expect(journeyVO.lastDate.getMonth()).toBe(7); }); - it('should throw an exception if day is invalid', () => { - expect(() => { - new Journey({ - firstDate: new Date('2023-09-20'), - lastDate: new Date('2024-09-20'), - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07:15', - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.START, - time: '07:10', - minTime: '06:55', - maxTime: '07:25', - }), - new ActorTime({ - role: Role.DRIVER, - target: Target.FINISH, - time: '08:30', - minTime: '08:15', - maxTime: '08:45', - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.FINISH, - time: '08:40', - minTime: '08:25', - maxTime: '08:55', - }), - ], - day: 7, - time: '07:00', - margin: 900, - }); - }).toThrow(ArgumentOutOfRangeException); + it('should throw an error if day is wrong', () => { + expect( + () => + new Journey({ + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-30'), + day: 7, + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }), + new JourneyItem({ + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:35'), + firstMinDatetime: new Date('2023-09-01 07:20'), + firstMaxDatetime: new Date('2023-09-01 07:50'), + lastDatetime: new Date('2024-08-30 07:35'), + lastMinDatetime: new Date('2024-08-30 07:20'), + lastMaxDatetime: new Date('2024-08-30 07:50'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:32'), + firstMinDatetime: new Date('2023-09-01 07:17'), + firstMaxDatetime: new Date('2023-09-01 07:47'), + lastDatetime: new Date('2024-08-30 07:32'), + lastMinDatetime: new Date('2024-08-30 07:17'), + lastMaxDatetime: new Date('2024-08-30 07:47'), + }), + ], + }), + new JourneyItem({ + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 08:04'), + firstMinDatetime: new Date('2023-09-01 07:51'), + firstMaxDatetime: new Date('2023-09-01 08:19'), + lastDatetime: new Date('2024-08-30 08:04'), + lastMinDatetime: new Date('2024-08-30 07:51'), + lastMaxDatetime: new Date('2024-08-30 08:19'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:01'), + firstMinDatetime: new Date('2023-09-01 07:46'), + firstMaxDatetime: new Date('2023-09-01 08:16'), + lastDatetime: new Date('2024-08-30 08:01'), + lastMinDatetime: new Date('2024-08-30 07:46'), + lastMaxDatetime: new Date('2024-08-30 08:16'), + }), + ], + }), + new JourneyItem({ + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:23'), + firstMinDatetime: new Date('2023-09-01 08:08'), + firstMaxDatetime: new Date('2023-09-01 08:38'), + lastDatetime: new Date('2024-08-30 08:23'), + lastMinDatetime: new Date('2024-08-30 08:08'), + lastMaxDatetime: new Date('2024-08-30 08:38'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentOutOfRangeException); }); - it('should throw an exception if actor times is too short', () => { - expect(() => { - new Journey({ - firstDate: new Date('2023-09-20'), - lastDate: new Date('2024-09-20'), - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07:15', - }), - new ActorTime({ - role: Role.DRIVER, - target: Target.FINISH, - time: '08:30', - minTime: '08:15', - maxTime: '08:45', - }), - ], - day: 0, - time: '07:00', - margin: 900, - }); - }).toThrow(ArgumentOutOfRangeException); + it('should throw an error if dates are inconsistent', () => { + expect( + () => + new Journey({ + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-31'), + day: 5, + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }), + new JourneyItem({ + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:35'), + firstMinDatetime: new Date('2023-09-01 07:20'), + firstMaxDatetime: new Date('2023-09-01 07:50'), + lastDatetime: new Date('2024-08-30 07:35'), + lastMinDatetime: new Date('2024-08-30 07:20'), + lastMaxDatetime: new Date('2024-08-30 07:50'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:32'), + firstMinDatetime: new Date('2023-09-01 07:17'), + firstMaxDatetime: new Date('2023-09-01 07:47'), + lastDatetime: new Date('2024-08-30 07:32'), + lastMinDatetime: new Date('2024-08-30 07:17'), + lastMaxDatetime: new Date('2024-08-30 07:47'), + }), + ], + }), + new JourneyItem({ + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 08:04'), + firstMinDatetime: new Date('2023-09-01 07:51'), + firstMaxDatetime: new Date('2023-09-01 08:19'), + lastDatetime: new Date('2024-08-30 08:04'), + lastMinDatetime: new Date('2024-08-30 07:51'), + lastMaxDatetime: new Date('2024-08-30 08:19'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:01'), + firstMinDatetime: new Date('2023-09-01 07:46'), + firstMaxDatetime: new Date('2023-09-01 08:16'), + lastDatetime: new Date('2024-08-30 08:01'), + lastMinDatetime: new Date('2024-08-30 07:46'), + lastMaxDatetime: new Date('2024-08-30 08:16'), + }), + ], + }), + new JourneyItem({ + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:23'), + firstMinDatetime: new Date('2023-09-01 08:08'), + firstMaxDatetime: new Date('2023-09-01 08:38'), + lastDatetime: new Date('2024-08-30 08:23'), + lastMinDatetime: new Date('2024-08-30 08:08'), + lastMaxDatetime: new Date('2024-08-30 08:38'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); + expect( + () => + new Journey({ + firstDate: new Date('2024-08-30'), + lastDate: new Date('2023-09-01'), + day: 5, + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }), + new JourneyItem({ + lat: 48.369445, + lon: 6.67487, + duration: 2100, + distance: 56878, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 07:35'), + firstMinDatetime: new Date('2023-09-01 07:20'), + firstMaxDatetime: new Date('2023-09-01 07:50'), + lastDatetime: new Date('2024-08-30 07:35'), + lastMinDatetime: new Date('2024-08-30 07:20'), + lastMaxDatetime: new Date('2024-08-30 07:50'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:32'), + firstMinDatetime: new Date('2023-09-01 07:17'), + firstMaxDatetime: new Date('2023-09-01 07:47'), + lastDatetime: new Date('2024-08-30 07:32'), + lastMinDatetime: new Date('2024-08-30 07:17'), + lastMaxDatetime: new Date('2024-08-30 07:47'), + }), + ], + }), + new JourneyItem({ + lat: 47.98487, + lon: 6.9427, + duration: 3840, + distance: 76491, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.NEUTRAL, + firstDatetime: new Date('2023-09-01 08:04'), + firstMinDatetime: new Date('2023-09-01 07:51'), + firstMaxDatetime: new Date('2023-09-01 08:19'), + lastDatetime: new Date('2024-08-30 08:04'), + lastMinDatetime: new Date('2024-08-30 07:51'), + lastMaxDatetime: new Date('2024-08-30 08:19'), + }), + new ActorTime({ + role: Role.PASSENGER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:01'), + firstMinDatetime: new Date('2023-09-01 07:46'), + firstMaxDatetime: new Date('2023-09-01 08:16'), + lastDatetime: new Date('2024-08-30 08:01'), + lastMinDatetime: new Date('2024-08-30 07:46'), + lastMaxDatetime: new Date('2024-08-30 08:16'), + }), + ], + }), + new JourneyItem({ + lat: 47.365987, + lon: 7.02154, + duration: 4980, + distance: 96475, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.FINISH, + firstDatetime: new Date('2023-09-01 08:23'), + firstMinDatetime: new Date('2023-09-01 08:08'), + firstMaxDatetime: new Date('2023-09-01 08:38'), + lastDatetime: new Date('2024-08-30 08:23'), + lastMinDatetime: new Date('2024-08-30 08:08'), + lastMaxDatetime: new Date('2024-08-30 08:38'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); }); - it('should throw an exception if dates are invalid', () => { - expect(() => { - new Journey({ - firstDate: new Date('2023-09-20'), - lastDate: new Date('2023-09-19'), - actorTimes: [ - new ActorTime({ - role: Role.DRIVER, - target: Target.START, - time: '07:00', - minTime: '06:45', - maxTime: '07:15', - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.START, - time: '07:10', - minTime: '06:55', - maxTime: '07:25', - }), - new ActorTime({ - role: Role.DRIVER, - target: Target.FINISH, - time: '08:30', - minTime: '08:15', - maxTime: '08:45', - }), - new ActorTime({ - role: Role.PASSENGER, - target: Target.FINISH, - time: '08:40', - minTime: '08:25', - maxTime: '08:55', - }), - ], - day: 0, - time: '07:00', - margin: 900, - }); - }).toThrow(ArgumentInvalidException); + it('should throw an error if journeyItems is too short', () => { + expect( + () => + new Journey({ + firstDate: new Date('2023-09-01'), + lastDate: new Date('2024-08-30'), + day: 5, + journeyItems: [ + new JourneyItem({ + lat: 48.689445, + lon: 6.17651, + duration: 0, + distance: 0, + actorTimes: [ + new ActorTime({ + role: Role.DRIVER, + target: Target.START, + firstDatetime: new Date('2023-09-01 07:00'), + firstMinDatetime: new Date('2023-09-01 06:45'), + firstMaxDatetime: new Date('2023-09-01 07:15'), + lastDatetime: new Date('2024-08-30 07:00'), + lastMinDatetime: new Date('2024-08-30 06:45'), + lastMaxDatetime: new Date('2024-08-30 07:15'), + }), + ], + }), + ], + }), + ).toThrow(ArgumentInvalidException); }); }); diff --git a/src/modules/ad/tests/unit/core/point.value-object.spec.ts b/src/modules/ad/tests/unit/core/point.value-object.spec.ts index 7f1fda0..8ae5913 100644 --- a/src/modules/ad/tests/unit/core/point.value-object.spec.ts +++ b/src/modules/ad/tests/unit/core/point.value-object.spec.ts @@ -23,8 +23,8 @@ describe('Point value object', () => { lat: 48.689446, lon: 6.17651, }); - expect(pointVO.isSame(identicalPointVO)).toBeTruthy(); - expect(pointVO.isSame(differentPointVO)).toBeFalsy(); + expect(pointVO.equals(identicalPointVO)).toBeTruthy(); + expect(pointVO.equals(differentPointVO)).toBeFalsy(); }); it('should throw an exception if longitude is invalid', () => { expect(() => {