refactor to ddh, first commit

This commit is contained in:
sbriat
2023-08-16 12:28:20 +02:00
parent 0a6e4c0bf6
commit ce48890a66
208 changed files with 2596 additions and 2052 deletions

View File

@@ -0,0 +1,59 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '../../../utils/pipes/rpc.validation-pipe';
import { MatchRequest } from '../../domain/dtos/match.request';
import { ICollection } from '../../../database/interfaces/collection.interface';
import { MatchQuery } from '../../queries/match.query';
import { MatchPresenter } from '../secondaries/match.presenter';
import { DefaultParamsProvider } from '../secondaries/default-params.provider';
import { GeorouterCreator } from '../secondaries/georouter-creator';
import { Match } from '../../domain/entities/ecosystem/match';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { TimeConverter } from '../secondaries/time-converter';
@UsePipes(
new RpcValidationPipe({
whitelist: false,
forbidUnknownValues: false,
}),
)
@Controller()
export class MatcherController {
constructor(
private readonly queryBus: QueryBus,
private readonly defaultParamsProvider: DefaultParamsProvider,
@InjectMapper() private readonly mapper: Mapper,
private readonly georouterCreator: GeorouterCreator,
private readonly timezoneFinder: GeoTimezoneFinder,
private readonly timeConverter: TimeConverter,
) {}
@GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequest): Promise<ICollection<Match>> {
try {
const matchCollection = await this.queryBus.execute(
new MatchQuery(
data,
this.defaultParamsProvider.getParams(),
this.georouterCreator,
this.timezoneFinder,
this.timeConverter,
),
);
return Promise.resolve({
data: matchCollection.data.map((match: Match) =>
this.mapper.map(match, Match, MatchPresenter),
),
total: matchCollection.total,
});
} catch (e) {
throw new RpcException({
code: e.code,
message: e.message,
});
}
}
}

View File

@@ -0,0 +1,70 @@
syntax = "proto3";
package matcher;
service MatcherService {
rpc Match(MatchRequest) returns (Matches);
}
message MatchRequest {
string uuid = 1;
repeated Coordinates waypoints = 2;
string departure = 3;
string fromDate = 4;
Schedule schedule = 5;
bool driver = 6;
bool passenger = 7;
string toDate = 8;
int32 marginDuration = 9;
MarginDurations marginDurations = 10;
int32 seatsPassenger = 11;
int32 seatsDriver = 12;
bool strict = 13;
Algorithm algorithm = 14;
int32 remoteness = 15;
bool useProportion = 16;
int32 proportion = 17;
bool useAzimuth = 18;
int32 azimuthMargin = 19;
float maxDetourDistanceRatio = 20;
float maxDetourDurationRatio = 21;
repeated int32 exclusions = 22;
}
message Coordinates {
float lon = 1;
float lat = 2;
}
message Schedule {
string mon = 1;
string tue = 2;
string wed = 3;
string thu = 4;
string fri = 5;
string sat = 6;
string sun = 7;
}
message MarginDurations {
int32 mon = 1;
int32 tue = 2;
int32 wed = 3;
int32 thu = 4;
int32 fri = 5;
int32 sat = 6;
int32 sun = 7;
}
enum Algorithm {
CLASSIC = 0;
}
message Match {
string uuid = 1;
}
message Matches {
repeated Match data = 1;
int32 total = 2;
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IDefaultParams } from '../../domain/types/default-params.type';
@Injectable()
export class DefaultParamsProvider {
constructor(private readonly configService: ConfigService) {}
getParams = (): IDefaultParams => {
return {
DEFAULT_UUID: this.configService.get('DEFAULT_UUID'),
MARGIN_DURATION: parseInt(this.configService.get('MARGIN_DURATION')),
VALIDITY_DURATION: parseInt(this.configService.get('VALIDITY_DURATION')),
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
DEFAULT_SEATS: parseInt(this.configService.get('DEFAULT_SEATS')),
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: this.configService.get('ALGORITHM'),
STRICT: !!parseInt(this.configService.get('STRICT_ALGORITHM')),
REMOTENESS: parseInt(this.configService.get('REMOTENESS')),
USE_PROPORTION: !!parseInt(this.configService.get('USE_PROPORTION')),
PROPORTION: parseInt(this.configService.get('PROPORTION')),
USE_AZIMUTH: !!parseInt(this.configService.get('USE_AZIMUTH')),
AZIMUTH_MARGIN: parseInt(this.configService.get('AZIMUTH_MARGIN')),
MAX_DETOUR_DISTANCE_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DISTANCE_RATIO'),
),
MAX_DETOUR_DURATION_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DURATION_RATIO'),
),
GEOROUTER_TYPE: this.configService.get('GEOROUTER_TYPE'),
GEOROUTER_URL: this.configService.get('GEOROUTER_URL'),
},
};
};
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { Geodesic } from '../../../geography/adapters/secondaries/geodesic';
import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
@Injectable()
export class MatcherGeodesic implements IGeodesic {
constructor(private readonly geodesic: Geodesic) {}
inverse = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): { azimuth: number; distance: number } =>
this.geodesic.inverse(lon1, lat1, lon2, lat2);
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { ICreateGeorouter } from '../../domain/interfaces/georouter-creator.interface';
import { IGeorouter } from '../../domain/interfaces/georouter.interface';
import { GraphhopperGeorouter } from './graphhopper-georouter';
import { HttpService } from '@nestjs/axios';
import { MatcherGeodesic } from './geodesic';
import {
MatcherException,
MatcherExceptionCode,
} from '../../exceptions/matcher.exception';
@Injectable()
export class GeorouterCreator implements ICreateGeorouter {
constructor(
private readonly httpService: HttpService,
private readonly geodesic: MatcherGeodesic,
) {}
create = (type: string, url: string): IGeorouter => {
switch (type) {
case 'graphhopper':
return new GraphhopperGeorouter(url, this.httpService, this.geodesic);
default:
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'Unknown geocoder',
);
}
};
}

View File

@@ -0,0 +1,326 @@
import { HttpService } from '@nestjs/axios';
import { IGeorouter } from '../../domain/interfaces/georouter.interface';
import { GeorouterSettings } from '../../domain/types/georouter-settings.type';
import { Path } from '../../domain/types/path.type';
import { Injectable } from '@nestjs/common';
import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
import { NamedRoute } from '../../domain/entities/ecosystem/named-route';
import { MatcherRoute } from '../../domain/entities/ecosystem/matcher-route';
import { SpacetimePoint } from '../../domain/entities/ecosystem/spacetime-point';
import {
MatcherException,
MatcherExceptionCode,
} from '../../exceptions/matcher.exception';
@Injectable()
export class GraphhopperGeorouter implements IGeorouter {
private url: string;
private urlArgs: string[];
private withTime: boolean;
private withPoints: boolean;
private withDistance: boolean;
private paths: Path[];
private httpService: HttpService;
private geodesic: IGeodesic;
constructor(url: string, httpService: HttpService, geodesic: IGeodesic) {
this.url = url + '/route?';
this.httpService = httpService;
this.geodesic = geodesic;
}
route = async (
paths: Path[],
settings: GeorouterSettings,
): Promise<NamedRoute[]> => {
this.setDefaultUrlArgs();
this.setWithTime(settings.withTime);
this.setWithPoints(settings.withPoints);
this.setWithDistance(settings.withDistance);
this.paths = paths;
return await this.getRoutes();
};
private setDefaultUrlArgs = (): void => {
this.urlArgs = ['vehicle=car', 'weighting=fastest', 'points_encoded=false'];
};
private setWithTime = (withTime: boolean): void => {
this.withTime = withTime;
if (withTime) {
this.urlArgs.push('details=time');
}
};
private setWithPoints = (withPoints: boolean): void => {
this.withPoints = withPoints;
if (!withPoints) {
this.urlArgs.push('calc_points=false');
}
};
private setWithDistance = (withDistance: boolean): void => {
this.withDistance = withDistance;
if (withDistance) {
this.urlArgs.push('instructions=true');
} else {
this.urlArgs.push('instructions=false');
}
};
private getRoutes = async (): Promise<NamedRoute[]> => {
const routes = Promise.all(
this.paths.map(async (path) => {
const url: string = [
this.getUrl(),
'&point=',
path.points
.map((point) => [point.lat, point.lon].join())
.join('&point='),
].join('');
const route = await lastValueFrom(
this.httpService.get(url).pipe(
map((res) => (res.data ? this.createRoute(res) : undefined)),
catchError((error: AxiosError) => {
throw new MatcherException(
MatcherExceptionCode.INTERNAL,
'Georouter unavailable : ' + error.message,
);
}),
),
);
return <NamedRoute>{
key: path.key,
route,
};
}),
);
return routes;
};
private getUrl = (): string => {
return [this.url, this.urlArgs.join('&')].join('');
};
private createRoute = (
response: AxiosResponse<GraphhopperResponse>,
): MatcherRoute => {
const route = new MatcherRoute(this.geodesic);
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
route.duration = shortestPath.time ? shortestPath.time / 1000 : 0;
if (shortestPath.points && shortestPath.points.coordinates) {
route.setPoints(
shortestPath.points.coordinates.map((coordinate) => ({
lon: coordinate[0],
lat: coordinate[1],
})),
);
if (
shortestPath.details &&
shortestPath.details.time &&
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.setSpacetimePoints(
this.generateSpacetimePoints(
shortestPath.points.coordinates,
shortestPath.snapped_waypoints.coordinates,
shortestPath.details.time,
instructions,
),
);
}
}
}
return route;
};
private generateSpacetimePoints = (
points: Array<number[]>,
snappedWaypoints: Array<number[]>,
durations: Array<number[]>,
instructions: GraphhopperInstruction[],
): SpacetimePoint[] => {
const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices);
return indices.map(
(index) =>
new SpacetimePoint(
{ lon: points[index][1], lat: points[index][0] },
times.find((time) => time.index == index)?.duration,
distances.find((distance) => distance.index == index)?.distance,
),
);
};
private getIndices = (
points: Array<number[]>,
snappedWaypoints: Array<number[]>,
): number[] => {
const indices = snappedWaypoints.map((waypoint) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
),
);
if (indices.find((index) => index == -1) === undefined) return indices;
const missedWaypoints = indices
.map(
(value, index) =>
<
{
index: number;
originIndex: number;
waypoint: number[];
nearest: number;
distance: number;
}
>{
index: value,
originIndex: index,
waypoint: snappedWaypoints[index],
nearest: undefined,
distance: 999999999,
},
)
.filter((element) => element.index == -1);
for (const index in points) {
for (const missedWaypoint of missedWaypoints) {
const inverse = this.geodesic.inverse(
missedWaypoint.waypoint[0],
missedWaypoint.waypoint[1],
points[index][0],
points[index][1],
);
if (inverse.distance < missedWaypoint.distance) {
missedWaypoint.distance = inverse.distance;
missedWaypoint.nearest = parseInt(index);
}
}
}
for (const missedWaypoint of missedWaypoints) {
indices[missedWaypoint.originIndex] = missedWaypoint.nearest;
}
return indices;
};
private getTimes = (
durations: Array<number[]>,
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
for (const [origin, destination, stepDuration] of durations) {
let indexFound = false;
const indexAsOrigin = indices.find((index) => index == origin);
if (
indexAsOrigin !== undefined &&
times.find((time) => origin == time.index) == undefined
) {
times.push({
index: indexAsOrigin,
duration: Math.round(stepDuration / 1000),
});
indexFound = true;
}
if (!indexFound) {
const indexAsDestination = indices.find(
(index) => index == destination,
);
if (
indexAsDestination !== undefined &&
times.find((time) => destination == time.index) == undefined
) {
times.push({
index: indexAsDestination,
duration: Math.round((duration + stepDuration) / 1000),
});
indexFound = true;
}
}
if (!indexFound) {
const indexInBetween = indices.find(
(index) => origin < index && index < destination,
);
if (indexInBetween !== undefined) {
times.push({
index: indexInBetween,
duration: Math.round((duration + stepDuration / 2) / 1000),
});
}
}
duration += stepDuration;
}
return times;
};
private getDistances = (
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
{
index: 0,
distance,
},
];
for (const instruction of instructions) {
distance += instruction.distance;
if (
(instruction.sign == GraphhopperSign.SIGN_WAYPOINT ||
instruction.sign == GraphhopperSign.SIGN_FINISH) &&
indices.find((index) => index == instruction.interval[0]) !== undefined
) {
distances.push({
index: instruction.interval[0],
distance: Math.round(distance),
});
}
}
return distances;
};
}
type GraphhopperResponse = {
paths: [
{
distance: number;
weight: number;
time: number;
points_encoded: boolean;
bbox: number[];
points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates;
details: {
time: Array<number[]>;
};
instructions: GraphhopperInstruction[];
},
];
};
type GraphhopperCoordinates = {
coordinates: Array<number[]>;
};
type GraphhopperInstruction = {
distance: number;
heading: number;
sign: GraphhopperSign;
interval: number[];
text: string;
};
enum GraphhopperSign {
SIGN_START = 0,
SIGN_FINISH = 4,
SIGN_WAYPOINT = 5,
}

View File

@@ -0,0 +1,6 @@
import { AutoMap } from '@automapper/classes';
export class MatchPresenter {
@AutoMap()
uuid: string;
}

View File

@@ -0,0 +1,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@Injectable()
export class MessagePublisher implements IPublishMessage {
constructor(
@Inject(MESSAGE_BROKER_PUBLISHER)
private readonly messageBrokerPublisher: MessageBrokerPublisher,
) {}
publish = (routingKey: string, message: string): void => {
this.messageBrokerPublisher.publish(routingKey, message);
};
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { DateTime, TimeZone } from 'timezonecomplete';
import { IConvertTime } from '../../domain/interfaces/time-converter.interface';
@Injectable()
export class TimeConverter implements IConvertTime {
toUtcDate = (date: Date, timezone: string): Date => {
try {
return new Date(
new DateTime(
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`,
TimeZone.zone(timezone, false),
)
.convert(TimeZone.zone('UTC'))
.toIsoString(),
);
} catch (e) {
return undefined;
}
};
}

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
@Injectable()
export class TimezoneFinder implements IFindTimezone {
constructor(private readonly geoTimezoneFinder: GeoTimezoneFinder) {}
timezones = (lon: number, lat: number): string[] =>
this.geoTimezoneFinder.timezones(lon, lat);
}

View File

@@ -0,0 +1,155 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { AutoMap } from '@automapper/classes';
import { Point } from '../../../geography/domain/types/point.type';
import { Schedule } from '../types/schedule.type';
import { MarginDurations } from '../types/margin-durations.type';
import { AlgorithmType } from '../types/algorithm.enum';
import { IRequestTime } from '../interfaces/time-request.interface';
import { IRequestAd } from '../interfaces/ad-request.interface';
import { IRequestGeography } from '../interfaces/geography-request.interface';
import { IRequestRequirement } from '../interfaces/requirement-request.interface';
import { IRequestAlgorithmSettings } from '../interfaces/algorithm-settings-request.interface';
import { Mode } from '../types/mode.enum';
export class MatchRequest
implements
IRequestTime,
IRequestAd,
IRequestGeography,
IRequestRequirement,
IRequestAlgorithmSettings
{
@IsOptional()
@IsString()
@AutoMap()
uuid: string;
@IsOptional()
@IsEnum(Mode)
@AutoMap()
mode: Mode;
@IsArray()
@AutoMap()
waypoints: Point[];
@IsOptional()
@IsString()
@AutoMap()
departure: string;
@IsOptional()
@IsString()
@AutoMap()
fromDate: string;
@IsOptional()
@AutoMap()
schedule: Schedule;
@IsOptional()
@IsBoolean()
@AutoMap()
driver: boolean;
@IsOptional()
@IsBoolean()
@AutoMap()
passenger: boolean;
@IsOptional()
@IsString()
@AutoMap()
toDate: string;
@IsOptional()
@IsInt()
@AutoMap()
marginDuration: number;
@IsOptional()
@AutoMap()
marginDurations: MarginDurations;
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
@AutoMap()
seatsPassenger: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(10)
@AutoMap()
seatsDriver: number;
@IsOptional()
@AutoMap()
strict: boolean;
@IsOptional()
@IsEnum(AlgorithmType)
@AutoMap()
algorithm: AlgorithmType;
@IsOptional()
@IsNumber()
@AutoMap()
remoteness: number;
@IsOptional()
@IsBoolean()
@AutoMap()
useProportion: boolean;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
proportion: number;
@IsOptional()
@IsBoolean()
@AutoMap()
useAzimuth: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(359)
@AutoMap()
azimuthMargin: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
maxDetourDistanceRatio: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
@AutoMap()
maxDetourDurationRatio: number;
@IsOptional()
@IsArray()
exclusions: string[];
timezone?: string;
}

View File

@@ -0,0 +1,15 @@
import { Role } from '../../types/role.enum';
import { Step } from '../../types/step.enum';
import { Ad } from './ad';
export class Actor {
ad: Ad;
role: Role;
step: Step;
constructor(ad: Ad, role: Role, step: Step) {
this.ad = ad;
this.role = role;
this.step = step;
}
}

View File

@@ -0,0 +1,40 @@
import { IRequestAd } from '../../interfaces/ad-request.interface';
export class Ad {
private adRequest: IRequestAd;
private defaultUuid: string;
private defaultMarginDuration: number;
uuid: string;
marginDurations: number[];
constructor(
adRequest: IRequestAd,
defaultUuid: string,
defaultMarginDuration: number,
) {
this.adRequest = adRequest;
this.defaultUuid = defaultUuid;
this.defaultMarginDuration = defaultMarginDuration;
}
init = (): void => {
this.setUuid(this.adRequest.uuid ?? this.defaultUuid);
this.setMarginDurations([
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
]);
};
setUuid = (uuid: string): void => {
this.uuid = uuid;
};
setMarginDurations = (marginDurations: number[]): void => {
this.marginDurations = marginDurations;
};
}

View File

@@ -0,0 +1,62 @@
import { IRequestAlgorithmSettings } from '../../interfaces/algorithm-settings-request.interface';
import { DefaultAlgorithmSettings } from '../../types/default-algorithm-settings.type';
import { AlgorithmType } from '../../types/algorithm.enum';
import { ICreateGeorouter } from '../../interfaces/georouter-creator.interface';
import { IGeorouter } from '../../interfaces/georouter.interface';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
export class AlgorithmSettings {
private algorithmSettingsRequest: IRequestAlgorithmSettings;
private strict: boolean;
algorithmType: AlgorithmType;
restrict: Frequency;
remoteness: number;
useProportion: boolean;
proportion: number;
useAzimuth: boolean;
azimuthMargin: number;
maxDetourDurationRatio: number;
maxDetourDistanceRatio: number;
georouter: IGeorouter;
constructor(
algorithmSettingsRequest: IRequestAlgorithmSettings,
defaultAlgorithmSettings: DefaultAlgorithmSettings,
frequency: Frequency,
georouterCreator: ICreateGeorouter,
) {
this.algorithmSettingsRequest = algorithmSettingsRequest;
this.algorithmType =
algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.ALGORITHM;
this.strict =
algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.STRICT;
this.remoteness = algorithmSettingsRequest.remoteness
? Math.abs(algorithmSettingsRequest.remoteness)
: defaultAlgorithmSettings.REMOTENESS;
this.useProportion =
algorithmSettingsRequest.useProportion ??
defaultAlgorithmSettings.USE_PROPORTION;
this.proportion = algorithmSettingsRequest.proportion
? Math.abs(algorithmSettingsRequest.proportion)
: defaultAlgorithmSettings.PROPORTION;
this.useAzimuth =
algorithmSettingsRequest.useAzimuth ??
defaultAlgorithmSettings.USE_AZIMUTH;
this.azimuthMargin = algorithmSettingsRequest.azimuthMargin
? Math.abs(algorithmSettingsRequest.azimuthMargin)
: defaultAlgorithmSettings.AZIMUTH_MARGIN;
this.maxDetourDistanceRatio =
algorithmSettingsRequest.maxDetourDistanceRatio ??
defaultAlgorithmSettings.MAX_DETOUR_DISTANCE_RATIO;
this.maxDetourDurationRatio =
algorithmSettingsRequest.maxDetourDurationRatio ??
defaultAlgorithmSettings.MAX_DETOUR_DURATION_RATIO;
this.georouter = georouterCreator.create(
defaultAlgorithmSettings.GEOROUTER_TYPE,
defaultAlgorithmSettings.GEOROUTER_URL,
);
if (this.strict) {
this.restrict = frequency;
}
}
}

View File

@@ -0,0 +1,196 @@
import {
MatcherException,
MatcherExceptionCode,
} from '../../../exceptions/matcher.exception';
import { IRequestGeography } from '../../interfaces/geography-request.interface';
import { PointType } from '../../../../geography/domain/types/point-type.enum';
import { Point } from '../../../../geography/domain/types/point.type';
import { MatcherRoute } from './matcher-route';
import { Role } from '../../types/role.enum';
import { IGeorouter } from '../../interfaces/georouter.interface';
import { Waypoint } from './waypoint';
import { Actor } from './actor';
import { Ad } from './ad';
import { Step } from '../../types/step.enum';
import { Path } from '../../types/path.type';
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
import { Timezoner } from './timezoner';
export class Geography {
private geographyRequest: IRequestGeography;
private ad: Ad;
private points: Point[];
originType: PointType;
destinationType: PointType;
timezones: string[];
driverRoute: MatcherRoute;
passengerRoute: MatcherRoute;
timezoneFinder: IFindTimezone;
constructor(
geographyRequest: IRequestGeography,
timezoner: Timezoner,
ad: Ad,
) {
this.geographyRequest = geographyRequest;
this.ad = ad;
this.points = [];
this.originType = undefined;
this.destinationType = undefined;
this.timezones = [timezoner.timezone];
this.timezoneFinder = timezoner.finder;
}
init = (): void => {
this.validateWaypoints();
this.setTimezones();
this.setPointTypes();
};
createRoutes = async (
roles: Role[],
georouter: IGeorouter,
): Promise<void> => {
let driverWaypoints: Waypoint[] = [];
let passengerWaypoints: Waypoint[] = [];
const paths: Path[] = [];
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (this.points.length == 2) {
// 2 points => same route for driver and passenger
const commonPath: Path = {
key: RouteKey.COMMON,
points: this.points,
};
driverWaypoints = this.createWaypoints(commonPath.points, Role.DRIVER);
passengerWaypoints = this.createWaypoints(
commonPath.points,
Role.PASSENGER,
);
paths.push(commonPath);
} else {
const driverPath: Path = {
key: RouteKey.DRIVER,
points: this.points,
};
driverWaypoints = this.createWaypoints(driverPath.points, Role.DRIVER);
const passengerPath: Path = {
key: RouteKey.PASSENGER,
points: [this.points[0], this.points[this.points.length - 1]],
};
passengerWaypoints = this.createWaypoints(
passengerPath.points,
Role.PASSENGER,
);
paths.push(driverPath, passengerPath);
}
} else if (roles.includes(Role.DRIVER)) {
const driverPath: Path = {
key: RouteKey.DRIVER,
points: this.points,
};
driverWaypoints = this.createWaypoints(driverPath.points, Role.DRIVER);
paths.push(driverPath);
} else if (roles.includes(Role.PASSENGER)) {
const passengerPath: Path = {
key: RouteKey.PASSENGER,
points: [this.points[0], this.points[this.points.length - 1]],
};
passengerWaypoints = this.createWaypoints(
passengerPath.points,
Role.PASSENGER,
);
paths.push(passengerPath);
}
const routes = await georouter.route(paths, {
withDistance: false,
withPoints: true,
withTime: false,
});
if (routes.some((route) => route.key == RouteKey.COMMON)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
this.driverRoute.setWaypoints(driverWaypoints);
this.passengerRoute.setWaypoints(passengerWaypoints);
} else {
if (routes.some((route) => route.key == RouteKey.DRIVER)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.DRIVER,
).route;
this.driverRoute.setWaypoints(driverWaypoints);
}
if (routes.some((route) => route.key == RouteKey.PASSENGER)) {
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.PASSENGER,
).route;
this.passengerRoute.setWaypoints(passengerWaypoints);
}
}
};
private validateWaypoints = (): void => {
if (this.geographyRequest.waypoints.length < 2) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'At least 2 waypoints are required',
);
}
this.geographyRequest.waypoints.map((point) => {
if (!this.isValidPoint(point)) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
`Waypoint { Lon: ${point.lon}, Lat: ${point.lat} } is not valid`,
);
}
this.points.push(point);
});
};
private setTimezones = (): void => {
this.timezones = this.timezoneFinder.timezones(
this.geographyRequest.waypoints[0].lat,
this.geographyRequest.waypoints[0].lon,
);
};
private setPointTypes = (): void => {
this.originType =
this.geographyRequest.waypoints[0].type ?? PointType.OTHER;
this.destinationType =
this.geographyRequest.waypoints[
this.geographyRequest.waypoints.length - 1
].type ?? PointType.OTHER;
};
private isValidPoint = (point: Point): boolean =>
this.isValidLongitude(point.lon) && this.isValidLatitude(point.lat);
private isValidLongitude = (longitude: number): boolean =>
longitude >= -180 && longitude <= 180;
private isValidLatitude = (latitude: number): boolean =>
latitude >= -90 && latitude <= 90;
private createWaypoints = (points: Point[], role: Role): Waypoint[] => {
return points.map((point, index) => {
const waypoint = new Waypoint(point);
if (index == 0) {
waypoint.addActor(new Actor(this.ad, role, Step.START));
} else if (index == points.length - 1) {
waypoint.addActor(new Actor(this.ad, role, Step.FINISH));
} else {
waypoint.addActor(new Actor(this.ad, role, Step.INTERMEDIATE));
}
return waypoint;
});
};
}
export enum RouteKey {
COMMON = 'common',
DRIVER = 'driver',
PASSENGER = 'passenger',
}

View File

@@ -0,0 +1,6 @@
import { AutoMap } from '@automapper/classes';
export class Match {
@AutoMap()
uuid: string;
}

View File

@@ -0,0 +1,16 @@
import { Route } from '../../../../geography/domain/entities/route';
import { IGeodesic } from '../../../../geography/domain/interfaces/geodesic.interface';
import { Waypoint } from './waypoint';
export class MatcherRoute extends Route {
waypoints: Waypoint[];
constructor(geodesic: IGeodesic) {
super(geodesic);
}
setWaypoints = (waypoints: Waypoint[]): void => {
this.waypoints = waypoints;
this.setAzimuth(waypoints.map((waypoint) => waypoint.point));
};
}

View File

@@ -0,0 +1,6 @@
import { MatcherRoute } from './matcher-route';
export type NamedRoute = {
key: string;
route: MatcherRoute;
};

View File

@@ -0,0 +1,13 @@
import { IRequestRequirement } from '../../interfaces/requirement-request.interface';
export class Requirement {
private requirementRequest: IRequestRequirement;
seatsDriver: number;
seatsPassenger: number;
constructor(requirementRequest: IRequestRequirement, defaultSeats: number) {
this.requirementRequest = requirementRequest;
this.seatsDriver = requirementRequest.seatsDriver ?? defaultSeats;
this.seatsPassenger = requirementRequest.seatsPassenger ?? 1;
}
}

View File

@@ -0,0 +1,13 @@
import { Coordinate } from '../../../../geography/domain/entities/coordinate';
export class SpacetimePoint {
coordinate: Coordinate;
duration: number;
distance: number;
constructor(coordinate: Coordinate, duration: number, distance: number) {
this.coordinate = coordinate;
this.duration = duration;
this.distance = distance;
}
}

View File

@@ -0,0 +1,206 @@
import {
MatcherException,
MatcherExceptionCode,
} from '../../../exceptions/matcher.exception';
import { MarginDurations } from '../../types/margin-durations.type';
import { IRequestTime } from '../../interfaces/time-request.interface';
import { DAYS } from '../../types/days.const';
import { TimeSchedule } from '../../types/time-schedule.type';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
import { Day } from '../../types/day.type';
import { IConvertTime } from '../../interfaces/time-converter.interface';
export class Time {
private timeRequest: IRequestTime;
private defaultValidityDuration: number;
private timeConverter: IConvertTime;
frequency: Frequency;
fromDate: Date;
toDate: Date;
schedule: TimeSchedule;
marginDurations: MarginDurations;
constructor(
timeRequest: IRequestTime,
defaultMarginDuration: number,
defaultValidityDuration: number,
timeConverter: IConvertTime,
) {
this.timeRequest = timeRequest;
this.defaultValidityDuration = defaultValidityDuration;
this.timeConverter = timeConverter;
this.schedule = {};
this.marginDurations = {
mon: defaultMarginDuration,
tue: defaultMarginDuration,
wed: defaultMarginDuration,
thu: defaultMarginDuration,
fri: defaultMarginDuration,
sat: defaultMarginDuration,
sun: defaultMarginDuration,
};
}
init = (): void => {
this.validateBaseDate();
this.validatePunctualRequest();
this.validateRecurrentRequest();
this.setPunctualRequest();
this.setRecurrentRequest();
this.setMargindurations();
};
private validateBaseDate = (): void => {
if (!this.timeRequest.departure && !this.timeRequest.fromDate) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'departure or fromDate is required',
);
}
};
private validatePunctualRequest = (): void => {
if (this.timeRequest.departure) {
this.fromDate = this.toDate = new Date(this.timeRequest.departure);
if (!this.isDate(this.fromDate)) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'Wrong departure date',
);
}
}
};
private validateRecurrentRequest = (): void => {
if (this.timeRequest.fromDate) {
this.fromDate = new Date(this.timeRequest.fromDate);
if (!this.isDate(this.fromDate)) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'Wrong fromDate',
);
}
}
if (this.timeRequest.toDate) {
this.toDate = new Date(this.timeRequest.toDate);
if (!this.isDate(this.toDate)) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'Wrong toDate',
);
}
if (this.toDate < this.fromDate) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'toDate must be after fromDate',
);
}
}
if (this.timeRequest.fromDate) {
this.validateSchedule();
}
};
private validateSchedule = (): void => {
if (!this.timeRequest.schedule) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'Schedule is required',
);
}
if (
!Object.keys(this.timeRequest.schedule).some((elem) =>
DAYS.includes(elem),
)
) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'No valid day in the given schedule',
);
}
Object.keys(this.timeRequest.schedule).map((day) => {
const time = new Date('1970-01-01 ' + this.timeRequest.schedule[day]);
if (!this.isDate(time)) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
`Wrong time for ${day} in schedule`,
);
}
});
};
private setPunctualRequest = (): void => {
if (this.timeRequest.departure) {
this.frequency = Frequency.PUNCTUAL;
this.schedule[Day[this.fromDate.getDay()]] = this.timeConverter.toUtcDate(
this.fromDate,
this.timeRequest.timezone,
);
}
};
private setRecurrentRequest = (): void => {
if (this.timeRequest.fromDate) {
this.frequency = Frequency.RECURRENT;
if (!this.toDate) {
this.toDate = this.addDays(this.fromDate, this.defaultValidityDuration);
}
this.setSchedule();
}
};
private setSchedule = (): void => {
Object.keys(this.timeRequest.schedule).map((day) => {
this.schedule[day] = this.timeConverter.toUtcDate(
new Date(
`${this.fromDate.getFullYear()}-${this.fromDate.getMonth()}-${this.fromDate.getDate()} ${
this.timeRequest.schedule[day]
}`,
),
this.timeRequest.timezone,
);
});
};
private setMargindurations = (): void => {
if (this.timeRequest.marginDuration) {
const duration = Math.abs(this.timeRequest.marginDuration);
this.marginDurations = {
mon: duration,
tue: duration,
wed: duration,
thu: duration,
fri: duration,
sat: duration,
sun: duration,
};
}
if (this.timeRequest.marginDurations) {
if (
!Object.keys(this.timeRequest.marginDurations).some((elem) =>
DAYS.includes(elem),
)
) {
throw new MatcherException(
MatcherExceptionCode.INVALID_ARGUMENT,
'No valid day in the given margin durations',
);
}
Object.keys(this.timeRequest.marginDurations).map((day) => {
this.marginDurations[day] = Math.abs(
this.timeRequest.marginDurations[day],
);
});
}
};
private isDate = (date: Date): boolean => {
return date instanceof Date && isFinite(+date);
};
private addDays = (date: Date, days: number): Date => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
}

View File

@@ -0,0 +1,6 @@
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
export type Timezoner = {
timezone: string;
finder: IFindTimezone;
};

View File

@@ -0,0 +1,14 @@
import { Point } from '../../../../geography/domain/types/point.type';
import { Actor } from './actor';
export class Waypoint {
point: Point;
actors: Actor[];
constructor(point: Point) {
this.point = point;
this.actors = [];
}
addActor = (actor: Actor) => this.actors.push(actor);
}

View File

@@ -0,0 +1,5 @@
import { Ad } from '../ecosystem/ad';
export class Candidate {
ad: Ad;
}

View File

@@ -0,0 +1,18 @@
import { MatchQuery } from 'src/modules/matcher/queries/match.query';
import { AlgorithmType } from '../../../types/algorithm.enum';
import { AlgorithmFactory } from './algorithm-factory.abstract';
import { ClassicAlgorithmFactory } from './classic';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AlgorithmFactoryCreator {
create = (matchQuery: MatchQuery): AlgorithmFactory => {
let algorithmFactory: AlgorithmFactory;
switch (matchQuery.algorithmSettings.algorithmType) {
case AlgorithmType.CLASSIC:
algorithmFactory = new ClassicAlgorithmFactory(matchQuery);
break;
}
return algorithmFactory;
};
}

View File

@@ -0,0 +1,17 @@
import { MatchQuery } from 'src/modules/matcher/queries/match.query';
import { Processor } from '../processor/processor.abstract';
import { Candidate } from '../candidate';
import { Selector } from '../selector/selector.abstract';
export abstract class AlgorithmFactory {
protected matchQuery: MatchQuery;
private candidates: Candidate[];
constructor(matchQuery: MatchQuery) {
this.matchQuery = matchQuery;
this.candidates = [];
}
abstract createSelector(): Selector;
abstract createProcessors(): Processor[];
}

View File

@@ -0,0 +1,21 @@
import { AlgorithmFactory } from './algorithm-factory.abstract';
import { ClassicWaypointsCompleter } from '../processor/completer/classic-waypoint.completer.processor';
import { RouteCompleter } from '../processor/completer/route.completer.processor';
import { ClassicGeoFilter } from '../processor/filter/geofilter/classic.filter.processor';
import { JourneyCompleter } from '../processor/completer/journey.completer.processor';
import { ClassicTimeFilter } from '../processor/filter/timefilter/classic.filter.processor';
import { Processor } from '../processor/processor.abstract';
import { Selector } from '../selector/selector.abstract';
import { ClassicSelector } from '../selector/classic.selector';
export class ClassicAlgorithmFactory extends AlgorithmFactory {
createSelector = (): Selector => new ClassicSelector(this.matchQuery);
createProcessors = (): Processor[] => [
new ClassicWaypointsCompleter(this.matchQuery),
new RouteCompleter(this.matchQuery, true, true, true),
new ClassicGeoFilter(this.matchQuery),
new RouteCompleter(this.matchQuery),
new JourneyCompleter(this.matchQuery),
new ClassicTimeFilter(this.matchQuery),
];
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { MatchQuery } from '../../../queries/match.query';
import { Match } from '../ecosystem/match';
import { Candidate } from './candidate';
import { AlgorithmFactory } from './factory/algorithm-factory.abstract';
import { AlgorithmFactoryCreator } from './factory/algorithm-factory-creator';
@Injectable()
export class Matcher {
constructor(
private readonly algorithmFactoryCreator: AlgorithmFactoryCreator,
) {}
match = async (matchQuery: MatchQuery): Promise<Match[]> => {
const algorithmFactory: AlgorithmFactory =
this.algorithmFactoryCreator.create(matchQuery);
let candidates: Candidate[] = await algorithmFactory
.createSelector()
.select();
for (const processor of algorithmFactory.createProcessors()) {
candidates = processor.execute(candidates);
}
const match = new Match();
match.uuid = 'e23f9725-2c19-49a0-9ef6-17d8b9a5ec85';
return [match];
};
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../candidate';
import { Completer } from './completer.abstract';
export class ClassicWaypointsCompleter extends Completer {
complete = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../candidate';
import { Processor } from '../processor.abstract';
export abstract class Completer extends Processor {
execute = (candidates: Candidate[]): Candidate[] => this.complete(candidates);
abstract complete(candidates: Candidate[]): Candidate[];
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../candidate';
import { Completer } from './completer.abstract';
export class JourneyCompleter extends Completer {
complete = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@@ -0,0 +1,25 @@
import { MatchQuery } from 'src/modules/matcher/queries/match.query';
import { Candidate } from '../../candidate';
import { Completer } from './completer.abstract';
export class RouteCompleter extends Completer {
private withPoints: boolean;
private withTime: boolean;
private withDistance: boolean;
constructor(
matchQuery: MatchQuery,
withPoints = false,
withTime = false,
withDistance = false,
) {
super(matchQuery);
this.withPoints = withPoints;
this.withTime = withTime;
this.withDistance = withDistance;
}
complete = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../candidate';
import { Processor } from '../processor.abstract';
export abstract class Filter extends Processor {
execute = (candidates: Candidate[]): Candidate[] => this.filter(candidates);
abstract filter(candidates: Candidate[]): Candidate[];
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../../candidate';
import { Filter } from '../filter.abstract';
export class ClassicGeoFilter extends Filter {
filter = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../../../candidate';
import { Filter } from '../filter.abstract';
export class ClassicTimeFilter extends Filter {
filter = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@@ -0,0 +1,12 @@
import { MatchQuery } from 'src/modules/matcher/queries/match.query';
import { Candidate } from '../candidate';
export abstract class Processor {
private matchQuery: MatchQuery;
constructor(matchQuery: MatchQuery) {
this.matchQuery = matchQuery;
}
abstract execute(candidates: Candidate[]): Candidate[];
}

View File

@@ -0,0 +1,8 @@
import { Candidate } from '../candidate';
import { Selector } from './selector.abstract';
export class ClassicSelector extends Selector {
select = async (): Promise<Candidate[]> => {
return [];
};
}

View File

@@ -0,0 +1,12 @@
import { MatchQuery } from 'src/modules/matcher/queries/match.query';
import { Candidate } from '../candidate';
export abstract class Selector {
private matchQuery: MatchQuery;
constructor(matchQuery: MatchQuery) {
this.matchQuery = matchQuery;
}
abstract select(): Promise<Candidate[]>;
}

View File

@@ -0,0 +1,3 @@
export interface IRequestAd {
uuid?: string;
}

View File

@@ -0,0 +1,13 @@
import { AlgorithmType } from '../types/algorithm.enum';
export interface IRequestAlgorithmSettings {
algorithm: AlgorithmType;
strict: boolean;
remoteness: number;
useProportion: boolean;
proportion: number;
useAzimuth: boolean;
azimuthMargin: number;
maxDetourDistanceRatio: number;
maxDetourDurationRatio: number;
}

View File

@@ -0,0 +1,5 @@
import { Point } from '../../../geography/domain/types/point.type';
export interface IRequestGeography {
waypoints: Point[];
}

View File

@@ -0,0 +1,5 @@
import { IGeorouter } from './georouter.interface';
export interface ICreateGeorouter {
create(type: string, url: string): IGeorouter;
}

View File

@@ -0,0 +1,7 @@
import { NamedRoute } from '../entities/ecosystem/named-route';
import { GeorouterSettings } from '../types/georouter-settings.type';
import { Path } from '../types/path.type';
export interface IGeorouter {
route(paths: Path[], settings: GeorouterSettings): Promise<NamedRoute[]>;
}

View File

@@ -0,0 +1,4 @@
export interface IRequestRequirement {
seatsDriver?: number;
seatsPassenger?: number;
}

View File

@@ -0,0 +1,3 @@
export interface IConvertTime {
toUtcDate(date: Date, timezone: string): Date;
}

View File

@@ -0,0 +1,12 @@
import { MarginDurations } from '../types/margin-durations.type';
import { Schedule } from '../types/schedule.type';
export interface IRequestTime {
departure?: string;
fromDate?: string;
toDate?: string;
schedule?: Schedule;
marginDuration?: number;
marginDurations?: MarginDurations;
timezone?: string;
}

View File

@@ -0,0 +1,9 @@
import { Ad } from '../entities/ecosystem/ad';
import { Role } from './role.enum';
import { Step } from './step.enum';
export type Actor = {
ad: Ad;
role: Role;
step: Step;
};

View File

@@ -0,0 +1,3 @@
export enum AlgorithmType {
CLASSIC = 'CLASSIC',
}

View File

@@ -0,0 +1,9 @@
export enum Day {
'sun',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
}

View File

@@ -0,0 +1 @@
export const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];

View File

@@ -0,0 +1,15 @@
import { AlgorithmType } from './algorithm.enum';
export type DefaultAlgorithmSettings = {
ALGORITHM: AlgorithmType;
STRICT: boolean;
REMOTENESS: number;
USE_PROPORTION: boolean;
PROPORTION: number;
USE_AZIMUTH: boolean;
AZIMUTH_MARGIN: number;
MAX_DETOUR_DISTANCE_RATIO: number;
MAX_DETOUR_DURATION_RATIO: number;
GEOROUTER_TYPE: string;
GEOROUTER_URL: string;
};

View File

@@ -0,0 +1,10 @@
import { DefaultAlgorithmSettings } from './default-algorithm-settings.type';
export type IDefaultParams = {
DEFAULT_UUID: string;
MARGIN_DURATION: number;
VALIDITY_DURATION: number;
DEFAULT_TIMEZONE: string;
DEFAULT_SEATS: number;
DEFAULT_ALGORITHM_SETTINGS: DefaultAlgorithmSettings;
};

View File

@@ -0,0 +1,5 @@
export type GeorouterSettings = {
withPoints: boolean;
withTime: boolean;
withDistance: boolean;
};

View File

@@ -0,0 +1,9 @@
export type MarginDurations = {
mon?: number;
tue?: number;
wed?: number;
thu?: number;
fri?: number;
sat?: number;
sun?: number;
};

View File

@@ -0,0 +1,5 @@
export enum Mode {
MATCH = 'MATCH',
PUBLISH = 'PUBLISH',
PUBLISH_AND_MATCH = 'PUBLISH_AND_MATCH',
}

View File

@@ -0,0 +1,6 @@
import { Point } from '../../../geography/domain/types/point.type';
export type Path = {
key: string;
points: Point[];
};

View File

@@ -0,0 +1,4 @@
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}

View File

@@ -0,0 +1,9 @@
export type Schedule = {
mon?: string;
tue?: string;
wed?: string;
thu?: string;
fri?: string;
sat?: string;
sun?: string;
};

View File

@@ -0,0 +1,6 @@
export enum Step {
START = 'start',
INTERMEDIATE = 'intermediate',
NEUTRAL = 'neutral',
FINISH = 'finish',
}

View File

@@ -0,0 +1,9 @@
export type TimeSchedule = {
mon?: Date;
tue?: Date;
wed?: Date;
thu?: Date;
fri?: Date;
sat?: Date;
sun?: Date;
};

View File

@@ -0,0 +1,7 @@
import { Actor } from './actor.type.';
import { Point } from '../../../geography/domain/types/point.type';
export type Waypoint = {
point: Point;
actors: Actor[];
};

View File

@@ -0,0 +1,80 @@
import { Mapper } from '@automapper/core';
import { InjectMapper } from '@automapper/nestjs';
import { QueryHandler } from '@nestjs/cqrs';
import { MatchQuery } from '../../queries/match.query';
import { Match } from '../entities/ecosystem/match';
import { ICollection } from '../../../database/interfaces/collection.interface';
import { Matcher } from '../entities/engine/matcher';
import { MESSAGE_PUBLISHER } from '../../../../app.constants';
import { Inject } from '@nestjs/common';
import { IPublishMessage } from '../../../../interfaces/message-publisher';
@QueryHandler(MatchQuery)
export class MatchUseCase {
constructor(
private readonly matcher: Matcher,
@Inject(MESSAGE_PUBLISHER)
private readonly messagePublisher: IPublishMessage,
@InjectMapper() private readonly mapper: Mapper,
) {}
execute = async (matchQuery: MatchQuery): Promise<ICollection<Match>> => {
try {
const data: Match[] = await this.matcher.match(matchQuery);
this.messagePublisher.publish('matcher.match', 'match !');
return {
data,
total: data.length,
};
} catch (error) {
const err: Error = error;
this.messagePublisher.publish(
'logging.matcher.match.crit',
JSON.stringify({
matchQuery,
error: err.message,
}),
);
throw error;
}
};
}
// const paths = [];
// for (let i = 0; i < 1; i++) {
// paths.push({
// key: 'route' + i,
// points: [
// {
// lat: 48.110899,
// lon: -1.68365,
// },
// {
// lat: 48.131105,
// lon: -1.690067,
// },
// {
// lat: 48.534769,
// lon: -1.894032,
// },
// {
// lat: 48.56516,
// lon: -1.923553,
// },
// {
// lat: 48.622813,
// lon: -1.997177,
// },
// {
// lat: 48.67846,
// lon: -1.8554,
// },
// ],
// });
// }
// const routes = await matchQuery.algorithmSettings.georouter.route(paths, {
// withDistance: false,
// withPoints: true,
// withTime: true,
// });
// routes.map((route) => console.log(route.route.spacetimePoints));

View File

@@ -0,0 +1,33 @@
export class MatcherException implements Error {
name: string;
message: string;
constructor(private _code: number, private _message: string) {
this.name = 'MatcherException';
this.message = _message;
}
get code(): number {
return this._code;
}
}
export enum MatcherExceptionCode {
OK = 0,
CANCELLED = 1,
UNKNOWN = 2,
INVALID_ARGUMENT = 3,
DEADLINE_EXCEEDED = 4,
NOT_FOUND = 5,
ALREADY_EXISTS = 6,
PERMISSION_DENIED = 7,
RESOURCE_EXHAUSTED = 8,
FAILED_PRECONDITION = 9,
ABORTED = 10,
OUT_OF_RANGE = 11,
UNIMPLEMENTED = 12,
INTERNAL = 13,
UNAVAILABLE = 14,
DATA_LOSS = 15,
UNAUTHENTICATED = 16,
}

View File

@@ -0,0 +1,18 @@
import { createMap, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { MatchPresenter } from '../adapters/secondaries/match.presenter';
import { Match } from '../domain/entities/ecosystem/match';
@Injectable()
export class MatchProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper: Mapper) => {
createMap(mapper, Match, MatchPresenter);
};
}
}

View File

@@ -0,0 +1,67 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CqrsModule } from '@nestjs/cqrs';
import { DatabaseModule } from '../database/database.module';
import { MatcherController } from './adapters/primaries/matcher.controller';
import { MatchProfile } from './mappers/match.profile';
import { MatchUseCase } from './domain/usecases/match.usecase';
import { CacheModule } from '@nestjs/cache-manager';
import { RedisClientOptions } from '@liaoliaots/nestjs-redis';
import { redisStore } from 'cache-manager-ioredis-yet';
import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider';
import { GeorouterCreator } from './adapters/secondaries/georouter-creator';
import { HttpModule } from '@nestjs/axios';
import { MatcherGeodesic } from './adapters/secondaries/geodesic';
import { Matcher } from './domain/entities/engine/matcher';
import { AlgorithmFactoryCreator } from './domain/entities/engine/factory/algorithm-factory-creator';
import { TimezoneFinder } from './adapters/secondaries/timezone-finder';
import { GeoTimezoneFinder } from '../geography/adapters/secondaries/geo-timezone-finder';
import { GeographyModule } from '../geography/geography.module';
import { TimeConverter } from './adapters/secondaries/time-converter';
import { MESSAGE_BROKER_PUBLISHER, MESSAGE_PUBLISHER } from 'src/app.constants';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { MessagePublisher } from './adapters/secondaries/message-publisher';
@Module({
imports: [
GeographyModule,
DatabaseModule,
CqrsModule,
HttpModule,
CacheModule.registerAsync<RedisClientOptions>({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
store: await redisStore({
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
password: configService.get<string>('REDIS_PASSWORD'),
ttl: configService.get<number>('CACHE_TTL'),
}),
}),
inject: [ConfigService],
}),
],
controllers: [MatcherController],
providers: [
MatchProfile,
DefaultParamsProvider,
MatchUseCase,
GeorouterCreator,
MatcherGeodesic,
TimezoneFinder,
TimeConverter,
Matcher,
AlgorithmFactoryCreator,
GeoTimezoneFinder,
{
provide: MESSAGE_BROKER_PUBLISHER,
useClass: MessageBrokerPublisher,
},
{
provide: MESSAGE_PUBLISHER,
useClass: MessagePublisher,
},
],
exports: [],
})
export class MatcherModule {}

View File

@@ -0,0 +1,123 @@
import { MatchRequest } from '../domain/dtos/match.request';
import { Geography } from '../domain/entities/ecosystem/geography';
import { Ad } from '../domain/entities/ecosystem/ad';
import { Requirement } from '../domain/entities/ecosystem/requirement';
import { Role } from '../domain/types/role.enum';
import { AlgorithmSettings } from '../domain/entities/ecosystem/algorithm-settings';
import { Time } from '../domain/entities/ecosystem/time';
import { IDefaultParams } from '../domain/types/default-params.type';
import { IGeorouter } from '../domain/interfaces/georouter.interface';
import { ICreateGeorouter } from '../domain/interfaces/georouter-creator.interface';
import { IFindTimezone } from '../../geography/domain/interfaces/timezone-finder.interface';
import { Mode } from '../domain/types/mode.enum';
import { IConvertTime } from '../domain/interfaces/time-converter.interface';
export class MatchQuery {
private readonly matchRequest: MatchRequest;
private readonly defaultParams: IDefaultParams;
private readonly georouterCreator: ICreateGeorouter;
mode: Mode;
ad: Ad;
roles: Role[];
time: Time;
geography: Geography;
exclusions: string[];
requirement: Requirement;
algorithmSettings: AlgorithmSettings;
georouter: IGeorouter;
timezoneFinder: IFindTimezone;
timeConverter: IConvertTime;
constructor(
matchRequest: MatchRequest,
defaultParams: IDefaultParams,
georouterCreator: ICreateGeorouter,
timezoneFinder: IFindTimezone,
timeConverter: IConvertTime,
) {
this.matchRequest = matchRequest;
this.defaultParams = defaultParams;
this.georouterCreator = georouterCreator;
this.timezoneFinder = timezoneFinder;
this.timeConverter = timeConverter;
this.setMode();
this.setAd();
this.setRoles();
this.setGeography();
this.setTime();
this.setRequirement();
this.setAlgorithmSettings();
this.setExclusions();
}
createRoutes = (): void => {
this.geography.createRoutes(this.roles, this.algorithmSettings.georouter);
};
private setMode = (): void => {
this.mode = this.matchRequest.mode ?? Mode.MATCH;
};
private setAd = (): void => {
this.ad = new Ad(
this.matchRequest,
this.defaultParams.DEFAULT_UUID,
this.defaultParams.MARGIN_DURATION,
);
this.ad.init();
};
private setRoles = (): void => {
this.roles = [];
if (this.matchRequest.driver) this.roles.push(Role.DRIVER);
if (this.matchRequest.passenger) this.roles.push(Role.PASSENGER);
if (this.roles.length == 0) this.roles.push(Role.PASSENGER);
};
private setGeography = (): void => {
this.geography = new Geography(
this.matchRequest,
{
timezone: this.defaultParams.DEFAULT_TIMEZONE,
finder: this.timezoneFinder,
},
this.ad,
);
this.geography.init();
if (this.geography.timezones.length > 0)
this.matchRequest.timezone = this.geography.timezones[0];
};
private setTime = (): void => {
this.time = new Time(
this.matchRequest,
this.defaultParams.MARGIN_DURATION,
this.defaultParams.VALIDITY_DURATION,
this.timeConverter,
);
this.time.init();
};
private setRequirement = (): void => {
this.requirement = new Requirement(
this.matchRequest,
this.defaultParams.DEFAULT_SEATS,
);
};
private setAlgorithmSettings = (): void => {
this.algorithmSettings = new AlgorithmSettings(
this.matchRequest,
this.defaultParams.DEFAULT_ALGORITHM_SETTINGS,
this.time.frequency,
this.georouterCreator,
);
};
private setExclusions = (): void => {
this.exclusions = [];
if (this.matchRequest.uuid) this.exclusions.push(this.matchRequest.uuid);
if (this.matchRequest.exclusions)
this.exclusions.push(...this.matchRequest.exclusions);
};
}

View File

@@ -0,0 +1,38 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DefaultParamsProvider } from '../../../../adapters/secondaries/default-params.provider';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
const mockConfigService = {
get: jest.fn().mockImplementationOnce(() => 99),
};
describe('DefaultParamsProvider', () => {
let defaultParamsProvider: DefaultParamsProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
DefaultParamsProvider,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
defaultParamsProvider = module.get<DefaultParamsProvider>(
DefaultParamsProvider,
);
});
it('should be defined', () => {
expect(defaultParamsProvider).toBeDefined();
});
it('should provide default params', async () => {
const params: IDefaultParams = defaultParamsProvider.getParams();
expect(params.DEFAULT_UUID).toBe(99);
});
});

View File

@@ -0,0 +1,38 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic';
import { Geodesic } from '../../../../../geography/adapters/secondaries/geodesic';
const mockGeodesic = {
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
};
describe('Matcher geodesic', () => {
let matcherGeodesic: MatcherGeodesic;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MatcherGeodesic,
{
provide: Geodesic,
useValue: mockGeodesic,
},
],
}).compile();
matcherGeodesic = module.get<MatcherGeodesic>(MatcherGeodesic);
});
it('should be defined', () => {
expect(matcherGeodesic).toBeDefined();
});
it('should get inverse values', () => {
const inv = matcherGeodesic.inverse(0, 0, 1, 1);
expect(Math.round(inv.azimuth)).toBe(45);
expect(Math.round(inv.distance)).toBe(50000);
});
});

View File

@@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GeorouterCreator } from '../../../../adapters/secondaries/georouter-creator';
import { GraphhopperGeorouter } from '../../../../adapters/secondaries/graphhopper-georouter';
import { HttpService } from '@nestjs/axios';
import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic';
const mockHttpService = jest.fn();
const mockMatcherGeodesic = jest.fn();
describe('Georouter creator', () => {
let georouterCreator: GeorouterCreator;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
GeorouterCreator,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: MatcherGeodesic,
useValue: mockMatcherGeodesic,
},
],
}).compile();
georouterCreator = module.get<GeorouterCreator>(GeorouterCreator);
});
it('should be defined', () => {
expect(georouterCreator).toBeDefined();
});
it('should create a graphhopper georouter', () => {
const georouter = georouterCreator.create(
'graphhopper',
'http://localhost',
);
expect(georouter).toBeInstanceOf(GraphhopperGeorouter);
});
it('should throw an exception if georouter type is unknown', () => {
expect(() =>
georouterCreator.create('unknown', 'http://localhost'),
).toThrow();
});
});

View File

@@ -0,0 +1,456 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { GeorouterCreator } from '../../../../adapters/secondaries/georouter-creator';
import { IGeorouter } from '../../../../domain/interfaces/georouter.interface';
import { of } from 'rxjs';
import { AxiosError } from 'axios';
import { MatcherGeodesic } from '../../../../adapters/secondaries/geodesic';
const mockHttpService = {
get: jest
.fn()
.mockImplementationOnce(() => {
throw new AxiosError('Axios error !');
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 5, 180000],
[5, 6, 180000],
[6, 7, 180000],
[7, 9, 360000],
[9, 10, 180000],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
instructions: [
{
distance: 25000,
sign: 0,
interval: [0, 5],
text: 'Some instructions',
time: 900000,
},
{
distance: 0,
sign: 5,
interval: [5, 5],
text: 'Waypoint 1',
time: 0,
},
{
distance: 25000,
sign: 2,
interval: [5, 10],
text: 'Some instructions',
time: 900000,
},
{
distance: 0.0,
sign: 4,
interval: [10, 10],
text: 'Arrive at destination',
time: 0,
},
],
},
],
},
});
}),
};
const mockMatcherGeodesic = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
};
describe('Graphhopper Georouter', () => {
let georouterCreator: GeorouterCreator;
let graphhopperGeorouter: IGeorouter;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
GeorouterCreator,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: MatcherGeodesic,
useValue: mockMatcherGeodesic,
},
],
}).compile();
georouterCreator = module.get<GeorouterCreator>(GeorouterCreator);
graphhopperGeorouter = georouterCreator.create(
'graphhopper',
'http://localhost',
);
});
it('should be defined', () => {
expect(graphhopperGeorouter).toBeDefined();
});
describe('route function', () => {
it('should fail on axios error', async () => {
await expect(
graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 1,
lon: 1,
},
],
},
],
{
withDistance: false,
withPoints: false,
withTime: false,
},
),
).rejects.toBeInstanceOf(Error);
});
it('should create one route with all settings to false', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: false,
withTime: false,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.distance).toBe(50000);
});
it('should create one route with points', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: false,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.distance).toBe(50000);
expect(routes[0].route.duration).toBe(1800);
expect(routes[0].route.fwdAzimuth).toBe(45);
expect(routes[0].route.backAzimuth).toBe(225);
expect(routes[0].route.points.length).toBe(11);
});
it('should create one route with points and time', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(2);
expect(routes[0].route.spacetimePoints[1].duration).toBe(1800);
expect(routes[0].route.spacetimePoints[1].distance).toBeUndefined();
});
it('should create one route with points and missed waypoints extrapolations', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 5,
lon: 5,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(3);
expect(routes[0].route.distance).toBe(50000);
expect(routes[0].route.duration).toBe(1800);
expect(routes[0].route.fwdAzimuth).toBe(45);
expect(routes[0].route.backAzimuth).toBe(225);
expect(routes[0].route.points.length).toBe(9);
});
it('should create one route with points, time and distance', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: true,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(3);
expect(routes[0].route.spacetimePoints[1].duration).toBe(990);
expect(routes[0].route.spacetimePoints[1].distance).toBe(25000);
});
});
});

View File

@@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MessagePublisher } from '../../../../adapters/secondaries/message-publisher';
import { MESSAGE_BROKER_PUBLISHER } from '../../../../../../app.constants';
const mockMessageBrokerPublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Messager', () => {
let messagePublisher: MessagePublisher;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
MessagePublisher,
{
provide: MESSAGE_BROKER_PUBLISHER,
useValue: mockMessageBrokerPublisher,
},
],
}).compile();
messagePublisher = module.get<MessagePublisher>(MessagePublisher);
});
it('should be defined', () => {
expect(messagePublisher).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockMessageBrokerPublisher, 'publish');
messagePublisher.publish('ad.info', 'my-test');
expect(mockMessageBrokerPublisher.publish).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TimezoneFinder } from '../../../../adapters/secondaries/timezone-finder';
import { GeoTimezoneFinder } from '../../../../../geography/adapters/secondaries/geo-timezone-finder';
const mockGeoTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
describe('Timezone Finder', () => {
let timezoneFinder: TimezoneFinder;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
TimezoneFinder,
{
provide: GeoTimezoneFinder,
useValue: mockGeoTimezoneFinder,
},
],
}).compile();
timezoneFinder = module.get<TimezoneFinder>(TimezoneFinder);
});
it('should be defined', () => {
expect(timezoneFinder).toBeDefined();
});
it('should get timezone for Nancy(France) as Europe/Paris', () => {
const timezones = timezoneFinder.timezones(6.179373, 48.687913);
expect(timezones.length).toBe(1);
expect(timezones[0]).toBe('Europe/Paris');
});
});

View File

@@ -0,0 +1,315 @@
import { Ad } from '../../../../domain/entities/ecosystem/ad';
import {
Geography,
RouteKey,
} from '../../../../domain/entities/ecosystem/geography';
import { Role } from '../../../../domain/types/role.enum';
import { NamedRoute } from '../../../../domain/entities/ecosystem/named-route';
import { MatcherRoute } from '../../../../domain/entities/ecosystem/matcher-route';
import { IGeodesic } from '../../../../../geography/domain/interfaces/geodesic.interface';
import { PointType } from '../../../../../geography/domain/types/point-type.enum';
const ad: Ad = new Ad(
{
uuid: '774aaab2-77df-4c6c-b70d-7b9e972e5bbc',
},
'00000000-0000-0000-0000-000000000000',
900,
);
const mockGeodesic: IGeodesic = {
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
};
const mockGeorouter = {
route: jest
.fn()
.mockImplementationOnce(() => {
return [
<NamedRoute>{
key: RouteKey.COMMON,
route: new MatcherRoute(mockGeodesic),
},
];
})
.mockImplementationOnce(() => {
return [
<NamedRoute>{
key: RouteKey.DRIVER,
route: new MatcherRoute(mockGeodesic),
},
<NamedRoute>{
key: RouteKey.PASSENGER,
route: new MatcherRoute(mockGeodesic),
},
];
})
.mockImplementationOnce(() => {
return [
<NamedRoute>{
key: RouteKey.DRIVER,
route: new MatcherRoute(mockGeodesic),
},
];
})
.mockImplementationOnce(() => {
return [
<NamedRoute>{
key: RouteKey.PASSENGER,
route: new MatcherRoute(mockGeodesic),
},
];
}),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
describe('Geography entity', () => {
it('should be defined', () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
expect(geography).toBeDefined();
});
describe('init', () => {
it('should initialize a geography request with point types', () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
type: PointType.LOCALITY,
},
{
lat: 50.630992,
lon: 3.045432,
type: PointType.LOCALITY,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
geography.init();
expect(geography.originType).toBe(PointType.LOCALITY);
expect(geography.destinationType).toBe(PointType.LOCALITY);
});
it('should throw an exception if waypoints are empty', () => {
const geography = new Geography(
{
waypoints: [],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
expect(() => geography.init()).toThrow();
});
it('should throw an exception if only one waypoint is provided', () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
expect(() => geography.init()).toThrow();
});
it('should throw an exception if a waypoint has invalid longitude', () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 201.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
expect(() => geography.init()).toThrow();
});
it('should throw an exception if a waypoint has invalid latitude', () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 250.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
expect(() => geography.init()).toThrow();
});
});
describe('create route', () => {
it('should create routes as driver and passenger', async () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
geography.init();
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
);
expect(geography.driverRoute.waypoints.length).toBe(2);
expect(geography.passengerRoute.waypoints.length).toBe(2);
});
it('should create routes as driver and passenger with 3 waypoints', async () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 49.781215,
lon: 2.198475,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
geography.init();
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
);
expect(geography.driverRoute.waypoints.length).toBe(3);
expect(geography.passengerRoute.waypoints.length).toBe(2);
});
it('should create routes as driver', async () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
geography.init();
await geography.createRoutes([Role.DRIVER], mockGeorouter);
expect(geography.driverRoute.waypoints.length).toBe(2);
expect(geography.passengerRoute).toBeUndefined();
});
it('should create routes as passenger', async () => {
const geography = new Geography(
{
waypoints: [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
],
},
{
timezone: 'Europe/Paris',
finder: mockTimezoneFinder,
},
ad,
);
geography.init();
await geography.createRoutes([Role.PASSENGER], mockGeorouter);
expect(geography.passengerRoute.waypoints.length).toBe(2);
expect(geography.driverRoute).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,65 @@
import { MatcherRoute } from '../../../../domain/entities/ecosystem/matcher-route';
import { SpacetimePoint } from '../../../../domain/entities/ecosystem/spacetime-point';
import { Waypoint } from '../../../../domain/entities/ecosystem/waypoint';
const mockGeodesic = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
inverse: jest.fn().mockImplementation((lon1, lat1, lon2, lat2) => {
return lon1 == 0
? {
azimuth: 45,
distance: 50000,
}
: {
azimuth: -45,
distance: 60000,
};
}),
};
describe('Matcher route entity', () => {
it('should be defined', () => {
const route = new MatcherRoute(mockGeodesic);
expect(route).toBeDefined();
});
it('should set waypoints and geodesic values for a route', () => {
const route = new MatcherRoute(mockGeodesic);
const waypoint1: Waypoint = new Waypoint({
lon: 0,
lat: 0,
});
const waypoint2: Waypoint = new Waypoint({
lon: 10,
lat: 10,
});
route.setWaypoints([waypoint1, waypoint2]);
expect(route.waypoints.length).toBe(2);
expect(route.fwdAzimuth).toBe(45);
expect(route.backAzimuth).toBe(225);
expect(route.distanceAzimuth).toBe(50000);
});
it('should set points and geodesic values for a route', () => {
const route = new MatcherRoute(mockGeodesic);
route.setPoints([
{
lon: 10,
lat: 10,
},
{
lon: 20,
lat: 20,
},
]);
expect(route.points.length).toBe(2);
expect(route.fwdAzimuth).toBe(315);
expect(route.backAzimuth).toBe(135);
expect(route.distanceAzimuth).toBe(60000);
});
it('should set spacetimePoints for a route', () => {
const route = new MatcherRoute(mockGeodesic);
const spacetimePoint1 = new SpacetimePoint({ lon: 0, lat: 0 }, 0, 0);
const spacetimePoint2 = new SpacetimePoint({ lon: 10, lat: 10 }, 500, 5000);
route.setSpacetimePoints([spacetimePoint1, spacetimePoint2]);
expect(route.spacetimePoints.length).toBe(2);
});
});

View File

@@ -0,0 +1,40 @@
import { Ad } from '../../../../domain/entities/ecosystem/ad';
const DEFAULT_UUID = '00000000-0000-0000-0000-000000000000';
const MARGIN_DURATION = 900;
describe('Ad entity', () => {
it('should be defined', () => {
const ad = new Ad(
{
uuid: '774aaab2-77df-4c6c-b70d-7b9e972e5bbc',
},
DEFAULT_UUID,
MARGIN_DURATION,
);
expect(ad).toBeDefined();
});
describe('init', () => {
it('should initialize an ad with a uuid', () => {
const ad = new Ad(
{
uuid: '774aaab2-77df-4c6c-b70d-7b9e972e5bbc',
},
DEFAULT_UUID,
MARGIN_DURATION,
);
ad.init();
expect(ad.uuid).toBe('774aaab2-77df-4c6c-b70d-7b9e972e5bbc');
expect(ad.marginDurations[0]).toBe(900);
expect(ad.marginDurations[6]).toBe(900);
});
it('should initialize an ad without a uuid', () => {
const ad = new Ad({}, DEFAULT_UUID, MARGIN_DURATION);
ad.init();
expect(ad.uuid).toBe('00000000-0000-0000-0000-000000000000');
expect(ad.marginDurations[0]).toBe(900);
expect(ad.marginDurations[6]).toBe(900);
});
});
});

View File

@@ -0,0 +1,208 @@
import { Time } from '../../../../domain/entities/ecosystem/time';
const MARGIN_DURATION = 900;
const VALIDITY_DURATION = 365;
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
describe('Time entity', () => {
it('should be defined', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(time).toBeDefined();
});
describe('init', () => {
it('should initialize a punctual time request', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
time.init();
expect(time.fromDate.getFullYear()).toBe(
new Date('2023-04-01 12:24:00').getFullYear(),
);
});
it('should initialize a punctual time request with specific single margin duration', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
marginDuration: 300,
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
time.init();
expect(time.marginDurations['tue']).toBe(300);
});
it('should initialize a punctual time request with specific margin durations', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
marginDurations: {
sat: 350,
},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
time.init();
expect(time.marginDurations['tue']).toBe(900);
expect(time.marginDurations['sat']).toBe(350);
});
it('should initialize a punctual time request with specific single margin duration and margin durations', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
marginDuration: 500,
marginDurations: {
sat: 350,
},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
time.init();
expect(time.marginDurations['tue']).toBe(500);
expect(time.marginDurations['sat']).toBe(350);
});
it('should initialize a recurrent time request', () => {
const time = new Time(
{
fromDate: '2023-04-01',
schedule: {
mon: '12:00',
},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
time.init();
expect(time.fromDate.getFullYear()).toBe(
new Date('2023-04-01').getFullYear(),
);
});
it('should throw an exception if no date is provided', () => {
const time = new Time(
{},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if punctual date is invalid', () => {
const time = new Time(
{
departure: '2023-15-01 12:24:00',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if recurrent fromDate is invalid', () => {
const time = new Time(
{
fromDate: '2023-15-01',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if recurrent toDate is invalid', () => {
const time = new Time(
{
fromDate: '2023-04-01',
toDate: '2023-13-01',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if recurrent toDate is before fromDate', () => {
const time = new Time(
{
fromDate: '2023-04-01',
toDate: '2023-03-01',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if schedule is missing', () => {
const time = new Time(
{
fromDate: '2023-04-01',
toDate: '2024-03-31',
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if schedule is empty', () => {
const time = new Time(
{
fromDate: '2023-04-01',
toDate: '2024-03-31',
schedule: {},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
it('should throw an exception if schedule is invalid', () => {
const time = new Time(
{
fromDate: '2023-04-01',
toDate: '2024-03-31',
schedule: {
mon: '32:78',
},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
});
it('should throw an exception if margin durations is provided but empty', () => {
const time = new Time(
{
departure: '2023-04-01 12:24:00',
marginDurations: {},
},
MARGIN_DURATION,
VALIDITY_DURATION,
mockTimeConverter,
);
expect(() => time.init()).toThrow();
});
});

View File

@@ -0,0 +1,71 @@
import { AlgorithmFactoryCreator } from '../../../../domain/entities/engine/factory/algorithm-factory-creator';
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { ClassicAlgorithmFactory } from '../../../../domain/entities/engine/factory/classic';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('AlgorithmFactoryCreator', () => {
it('should be defined', () => {
expect(new AlgorithmFactoryCreator()).toBeDefined();
});
it('should create a classic algorithm factory', () => {
expect(new AlgorithmFactoryCreator().create(matchQuery)).toBeInstanceOf(
ClassicAlgorithmFactory,
);
});
});

View File

@@ -0,0 +1,88 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { AlgorithmFactory } from '../../../../domain/entities/engine/factory/algorithm-factory.abstract';
import { Processor } from '../../../../domain/entities/engine/processor/processor.abstract';
import { Selector } from '../../../../domain/entities/engine/selector/selector.abstract';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
class FakeSelector extends Selector {
select = (): Promise<Candidate[]> => {
return Promise.resolve([new Candidate()]);
};
}
class FakeProcessor extends Processor {
execute = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}
class FakeAlgorithmFactory extends AlgorithmFactory {
createSelector = (): Selector => {
return new FakeSelector(matchQuery);
};
createProcessors = (): Processor[] => {
return [new FakeProcessor(matchQuery)];
};
}
describe('AlgorithmFactory', () => {
it('should create an extended class', () => {
expect(new FakeAlgorithmFactory(matchQuery)).toBeDefined();
});
});

View File

@@ -0,0 +1,79 @@
import { ClassicSelector } from '../../../../domain/entities/engine/selector/classic.selector';
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { ClassicAlgorithmFactory } from '../../../../domain/entities/engine/factory/classic';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('ClassicAlgorithmFactory', () => {
it('should be defined', () => {
expect(new ClassicAlgorithmFactory(matchQuery)).toBeDefined();
});
it('should create a classic selector', () => {
const classicAlgorithmFactory: ClassicAlgorithmFactory =
new ClassicAlgorithmFactory(matchQuery);
expect(classicAlgorithmFactory.createSelector()).toBeInstanceOf(
ClassicSelector,
);
});
it('should create processors', () => {
const classicAlgorithmFactory: ClassicAlgorithmFactory =
new ClassicAlgorithmFactory(matchQuery);
expect(classicAlgorithmFactory.createProcessors().length).toBe(6);
});
});

View File

@@ -0,0 +1,73 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { ClassicGeoFilter } from '../../../../domain/entities/engine/processor/filter/geofilter/classic.filter.processor';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('ClassicGeoFilter', () => {
it('should be defined', () => {
expect(new ClassicGeoFilter(matchQuery)).toBeDefined();
});
it('should filter candidates', () => {
const candidates = [new Candidate(), new Candidate()];
const classicWaypointCompleter: ClassicGeoFilter = new ClassicGeoFilter(
matchQuery,
);
expect(classicWaypointCompleter.filter(candidates).length).toBe(2);
});
});

View File

@@ -0,0 +1,73 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { ClassicTimeFilter } from '../../../../domain/entities/engine/processor/filter/timefilter/classic.filter.processor';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('ClassicTimeFilter', () => {
it('should be defined', () => {
expect(new ClassicTimeFilter(matchQuery)).toBeDefined();
});
it('should filter candidates', () => {
const candidates = [new Candidate(), new Candidate()];
const classicWaypointCompleter: ClassicTimeFilter = new ClassicTimeFilter(
matchQuery,
);
expect(classicWaypointCompleter.filter(candidates).length).toBe(2);
});
});

View File

@@ -0,0 +1,72 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { ClassicWaypointsCompleter } from '../../../../domain/entities/engine/processor/completer/classic-waypoint.completer.processor';
import { Candidate } from '../../../../domain/entities/engine/candidate';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('ClassicWaypointCompleter', () => {
it('should be defined', () => {
expect(new ClassicWaypointsCompleter(matchQuery)).toBeDefined();
});
it('should complete candidates', () => {
const candidates = [new Candidate(), new Candidate()];
const classicWaypointCompleter: ClassicWaypointsCompleter =
new ClassicWaypointsCompleter(matchQuery);
expect(classicWaypointCompleter.complete(candidates).length).toBe(2);
});
});

View File

@@ -0,0 +1,71 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { ClassicSelector } from '../../../../domain/entities/engine/selector/classic.selector';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('ClassicSelector', () => {
it('should be defined', () => {
expect(new ClassicSelector(matchQuery)).toBeDefined();
});
it('should select candidates', async () => {
const classicSelector: ClassicSelector = new ClassicSelector(matchQuery);
const candidates: Candidate[] = await classicSelector.select();
expect(candidates.length).toBe(0);
});
});

View File

@@ -0,0 +1,78 @@
import { Completer } from '../../../../domain/entities/engine/processor/completer/completer.abstract';
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
class FakeCompleter extends Completer {
complete = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}
describe('Completer', () => {
it('should create an extended class', () => {
expect(new FakeCompleter(matchQuery)).toBeDefined();
});
it('should call complete method', () => {
const fakeCompleter: Completer = new FakeCompleter(matchQuery);
const completerSpy = jest.spyOn(fakeCompleter, 'complete');
fakeCompleter.execute([new Candidate()]);
expect(completerSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,78 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Filter } from '../../../../domain/entities/engine/processor/filter/filter.abstract';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
class FakeFilter extends Filter {
filter = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}
describe('Filter', () => {
it('should create an extended class', () => {
expect(new FakeFilter(matchQuery)).toBeDefined();
});
it('should call complete method', () => {
const fakeFilter: Filter = new FakeFilter(matchQuery);
const filterSpy = jest.spyOn(fakeFilter, 'filter');
fakeFilter.execute([new Candidate()]);
expect(filterSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { JourneyCompleter } from '../../../../domain/entities/engine/processor/completer/journey.completer.processor';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('JourneyCompleter', () => {
it('should be defined', () => {
expect(new JourneyCompleter(matchQuery)).toBeDefined();
});
it('should complete candidates', () => {
const candidates = [new Candidate(), new Candidate()];
const journeyCompleter: JourneyCompleter = new JourneyCompleter(matchQuery);
expect(journeyCompleter.complete(candidates).length).toBe(2);
});
});

View File

@@ -0,0 +1,83 @@
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { MatchQuery } from '../../../../queries/match.query';
import { Matcher } from '../../../../domain/entities/engine/matcher';
const mockAlgorithmFactoryCreator = {
create: jest.fn().mockReturnValue({
createSelector: jest.fn().mockReturnValue({
select: jest.fn(),
}),
createProcessors: jest.fn().mockReturnValue([
{
execute: jest.fn(),
},
]),
}),
};
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('Matcher', () => {
it('should be defined', () => {
expect(new Matcher(mockAlgorithmFactoryCreator)).toBeDefined();
});
it('should return matches', async () => {
const matcher = new Matcher(mockAlgorithmFactoryCreator);
const matches = await matcher.match(matchQuery);
expect(matches.length).toBe(1);
});
});

View File

@@ -0,0 +1,71 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { Processor } from '../../../../domain/entities/engine/processor/processor.abstract';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn().mockImplementation(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
class FakeProcessor extends Processor {
execute = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}
describe('Processor', () => {
it('should create an extended class', () => {
expect(new FakeProcessor(matchQuery)).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { RouteCompleter } from '../../../../domain/entities/engine/processor/completer/route.completer.processor';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
describe('RouteCompleter', () => {
it('should be defined', () => {
expect(new RouteCompleter(matchQuery)).toBeDefined();
});
it('should complete candidates', () => {
const candidates = [new Candidate(), new Candidate()];
const routeCompleter: RouteCompleter = new RouteCompleter(matchQuery);
expect(routeCompleter.complete(candidates).length).toBe(2);
});
});

View File

@@ -0,0 +1,71 @@
import { MatchRequest } from '../../../../domain/dtos/match.request';
import { Candidate } from '../../../../domain/entities/engine/candidate';
import { AlgorithmType } from '../../../../domain/types/algorithm.enum';
import { IDefaultParams } from '../../../../domain/types/default-params.type';
import { MatchQuery } from '../../../../queries/match.query';
import { Selector } from '../../../../domain/entities/engine/selector/selector.abstract';
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
class FakeSelector extends Selector {
select = (): Promise<Candidate[]> => {
return Promise.resolve([new Candidate()]);
};
}
describe('Selector', () => {
it('should create an extended class', () => {
expect(new FakeSelector(matchQuery)).toBeDefined();
});
});

View File

@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MatchUseCase } from '../../../domain/usecases/match.usecase';
import { MatchRequest } from '../../../domain/dtos/match.request';
import { MatchQuery } from '../../../queries/match.query';
import { AutomapperModule } from '@automapper/nestjs';
import { classes } from '@automapper/classes';
import { IDefaultParams } from '../../../domain/types/default-params.type';
import { AlgorithmType } from '../../../domain/types/algorithm.enum';
import { Matcher } from '../../../domain/entities/engine/matcher';
import { Match } from '../../../domain/entities/ecosystem/match';
import {
MatcherException,
MatcherExceptionCode,
} from '../../../exceptions/matcher.exception';
import { MESSAGE_PUBLISHER } from '../../../../../app.constants';
const mockMatcher = {
match: jest
.fn()
.mockImplementationOnce(() => [new Match(), new Match(), new Match()])
.mockImplementationOnce(() => {
throw new MatcherException(
MatcherExceptionCode.INTERNAL,
'Something terrible happened !',
);
}),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.waypoints = [
{
lon: 1.093912,
lat: 49.440041,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.departure = '2023-04-01 12:23:00';
describe('MatchUseCase', () => {
let matchUseCase: MatchUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: Matcher,
useValue: mockMatcher,
},
MatchUseCase,
],
}).compile();
matchUseCase = module.get<MatchUseCase>(MatchUseCase);
});
it('should be defined', () => {
expect(matchUseCase).toBeDefined();
});
describe('execute', () => {
it('should return matches', async () => {
const matches = await matchUseCase.execute(
new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
),
);
expect(matches.total).toBe(3);
});
it('should throw an exception when error occurs', async () => {
await expect(
matchUseCase.execute(
new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
),
),
).rejects.toBeInstanceOf(MatcherException);
});
});
});

View File

@@ -0,0 +1,262 @@
import { MatchRequest } from '../../../domain/dtos/match.request';
import { Role } from '../../../domain/types/role.enum';
import { IDefaultParams } from '../../../domain/types/default-params.type';
import { MatchQuery } from '../../../queries/match.query';
import { AlgorithmType } from '../../../domain/types/algorithm.enum';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
import { Mode } from '../../../domain/types/mode.enum';
const defaultParams: IDefaultParams = {
DEFAULT_UUID: '00000000-0000-0000-0000-000000000000',
MARGIN_DURATION: 900,
VALIDITY_DURATION: 365,
DEFAULT_TIMEZONE: 'Europe/Paris',
DEFAULT_SEATS: 3,
DEFAULT_ALGORITHM_SETTINGS: {
ALGORITHM: AlgorithmType.CLASSIC,
STRICT: false,
REMOTENESS: 15000,
USE_PROPORTION: true,
PROPORTION: 0.3,
USE_AZIMUTH: true,
AZIMUTH_MARGIN: 10,
MAX_DETOUR_DISTANCE_RATIO: 0.3,
MAX_DETOUR_DURATION_RATIO: 0.3,
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'http://localhost',
},
};
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockTimeConverter = {
toUtcDate: jest.fn(),
};
describe('Match query', () => {
it('should be defined', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery).toBeDefined();
expect(matchQuery.mode).toBe(Mode.MATCH);
});
it('should create a query with publish and match mode', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.mode = Mode.PUBLISH_AND_MATCH;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.mode).toBe(Mode.PUBLISH_AND_MATCH);
});
it('should create a query with excluded identifiers', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.uuid = '445aa6e4-99e4-4899-9456-3be8c3ada368';
matchRequest.exclusions = [
'eacf5e53-e63c-4551-860c-73f95b8a8895',
'a4098161-13a9-4e55-8999-de134fbf89c4',
'b18f7ffa-20b9-4a1a-89bc-e238ea8289f3',
];
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.exclusions.length).toBe(4);
});
it('should create a query with driver role only', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.driver = true;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.roles).toEqual([Role.DRIVER]);
});
it('should create a query with passenger role only', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.passenger = true;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.roles).toEqual([Role.PASSENGER]);
});
it('should create a query with driver and passenger roles', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.passenger = true;
matchRequest.driver = true;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.roles.length).toBe(2);
expect(matchQuery.roles).toContain(Role.PASSENGER);
expect(matchQuery.roles).toContain(Role.DRIVER);
});
it('should create a query with number of seats modified', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.seatsDriver = 1;
matchRequest.seatsPassenger = 2;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.requirement.seatsDriver).toBe(1);
expect(matchQuery.requirement.seatsPassenger).toBe(2);
});
it('should create a query with modified algorithm settings', () => {
const matchRequest: MatchRequest = new MatchRequest();
matchRequest.departure = '2023-04-01 12:00';
matchRequest.waypoints = [
{
lat: 49.440041,
lon: 1.093912,
},
{
lat: 50.630992,
lon: 3.045432,
},
];
matchRequest.algorithm = AlgorithmType.CLASSIC;
matchRequest.strict = true;
matchRequest.useProportion = true;
matchRequest.proportion = 0.45;
matchRequest.useAzimuth = true;
matchRequest.azimuthMargin = 15;
matchRequest.remoteness = 20000;
matchRequest.maxDetourDistanceRatio = 0.41;
matchRequest.maxDetourDurationRatio = 0.42;
const matchQuery: MatchQuery = new MatchQuery(
matchRequest,
defaultParams,
mockGeorouterCreator,
mockTimezoneFinder,
mockTimeConverter,
);
expect(matchQuery.algorithmSettings.algorithmType).toBe(
AlgorithmType.CLASSIC,
);
expect(matchQuery.algorithmSettings.restrict).toBe(Frequency.PUNCTUAL);
expect(matchQuery.algorithmSettings.useProportion).toBeTruthy();
expect(matchQuery.algorithmSettings.proportion).toBe(0.45);
expect(matchQuery.algorithmSettings.useAzimuth).toBeTruthy();
expect(matchQuery.algorithmSettings.azimuthMargin).toBe(15);
expect(matchQuery.algorithmSettings.remoteness).toBe(20000);
expect(matchQuery.algorithmSettings.maxDetourDistanceRatio).toBe(0.41);
expect(matchQuery.algorithmSettings.maxDetourDurationRatio).toBe(0.42);
});
});