diff --git a/src/modules/ad/core/application/queries/match/filter/journey.filter.ts b/src/modules/ad/core/application/queries/match/filter/journey.filter.ts new file mode 100644 index 0000000..bcb9c09 --- /dev/null +++ b/src/modules/ad/core/application/queries/match/filter/journey.filter.ts @@ -0,0 +1,10 @@ +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; +import { Filter } from './filter.abstract'; + +/** + * Filter candidates with empty journeys + */ +export class JourneyFilter extends Filter { + filter = async (candidates: CandidateEntity[]): Promise => + candidates.filter((candidate: CandidateEntity) => candidate.hasJourneys()); +} 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 6baa3b4..8621d6f 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 @@ -9,6 +9,7 @@ import { RouteCompleterType, } from './completer/route.completer'; import { JourneyCompleter } from './completer/journey.completer'; +import { JourneyFilter } from './filter/journey.filter'; export class PassengerOrientedAlgorithm extends Algorithm { constructor( @@ -23,6 +24,7 @@ export class PassengerOrientedAlgorithm extends Algorithm { new PassengerOrientedGeoFilter(query), new RouteCompleter(query, RouteCompleterType.DETAILED), new JourneyCompleter(query), + new JourneyFilter(query), ]; } } diff --git a/src/modules/ad/core/domain/candidate.entity.ts b/src/modules/ad/core/domain/candidate.entity.ts index a61369e..ec3a7c9 100644 --- a/src/modules/ad/core/domain/candidate.entity.ts +++ b/src/modules/ad/core/domain/candidate.entity.ts @@ -44,15 +44,22 @@ export class CandidateEntity extends AggregateRoot { isDetourValid = (): boolean => this._validateDistanceDetour() && this._validateDurationDetour(); + hasJourneys = (): boolean => + this.getProps().journeys !== undefined && + (this.getProps().journeys as Journey[]).length > 0; + /** * Create the journeys based on the driver schedule (the driver 'drives' the carpool !) * This is a tedious process : additional information can be found in deeper methods ! */ createJourneys = (): CandidateEntity => { - this.props.journeys = this.props.driverSchedule.map( - (driverScheduleItem: ScheduleItem) => + this.props.journeys = this.props.driverSchedule + // first we create the journeys + .map((driverScheduleItem: ScheduleItem) => this._createJourney(driverScheduleItem), - ); + ) + // then we filter the ones with invalid pickups + .filter((journey: Journey) => journey.hasValidPickUp()); return this; }; 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 df03132..4b6a0e6 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 @@ -1,5 +1,8 @@ import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; import { JourneyItem } from './journey-item.value-object'; +import { ActorTime } from './actor-time.value-object'; +import { Role } from '../ad.types'; +import { Target } from '../candidate.types'; /** Note: * Value Objects with multiple properties can contain @@ -25,6 +28,37 @@ export class Journey extends ValueObject { return this.props.journeyItems; } + hasValidPickUp = (): boolean => { + const passengerDepartureJourneyItem: JourneyItem = this.journeyItems.find( + (journeyItem: JourneyItem) => + journeyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.PASSENGER && + actorTime.target == Target.START, + ) as ActorTime, + ) as JourneyItem; + const passengerDepartureActorTime = + passengerDepartureJourneyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.PASSENGER && actorTime.target == Target.START, + ) as ActorTime; + const driverNeutralActorTime = + passengerDepartureJourneyItem.actorTimes.find( + (actorTime: ActorTime) => + actorTime.role == Role.DRIVER && actorTime.target == Target.NEUTRAL, + ) as ActorTime; + return ( + (passengerDepartureActorTime.firstMinDatetime <= + driverNeutralActorTime.firstMaxDatetime && + driverNeutralActorTime.firstMaxDatetime <= + passengerDepartureActorTime.firstMaxDatetime) || + (passengerDepartureActorTime.firstMinDatetime <= + driverNeutralActorTime.firstMinDatetime && + driverNeutralActorTime.firstMinDatetime <= + passengerDepartureActorTime.firstMaxDatetime) + ); + }; + protected validate(props: JourneyProps): void { if (props.firstDate.getUTCDay() != props.lastDate.getUTCDay()) throw new ArgumentInvalidException( diff --git a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts index bcc7961..f1e4b7d 100644 --- a/src/modules/ad/tests/unit/core/candidate.entity.spec.ts +++ b/src/modules/ad/tests/unit/core/candidate.entity.spec.ts @@ -103,7 +103,7 @@ const schedule4: ScheduleItemProps[] = [ const schedule5: ScheduleItemProps[] = [ { day: 0, - time: '00:10', + time: '00:02', margin: 900, }, { @@ -121,7 +121,15 @@ const schedule6: ScheduleItemProps[] = [ }, { day: 6, - time: '23:45', + time: '23:57', + margin: 900, + }, +]; + +const schedule7: ScheduleItemProps[] = [ + { + day: 4, + time: '19:00', margin: 900, }, ]; @@ -379,7 +387,7 @@ describe('Candidate entity', () => { .setCarpoolPath(carpoolPath2) .setSteps(steps) .createJourneys(); - expect(candidateEntity.getProps().journeys).toHaveLength(5); + expect(candidateEntity.getProps().journeys).toHaveLength(4); expect( ( candidateEntity.getProps().journeys as Journey[] @@ -415,7 +423,7 @@ describe('Candidate entity', () => { .setCarpoolPath(carpoolPath2) .setSteps(steps) .createJourneys(); - expect(candidateEntity.getProps().journeys).toHaveLength(2); + expect(candidateEntity.getProps().journeys).toHaveLength(1); expect( (candidateEntity.getProps().journeys as Journey[])[0].journeyItems[1] .actorTimes[0].target, @@ -429,7 +437,7 @@ describe('Candidate entity', () => { ( candidateEntity.getProps().journeys as Journey[] )[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(), - ).toBe(30); + ).toBe(22); expect( ( candidateEntity.getProps().journeys as Journey[] @@ -439,7 +447,51 @@ describe('Candidate entity', () => { ( candidateEntity.getProps().journeys as Journey[] )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(), - ).toBe(30); + ).toBe(42); + }); + + it('should not create journeys if dates does not match', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule7, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps) + .createJourneys(); + expect(candidateEntity.getProps().journeys).toHaveLength(0); + expect(candidateEntity.hasJourneys()).toBeFalsy(); + }); + + it('should not verify journeys if journeys is undefined', () => { + const candidateEntity: CandidateEntity = CandidateEntity.create({ + id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', + role: Role.PASSENGER, + dateInterval: { + lowerDate: '2023-09-01', + higherDate: '2024-09-01', + }, + driverWaypoints: waypointsSet1, + passengerWaypoints: waypointsSet2, + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: schedule1, + passengerSchedule: schedule7, + spacetimeDetourRatio, + }) + .setCarpoolPath(carpoolPath2) + .setSteps(steps); + expect(candidateEntity.hasJourneys()).toBeFalsy(); }); }); }); diff --git a/src/modules/ad/tests/unit/core/journey.filter.spec.ts b/src/modules/ad/tests/unit/core/journey.filter.spec.ts new file mode 100644 index 0000000..8f707c1 --- /dev/null +++ b/src/modules/ad/tests/unit/core/journey.filter.spec.ts @@ -0,0 +1,117 @@ +import { JourneyFilter } from '@modules/ad/core/application/queries/match/filter/journey.filter'; +import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; +import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; +import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; +import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; +import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; + +const originWaypoint: Waypoint = { + position: 0, + lat: 48.689445, + lon: 6.17651, + houseNumber: '5', + street: 'Avenue Foch', + locality: 'Nancy', + postalCode: '54000', + country: 'France', +}; +const destinationWaypoint: Waypoint = { + position: 1, + lat: 48.8566, + lon: 2.3522, + locality: 'Paris', + postalCode: '75000', + country: 'France', +}; + +const matchQuery = new MatchQuery( + { + algorithmType: AlgorithmType.PASSENGER_ORIENTED, + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-08-28', + toDate: '2023-08-28', + schedule: [ + { + time: '07:05', + }, + ], + strict: false, + waypoints: [originWaypoint, destinationWaypoint], + }, + { + getBasic: jest.fn(), + getDetailed: jest.fn(), + }, +); + +const candidate: CandidateEntity = CandidateEntity.create({ + id: 'cc260669-1c6d-441f-80a5-19cd59afb777', + role: Role.DRIVER, + dateInterval: { + lowerDate: '2023-08-28', + higherDate: '2023-08-28', + }, + driverWaypoints: [ + { + lat: 48.678454, + lon: 6.189745, + }, + { + lat: 48.84877, + lon: 2.398457, + }, + ], + passengerWaypoints: [ + { + lat: 48.689445, + lon: 6.17651, + }, + { + lat: 48.8566, + lon: 2.3522, + }, + ], + driverDistance: 350145, + driverDuration: 13548, + driverSchedule: [ + { + day: 0, + time: '07:00', + margin: 900, + }, + ], + passengerSchedule: [ + { + day: 0, + time: '07:10', + margin: 900, + }, + ], + spacetimeDetourRatio: { + maxDistanceDetourRatio: 0.3, + maxDurationDetourRatio: 0.3, + }, +}); + +describe('Passenger oriented time filter', () => { + it('should not filter valid candidates', async () => { + const passengerOrientedTimeFilter: JourneyFilter = new JourneyFilter( + matchQuery, + ); + candidate.hasJourneys = () => true; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedTimeFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(1); + }); + it('should filter invalid candidates', async () => { + const passengerOrientedTimeFilter: JourneyFilter = new JourneyFilter( + matchQuery, + ); + candidate.hasJourneys = () => false; + const filteredCandidates: CandidateEntity[] = + await passengerOrientedTimeFilter.filter([candidate]); + expect(filteredCandidates.length).toBe(0); + }); +});