diff --git a/AGENTS.md b/CLAUDE.md similarity index 90% rename from AGENTS.md rename to CLAUDE.md index cfeabf8..ff2a556 100644 --- a/AGENTS.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# AGENTS.md +# CLAUDE.md -This file provides guidance to coding agents (e.g., Codex CLI, Claude Code, and other AI coding assistants) when working with code in this repository. +This file provides guidance to Claude Code when working with code in this repository. ## Development Commands @@ -19,17 +19,8 @@ npm run generate npm run build:dev ``` -### Testing -```bash -# Run all tests via npm -npm test - -# Run specific test file -npm test -- --testPathPattern= - -# Run tests with coverage -npm test -- --coverage -``` +Only use Docker Compose for running tests, and only use `docker compose run test` +to run tests; don't try to run tests for individual files. ## Architecture Overview diff --git a/src/entities/InterchangeSystem.ts b/src/entities/InterchangeSystem.ts index 03428a2..985a819 100644 --- a/src/entities/InterchangeSystem.ts +++ b/src/entities/InterchangeSystem.ts @@ -14,6 +14,15 @@ import { ParkingRepositoryLoaderBuilderArguments } from "../loaders/parking/buildParkingRepositoryLoaderIfExists"; import { RedisParkingRepository } from "../repositories/parking/RedisParkingRepository"; +import { RedisShuttleRepository } from "../repositories/shuttle/RedisShuttleRepository"; +import { ShuttleGetterRepository } from "../repositories/shuttle/ShuttleGetterRepository"; +import { InMemoryExternalSourceETARepository } from "../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; +import { ETAGetterRepository } from "../repositories/shuttle/eta/ETAGetterRepository"; +import { RedisSelfUpdatingETARepository } from "../repositories/shuttle/eta/RedisSelfUpdatingETARepository"; +import { RedisExternalSourceETARepository } from "../repositories/shuttle/eta/RedisExternalSourceETARepository"; +import { InMemorySelfUpdatingETARepository } from "../repositories/shuttle/eta/InMemorySelfUpdatingETARepository"; +import { BaseRedisETARepository } from "../repositories/shuttle/eta/BaseRedisETARepository"; +import { BaseInMemoryETARepository } from "../repositories/shuttle/eta/BaseInMemoryETARepository"; export interface InterchangeSystemBuilderArguments { name: string; @@ -32,6 +41,12 @@ export interface InterchangeSystemBuilderArguments { * ID for the parking repository ID in the codebase. */ parkingSystemId?: string; + + /** + * Controls whether to self-calculate ETAs or use the external + * shuttle provider for them. + */ + useSelfUpdatingEtas: boolean } export class InterchangeSystem { @@ -40,6 +55,7 @@ export class InterchangeSystem { public id: string, public shuttleTimedDataLoader: TimedApiBasedRepositoryLoader, public shuttleRepository: ShuttleGetterSetterRepository, + public etaRepository: ETAGetterRepository, public notificationScheduler: ETANotificationScheduler, public notificationRepository: NotificationRepository, public parkingTimedDataLoader: TimedApiBasedRepositoryLoader | null, @@ -55,28 +71,17 @@ export class InterchangeSystem { static async build( args: InterchangeSystemBuilderArguments, ) { - const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); - const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( - args.passioSystemId, - args.id, - shuttleRepository - ); - const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( - shuttleDataLoader, - ); - await timedShuttleDataLoader.start(); + const { shuttleRepository, timedShuttleDataLoader, etaRepository } = await InterchangeSystem.buildRedisShuttleLoaderAndRepositories(args); + timedShuttleDataLoader.start(); - const notificationRepository = new RedisNotificationRepository(); - await notificationRepository.connect(); - const notificationScheduler = new ETANotificationScheduler( + const { notificationScheduler, notificationRepository } = await InterchangeSystem.buildNotificationSchedulerAndRepository( + etaRepository, shuttleRepository, - notificationRepository, - new AppleNotificationSender(), - args.id, + args ); notificationScheduler.startListeningForUpdates(); - let { parkingRepository, timedParkingLoader } = await this.buildRedisParkingLoaderAndRepository(args.parkingSystemId); + let { parkingRepository, timedParkingLoader } = await InterchangeSystem.buildRedisParkingLoaderAndRepository(args.parkingSystemId); timedParkingLoader?.start(); return new InterchangeSystem( @@ -84,6 +89,7 @@ export class InterchangeSystem { args.id, timedShuttleDataLoader, shuttleRepository, + etaRepository, notificationScheduler, notificationRepository, timedParkingLoader, @@ -91,49 +97,54 @@ export class InterchangeSystem { ); } - /** - * Construct an instance of the class where all composited - * classes are correctly linked, meant for unit tests, and server/app - * integration tests. - * @param args - */ - static buildForTesting( - args: InterchangeSystemBuilderArguments, - ) { - const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); - const shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( - args.passioSystemId, - args.id, - shuttleRepository - ); - // Note that this loader should not be started, - // so the test data doesn't get overwritten - const timedShuttleLoader = new TimedApiBasedRepositoryLoader( + private static async buildRedisShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { + const shuttleRepository = new RedisShuttleRepository(); + await shuttleRepository.connect(); + + let etaRepository: BaseRedisETARepository; + let shuttleDataLoader: ApiBasedShuttleRepositoryLoader; + if (args.useSelfUpdatingEtas) { + etaRepository = new RedisSelfUpdatingETARepository(shuttleRepository); + (etaRepository as RedisSelfUpdatingETARepository).startListeningForUpdates(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + ); + } else { + etaRepository = new RedisExternalSourceETARepository(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + etaRepository as RedisExternalSourceETARepository, + ); + } + await etaRepository.connect(); + + const timedShuttleDataLoader = new TimedApiBasedRepositoryLoader( shuttleDataLoader, ); - const notificationRepository = new InMemoryNotificationRepository(); + + return { shuttleRepository, etaRepository, timedShuttleDataLoader }; + } + + private static async buildNotificationSchedulerAndRepository( + etaRepository: ETAGetterRepository, + shuttleRepository: ShuttleGetterRepository, + args: InterchangeSystemBuilderArguments + ) { + const notificationRepository = new RedisNotificationRepository(); + await notificationRepository.connect(); const notificationScheduler = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, - new AppleNotificationSender(false), - args.id, - ); - notificationScheduler.startListeningForUpdates(); - - let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); - // Timed parking loader is not started here - - return new InterchangeSystem( - args.name, - args.id, - timedShuttleLoader, - shuttleRepository, - notificationScheduler, - notificationRepository, - timedParkingLoader, - parkingRepository, + new AppleNotificationSender(), + args.id ); + return { notificationScheduler, notificationRepository }; } private static async buildRedisParkingLoaderAndRepository(id?: string) { @@ -161,6 +172,57 @@ export class InterchangeSystem { return { parkingRepository, timedParkingLoader }; } + /** + * Construct an instance of the class where all composited + * classes are correctly linked, meant for unit tests, and server/app + * integration tests. + * @param args + */ + static buildForTesting( + args: InterchangeSystemBuilderArguments, + ) { + const { shuttleRepository, timedShuttleLoader, etaRepository } = InterchangeSystem.buildInMemoryShuttleLoaderAndRepositories(args); + // Timed shuttle loader is not started here + + const { notificationScheduler, notificationRepository } = InterchangeSystem.buildInMemoryNotificationSchedulerAndRepository( + etaRepository, + shuttleRepository, + args + ); + notificationScheduler.startListeningForUpdates(); + + let { parkingRepository, timedParkingLoader } = this.buildInMemoryParkingLoaderAndRepository(args.parkingSystemId); + // Timed parking loader is not started here + + return new InterchangeSystem( + args.name, + args.id, + timedShuttleLoader, + shuttleRepository, + etaRepository, + notificationScheduler, + notificationRepository, + timedParkingLoader, + parkingRepository, + ); + } + + private static buildInMemoryNotificationSchedulerAndRepository( + etaRepository: ETAGetterRepository, + shuttleRepository: UnoptimizedInMemoryShuttleRepository, + args: InterchangeSystemBuilderArguments + ) { + const notificationRepository = new InMemoryNotificationRepository(); + const notificationScheduler = new ETANotificationScheduler( + etaRepository, + shuttleRepository, + notificationRepository, + new AppleNotificationSender(false), + args.id + ); + return { notificationScheduler, notificationRepository }; + } + private static buildInMemoryParkingLoaderAndRepository(id?: string) { if (id === undefined) { return { parkingRepository: null, timedParkingLoader: null }; @@ -184,4 +246,37 @@ export class InterchangeSystem { return { parkingRepository, timedParkingLoader }; } + private static buildInMemoryShuttleLoaderAndRepositories(args: InterchangeSystemBuilderArguments) { + const shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); + + let etaRepository: BaseInMemoryETARepository; + let shuttleDataLoader: ApiBasedShuttleRepositoryLoader; + if (args.useSelfUpdatingEtas) { + etaRepository = new InMemorySelfUpdatingETARepository(shuttleRepository); + (etaRepository as InMemorySelfUpdatingETARepository).startListeningForUpdates(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + ); + } else { + etaRepository = new InMemoryExternalSourceETARepository(); + shuttleDataLoader = new ApiBasedShuttleRepositoryLoader( + args.passioSystemId, + args.id, + shuttleRepository, + etaRepository as InMemoryExternalSourceETARepository, + ); + } + + // Note that this loader should not be started, + // so the test data doesn't get overwritten + const timedShuttleLoader = new TimedApiBasedRepositoryLoader( + shuttleDataLoader + ); + + + return { shuttleRepository, etaRepository, timedShuttleLoader }; + } + } diff --git a/src/entities/ShuttleRepositoryEntities.ts b/src/entities/ShuttleRepositoryEntities.ts index 608bda6..e16b1f4 100644 --- a/src/entities/ShuttleRepositoryEntities.ts +++ b/src/entities/ShuttleRepositoryEntities.ts @@ -37,3 +37,19 @@ export interface IOrderedStop extends IEntityWithTimestamp { systemId: string; } +/** + * Checks if a shuttle has arrived at a stop based on coordinate proximity. + * Uses a threshold of 0.001 degrees (~111 meters at the equator). + */ +export function shuttleHasArrivedAtStop( + shuttle: IShuttle, + stop: IStop, + delta = 0.001 +) { + const isWithinLatitudeRange = shuttle.coordinates.latitude > stop.coordinates.latitude - delta + && shuttle.coordinates.latitude < stop.coordinates.latitude + delta; + const isWithinLongitudeRange = shuttle.coordinates.longitude > stop.coordinates.longitude - delta + && shuttle.coordinates.longitude < stop.coordinates.longitude + delta + return isWithinLatitudeRange && isWithinLongitudeRange; +} + diff --git a/src/entities/__tests__/ShuttleRepositoryEntities.test.ts b/src/entities/__tests__/ShuttleRepositoryEntities.test.ts new file mode 100644 index 0000000..084b933 --- /dev/null +++ b/src/entities/__tests__/ShuttleRepositoryEntities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@jest/globals"; +import { shuttleHasArrivedAtStop, IShuttle, IStop } from "../ShuttleRepositoryEntities"; + +describe("shuttleHasArrivedAtStop", () => { + const baseStop: IStop = { + id: "stop1", + name: "Test Stop", + systemId: "263", + coordinates: { + latitude: 33.7963, + longitude: -117.8540, + }, + updatedTime: new Date(), + }; + + const createShuttle = (latitude: number, longitude: number): IShuttle => ({ + id: "shuttle1", + name: "Test Shuttle", + routeId: "route1", + systemId: "263", + coordinates: { latitude, longitude }, + orientationInDegrees: 0, + updatedTime: new Date(), + }); + + it("returns false when shuttle is above latitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude + 0.0011, + baseStop.coordinates.longitude + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is below latitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude - 0.0011, + baseStop.coordinates.longitude + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is to left of longitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude, + baseStop.coordinates.longitude - 0.0011 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns false when shuttle is to right of longitude range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude, + baseStop.coordinates.longitude + 0.0011 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(false); + }); + + it("returns true when shuttle is in the range", () => { + const shuttle = createShuttle( + baseStop.coordinates.latitude + 0.0005, + baseStop.coordinates.longitude - 0.0005 + ); + expect(shuttleHasArrivedAtStop(shuttle, baseStop)).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index bcc32a9..a8fd797 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ const supportedSystems: InterchangeSystemBuilderArguments[] = [ passioSystemId: "263", parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, name: "Chapman University", + useSelfUpdatingEtas: true, } ] diff --git a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts index 7e6f64e..19d4a14 100644 --- a/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts +++ b/src/loaders/shuttle/ApiBasedShuttleRepositoryLoader.ts @@ -4,6 +4,7 @@ import { ShuttleRepositoryLoader } from "./ShuttleRepositoryLoader"; import { ICoordinates, IEntityWithId } from "../../entities/SharedEntities"; import { ApiResponseError } from "../ApiResponseError"; import { SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES } from "../../environment"; +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; /** * Class which can load data into a repository from the @@ -16,7 +17,8 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader constructor( public passioSystemId: string, public systemIdForConstructedData: string, - public repository: ShuttleGetterSetterRepository, + public shuttleRepository: ShuttleGetterSetterRepository, + public etaRepository?: ExternalSourceETARepository, readonly shuttleToRouteCoordinateMaximumDistanceMiles = SHUTTLE_TO_ROUTE_COORDINATE_MAXIMUM_DISTANCE_MILES, ) { } @@ -37,7 +39,6 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader // Because ETA method doesn't support pruning yet, // add a call to the clear method here - await this.repository.clearEtaData(); await this.updateEtaDataForExistingStopsForSystem(); } @@ -57,16 +58,16 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateRouteDataInRepository(routes: IRoute[]) { const routeIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getRoutes(); + return await this.shuttleRepository.getRoutes(); }); await Promise.all(routes.map(async (route) => { - await this.repository.addOrUpdateRoute(route); + await this.shuttleRepository.addOrUpdateRoute(route); routeIdsToPrune.delete(route.id); })); await Promise.all(Array.from(routeIdsToPrune).map(async (routeId) => { - await this.repository.removeRouteIfExists(routeId); + await this.shuttleRepository.removeRouteIfExists(routeId); })); } @@ -122,7 +123,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateStopAndPolylineDataInRepository(json: any) { const stopIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getStops(); + return await this.shuttleRepository.getStops(); }); await this.updateStopDataForSystemAndApiResponse(json, stopIdsToPrune); @@ -130,7 +131,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await this.updatePolylineDataForExistingRoutesAndApiResponse(json); await Promise.all(Array.from(stopIdsToPrune).map(async (stopId) => { - await this.repository.removeStopIfExists(stopId); + await this.shuttleRepository.removeStopIfExists(stopId); })); } @@ -174,16 +175,16 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateShuttleDataInRepository(shuttles: IShuttle[]) { const shuttleIdsToPrune = await this.constructExistingEntityIdSet(async () => { - return await this.repository.getShuttles(); + return await this.shuttleRepository.getShuttles(); }); await Promise.all(shuttles.map(async (shuttle) => { - await this.repository.addOrUpdateShuttle(shuttle); + await this.shuttleRepository.addOrUpdateShuttle(shuttle); shuttleIdsToPrune.delete(shuttle.id); })); await Promise.all(Array.from(shuttleIdsToPrune).map(async (shuttleId) => { - await this.repository.removeShuttleIfExists(shuttleId); + await this.shuttleRepository.removeShuttleIfExists(shuttleId); })); } @@ -239,7 +240,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader } public async updateEtaDataForExistingStopsForSystem() { - const stops = await this.repository.getStops(); + const stops = await this.shuttleRepository.getStops(); await Promise.all(stops.map(async (stop) => { let stopId = stop.id; await this.updateEtaDataForStopId(stopId); @@ -262,7 +263,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader private async updateEtaDataInRepository(etas: IEta[]) { await Promise.all(etas.map(async (eta) => { - await this.repository.addOrUpdateEta(eta); + await this.etaRepository?.addOrUpdateEtaFromExternalSource(eta); })); } @@ -317,7 +318,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader updatedTime: new Date(), }; - await this.repository.addOrUpdateStop(constructedStop); + await this.shuttleRepository.addOrUpdateStop(constructedStop); setOfIdsToPrune.delete(constructedStop.id); })); @@ -339,7 +340,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader const orderedStopDataArray = jsonOrderedStopData[index]; const stopId = orderedStopDataArray[1]; - let constructedOrderedStop = await this.repository.getOrderedStopByRouteAndStopId(routeId, stopId) + let constructedOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(routeId, stopId) if (constructedOrderedStop === null) { constructedOrderedStop = { routeId, @@ -369,7 +370,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader }; } - await this.repository.addOrUpdateOrderedStop(constructedOrderedStop); + await this.shuttleRepository.addOrUpdateOrderedStop(constructedOrderedStop); } })); } @@ -380,7 +381,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader await Promise.all(Object.keys(json.routePoints).map(async (routeId) => { const routePoints = json.routePoints[routeId][0]; - const existingRoute = await this.repository.getRouteById(routeId); + const existingRoute = await this.shuttleRepository.getRouteById(routeId); if (!existingRoute) return; existingRoute.polylineCoordinates = routePoints.map((point: any) => { @@ -390,7 +391,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader }; }); - await this.repository.addOrUpdateRoute(existingRoute); + await this.shuttleRepository.addOrUpdateRoute(existingRoute); })) } } @@ -399,7 +400,7 @@ export class ApiBasedShuttleRepositoryLoader implements ShuttleRepositoryLoader let filteredShuttles: IShuttle[] = []; await Promise.all(shuttles.map(async (shuttle) => { - const route = await this.repository.getRouteById(shuttle.routeId); + const route = await this.shuttleRepository.getRouteById(shuttle.routeId); if (route != null) { let closestDistanceMiles = Number.MAX_VALUE; diff --git a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts index 799b2bd..ff98c40 100644 --- a/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts +++ b/src/loaders/shuttle/__tests__/ApiBasedShuttleRepositoryLoaderTests.test.ts @@ -9,7 +9,7 @@ import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../ import { fetchShuttleDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchShuttleData/fetchShuttleDataSuccessfulResponse"; -import { fetchEtaDataSuccessfulResponse } from "../../../../testHelpers/jsonSnapshots/fetchEtaData/fetchEtaDataSuccessfulResponse"; +import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; import { resetGlobalFetchMockJson, updateGlobalFetchMockJson, @@ -38,7 +38,6 @@ describe("ApiBasedShuttleRepositoryLoader", () => { updateRouteDataForSystem: jest.spyOn(loader, "updateRouteDataForSystem"), updateStopAndPolylineDataForRoutesInSystem: jest.spyOn(loader, "updateStopAndPolylineDataForRoutesInSystem"), updateShuttleDataForSystem: jest.spyOn(loader, "updateShuttleDataForSystemBasedOnProximityToRoutes"), - updateEtaDataForExistingStopsForSystem: jest.spyOn(loader, "updateEtaDataForExistingStopsForSystem"), }; Object.values(spies).forEach((spy: any) => { @@ -60,7 +59,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const routesToPrune = generateMockRoutes(); await Promise.all(routesToPrune.map(async (route) => { route.systemId = systemId; - await loader.repository.addOrUpdateRoute(route); + await loader.shuttleRepository.addOrUpdateRoute(route); })); updateGlobalFetchMockJson(fetchRouteDataSuccessfulResponse); @@ -69,7 +68,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateRouteDataForSystem(); // Assert - const routes = await loader.repository.getRoutes(); + const routes = await loader.shuttleRepository.getRoutes(); expect(routes.length).toEqual(fetchRouteDataSuccessfulResponse.all.length) }); @@ -93,7 +92,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const stopsToPrune = generateMockStops(); await Promise.all(stopsToPrune.map(async (stop) => { stop.systemId = systemId; - await loader.repository.addOrUpdateStop(stop); + await loader.shuttleRepository.addOrUpdateStop(stop); })); updateGlobalFetchMockJson(fetchStopAndPolylineDataSuccessfulResponse); @@ -102,15 +101,15 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateStopAndPolylineDataForRoutesInSystem(); - const stops = await loader.repository.getStops(); + const stops = await loader.shuttleRepository.getStops(); expect(stops.length).toEqual(stopsArray.length); await Promise.all(stops.map(async (stop) => { - const orderedStops = await loader.repository.getOrderedStopsByStopId(stop.id) + const orderedStops = await loader.shuttleRepository.getOrderedStopsByStopId(stop.id) expect(orderedStops.length).toBeGreaterThan(0); })); - const routes = await loader.repository.getRoutes(); + const routes = await loader.shuttleRepository.getRoutes(); routes.forEach((route) => { expect(route.polylineCoordinates.length).toBeGreaterThan(0); }); @@ -152,7 +151,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { async function addMockRoutes(routes: IRoute[]) { await Promise.all(routes.map(async (route) => { - await loader.repository.addOrUpdateRoute(route); + await loader.shuttleRepository.addOrUpdateRoute(route); })); } @@ -162,6 +161,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -177,7 +177,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); expect(shuttles.length).toEqual(busesInResponse.length); }); @@ -187,6 +187,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -202,7 +203,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); expect(shuttles.length).toEqual(0); }); @@ -212,6 +213,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { "263", "1", new UnoptimizedInMemoryShuttleRepository(), + new InMemoryExternalSourceETARepository(), distanceMiles, ); @@ -219,7 +221,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { const shuttlesToPrune = generateMockShuttles(); await Promise.all(shuttlesToPrune.map(async (shuttle) => { shuttle.systemId = systemId; - await loader.repository.addOrUpdateShuttle(shuttle); + await loader.shuttleRepository.addOrUpdateShuttle(shuttle); })); const routes = generateMockRoutesWithPolylineCoordinates(); @@ -237,7 +239,7 @@ describe("ApiBasedShuttleRepositoryLoader", () => { await loader.updateShuttleDataForSystemBasedOnProximityToRoutes(); // Old shuttles should be pruned, only API shuttles should remain - const shuttles = await loader.repository.getShuttles(); + const shuttles = await loader.shuttleRepository.getShuttles(); const busesInResponse = Object.values(modifiedSuccessfulResponse.buses); expect(shuttles.length).toEqual(busesInResponse.length); @@ -257,46 +259,5 @@ describe("ApiBasedShuttleRepositoryLoader", () => { }); }); }); - - describe("updateEtaDataForExistingStopsForSystem", () => { - it("calls updateEtaDataForStopId for every stop in repository", async () => { - const spy = jest.spyOn(loader, "updateEtaDataForStopId"); - - const stops = generateMockStops(); - stops.forEach((stop) => { - stop.systemId = "1"; - }); - - await Promise.all(stops.map(async (stop) => { - await loader.repository.addOrUpdateStop(stop); - })); - - await loader.updateEtaDataForExistingStopsForSystem(); - - expect(spy.mock.calls.length).toEqual(stops.length); - }); - }); - - describe("updateEtaDataForStopId", () => { - const stopId = "177666"; - it("updates ETA data for stop id if response received", async () => { - updateGlobalFetchMockJson(fetchEtaDataSuccessfulResponse); - // @ts-ignore - const etasFromResponse = fetchEtaDataSuccessfulResponse.ETAs[stopId] - - await loader.updateEtaDataForStopId(stopId); - - const etas = await loader.repository.getEtasForStopId(stopId); - expect(etas.length).toEqual(etasFromResponse.length); - }); - - it("throws the correct error if the API response contains no data", async () => { - updateGlobalFetchMockJsonToThrowSyntaxError(); - - await assertAsyncCallbackThrowsApiResponseError(async () => { - await loader.updateEtaDataForStopId("263"); - }); - }); - }); }); diff --git a/src/loaders/shuttle/loadShuttleTestData.ts b/src/loaders/shuttle/loadShuttleTestData.ts deleted file mode 100644 index 4713716..0000000 --- a/src/loaders/shuttle/loadShuttleTestData.ts +++ /dev/null @@ -1,4515 +0,0 @@ -// Mock data -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; -import { ShuttleGetterSetterRepository } from "../../repositories/shuttle/ShuttleGetterSetterRepository"; -import { supportedIntegrationTestSystems } from "../supportedIntegrationTestSystems"; - -const redRoutePolylineCoordinates = [ - { - latitude: 33.793316000, - longitude: -117.852810000 - }, - { - latitude: 33.793315000, - longitude: -117.852996000 - }, - { - latitude: 33.793311000, - longitude: -117.853097000 - }, - { - latitude: 33.793311000, - longitude: -117.853212000 - }, - { - latitude: 33.793311000, - longitude: -117.853367000 - }, - { - latitude: 33.793311000, - longitude: -117.853698000 - }, - { - latitude: 33.793310000, - longitude: -117.854187000 - }, - { - latitude: 33.793322000, - longitude: -117.855284000 - }, - { - latitude: 33.793323000, - longitude: -117.856355000 - }, - { - latitude: 33.793230000, - longitude: -117.856356000 - }, - { - latitude: 33.791598000, - longitude: -117.856366000 - }, - { - latitude: 33.791517000, - longitude: -117.856367000 - }, - { - latitude: 33.791437000, - longitude: -117.856363000 - }, - { - latitude: 33.791312000, - longitude: -117.856358000 - }, - { - latitude: 33.790351000, - longitude: -117.856360000 - }, - { - latitude: 33.789782000, - longitude: -117.856364000 - }, - { - latitude: 33.789701000, - longitude: -117.856365000 - }, - { - latitude: 33.789620000, - longitude: -117.856370000 - }, - { - latitude: 33.789279000, - longitude: -117.856365000 - }, - { - latitude: 33.788321000, - longitude: -117.856366000 - }, - { - latitude: 33.787970000, - longitude: -117.856375000 - }, - { - latitude: 33.787889000, - longitude: -117.856375000 - }, - { - latitude: 33.787889000, - longitude: -117.856470000 - }, - { - latitude: 33.787890000, - longitude: -117.856602000 - }, - { - latitude: 33.787891000, - longitude: -117.856783000 - }, - { - latitude: 33.787891000, - longitude: -117.856888000 - }, - { - latitude: 33.787891000, - longitude: -117.856976000 - }, - { - latitude: 33.787893000, - longitude: -117.856979000 - }, - { - latitude: 33.787933000, - longitude: -117.857043000 - }, - { - latitude: 33.787933000, - longitude: -117.857458000 - }, - { - latitude: 33.787933000, - longitude: -117.857518000 - }, - { - latitude: 33.787933000, - longitude: -117.857827000 - }, - { - latitude: 33.787933000, - longitude: -117.857907000 - }, - { - latitude: 33.787886000, - longitude: -117.857986000 - }, - { - latitude: 33.787887000, - longitude: -117.858081000 - }, - { - latitude: 33.787887000, - longitude: -117.858293000 - }, - { - latitude: 33.787887000, - longitude: -117.858476000 - }, - { - latitude: 33.787887000, - longitude: -117.858556000 - }, - { - latitude: 33.787888000, - longitude: -117.858716000 - }, - { - latitude: 33.787888000, - longitude: -117.858868000 - }, - { - latitude: 33.787888000, - longitude: -117.859286000 - }, - { - latitude: 33.787888000, - longitude: -117.859323000 - }, - { - latitude: 33.787889000, - longitude: -117.859655000 - }, - { - latitude: 33.787888000, - longitude: -117.859990000 - }, - { - latitude: 33.787888000, - longitude: -117.860093000 - }, - { - latitude: 33.787887000, - longitude: -117.860392000 - }, - { - latitude: 33.787887000, - longitude: -117.860435000 - }, - { - latitude: 33.787887000, - longitude: -117.860739000 - }, - { - latitude: 33.787886000, - longitude: -117.861197000 - }, - { - latitude: 33.787885000, - longitude: -117.861519000 - }, - { - latitude: 33.787885000, - longitude: -117.861692000 - }, - { - latitude: 33.787884000, - longitude: -117.861825000 - }, - { - latitude: 33.787885000, - longitude: -117.861974000 - }, - { - latitude: 33.787884000, - longitude: -117.862261000 - }, - { - latitude: 33.787884000, - longitude: -117.862405000 - }, - { - latitude: 33.787884000, - longitude: -117.862684000 - }, - { - latitude: 33.787884000, - longitude: -117.862841000 - }, - { - latitude: 33.787884000, - longitude: -117.862915000 - }, - { - latitude: 33.787883000, - longitude: -117.863160000 - }, - { - latitude: 33.787883000, - longitude: -117.863697000 - }, - { - latitude: 33.787883000, - longitude: -117.863948000 - }, - { - latitude: 33.787883000, - longitude: -117.864010000 - }, - { - latitude: 33.787882000, - longitude: -117.864622000 - }, - { - latitude: 33.787882000, - longitude: -117.864681000 - }, - { - latitude: 33.787882000, - longitude: -117.864810000 - }, - { - latitude: 33.787882000, - longitude: -117.865075000 - }, - { - latitude: 33.787881000, - longitude: -117.865167000 - }, - { - latitude: 33.787881000, - longitude: -117.865442000 - }, - { - latitude: 33.787881000, - longitude: -117.865554000 - }, - { - latitude: 33.787938000, - longitude: -117.865679000 - }, - { - latitude: 33.787938000, - longitude: -117.866004000 - }, - { - latitude: 33.787939000, - longitude: -117.866335000 - }, - { - latitude: 33.787940000, - longitude: -117.866502000 - }, - { - latitude: 33.787940000, - longitude: -117.866645000 - }, - { - latitude: 33.787941000, - longitude: -117.866964000 - }, - { - latitude: 33.787942000, - longitude: -117.867104000 - }, - { - latitude: 33.787888000, - longitude: -117.867306000 - }, - { - latitude: 33.787880000, - longitude: -117.867944000 - }, - { - latitude: 33.787880000, - longitude: -117.868030000 - }, - { - latitude: 33.787881000, - longitude: -117.868222000 - }, - { - latitude: 33.787881000, - longitude: -117.868308000 - }, - { - latitude: 33.787881000, - longitude: -117.868433000 - }, - { - latitude: 33.787883000, - longitude: -117.868666000 - }, - { - latitude: 33.787885000, - longitude: -117.868824000 - }, - { - latitude: 33.787884000, - longitude: -117.869024000 - }, - { - latitude: 33.787886000, - longitude: -117.869190000 - }, - { - latitude: 33.787882000, - longitude: -117.869589000 - }, - { - latitude: 33.787882000, - longitude: -117.869975000 - }, - { - latitude: 33.787881000, - longitude: -117.870171000 - }, - { - latitude: 33.787883000, - longitude: -117.870517000 - }, - { - latitude: 33.787883000, - longitude: -117.870767000 - }, - { - latitude: 33.787883000, - longitude: -117.870965000 - }, - { - latitude: 33.787883000, - longitude: -117.871162000 - }, - { - latitude: 33.787883000, - longitude: -117.871513000 - }, - { - latitude: 33.787884000, - longitude: -117.871607000 - }, - { - latitude: 33.787883000, - longitude: -117.871711000 - }, - { - latitude: 33.787883000, - longitude: -117.871983000 - }, - { - latitude: 33.787883000, - longitude: -117.872161000 - }, - { - latitude: 33.787883000, - longitude: -117.872335000 - }, - { - latitude: 33.787883000, - longitude: -117.872562000 - }, - { - latitude: 33.787883000, - longitude: -117.872633000 - }, - { - latitude: 33.787883000, - longitude: -117.872802000 - }, - { - latitude: 33.787883000, - longitude: -117.873037000 - }, - { - latitude: 33.787883000, - longitude: -117.873109000 - }, - { - latitude: 33.787883000, - longitude: -117.873275000 - }, - { - latitude: 33.787883000, - longitude: -117.873361000 - }, - { - latitude: 33.787884000, - longitude: -117.873785000 - }, - { - latitude: 33.787884000, - longitude: -117.873824000 - }, - { - latitude: 33.787886000, - longitude: -117.874423000 - }, - { - latitude: 33.787887000, - longitude: -117.874561000 - }, - { - latitude: 33.787886000, - longitude: -117.874578000 - }, - { - latitude: 33.787887000, - longitude: -117.875020000 - }, - { - latitude: 33.787887000, - longitude: -117.875270000 - }, - { - latitude: 33.787887000, - longitude: -117.875389000 - }, - { - latitude: 33.787887000, - longitude: -117.875705000 - }, - { - latitude: 33.787889000, - longitude: -117.876046000 - }, - { - latitude: 33.787888000, - longitude: -117.876358000 - }, - { - latitude: 33.787888000, - longitude: -117.876702000 - }, - { - latitude: 33.787888000, - longitude: -117.876796000 - }, - { - latitude: 33.787889000, - longitude: -117.877097000 - }, - { - latitude: 33.787888000, - longitude: -117.877582000 - }, - { - latitude: 33.787888000, - longitude: -117.877601000 - }, - { - latitude: 33.787889000, - longitude: -117.877685000 - }, - { - latitude: 33.787889000, - longitude: -117.877739000 - }, - { - latitude: 33.787889000, - longitude: -117.877933000 - }, - { - latitude: 33.787890000, - longitude: -117.878024000 - }, - { - latitude: 33.787890000, - longitude: -117.878150000 - }, - { - latitude: 33.787890000, - longitude: -117.878380000 - }, - { - latitude: 33.787890000, - longitude: -117.878542000 - }, - { - latitude: 33.787891000, - longitude: -117.878604000 - }, - { - latitude: 33.787891000, - longitude: -117.878628000 - }, - { - latitude: 33.787940000, - longitude: -117.878721000 - }, - { - latitude: 33.787946000, - longitude: -117.878943000 - }, - { - latitude: 33.787950000, - longitude: -117.879220000 - }, - { - latitude: 33.787947000, - longitude: -117.879543000 - }, - { - latitude: 33.787932000, - longitude: -117.880310000 - }, - { - latitude: 33.787929000, - longitude: -117.880465000 - }, - { - latitude: 33.787930000, - longitude: -117.880566000 - }, - { - latitude: 33.787930000, - longitude: -117.880625000 - }, - { - latitude: 33.787932000, - longitude: -117.881015000 - }, - { - latitude: 33.787869000, - longitude: -117.881189000 - }, - { - latitude: 33.787874000, - longitude: -117.881424000 - }, - { - latitude: 33.787877000, - longitude: -117.881621000 - }, - { - latitude: 33.787878000, - longitude: -117.881727000 - }, - { - latitude: 33.787883000, - longitude: -117.882974000 - }, - { - latitude: 33.787901000, - longitude: -117.883304000 - }, - { - latitude: 33.787909000, - longitude: -117.883363000 - }, - { - latitude: 33.787956000, - longitude: -117.883695000 - }, - { - latitude: 33.787992000, - longitude: -117.883830000 - }, - { - latitude: 33.788014000, - longitude: -117.883957000 - }, - { - latitude: 33.788100000, - longitude: -117.884219000 - }, - { - latitude: 33.788216000, - longitude: -117.884521000 - }, - { - latitude: 33.788229000, - longitude: -117.884547000 - }, - { - latitude: 33.788366000, - longitude: -117.884815000 - }, - { - latitude: 33.788446000, - longitude: -117.884941000 - }, - { - latitude: 33.788819000, - longitude: -117.885531000 - }, - { - latitude: 33.788893000, - longitude: -117.885639000 - }, - { - latitude: 33.789115000, - longitude: -117.886014000 - }, - { - latitude: 33.789139000, - longitude: -117.886074000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789583000, - longitude: -117.886182000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789638000, - longitude: -117.886143000 - }, - { - latitude: 33.789733000, - longitude: -117.886236000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789573000, - longitude: -117.886197000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789306000, - longitude: -117.886505000 - }, - { - latitude: 33.789392000, - longitude: -117.886814000 - }, - { - latitude: 33.789494000, - longitude: -117.887171000 - }, - { - latitude: 33.789507000, - longitude: -117.887303000 - }, - { - latitude: 33.789519000, - longitude: -117.887449000 - }, - { - latitude: 33.789504000, - longitude: -117.887942000 - }, - { - latitude: 33.789481000, - longitude: -117.888098000 - }, - { - latitude: 33.789454000, - longitude: -117.888414000 - }, - { - latitude: 33.789467000, - longitude: -117.888536000 - }, - { - latitude: 33.789477000, - longitude: -117.888629000 - }, - { - latitude: 33.789431000, - longitude: -117.888858000 - }, - { - latitude: 33.789193000, - longitude: -117.889833000 - }, - { - latitude: 33.789079000, - longitude: -117.890474000 - }, - { - latitude: 33.789070000, - longitude: -117.890540000 - }, - { - latitude: 33.789050000, - longitude: -117.890692000 - }, - { - latitude: 33.789213000, - longitude: -117.890685000 - }, - { - latitude: 33.789240000, - longitude: -117.890682000 - }, - { - latitude: 33.789443000, - longitude: -117.890661000 - }, - { - latitude: 33.789572000, - longitude: -117.890639000 - }, - { - latitude: 33.789673000, - longitude: -117.890617000 - }, - { - latitude: 33.789791000, - longitude: -117.890583000 - }, - { - latitude: 33.789972000, - longitude: -117.890523000 - }, - { - latitude: 33.789994000, - longitude: -117.890515000 - }, - { - latitude: 33.790393000, - longitude: -117.890382000 - }, - { - latitude: 33.790715000, - longitude: -117.890275000 - }, - { - latitude: 33.790840000, - longitude: -117.890233000 - }, - { - latitude: 33.791287000, - longitude: -117.890090000 - }, - { - latitude: 33.791676000, - longitude: -117.889959000 - }, - { - latitude: 33.791969000, - longitude: -117.889853000 - }, - { - latitude: 33.792429000, - longitude: -117.889707000 - }, - { - latitude: 33.792554000, - longitude: -117.889667000 - }, - { - latitude: 33.792652000, - longitude: -117.889635000 - }, - { - latitude: 33.793067000, - longitude: -117.889526000 - }, - { - latitude: 33.793455000, - longitude: -117.889409000 - }, - { - latitude: 33.793623000, - longitude: -117.889359000 - }, - { - latitude: 33.794047000, - longitude: -117.889238000 - }, - { - latitude: 33.794232000, - longitude: -117.889198000 - }, - { - latitude: 33.794418000, - longitude: -117.889168000 - }, - { - latitude: 33.794570000, - longitude: -117.889154000 - }, - { - latitude: 33.794724000, - longitude: -117.889139000 - }, - { - latitude: 33.794892000, - longitude: -117.889128000 - }, - { - latitude: 33.795108000, - longitude: -117.889122000 - }, - { - latitude: 33.795424000, - longitude: -117.889117000 - }, - { - latitude: 33.795532000, - longitude: -117.889111000 - }, - { - latitude: 33.795727000, - longitude: -117.889100000 - }, - { - latitude: 33.795934000, - longitude: -117.889091000 - }, - { - latitude: 33.796137000, - longitude: -117.889082000 - }, - { - latitude: 33.796513000, - longitude: -117.889083000 - }, - { - latitude: 33.796740000, - longitude: -117.889083000 - }, - { - latitude: 33.797121000, - longitude: -117.889083000 - }, - { - latitude: 33.797353000, - longitude: -117.889084000 - }, - { - latitude: 33.797687000, - longitude: -117.889084000 - }, - { - latitude: 33.797876000, - longitude: -117.889085000 - }, - { - latitude: 33.798169000, - longitude: -117.889089000 - }, - { - latitude: 33.798868000, - longitude: -117.889102000 - }, - { - latitude: 33.799531000, - longitude: -117.889114000 - }, - { - latitude: 33.799563000, - longitude: -117.889117000 - }, - { - latitude: 33.799711000, - longitude: -117.889176000 - }, - { - latitude: 33.799711000, - longitude: -117.889396000 - }, - { - latitude: 33.799712000, - longitude: -117.889838000 - }, - { - latitude: 33.799712000, - longitude: -117.890642000 - }, - { - latitude: 33.799556000, - longitude: -117.890647000 - }, - { - latitude: 33.799500000, - longitude: -117.890647000 - }, - { - latitude: 33.797874000, - longitude: -117.890640000 - }, - { - latitude: 33.797874000, - longitude: -117.890360000 - }, - { - latitude: 33.797874000, - longitude: -117.889265000 - }, - { - latitude: 33.797506000, - longitude: -117.889272000 - }, - { - latitude: 33.797103000, - longitude: -117.889280000 - }, - { - latitude: 33.796975000, - longitude: -117.889282000 - }, - { - latitude: 33.796354000, - longitude: -117.889295000 - }, - { - latitude: 33.796134000, - longitude: -117.889299000 - }, - { - latitude: 33.795536000, - longitude: -117.889327000 - }, - { - latitude: 33.794906000, - longitude: -117.889329000 - }, - { - latitude: 33.794596000, - longitude: -117.889345000 - }, - { - latitude: 33.794387000, - longitude: -117.889372000 - }, - { - latitude: 33.794267000, - longitude: -117.889394000 - }, - { - latitude: 33.793940000, - longitude: -117.889494000 - }, - { - latitude: 33.793821000, - longitude: -117.889529000 - }, - { - latitude: 33.793667000, - longitude: -117.889574000 - }, - { - latitude: 33.793509000, - longitude: -117.889624000 - }, - { - latitude: 33.793349000, - longitude: -117.889674000 - }, - { - latitude: 33.792856000, - longitude: -117.889863000 - }, - { - latitude: 33.792685000, - longitude: -117.889920000 - }, - { - latitude: 33.792476000, - longitude: -117.889990000 - }, - { - latitude: 33.791770000, - longitude: -117.890225000 - }, - { - latitude: 33.791177000, - longitude: -117.890419000 - }, - { - latitude: 33.791019000, - longitude: -117.890471000 - }, - { - latitude: 33.790887000, - longitude: -117.890516000 - }, - { - latitude: 33.790764000, - longitude: -117.890559000 - }, - { - latitude: 33.790126000, - longitude: -117.890770000 - }, - { - latitude: 33.789925000, - longitude: -117.890837000 - }, - { - latitude: 33.789802000, - longitude: -117.890868000 - }, - { - latitude: 33.789759000, - longitude: -117.890877000 - }, - { - latitude: 33.789675000, - longitude: -117.890893000 - }, - { - latitude: 33.789591000, - longitude: -117.890907000 - }, - { - latitude: 33.789475000, - longitude: -117.890916000 - }, - { - latitude: 33.789328000, - longitude: -117.890924000 - }, - { - latitude: 33.789202000, - longitude: -117.890932000 - }, - { - latitude: 33.789041000, - longitude: -117.890936000 - }, - { - latitude: 33.788903000, - longitude: -117.890936000 - }, - { - latitude: 33.788904000, - longitude: -117.890700000 - }, - { - latitude: 33.788909000, - longitude: -117.890516000 - }, - { - latitude: 33.788913000, - longitude: -117.890489000 - }, - { - latitude: 33.788954000, - longitude: -117.890197000 - }, - { - latitude: 33.789050000, - longitude: -117.889708000 - }, - { - latitude: 33.789086000, - longitude: -117.889537000 - }, - { - latitude: 33.789321000, - longitude: -117.888520000 - }, - { - latitude: 33.789397000, - longitude: -117.888460000 - }, - { - latitude: 33.789454000, - longitude: -117.888414000 - }, - { - latitude: 33.789481000, - longitude: -117.888098000 - }, - { - latitude: 33.789504000, - longitude: -117.887942000 - }, - { - latitude: 33.789519000, - longitude: -117.887449000 - }, - { - latitude: 33.789507000, - longitude: -117.887303000 - }, - { - latitude: 33.789494000, - longitude: -117.887171000 - }, - { - latitude: 33.789392000, - longitude: -117.886814000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789336000, - longitude: -117.886428000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789583000, - longitude: -117.886182000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789732000, - longitude: -117.886235000 - }, - { - latitude: 33.789622000, - longitude: -117.886127000 - }, - { - latitude: 33.789589000, - longitude: -117.886174000 - }, - { - latitude: 33.789491000, - longitude: -117.886321000 - }, - { - latitude: 33.789293000, - longitude: -117.886458000 - }, - { - latitude: 33.789241000, - longitude: -117.886328000 - }, - { - latitude: 33.789139000, - longitude: -117.886074000 - }, - { - latitude: 33.789115000, - longitude: -117.886014000 - }, - { - latitude: 33.788893000, - longitude: -117.885639000 - }, - { - latitude: 33.788819000, - longitude: -117.885531000 - }, - { - latitude: 33.788446000, - longitude: -117.884941000 - }, - { - latitude: 33.788366000, - longitude: -117.884815000 - }, - { - latitude: 33.788229000, - longitude: -117.884547000 - }, - { - latitude: 33.788216000, - longitude: -117.884521000 - }, - { - latitude: 33.788100000, - longitude: -117.884219000 - }, - { - latitude: 33.788014000, - longitude: -117.883957000 - }, - { - latitude: 33.787992000, - longitude: -117.883830000 - }, - { - latitude: 33.787956000, - longitude: -117.883695000 - }, - { - latitude: 33.787909000, - longitude: -117.883363000 - }, - { - latitude: 33.787901000, - longitude: -117.883304000 - }, - { - latitude: 33.787883000, - longitude: -117.882974000 - }, - { - latitude: 33.787878000, - longitude: -117.881727000 - }, - { - latitude: 33.787877000, - longitude: -117.881621000 - }, - { - latitude: 33.787874000, - longitude: -117.881424000 - }, - { - latitude: 33.787869000, - longitude: -117.881189000 - }, - { - latitude: 33.787837000, - longitude: -117.881017000 - }, - { - latitude: 33.787840000, - longitude: -117.880648000 - }, - { - latitude: 33.787840000, - longitude: -117.880625000 - }, - { - latitude: 33.787841000, - longitude: -117.880435000 - }, - { - latitude: 33.787847000, - longitude: -117.879556000 - }, - { - latitude: 33.787844000, - longitude: -117.879130000 - }, - { - latitude: 33.787844000, - longitude: -117.879052000 - }, - { - latitude: 33.787843000, - longitude: -117.878851000 - }, - { - latitude: 33.787842000, - longitude: -117.878720000 - }, - { - latitude: 33.787891000, - longitude: -117.878628000 - }, - { - latitude: 33.787891000, - longitude: -117.878604000 - }, - { - latitude: 33.787890000, - longitude: -117.878542000 - }, - { - latitude: 33.787890000, - longitude: -117.878380000 - }, - { - latitude: 33.787890000, - longitude: -117.878150000 - }, - { - latitude: 33.787890000, - longitude: -117.878024000 - }, - { - latitude: 33.787889000, - longitude: -117.877933000 - }, - { - latitude: 33.787889000, - longitude: -117.877739000 - }, - { - latitude: 33.787889000, - longitude: -117.877685000 - }, - { - latitude: 33.787888000, - longitude: -117.877601000 - }, - { - latitude: 33.787888000, - longitude: -117.877582000 - }, - { - latitude: 33.787889000, - longitude: -117.877097000 - }, - { - latitude: 33.787888000, - longitude: -117.876796000 - }, - { - latitude: 33.787888000, - longitude: -117.876702000 - }, - { - latitude: 33.787888000, - longitude: -117.876358000 - }, - { - latitude: 33.787889000, - longitude: -117.876046000 - }, - { - latitude: 33.787887000, - longitude: -117.875705000 - }, - { - latitude: 33.787887000, - longitude: -117.875554000 - }, - { - latitude: 33.787887000, - longitude: -117.875389000 - }, - { - latitude: 33.787887000, - longitude: -117.875270000 - }, - { - latitude: 33.787887000, - longitude: -117.875020000 - }, - { - latitude: 33.787886000, - longitude: -117.874578000 - }, - { - latitude: 33.787887000, - longitude: -117.874561000 - }, - { - latitude: 33.787886000, - longitude: -117.874423000 - }, - { - latitude: 33.787884000, - longitude: -117.873824000 - }, - { - latitude: 33.787884000, - longitude: -117.873785000 - }, - { - latitude: 33.787883000, - longitude: -117.873361000 - }, - { - latitude: 33.787883000, - longitude: -117.873275000 - }, - { - latitude: 33.787883000, - longitude: -117.873109000 - }, - { - latitude: 33.787883000, - longitude: -117.873037000 - }, - { - latitude: 33.787883000, - longitude: -117.872802000 - }, - { - latitude: 33.787883000, - longitude: -117.872633000 - }, - { - latitude: 33.787883000, - longitude: -117.872562000 - }, - { - latitude: 33.787883000, - longitude: -117.872335000 - }, - { - latitude: 33.787883000, - longitude: -117.871983000 - }, - { - latitude: 33.787883000, - longitude: -117.871711000 - }, - { - latitude: 33.787884000, - longitude: -117.871607000 - }, - { - latitude: 33.787883000, - longitude: -117.871513000 - }, - { - latitude: 33.787883000, - longitude: -117.871162000 - }, - { - latitude: 33.787883000, - longitude: -117.870965000 - }, - { - latitude: 33.787883000, - longitude: -117.870767000 - }, - { - latitude: 33.787883000, - longitude: -117.870517000 - }, - { - latitude: 33.787881000, - longitude: -117.870171000 - }, - { - latitude: 33.787882000, - longitude: -117.869975000 - }, - { - latitude: 33.787882000, - longitude: -117.869589000 - }, - { - latitude: 33.787886000, - longitude: -117.869190000 - }, - { - latitude: 33.787884000, - longitude: -117.869024000 - }, - { - latitude: 33.787885000, - longitude: -117.868824000 - }, - { - latitude: 33.787883000, - longitude: -117.868666000 - }, - { - latitude: 33.787881000, - longitude: -117.868433000 - }, - { - latitude: 33.787881000, - longitude: -117.868308000 - }, - { - latitude: 33.787881000, - longitude: -117.868222000 - }, - { - latitude: 33.787880000, - longitude: -117.868030000 - }, - { - latitude: 33.787880000, - longitude: -117.867944000 - }, - { - latitude: 33.787888000, - longitude: -117.867306000 - }, - { - latitude: 33.787838000, - longitude: -117.867107000 - }, - { - latitude: 33.787838000, - longitude: -117.866715000 - }, - { - latitude: 33.787838000, - longitude: -117.866531000 - }, - { - latitude: 33.787838000, - longitude: -117.866114000 - }, - { - latitude: 33.787838000, - longitude: -117.865902000 - }, - { - latitude: 33.787838000, - longitude: -117.865670000 - }, - { - latitude: 33.787881000, - longitude: -117.865554000 - }, - { - latitude: 33.787881000, - longitude: -117.865442000 - }, - { - latitude: 33.787881000, - longitude: -117.865167000 - }, - { - latitude: 33.787882000, - longitude: -117.865075000 - }, - { - latitude: 33.787882000, - longitude: -117.864810000 - }, - { - latitude: 33.787882000, - longitude: -117.864681000 - }, - { - latitude: 33.787882000, - longitude: -117.864622000 - }, - { - latitude: 33.787883000, - longitude: -117.864010000 - }, - { - latitude: 33.787883000, - longitude: -117.863948000 - }, - { - latitude: 33.787883000, - longitude: -117.863697000 - }, - { - latitude: 33.787883000, - longitude: -117.863160000 - }, - { - latitude: 33.787884000, - longitude: -117.862915000 - }, - { - latitude: 33.787884000, - longitude: -117.862841000 - }, - { - latitude: 33.787884000, - longitude: -117.862684000 - }, - { - latitude: 33.787884000, - longitude: -117.862405000 - }, - { - latitude: 33.787884000, - longitude: -117.862261000 - }, - { - latitude: 33.787885000, - longitude: -117.861974000 - }, - { - latitude: 33.787884000, - longitude: -117.861825000 - }, - { - latitude: 33.787885000, - longitude: -117.861692000 - }, - { - latitude: 33.787885000, - longitude: -117.861519000 - }, - { - latitude: 33.787886000, - longitude: -117.861197000 - }, - { - latitude: 33.787887000, - longitude: -117.860739000 - }, - { - latitude: 33.787887000, - longitude: -117.860435000 - }, - { - latitude: 33.787887000, - longitude: -117.860392000 - }, - { - latitude: 33.787888000, - longitude: -117.860093000 - }, - { - latitude: 33.787888000, - longitude: -117.859990000 - }, - { - latitude: 33.787889000, - longitude: -117.859655000 - }, - { - latitude: 33.787888000, - longitude: -117.859323000 - }, - { - latitude: 33.787888000, - longitude: -117.859286000 - }, - { - latitude: 33.787888000, - longitude: -117.858868000 - }, - { - latitude: 33.787888000, - longitude: -117.858716000 - }, - { - latitude: 33.787887000, - longitude: -117.858556000 - }, - { - latitude: 33.787887000, - longitude: -117.858476000 - }, - { - latitude: 33.787887000, - longitude: -117.858293000 - }, - { - latitude: 33.787887000, - longitude: -117.858081000 - }, - { - latitude: 33.787886000, - longitude: -117.857986000 - }, - { - latitude: 33.787846000, - longitude: -117.857894000 - }, - { - latitude: 33.787846000, - longitude: -117.857828000 - }, - { - latitude: 33.787846000, - longitude: -117.857771000 - }, - { - latitude: 33.787847000, - longitude: -117.857518000 - }, - { - latitude: 33.787848000, - longitude: -117.857460000 - }, - { - latitude: 33.787848000, - longitude: -117.857265000 - }, - { - latitude: 33.787849000, - longitude: -117.857090000 - }, - { - latitude: 33.787849000, - longitude: -117.857041000 - }, - { - latitude: 33.787891000, - longitude: -117.856976000 - }, - { - latitude: 33.787891000, - longitude: -117.856888000 - }, - { - latitude: 33.787890000, - longitude: -117.856602000 - }, - { - latitude: 33.787889000, - longitude: -117.856470000 - }, - { - latitude: 33.787889000, - longitude: -117.856375000 - }, - { - latitude: 33.787970000, - longitude: -117.856375000 - }, - { - latitude: 33.788076000, - longitude: -117.856372000 - }, - { - latitude: 33.788321000, - longitude: -117.856366000 - }, - { - latitude: 33.789279000, - longitude: -117.856365000 - }, - { - latitude: 33.789620000, - longitude: -117.856370000 - }, - { - latitude: 33.789701000, - longitude: -117.856365000 - }, - { - latitude: 33.789782000, - longitude: -117.856364000 - }, - { - latitude: 33.790351000, - longitude: -117.856360000 - }, - { - latitude: 33.791312000, - longitude: -117.856358000 - }, - { - latitude: 33.791437000, - longitude: -117.856363000 - }, - { - latitude: 33.791517000, - longitude: -117.856367000 - }, - { - latitude: 33.791598000, - longitude: -117.856366000 - }, - { - latitude: 33.793323000, - longitude: -117.856355000 - }, - { - latitude: 33.793322000, - longitude: -117.855591000 - }, - { - latitude: 33.793322000, - longitude: -117.855284000 - }, - { - latitude: 33.793310000, - longitude: -117.854187000 - }, - { - latitude: 33.793311000, - longitude: -117.853698000 - }, - { - latitude: 33.793311000, - longitude: -117.853367000 - }, - { - latitude: 33.793311000, - longitude: -117.853212000 - }, - { - latitude: 33.793311000, - longitude: -117.853097000 - }, - { - latitude: 33.793315000, - longitude: -117.852996000 - }, - { - latitude: 33.793316000, - longitude: -117.852810000 - }, - { - latitude: 33.793325000, - longitude: -117.852810000 - } -]; - -const tealRoutePolylineCoordinates = [ - { - "latitude": 33.793316, - "longitude": -117.85281 - }, - { - "latitude": 33.793315, - "longitude": -117.852996 - }, - { - "latitude": 33.793311, - "longitude": -117.853097 - }, - { - "latitude": 33.793311, - "longitude": -117.853212 - }, - { - "latitude": 33.793311, - "longitude": -117.853367 - }, - { - "latitude": 33.793311, - "longitude": -117.853698 - }, - { - "latitude": 33.79331, - "longitude": -117.854187 - }, - { - "latitude": 33.793322, - "longitude": -117.855284 - }, - { - "latitude": 33.793323, - "longitude": -117.856355 - }, - { - "latitude": 33.79323, - "longitude": -117.856356 - }, - { - "latitude": 33.791598, - "longitude": -117.856366 - }, - { - "latitude": 33.791517, - "longitude": -117.856367 - }, - { - "latitude": 33.791437, - "longitude": -117.856363 - }, - { - "latitude": 33.791312, - "longitude": -117.856358 - }, - { - "latitude": 33.790351, - "longitude": -117.85636 - }, - { - "latitude": 33.789782, - "longitude": -117.856364 - }, - { - "latitude": 33.789701, - "longitude": -117.856365 - }, - { - "latitude": 33.78962, - "longitude": -117.85637 - }, - { - "latitude": 33.789279, - "longitude": -117.856365 - }, - { - "latitude": 33.788321, - "longitude": -117.856366 - }, - { - "latitude": 33.78797, - "longitude": -117.856375 - }, - { - "latitude": 33.787889, - "longitude": -117.856375 - }, - { - "latitude": 33.787889, - "longitude": -117.85647 - }, - { - "latitude": 33.78789, - "longitude": -117.856602 - }, - { - "latitude": 33.787891, - "longitude": -117.856783 - }, - { - "latitude": 33.787891, - "longitude": -117.856888 - }, - { - "latitude": 33.787891, - "longitude": -117.856976 - }, - { - "latitude": 33.787893, - "longitude": -117.856979 - }, - { - "latitude": 33.787933, - "longitude": -117.857043 - }, - { - "latitude": 33.787933, - "longitude": -117.857458 - }, - { - "latitude": 33.787933, - "longitude": -117.857518 - }, - { - "latitude": 33.787933, - "longitude": -117.857827 - }, - { - "latitude": 33.787933, - "longitude": -117.857907 - }, - { - "latitude": 33.787886, - "longitude": -117.857986 - }, - { - "latitude": 33.787887, - "longitude": -117.858081 - }, - { - "latitude": 33.787887, - "longitude": -117.858293 - }, - { - "latitude": 33.787887, - "longitude": -117.858476 - }, - { - "latitude": 33.787887, - "longitude": -117.858556 - }, - { - "latitude": 33.787888, - "longitude": -117.858716 - }, - { - "latitude": 33.787888, - "longitude": -117.858868 - }, - { - "latitude": 33.787888, - "longitude": -117.859286 - }, - { - "latitude": 33.787888, - "longitude": -117.859323 - }, - { - "latitude": 33.787889, - "longitude": -117.859655 - }, - { - "latitude": 33.787888, - "longitude": -117.85999 - }, - { - "latitude": 33.787888, - "longitude": -117.860093 - }, - { - "latitude": 33.787887, - "longitude": -117.860392 - }, - { - "latitude": 33.787887, - "longitude": -117.860435 - }, - { - "latitude": 33.787887, - "longitude": -117.860739 - }, - { - "latitude": 33.787886, - "longitude": -117.861197 - }, - { - "latitude": 33.787885, - "longitude": -117.861519 - }, - { - "latitude": 33.787885, - "longitude": -117.861692 - }, - { - "latitude": 33.787884, - "longitude": -117.861825 - }, - { - "latitude": 33.787885, - "longitude": -117.861974 - }, - { - "latitude": 33.787884, - "longitude": -117.862261 - }, - { - "latitude": 33.787884, - "longitude": -117.862405 - }, - { - "latitude": 33.787884, - "longitude": -117.862684 - }, - { - "latitude": 33.787884, - "longitude": -117.862841 - }, - { - "latitude": 33.787884, - "longitude": -117.862915 - }, - { - "latitude": 33.787883, - "longitude": -117.86316 - }, - { - "latitude": 33.787883, - "longitude": -117.863697 - }, - { - "latitude": 33.787883, - "longitude": -117.863948 - }, - { - "latitude": 33.787883, - "longitude": -117.86401 - }, - { - "latitude": 33.787882, - "longitude": -117.864622 - }, - { - "latitude": 33.787882, - "longitude": -117.864681 - }, - { - "latitude": 33.787882, - "longitude": -117.86481 - }, - { - "latitude": 33.787882, - "longitude": -117.865075 - }, - { - "latitude": 33.787881, - "longitude": -117.865167 - }, - { - "latitude": 33.787881, - "longitude": -117.865442 - }, - { - "latitude": 33.787881, - "longitude": -117.865554 - }, - { - "latitude": 33.787938, - "longitude": -117.865679 - }, - { - "latitude": 33.787938, - "longitude": -117.866004 - }, - { - "latitude": 33.787939, - "longitude": -117.866335 - }, - { - "latitude": 33.78794, - "longitude": -117.866502 - }, - { - "latitude": 33.78794, - "longitude": -117.866645 - }, - { - "latitude": 33.787941, - "longitude": -117.866964 - }, - { - "latitude": 33.787942, - "longitude": -117.867104 - }, - { - "latitude": 33.787888, - "longitude": -117.867306 - }, - { - "latitude": 33.78788, - "longitude": -117.867944 - }, - { - "latitude": 33.78788, - "longitude": -117.86803 - }, - { - "latitude": 33.787881, - "longitude": -117.868222 - }, - { - "latitude": 33.787881, - "longitude": -117.868308 - }, - { - "latitude": 33.787881, - "longitude": -117.868433 - }, - { - "latitude": 33.787883, - "longitude": -117.868666 - }, - { - "latitude": 33.787885, - "longitude": -117.868824 - }, - { - "latitude": 33.787884, - "longitude": -117.869024 - }, - { - "latitude": 33.787886, - "longitude": -117.86919 - }, - { - "latitude": 33.787882, - "longitude": -117.869589 - }, - { - "latitude": 33.787882, - "longitude": -117.869975 - }, - { - "latitude": 33.787881, - "longitude": -117.870171 - }, - { - "latitude": 33.787883, - "longitude": -117.870517 - }, - { - "latitude": 33.787883, - "longitude": -117.870767 - }, - { - "latitude": 33.787883, - "longitude": -117.870965 - }, - { - "latitude": 33.787883, - "longitude": -117.871162 - }, - { - "latitude": 33.787883, - "longitude": -117.871513 - }, - { - "latitude": 33.787884, - "longitude": -117.871607 - }, - { - "latitude": 33.787883, - "longitude": -117.871711 - }, - { - "latitude": 33.787883, - "longitude": -117.871983 - }, - { - "latitude": 33.787883, - "longitude": -117.872161 - }, - { - "latitude": 33.787883, - "longitude": -117.872335 - }, - { - "latitude": 33.787883, - "longitude": -117.872562 - }, - { - "latitude": 33.787883, - "longitude": -117.872633 - }, - { - "latitude": 33.787883, - "longitude": -117.872802 - }, - { - "latitude": 33.787883, - "longitude": -117.873037 - }, - { - "latitude": 33.787883, - "longitude": -117.873109 - }, - { - "latitude": 33.787883, - "longitude": -117.873275 - }, - { - "latitude": 33.787883, - "longitude": -117.873361 - }, - { - "latitude": 33.787884, - "longitude": -117.873785 - }, - { - "latitude": 33.787884, - "longitude": -117.873824 - }, - { - "latitude": 33.787886, - "longitude": -117.874423 - }, - { - "latitude": 33.787887, - "longitude": -117.874561 - }, - { - "latitude": 33.787886, - "longitude": -117.874578 - }, - { - "latitude": 33.787887, - "longitude": -117.87502 - }, - { - "latitude": 33.787887, - "longitude": -117.87527 - }, - { - "latitude": 33.787887, - "longitude": -117.875389 - }, - { - "latitude": 33.787887, - "longitude": -117.875705 - }, - { - "latitude": 33.787889, - "longitude": -117.876046 - }, - { - "latitude": 33.787888, - "longitude": -117.876358 - }, - { - "latitude": 33.787888, - "longitude": -117.876702 - }, - { - "latitude": 33.787888, - "longitude": -117.876796 - }, - { - "latitude": 33.787889, - "longitude": -117.877097 - }, - { - "latitude": 33.787888, - "longitude": -117.877582 - }, - { - "latitude": 33.787888, - "longitude": -117.877601 - }, - { - "latitude": 33.787889, - "longitude": -117.877685 - }, - { - "latitude": 33.787889, - "longitude": -117.877739 - }, - { - "latitude": 33.787889, - "longitude": -117.877933 - }, - { - "latitude": 33.78789, - "longitude": -117.878024 - }, - { - "latitude": 33.78789, - "longitude": -117.87815 - }, - { - "latitude": 33.78789, - "longitude": -117.87838 - }, - { - "latitude": 33.78789, - "longitude": -117.878542 - }, - { - "latitude": 33.787891, - "longitude": -117.878604 - }, - { - "latitude": 33.787891, - "longitude": -117.878628 - }, - { - "latitude": 33.78794, - "longitude": -117.878721 - }, - { - "latitude": 33.787946, - "longitude": -117.878943 - }, - { - "latitude": 33.78795, - "longitude": -117.87922 - }, - { - "latitude": 33.787947, - "longitude": -117.879543 - }, - { - "latitude": 33.787932, - "longitude": -117.88031 - }, - { - "latitude": 33.787929, - "longitude": -117.880465 - }, - { - "latitude": 33.78793, - "longitude": -117.880566 - }, - { - "latitude": 33.78793, - "longitude": -117.880625 - }, - { - "latitude": 33.787932, - "longitude": -117.881015 - }, - { - "latitude": 33.787869, - "longitude": -117.881189 - }, - { - "latitude": 33.787874, - "longitude": -117.881424 - }, - { - "latitude": 33.787877, - "longitude": -117.881621 - }, - { - "latitude": 33.787878, - "longitude": -117.881727 - }, - { - "latitude": 33.787883, - "longitude": -117.882974 - }, - { - "latitude": 33.787901, - "longitude": -117.883304 - }, - { - "latitude": 33.787909, - "longitude": -117.883363 - }, - { - "latitude": 33.787956, - "longitude": -117.883695 - }, - { - "latitude": 33.787992, - "longitude": -117.88383 - }, - { - "latitude": 33.788014, - "longitude": -117.883957 - }, - { - "latitude": 33.7881, - "longitude": -117.884219 - }, - { - "latitude": 33.788216, - "longitude": -117.884521 - }, - { - "latitude": 33.788229, - "longitude": -117.884547 - }, - { - "latitude": 33.788366, - "longitude": -117.884815 - }, - { - "latitude": 33.788446, - "longitude": -117.884941 - }, - { - "latitude": 33.788819, - "longitude": -117.885531 - }, - { - "latitude": 33.788893, - "longitude": -117.885639 - }, - { - "latitude": 33.789115, - "longitude": -117.886014 - }, - { - "latitude": 33.789139, - "longitude": -117.886074 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789583, - "longitude": -117.886182 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789638, - "longitude": -117.886143 - }, - { - "latitude": 33.789733, - "longitude": -117.886236 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789573, - "longitude": -117.886197 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789306, - "longitude": -117.886505 - }, - { - "latitude": 33.789392, - "longitude": -117.886814 - }, - { - "latitude": 33.789494, - "longitude": -117.887171 - }, - { - "latitude": 33.789507, - "longitude": -117.887303 - }, - { - "latitude": 33.789519, - "longitude": -117.887449 - }, - { - "latitude": 33.789504, - "longitude": -117.887942 - }, - { - "latitude": 33.789481, - "longitude": -117.888098 - }, - { - "latitude": 33.789454, - "longitude": -117.888414 - }, - { - "latitude": 33.789467, - "longitude": -117.888536 - }, - { - "latitude": 33.789477, - "longitude": -117.888629 - }, - { - "latitude": 33.789431, - "longitude": -117.888858 - }, - { - "latitude": 33.789193, - "longitude": -117.889833 - }, - { - "latitude": 33.789079, - "longitude": -117.890474 - }, - { - "latitude": 33.78907, - "longitude": -117.89054 - }, - { - "latitude": 33.78905, - "longitude": -117.890692 - }, - { - "latitude": 33.789213, - "longitude": -117.890685 - }, - { - "latitude": 33.78924, - "longitude": -117.890682 - }, - { - "latitude": 33.789443, - "longitude": -117.890661 - }, - { - "latitude": 33.789572, - "longitude": -117.890639 - }, - { - "latitude": 33.789673, - "longitude": -117.890617 - }, - { - "latitude": 33.789791, - "longitude": -117.890583 - }, - { - "latitude": 33.789972, - "longitude": -117.890523 - }, - { - "latitude": 33.789994, - "longitude": -117.890515 - }, - { - "latitude": 33.790393, - "longitude": -117.890382 - }, - { - "latitude": 33.790715, - "longitude": -117.890275 - }, - { - "latitude": 33.79084, - "longitude": -117.890233 - }, - { - "latitude": 33.791287, - "longitude": -117.89009 - }, - { - "latitude": 33.791676, - "longitude": -117.889959 - }, - { - "latitude": 33.791969, - "longitude": -117.889853 - }, - { - "latitude": 33.792429, - "longitude": -117.889707 - }, - { - "latitude": 33.792554, - "longitude": -117.889667 - }, - { - "latitude": 33.792652, - "longitude": -117.889635 - }, - { - "latitude": 33.793067, - "longitude": -117.889526 - }, - { - "latitude": 33.793455, - "longitude": -117.889409 - }, - { - "latitude": 33.793623, - "longitude": -117.889359 - }, - { - "latitude": 33.794047, - "longitude": -117.889238 - }, - { - "latitude": 33.794232, - "longitude": -117.889198 - }, - { - "latitude": 33.794418, - "longitude": -117.889168 - }, - { - "latitude": 33.79457, - "longitude": -117.889154 - }, - { - "latitude": 33.794724, - "longitude": -117.889139 - }, - { - "latitude": 33.794892, - "longitude": -117.889128 - }, - { - "latitude": 33.795108, - "longitude": -117.889122 - }, - { - "latitude": 33.795424, - "longitude": -117.889117 - }, - { - "latitude": 33.795532, - "longitude": -117.889111 - }, - { - "latitude": 33.795727, - "longitude": -117.8891 - }, - { - "latitude": 33.795934, - "longitude": -117.889091 - }, - { - "latitude": 33.796137, - "longitude": -117.889082 - }, - { - "latitude": 33.796513, - "longitude": -117.889083 - }, - { - "latitude": 33.79674, - "longitude": -117.889083 - }, - { - "latitude": 33.797121, - "longitude": -117.889083 - }, - { - "latitude": 33.797353, - "longitude": -117.889084 - }, - { - "latitude": 33.797687, - "longitude": -117.889084 - }, - { - "latitude": 33.797876, - "longitude": -117.889085 - }, - { - "latitude": 33.798169, - "longitude": -117.889089 - }, - { - "latitude": 33.798868, - "longitude": -117.889102 - }, - { - "latitude": 33.799531, - "longitude": -117.889114 - }, - { - "latitude": 33.799563, - "longitude": -117.889117 - }, - { - "latitude": 33.799711, - "longitude": -117.889176 - }, - { - "latitude": 33.799711, - "longitude": -117.889396 - }, - { - "latitude": 33.7997115, - "longitude": -117.889617 - }, - { - "latitude": 33.799712, - "longitude": -117.889838 - }, - { - "latitude": 33.799712, - "longitude": -117.890642 - }, - { - "latitude": 33.799556, - "longitude": -117.890647 - }, - { - "latitude": 33.7995, - "longitude": -117.890647 - }, - { - "latitude": 33.797874, - "longitude": -117.89064 - }, - { - "latitude": 33.797874, - "longitude": -117.89036 - }, - { - "latitude": 33.797874, - "longitude": -117.889265 - }, - { - "latitude": 33.797849, - "longitude": -117.889265 - }, - { - "latitude": 33.797103, - "longitude": -117.88928 - }, - { - "latitude": 33.796975, - "longitude": -117.889282 - }, - { - "latitude": 33.796354, - "longitude": -117.889295 - }, - { - "latitude": 33.796134, - "longitude": -117.889299 - }, - { - "latitude": 33.796133, - "longitude": -117.889494 - }, - { - "latitude": 33.796133, - "longitude": -117.889536 - }, - { - "latitude": 33.796132, - "longitude": -117.890043 - }, - { - "latitude": 33.796132, - "longitude": -117.89017 - }, - { - "latitude": 33.79613, - "longitude": -117.890704 - }, - { - "latitude": 33.796123, - "longitude": -117.891098 - }, - { - "latitude": 33.796187, - "longitude": -117.891393 - }, - { - "latitude": 33.796219, - "longitude": -117.891505 - }, - { - "latitude": 33.796219, - "longitude": -117.891519 - }, - { - "latitude": 33.79622, - "longitude": -117.891705 - }, - { - "latitude": 33.79622, - "longitude": -117.892011 - }, - { - "latitude": 33.796221, - "longitude": -117.892156 - }, - { - "latitude": 33.79622, - "longitude": -117.892656 - }, - { - "latitude": 33.796218, - "longitude": -117.893194 - }, - { - "latitude": 33.796221, - "longitude": -117.893779 - }, - { - "latitude": 33.796221, - "longitude": -117.893826 - }, - { - "latitude": 33.796222, - "longitude": -117.893992 - }, - { - "latitude": 33.796349, - "longitude": -117.894127 - }, - { - "latitude": 33.79697, - "longitude": -117.894781 - }, - { - "latitude": 33.797375, - "longitude": -117.895274 - }, - { - "latitude": 33.797553, - "longitude": -117.89546 - }, - { - "latitude": 33.797906, - "longitude": -117.895831 - }, - { - "latitude": 33.798837, - "longitude": -117.896728 - }, - { - "latitude": 33.799199, - "longitude": -117.897077 - }, - { - "latitude": 33.799409, - "longitude": -117.897262 - }, - { - "latitude": 33.799531, - "longitude": -117.897349 - }, - { - "latitude": 33.799914, - "longitude": -117.897622 - }, - { - "latitude": 33.800647, - "longitude": -117.898145 - }, - { - "latitude": 33.800834, - "longitude": -117.898141 - }, - { - "latitude": 33.800882, - "longitude": -117.89813 - }, - { - "latitude": 33.800913, - "longitude": -117.89811 - }, - { - "latitude": 33.800971, - "longitude": -117.89806 - }, - { - "latitude": 33.801007, - "longitude": -117.89801 - }, - { - "latitude": 33.801065, - "longitude": -117.897961 - }, - { - "latitude": 33.801111, - "longitude": -117.897921 - }, - { - "latitude": 33.801157, - "longitude": -117.897885 - }, - { - "latitude": 33.801252, - "longitude": -117.897844 - }, - { - "latitude": 33.801328, - "longitude": -117.897829 - }, - { - "latitude": 33.801397, - "longitude": -117.897823 - }, - { - "latitude": 33.8014, - "longitude": -117.897823 - }, - { - "latitude": 33.80156, - "longitude": -117.89783 - }, - { - "latitude": 33.801856, - "longitude": -117.897841 - }, - { - "latitude": 33.801885, - "longitude": -117.897842 - }, - { - "latitude": 33.802027, - "longitude": -117.897848 - }, - { - "latitude": 33.802583, - "longitude": -117.89787 - }, - { - "latitude": 33.802692, - "longitude": -117.897874 - }, - { - "latitude": 33.80274, - "longitude": -117.897876 - }, - { - "latitude": 33.802863, - "longitude": -117.897881 - }, - { - "latitude": 33.802899, - "longitude": -117.897882 - }, - { - "latitude": 33.802955, - "longitude": -117.897884 - }, - { - "latitude": 33.80317, - "longitude": -117.897893 - }, - { - "latitude": 33.803276, - "longitude": -117.897904 - }, - { - "latitude": 33.80342, - "longitude": -117.897907 - }, - { - "latitude": 33.803855, - "longitude": -117.897913 - }, - { - "latitude": 33.804052, - "longitude": -117.897914 - }, - { - "latitude": 33.804235, - "longitude": -117.897916 - }, - { - "latitude": 33.804746, - "longitude": -117.897919 - }, - { - "latitude": 33.805197, - "longitude": -117.897922 - }, - { - "latitude": 33.805198, - "longitude": -117.897256 - }, - { - "latitude": 33.805199, - "longitude": -117.896883 - }, - { - "latitude": 33.805194, - "longitude": -117.895951 - }, - { - "latitude": 33.805184, - "longitude": -117.895913 - }, - { - "latitude": 33.805155, - "longitude": -117.895877 - }, - { - "latitude": 33.805109, - "longitude": -117.895855 - }, - { - "latitude": 33.804577, - "longitude": -117.895853 - }, - { - "latitude": 33.804278, - "longitude": -117.895852 - }, - { - "latitude": 33.803586, - "longitude": -117.895845 - }, - { - "latitude": 33.803438, - "longitude": -117.895845 - }, - { - "latitude": 33.803435, - "longitude": -117.896293 - }, - { - "latitude": 33.803431, - "longitude": -117.89687 - }, - { - "latitude": 33.803428, - "longitude": -117.897051 - }, - { - "latitude": 33.803425, - "longitude": -117.897278 - }, - { - "latitude": 33.803424, - "longitude": -117.897351 - }, - { - "latitude": 33.80342, - "longitude": -117.897907 - }, - { - "latitude": 33.803404, - "longitude": -117.898058 - }, - { - "latitude": 33.803411, - "longitude": -117.898721 - }, - { - "latitude": 33.803415, - "longitude": -117.899152 - }, - { - "latitude": 33.803418, - "longitude": -117.899238 - }, - { - "latitude": 33.803422, - "longitude": -117.899462 - }, - { - "latitude": 33.803435, - "longitude": -117.899958 - }, - { - "latitude": 33.803436, - "longitude": -117.900207 - }, - { - "latitude": 33.803441, - "longitude": -117.900911 - }, - { - "latitude": 33.803438, - "longitude": -117.90115 - }, - { - "latitude": 33.803434, - "longitude": -117.901409 - }, - { - "latitude": 33.803424, - "longitude": -117.903146 - }, - { - "latitude": 33.803436, - "longitude": -117.903749 - }, - { - "latitude": 33.803445, - "longitude": -117.904164 - }, - { - "latitude": 33.803257, - "longitude": -117.904204 - }, - { - "latitude": 33.803135, - "longitude": -117.904206 - }, - { - "latitude": 33.803043, - "longitude": -117.904209 - }, - { - "latitude": 33.802649, - "longitude": -117.904221 - }, - { - "latitude": 33.802399, - "longitude": -117.90418 - }, - { - "latitude": 33.802255, - "longitude": -117.904083 - }, - { - "latitude": 33.802167, - "longitude": -117.90399 - }, - { - "latitude": 33.802157, - "longitude": -117.903976 - }, - { - "latitude": 33.80212, - "longitude": -117.903921 - }, - { - "latitude": 33.802043, - "longitude": -117.903771 - }, - { - "latitude": 33.802017, - "longitude": -117.903673 - }, - { - "latitude": 33.801993, - "longitude": -117.903558 - }, - { - "latitude": 33.801987, - "longitude": -117.90345 - }, - { - "latitude": 33.801983, - "longitude": -117.902367 - }, - { - "latitude": 33.801948, - "longitude": -117.902095 - }, - { - "latitude": 33.801926, - "longitude": -117.902026 - }, - { - "latitude": 33.801903, - "longitude": -117.901956 - }, - { - "latitude": 33.801839, - "longitude": -117.901826 - }, - { - "latitude": 33.801786, - "longitude": -117.901719 - }, - { - "latitude": 33.801701, - "longitude": -117.901609 - }, - { - "latitude": 33.801253, - "longitude": -117.901144 - }, - { - "latitude": 33.801145, - "longitude": -117.901027 - }, - { - "latitude": 33.801071, - "longitude": -117.900947 - }, - { - "latitude": 33.801056, - "longitude": -117.90093 - }, - { - "latitude": 33.800919, - "longitude": -117.900784 - }, - { - "latitude": 33.800634, - "longitude": -117.900488 - }, - { - "latitude": 33.800628, - "longitude": -117.900482 - }, - { - "latitude": 33.800479, - "longitude": -117.900333 - }, - { - "latitude": 33.799921, - "longitude": -117.899744 - }, - { - "latitude": 33.799472, - "longitude": -117.899273 - }, - { - "latitude": 33.799209, - "longitude": -117.898995 - }, - { - "latitude": 33.79906, - "longitude": -117.898839 - }, - { - "latitude": 33.798842, - "longitude": -117.8986 - }, - { - "latitude": 33.798741, - "longitude": -117.898492 - }, - { - "latitude": 33.798514, - "longitude": -117.898254 - }, - { - "latitude": 33.798244, - "longitude": -117.897975 - }, - { - "latitude": 33.7973, - "longitude": -117.896978 - }, - { - "latitude": 33.797195, - "longitude": -117.896868 - }, - { - "latitude": 33.796934, - "longitude": -117.8966 - }, - { - "latitude": 33.796921, - "longitude": -117.896587 - }, - { - "latitude": 33.796358, - "longitude": -117.896012 - }, - { - "latitude": 33.796335, - "longitude": -117.895988 - }, - { - "latitude": 33.7962, - "longitude": -117.89585 - }, - { - "latitude": 33.796072, - "longitude": -117.895716 - }, - { - "latitude": 33.79597, - "longitude": -117.895613 - }, - { - "latitude": 33.79589, - "longitude": -117.895532 - }, - { - "latitude": 33.795832, - "longitude": -117.895474 - }, - { - "latitude": 33.795668, - "longitude": -117.895309 - }, - { - "latitude": 33.79546, - "longitude": -117.895097 - }, - { - "latitude": 33.795195, - "longitude": -117.894826 - }, - { - "latitude": 33.794579, - "longitude": -117.894189 - }, - { - "latitude": 33.794425, - "longitude": -117.894039 - }, - { - "latitude": 33.794288, - "longitude": -117.893906 - }, - { - "latitude": 33.79418, - "longitude": -117.893804 - }, - { - "latitude": 33.794062, - "longitude": -117.893702 - }, - { - "latitude": 33.79396, - "longitude": -117.893618 - }, - { - "latitude": 33.793937, - "longitude": -117.893601 - }, - { - "latitude": 33.793865, - "longitude": -117.893551 - }, - { - "latitude": 33.793759, - "longitude": -117.893476 - }, - { - "latitude": 33.793631, - "longitude": -117.893388 - }, - { - "latitude": 33.793332, - "longitude": -117.893181 - }, - { - "latitude": 33.793226, - "longitude": -117.893108 - }, - { - "latitude": 33.793044, - "longitude": -117.892984 - }, - { - "latitude": 33.792893, - "longitude": -117.892889 - }, - { - "latitude": 33.792622, - "longitude": -117.892731 - }, - { - "latitude": 33.792372, - "longitude": -117.892583 - }, - { - "latitude": 33.792252, - "longitude": -117.892527 - }, - { - "latitude": 33.79216, - "longitude": -117.892494 - }, - { - "latitude": 33.79207, - "longitude": -117.892472 - }, - { - "latitude": 33.791975, - "longitude": -117.892457 - }, - { - "latitude": 33.791875, - "longitude": -117.89245 - }, - { - "latitude": 33.791768, - "longitude": -117.892452 - }, - { - "latitude": 33.791662, - "longitude": -117.892462 - }, - { - "latitude": 33.79155, - "longitude": -117.892486 - }, - { - "latitude": 33.791502, - "longitude": -117.892502 - }, - { - "latitude": 33.791409, - "longitude": -117.892539 - }, - { - "latitude": 33.791328, - "longitude": -117.892581 - }, - { - "latitude": 33.791066, - "longitude": -117.892748 - }, - { - "latitude": 33.790967, - "longitude": -117.89283 - }, - { - "latitude": 33.790931, - "longitude": -117.892855 - }, - { - "latitude": 33.790832, - "longitude": -117.892915 - }, - { - "latitude": 33.79073, - "longitude": -117.89299 - }, - { - "latitude": 33.790693, - "longitude": -117.893015 - }, - { - "latitude": 33.790582, - "longitude": -117.893076 - }, - { - "latitude": 33.790522, - "longitude": -117.8931 - }, - { - "latitude": 33.790457, - "longitude": -117.893121 - }, - { - "latitude": 33.790396, - "longitude": -117.893138 - }, - { - "latitude": 33.790328, - "longitude": -117.893149 - }, - { - "latitude": 33.790258, - "longitude": -117.893155 - }, - { - "latitude": 33.790169, - "longitude": -117.893159 - }, - { - "latitude": 33.790044, - "longitude": -117.893159 - }, - { - "latitude": 33.78993, - "longitude": -117.893134 - }, - { - "latitude": 33.789683, - "longitude": -117.893146 - }, - { - "latitude": 33.789567, - "longitude": -117.893145 - }, - { - "latitude": 33.789512, - "longitude": -117.893146 - }, - { - "latitude": 33.789169, - "longitude": -117.893144 - }, - { - "latitude": 33.789145, - "longitude": -117.893144 - }, - { - "latitude": 33.789016, - "longitude": -117.893145 - }, - { - "latitude": 33.788898, - "longitude": -117.893146 - }, - { - "latitude": 33.788902, - "longitude": -117.892245 - }, - { - "latitude": 33.788902, - "longitude": -117.892228 - }, - { - "latitude": 33.788905, - "longitude": -117.892119 - }, - { - "latitude": 33.788903, - "longitude": -117.891973 - }, - { - "latitude": 33.788903, - "longitude": -117.891704 - }, - { - "latitude": 33.788903, - "longitude": -117.891117 - }, - { - "latitude": 33.788903, - "longitude": -117.890936 - }, - { - "latitude": 33.788904, - "longitude": -117.8907 - }, - { - "latitude": 33.788909, - "longitude": -117.890516 - }, - { - "latitude": 33.788913, - "longitude": -117.890489 - }, - { - "latitude": 33.788954, - "longitude": -117.890197 - }, - { - "latitude": 33.78905, - "longitude": -117.889708 - }, - { - "latitude": 33.789086, - "longitude": -117.889537 - }, - { - "latitude": 33.789321, - "longitude": -117.88852 - }, - { - "latitude": 33.789397, - "longitude": -117.88846 - }, - { - "latitude": 33.789454, - "longitude": -117.888414 - }, - { - "latitude": 33.789481, - "longitude": -117.888098 - }, - { - "latitude": 33.789504, - "longitude": -117.887942 - }, - { - "latitude": 33.789519, - "longitude": -117.887449 - }, - { - "latitude": 33.789507, - "longitude": -117.887303 - }, - { - "latitude": 33.789494, - "longitude": -117.887171 - }, - { - "latitude": 33.789392, - "longitude": -117.886814 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789336, - "longitude": -117.886428 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789583, - "longitude": -117.886182 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789732, - "longitude": -117.886235 - }, - { - "latitude": 33.789622, - "longitude": -117.886127 - }, - { - "latitude": 33.789589, - "longitude": -117.886174 - }, - { - "latitude": 33.789491, - "longitude": -117.886321 - }, - { - "latitude": 33.789293, - "longitude": -117.886458 - }, - { - "latitude": 33.789241, - "longitude": -117.886328 - }, - { - "latitude": 33.789139, - "longitude": -117.886074 - }, - { - "latitude": 33.789115, - "longitude": -117.886014 - }, - { - "latitude": 33.788893, - "longitude": -117.885639 - }, - { - "latitude": 33.788819, - "longitude": -117.885531 - }, - { - "latitude": 33.788446, - "longitude": -117.884941 - }, - { - "latitude": 33.788366, - "longitude": -117.884815 - }, - { - "latitude": 33.788229, - "longitude": -117.884547 - }, - { - "latitude": 33.788216, - "longitude": -117.884521 - }, - { - "latitude": 33.7881, - "longitude": -117.884219 - }, - { - "latitude": 33.788014, - "longitude": -117.883957 - }, - { - "latitude": 33.787992, - "longitude": -117.88383 - }, - { - "latitude": 33.787956, - "longitude": -117.883695 - }, - { - "latitude": 33.787909, - "longitude": -117.883363 - }, - { - "latitude": 33.787901, - "longitude": -117.883304 - }, - { - "latitude": 33.787883, - "longitude": -117.882974 - }, - { - "latitude": 33.787878, - "longitude": -117.881727 - }, - { - "latitude": 33.787877, - "longitude": -117.881621 - }, - { - "latitude": 33.787874, - "longitude": -117.881424 - }, - { - "latitude": 33.787869, - "longitude": -117.881189 - }, - { - "latitude": 33.787837, - "longitude": -117.881017 - }, - { - "latitude": 33.78784, - "longitude": -117.880648 - }, - { - "latitude": 33.78784, - "longitude": -117.880625 - }, - { - "latitude": 33.787841, - "longitude": -117.880435 - }, - { - "latitude": 33.787847, - "longitude": -117.879556 - }, - { - "latitude": 33.787844, - "longitude": -117.87913 - }, - { - "latitude": 33.787844, - "longitude": -117.879052 - }, - { - "latitude": 33.787843, - "longitude": -117.878851 - }, - { - "latitude": 33.787842, - "longitude": -117.87872 - }, - { - "latitude": 33.787891, - "longitude": -117.878628 - }, - { - "latitude": 33.787891, - "longitude": -117.878604 - }, - { - "latitude": 33.78789, - "longitude": -117.878542 - }, - { - "latitude": 33.78789, - "longitude": -117.87838 - }, - { - "latitude": 33.78789, - "longitude": -117.87815 - }, - { - "latitude": 33.78789, - "longitude": -117.878024 - }, - { - "latitude": 33.787889, - "longitude": -117.877933 - }, - { - "latitude": 33.787889, - "longitude": -117.877739 - }, - { - "latitude": 33.787889, - "longitude": -117.877685 - }, - { - "latitude": 33.787888, - "longitude": -117.877601 - }, - { - "latitude": 33.787888, - "longitude": -117.877582 - }, - { - "latitude": 33.787889, - "longitude": -117.877097 - }, - { - "latitude": 33.787888, - "longitude": -117.876796 - }, - { - "latitude": 33.787888, - "longitude": -117.876702 - }, - { - "latitude": 33.787888, - "longitude": -117.876358 - }, - { - "latitude": 33.787889, - "longitude": -117.876046 - }, - { - "latitude": 33.787887, - "longitude": -117.875705 - }, - { - "latitude": 33.787887, - "longitude": -117.875554 - }, - { - "latitude": 33.787887, - "longitude": -117.875389 - }, - { - "latitude": 33.787887, - "longitude": -117.87527 - }, - { - "latitude": 33.787887, - "longitude": -117.87502 - }, - { - "latitude": 33.787886, - "longitude": -117.874578 - }, - { - "latitude": 33.787887, - "longitude": -117.874561 - }, - { - "latitude": 33.787886, - "longitude": -117.874423 - }, - { - "latitude": 33.787884, - "longitude": -117.873824 - }, - { - "latitude": 33.787884, - "longitude": -117.873785 - }, - { - "latitude": 33.787883, - "longitude": -117.873361 - }, - { - "latitude": 33.787883, - "longitude": -117.873275 - }, - { - "latitude": 33.787883, - "longitude": -117.873109 - }, - { - "latitude": 33.787883, - "longitude": -117.873037 - }, - { - "latitude": 33.787883, - "longitude": -117.872802 - }, - { - "latitude": 33.787883, - "longitude": -117.872633 - }, - { - "latitude": 33.787883, - "longitude": -117.872562 - }, - { - "latitude": 33.787883, - "longitude": -117.872335 - }, - { - "latitude": 33.787883, - "longitude": -117.871983 - }, - { - "latitude": 33.787883, - "longitude": -117.871711 - }, - { - "latitude": 33.787884, - "longitude": -117.871607 - }, - { - "latitude": 33.787883, - "longitude": -117.871513 - }, - { - "latitude": 33.787883, - "longitude": -117.871162 - }, - { - "latitude": 33.787883, - "longitude": -117.870965 - }, - { - "latitude": 33.787883, - "longitude": -117.870767 - }, - { - "latitude": 33.787883, - "longitude": -117.870517 - }, - { - "latitude": 33.787881, - "longitude": -117.870171 - }, - { - "latitude": 33.787882, - "longitude": -117.869975 - }, - { - "latitude": 33.787882, - "longitude": -117.869589 - }, - { - "latitude": 33.787886, - "longitude": -117.86919 - }, - { - "latitude": 33.787884, - "longitude": -117.869024 - }, - { - "latitude": 33.787885, - "longitude": -117.868824 - }, - { - "latitude": 33.787883, - "longitude": -117.868666 - }, - { - "latitude": 33.787881, - "longitude": -117.868433 - }, - { - "latitude": 33.787881, - "longitude": -117.868308 - }, - { - "latitude": 33.787881, - "longitude": -117.868222 - }, - { - "latitude": 33.78788, - "longitude": -117.86803 - }, - { - "latitude": 33.78788, - "longitude": -117.867944 - }, - { - "latitude": 33.787888, - "longitude": -117.867306 - }, - { - "latitude": 33.787838, - "longitude": -117.867107 - }, - { - "latitude": 33.787838, - "longitude": -117.866715 - }, - { - "latitude": 33.787838, - "longitude": -117.866531 - }, - { - "latitude": 33.787838, - "longitude": -117.866114 - }, - { - "latitude": 33.787838, - "longitude": -117.865902 - }, - { - "latitude": 33.787838, - "longitude": -117.86567 - }, - { - "latitude": 33.787881, - "longitude": -117.865554 - }, - { - "latitude": 33.787881, - "longitude": -117.865442 - }, - { - "latitude": 33.787881, - "longitude": -117.865167 - }, - { - "latitude": 33.787882, - "longitude": -117.865075 - }, - { - "latitude": 33.787882, - "longitude": -117.86481 - }, - { - "latitude": 33.787882, - "longitude": -117.864681 - }, - { - "latitude": 33.787882, - "longitude": -117.864622 - }, - { - "latitude": 33.787883, - "longitude": -117.86401 - }, - { - "latitude": 33.787883, - "longitude": -117.863948 - }, - { - "latitude": 33.787883, - "longitude": -117.863697 - }, - { - "latitude": 33.787883, - "longitude": -117.86316 - }, - { - "latitude": 33.787884, - "longitude": -117.862915 - }, - { - "latitude": 33.787884, - "longitude": -117.862841 - }, - { - "latitude": 33.787884, - "longitude": -117.862684 - }, - { - "latitude": 33.787884, - "longitude": -117.862405 - }, - { - "latitude": 33.787884, - "longitude": -117.862261 - }, - { - "latitude": 33.787885, - "longitude": -117.861974 - }, - { - "latitude": 33.787884, - "longitude": -117.861825 - }, - { - "latitude": 33.787885, - "longitude": -117.861692 - }, - { - "latitude": 33.787885, - "longitude": -117.861519 - }, - { - "latitude": 33.787886, - "longitude": -117.861197 - }, - { - "latitude": 33.787887, - "longitude": -117.860739 - }, - { - "latitude": 33.787887, - "longitude": -117.860435 - }, - { - "latitude": 33.787887, - "longitude": -117.860392 - }, - { - "latitude": 33.787888, - "longitude": -117.860093 - }, - { - "latitude": 33.787888, - "longitude": -117.85999 - }, - { - "latitude": 33.787889, - "longitude": -117.859655 - }, - { - "latitude": 33.787888, - "longitude": -117.859323 - }, - { - "latitude": 33.787888, - "longitude": -117.859286 - }, - { - "latitude": 33.787888, - "longitude": -117.858868 - }, - { - "latitude": 33.787888, - "longitude": -117.858716 - }, - { - "latitude": 33.787887, - "longitude": -117.858556 - }, - { - "latitude": 33.787887, - "longitude": -117.858476 - }, - { - "latitude": 33.787887, - "longitude": -117.858293 - }, - { - "latitude": 33.787887, - "longitude": -117.858081 - }, - { - "latitude": 33.787886, - "longitude": -117.857986 - }, - { - "latitude": 33.787846, - "longitude": -117.857894 - }, - { - "latitude": 33.787846, - "longitude": -117.857828 - }, - { - "latitude": 33.787846, - "longitude": -117.857771 - }, - { - "latitude": 33.787847, - "longitude": -117.857518 - }, - { - "latitude": 33.787848, - "longitude": -117.85746 - }, - { - "latitude": 33.787848, - "longitude": -117.857265 - }, - { - "latitude": 33.787849, - "longitude": -117.85709 - }, - { - "latitude": 33.787849, - "longitude": -117.857041 - }, - { - "latitude": 33.787891, - "longitude": -117.856976 - }, - { - "latitude": 33.787891, - "longitude": -117.856888 - }, - { - "latitude": 33.78789, - "longitude": -117.856602 - }, - { - "latitude": 33.787889, - "longitude": -117.85647 - }, - { - "latitude": 33.787889, - "longitude": -117.856375 - }, - { - "latitude": 33.78797, - "longitude": -117.856375 - }, - { - "latitude": 33.788076, - "longitude": -117.856372 - }, - { - "latitude": 33.788321, - "longitude": -117.856366 - }, - { - "latitude": 33.789279, - "longitude": -117.856365 - }, - { - "latitude": 33.78962, - "longitude": -117.85637 - }, - { - "latitude": 33.789701, - "longitude": -117.856365 - }, - { - "latitude": 33.789782, - "longitude": -117.856364 - }, - { - "latitude": 33.790351, - "longitude": -117.85636 - }, - { - "latitude": 33.791312, - "longitude": -117.856358 - }, - { - "latitude": 33.791437, - "longitude": -117.856363 - }, - { - "latitude": 33.791517, - "longitude": -117.856367 - }, - { - "latitude": 33.791598, - "longitude": -117.856366 - }, - { - "latitude": 33.793323, - "longitude": -117.856355 - }, - { - "latitude": 33.793322, - "longitude": -117.855591 - }, - { - "latitude": 33.793322, - "longitude": -117.855284 - }, - { - "latitude": 33.79331, - "longitude": -117.854187 - }, - { - "latitude": 33.793311, - "longitude": -117.853698 - }, - { - "latitude": 33.793311, - "longitude": -117.853367 - }, - { - "latitude": 33.793311, - "longitude": -117.853212 - }, - { - "latitude": 33.793311, - "longitude": -117.853097 - }, - { - "latitude": 33.793315, - "longitude": -117.852996 - }, - { - "latitude": 33.793316, - "longitude": -117.85281 - }, - { - "latitude": 33.793325, - "longitude": -117.85281 - } -] - -const routes: IRoute[] = [ - { - name: "Red Route", - id: "1", - systemId: supportedIntegrationTestSystems[0].id, - polylineCoordinates: redRoutePolylineCoordinates, - color: "#db2316", - updatedTime: new Date(), - }, - { - name: "Teal Route", - id: "2", - systemId: supportedIntegrationTestSystems[0].id, - polylineCoordinates: tealRoutePolylineCoordinates, - color: "#21bdd1", - updatedTime: new Date(), - }, -]; - -const stops: IStop[] = [ - { - id: "1", - name: "Chapman Court", - coordinates: { - latitude: 33.796001, - longitude: -117.8892805, - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - id: "2", - name: "Chapman Grand", - coordinates: { - latitude: 33.804433, - longitude: -117.895966, - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - id: "3", - name: "Schmid Gate", - coordinates: { - "latitude": 33.793325, - "longitude": -117.85281 - }, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - } -]; - -const orderedStopsForRedRoute: IOrderedStop[] = [ - { - routeId: routes[0].id, - stopId: stops[0].id, - position: 1, - systemId: "1", - updatedTime: new Date(), - }, - { - routeId: routes[0].id, - stopId: stops[2].id, - position: 2, - systemId: "1", - updatedTime: new Date(), - }, -]; - -const orderedStopsForTealRoute: IOrderedStop[] = [ - { - routeId: routes[1].id, - stopId: stops[0].id, - position: 1, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - routeId: routes[1].id, - stopId: stops[1].id, - position: 2, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - routeId: routes[1].id, - stopId: stops[2].id, - position: 2, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, -] - -orderedStopsForRedRoute[0].nextStop = orderedStopsForRedRoute[1]; -orderedStopsForRedRoute[1].previousStop = orderedStopsForRedRoute[0]; -orderedStopsForTealRoute[0].nextStop = orderedStopsForTealRoute[1]; -orderedStopsForTealRoute[1].previousStop = orderedStopsForTealRoute[0]; - -const shuttles: IShuttle[] = [ - { - name: "17", - id: "1", - coordinates: { - latitude: 33.788021, - longitude: -117.883698, - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 45.91, - updatedTime: new Date(), - }, - { - name: "24", - id: "2", - coordinates: { - latitude: 33.787841, - longitude: -117.862825, - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 90.24, - updatedTime: new Date(), - }, - { - name: "32", - id: "3", - coordinates: { - // 33.79243° N, 117.85638° W - latitude: 33.79243, - longitude: -117.85638 - }, - routeId: routes[0].id, - systemId: supportedIntegrationTestSystems[0].id, - orientationInDegrees: 180.11, - updatedTime: new Date(), - } -]; - -const etas: IEta[] = [ - { - stopId: stops[0].id, - shuttleId: shuttles[0].id, - secondsRemaining: 12.023, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[2].id, - shuttleId: shuttles[0].id, - secondsRemaining: 600.123, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[2].id, - shuttleId: shuttles[1].id, - secondsRemaining: 172.015, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[0].id, - shuttleId: shuttles[1].id, - secondsRemaining: 710.152, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, - { - stopId: stops[0].id, - shuttleId: shuttles[2].id, - secondsRemaining: 540.192, - systemId: supportedIntegrationTestSystems[0].id, - updatedTime: new Date(), - }, -]; - -export async function loadShuttleTestData(repository: ShuttleGetterSetterRepository) { - await Promise.all(routes.map(async (route) => { - await repository.addOrUpdateRoute(route); - })); - await Promise.all(shuttles.map(async (shuttle) => { - await repository.addOrUpdateShuttle(shuttle); - })); - await Promise.all(stops.map(async (stop) => { - await repository.addOrUpdateStop(stop); - })); - await Promise.all(orderedStopsForRedRoute.map(async (orderedStop) => { - await repository.addOrUpdateOrderedStop(orderedStop); - })); - await Promise.all(orderedStopsForTealRoute.map(async (orderedStop) => { - await repository.addOrUpdateOrderedStop(orderedStop); - })); - await Promise.all(etas.map(async (eta) => { - await repository.addOrUpdateEta(eta); - })); -} diff --git a/src/loaders/supportedIntegrationTestSystems.ts b/src/loaders/supportedIntegrationTestSystems.ts deleted file mode 100644 index 8fd7d6d..0000000 --- a/src/loaders/supportedIntegrationTestSystems.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { InterchangeSystemBuilderArguments } from "../entities/InterchangeSystem"; -import { ChapmanApiBasedParkingRepositoryLoader } from "./parking/ChapmanApiBasedParkingRepositoryLoader"; - -export const supportedIntegrationTestSystems: InterchangeSystemBuilderArguments[] = [ - { - id: "1", - name: "Chapman University", - passioSystemId: "263", - parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, - }, -]; diff --git a/src/notifications/schedulers/ETANotificationScheduler.ts b/src/notifications/schedulers/ETANotificationScheduler.ts index 62a22c9..c18f280 100644 --- a/src/notifications/schedulers/ETANotificationScheduler.ts +++ b/src/notifications/schedulers/ETANotificationScheduler.ts @@ -1,4 +1,3 @@ -import { ShuttleGetterRepository, ShuttleRepositoryEvent } from "../../repositories/shuttle/ShuttleGetterRepository"; import { IEta } from "../../entities/ShuttleRepositoryEntities"; import { AppleNotificationSender, NotificationAlertArguments } from "../senders/AppleNotificationSender"; import { @@ -6,11 +5,14 @@ import { ScheduledNotification } from "../../repositories/notifications/NotificationRepository"; import { InMemoryNotificationRepository } from "../../repositories/notifications/InMemoryNotificationRepository"; +import { ETAGetterRepository, ETARepositoryEvent } from "../../repositories/shuttle/eta/ETAGetterRepository"; +import { ShuttleGetterRepository } from "../../repositories/shuttle/ShuttleGetterRepository"; export class ETANotificationScheduler { public static readonly defaultSecondsThresholdForNotificationToFire = 180; constructor( + private etaRepository: ETAGetterRepository, private shuttleRepository: ShuttleGetterRepository, private notificationRepository: NotificationRepository = new InMemoryNotificationRepository(), private appleNotificationSender = new AppleNotificationSender(), @@ -26,7 +28,7 @@ export class ETANotificationScheduler { const shuttle = await this.shuttleRepository.getShuttleById(shuttleId); const stop = await this.shuttleRepository.getStopById(stopId); - const eta = await this.shuttleRepository.getEtaForShuttleAndStopId(shuttleId, stopId); + const eta = await this.etaRepository.getEtaForShuttleAndStopId(shuttleId, stopId); if (!shuttle) { console.warn(`Notification ${notificationData} fell through; no associated shuttle`); return false; @@ -90,10 +92,10 @@ export class ETANotificationScheduler { // The following is a workaround for the constructor being called twice public startListeningForUpdates() { - this.shuttleRepository.on(ShuttleRepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); + this.etaRepository.on(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); } public stopListeningForUpdates() { - this.shuttleRepository.off(ShuttleRepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); + this.etaRepository.off(ETARepositoryEvent.ETA_UPDATED, this.etaSubscriberCallback); } } diff --git a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts index 7fc621f..9c3ae89 100644 --- a/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts +++ b/src/notifications/schedulers/__tests__/ETANotificationSchedulerTests.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { ETANotificationScheduler } from "../ETANotificationScheduler"; import { UnoptimizedInMemoryShuttleRepository } from "../../../repositories/shuttle/UnoptimizedInMemoryShuttleRepository"; +import { InMemoryExternalSourceETARepository } from "../../../repositories/shuttle/eta/InMemoryExternalSourceETARepository"; import { IEta, IShuttle, IStop } from "../../../entities/ShuttleRepositoryEntities"; import { addMockShuttleToRepository, addMockStopToRepository } from "../../../../testHelpers/repositorySetupHelpers"; import { AppleNotificationSender } from "../../senders/AppleNotificationSender"; @@ -26,18 +27,21 @@ async function waitForMilliseconds(ms: number): Promise { describe("ETANotificationScheduler", () => { - let shuttleRepository: UnoptimizedInMemoryShuttleRepository + let shuttleRepository: UnoptimizedInMemoryShuttleRepository; + let etaRepository: InMemoryExternalSourceETARepository; let notificationService: ETANotificationScheduler; let notificationRepository: NotificationRepository; beforeEach(() => { shuttleRepository = new UnoptimizedInMemoryShuttleRepository(); notificationRepository = new InMemoryNotificationRepository(); + etaRepository = new InMemoryExternalSourceETARepository(); mockNotificationSenderMethods(true); const appleNotificationSender = new MockAppleNotificationSender(false); notificationService = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, appleNotificationSender, @@ -80,7 +84,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); await notificationRepository.addOrUpdateNotification(notificationData2); - await shuttleRepository.addOrUpdateEta(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // Wait for the callback to actually be called @@ -103,7 +107,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEta(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert await waitForMilliseconds(500); @@ -127,6 +131,7 @@ describe("ETANotificationScheduler", () => { mockNotificationSenderMethods(false); const updatedNotificationSender = new MockAppleNotificationSender(false); notificationService = new ETANotificationScheduler( + etaRepository, shuttleRepository, notificationRepository, updatedNotificationSender, @@ -136,7 +141,7 @@ describe("ETANotificationScheduler", () => { // Act await notificationRepository.addOrUpdateNotification(notificationData1); - await shuttleRepository.addOrUpdateEta(eta); + await etaRepository.addOrUpdateEtaFromExternalSource(eta); // Assert // The notification should stay scheduled to be retried once diff --git a/src/repositories/BaseRedisRepository.ts b/src/repositories/BaseRedisRepository.ts index c5c8b73..f722586 100644 --- a/src/repositories/BaseRedisRepository.ts +++ b/src/repositories/BaseRedisRepository.ts @@ -1,11 +1,12 @@ -import { createClient } from 'redis'; +import { createClient, RedisClientType } from 'redis'; import { REDIS_RECONNECT_INTERVAL } from "../environment"; +import { EventEmitter } from 'stream'; -export abstract class BaseRedisRepository { +export abstract class BaseRedisRepository extends EventEmitter { protected redisClient; constructor( - redisClient = createClient({ + redisClient: RedisClientType = createClient({ url: process.env.REDIS_URL, socket: { tls: process.env.NODE_ENV === 'production', @@ -14,6 +15,7 @@ export abstract class BaseRedisRepository { }, }), ) { + super(); this.redisClient = redisClient; this.redisClient.on('error', (err) => { console.error(err.stack); @@ -31,8 +33,4 @@ export abstract class BaseRedisRepository { public async disconnect() { await this.redisClient.disconnect(); } - - public async clearAllData() { - await this.redisClient.flushAll(); - } } diff --git a/src/repositories/notifications/RedisNotificationRepository.ts b/src/repositories/notifications/RedisNotificationRepository.ts index 2c7609e..d4b2e0f 100644 --- a/src/repositories/notifications/RedisNotificationRepository.ts +++ b/src/repositories/notifications/RedisNotificationRepository.ts @@ -9,7 +9,7 @@ import { import { BaseRedisRepository } from "../BaseRedisRepository"; export class RedisNotificationRepository extends BaseRedisRepository implements NotificationRepository { - private listeners: Listener[] = []; + private notificationListeners: Listener[] = []; private readonly NOTIFICATION_KEY_PREFIX = 'notification:'; private getNotificationKey = (shuttleId: string, stopId: string): string => { @@ -23,7 +23,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements await this.redisClient.hSet(key, deviceId, secondsThreshold.toString()); - this.listeners.forEach((listener: Listener) => { + this.notificationListeners.forEach((listener: Listener) => { const event: NotificationEvent = { event: 'addOrUpdate', notification @@ -46,7 +46,7 @@ export class RedisNotificationRepository extends BaseRedisRepository implements await this.redisClient.del(key); } - this.listeners.forEach((listener) => { + this.notificationListeners.forEach((listener) => { const event: NotificationEvent = { event: 'delete', notification: { @@ -94,20 +94,20 @@ export class RedisNotificationRepository extends BaseRedisRepository implements }; public subscribeToNotificationChanges = (listener: Listener): void => { - const index = this.listeners.findIndex( + const index = this.notificationListeners.findIndex( (existingListener) => existingListener === listener ); if (index < 0) { - this.listeners.push(listener); + this.notificationListeners.push(listener); } }; public unsubscribeFromNotificationChanges = (listener: Listener): void => { - const index = this.listeners.findIndex( + const index = this.notificationListeners.findIndex( (existingListener) => existingListener === listener ); if (index >= 0) { - this.listeners.splice(index, 1); + this.notificationListeners.splice(index, 1); } }; } diff --git a/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts b/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts index daf9944..86ccf07 100644 --- a/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts +++ b/src/repositories/notifications/__tests__/NotificationRepositorySharedTests.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { InMemoryNotificationRepository } from "../InMemoryNotificationRepository"; import { NotificationEvent, NotificationRepository } from "../NotificationRepository"; import { RedisNotificationRepository } from "../RedisNotificationRepository"; @@ -19,17 +20,21 @@ class InMemoryRepositoryHolder implements RepositoryHolder { class RedisNotificationRepositoryHolder implements RepositoryHolder { repo: RedisNotificationRepository | undefined; + redisClient: RedisClientType | undefined; name = 'RedisNotificationRepository'; factory = async () => { - this.repo = new RedisNotificationRepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisNotificationRepository(this.redisClient); return this.repo; } teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } } } diff --git a/src/repositories/parking/InMemoryParkingRepository.ts b/src/repositories/parking/InMemoryParkingRepository.ts index 84cea32..e07307a 100644 --- a/src/repositories/parking/InMemoryParkingRepository.ts +++ b/src/repositories/parking/InMemoryParkingRepository.ts @@ -1,9 +1,9 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { - IParkingStructure, - IParkingStructureTimestampRecord + IParkingStructure, + IParkingStructureTimestampRecord } from "../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository"; import { CircularQueue } from "../../types/CircularQueue"; import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; @@ -63,7 +63,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise => { const queue = this.historicalData.get(id); if (!queue || queue.size() === 0) { return []; @@ -107,7 +107,7 @@ export class InMemoryParkingRepository implements ParkingGetterSetterRepository private calculateAveragesFromRecords = ( records: IParkingStructureTimestampRecord[], - options: HistoricalParkingAverageQueryArguments + options: HistoricalParkingAverageFilterArguments ): HistoricalParkingAverageQueryResult[] => { const results: HistoricalParkingAverageQueryResult[] = []; const { from, to, intervalMs } = options; diff --git a/src/repositories/parking/ParkingGetterRepository.ts b/src/repositories/parking/ParkingGetterRepository.ts index e1346a4..f3823e2 100644 --- a/src/repositories/parking/ParkingGetterRepository.ts +++ b/src/repositories/parking/ParkingGetterRepository.ts @@ -1,6 +1,6 @@ import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; -export interface HistoricalParkingAverageQueryArguments { +export interface HistoricalParkingAverageFilterArguments { from: Date; to: Date; intervalMs: number; @@ -22,5 +22,5 @@ export interface ParkingGetterRepository { * @param id * @param options */ - getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageQueryArguments): Promise; + getHistoricalAveragesOfParkingStructureCounts(id: string, options: HistoricalParkingAverageFilterArguments): Promise; } diff --git a/src/repositories/parking/RedisParkingRepository.ts b/src/repositories/parking/RedisParkingRepository.ts index 5704226..48a74b1 100644 --- a/src/repositories/parking/RedisParkingRepository.ts +++ b/src/repositories/parking/RedisParkingRepository.ts @@ -1,6 +1,6 @@ import { ParkingGetterSetterRepository } from "./ParkingGetterSetterRepository"; import { IParkingStructure } from "../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageQueryArguments } from "./ParkingGetterRepository"; +import { HistoricalParkingAverageQueryResult, HistoricalParkingAverageFilterArguments } from "./ParkingGetterRepository"; import { BaseRedisRepository } from "../BaseRedisRepository"; import { PARKING_LOGGING_INTERVAL_MS } from "../../environment"; @@ -75,7 +75,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki return null; }; - getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageQueryArguments): Promise => { + getHistoricalAveragesOfParkingStructureCounts = async (id: string, options: HistoricalParkingAverageFilterArguments): Promise => { return this.calculateAveragesFromRecords(id, options); }; @@ -157,7 +157,7 @@ export class RedisParkingRepository extends BaseRedisRepository implements Parki private calculateAveragesFromRecords = async ( id: string, - options: HistoricalParkingAverageQueryArguments + options: HistoricalParkingAverageFilterArguments ): Promise => { const keys = this.createRedisKeys(id); const { from, to, intervalMs } = options; diff --git a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts index d6d5858..d9b4162 100644 --- a/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts +++ b/src/repositories/parking/__tests__/ParkingRepositorySharedTests.test.ts @@ -1,17 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { InMemoryParkingRepository, } from "../InMemoryParkingRepository"; import { IParkingStructure } from "../../../entities/ParkingRepositoryEntities"; -import { HistoricalParkingAverageQueryArguments } from "../ParkingGetterRepository"; import { ParkingGetterSetterRepository } from "../ParkingGetterSetterRepository"; import { RedisParkingRepository } from "../RedisParkingRepository"; +import { HistoricalParkingAverageFilterArguments } from "../ParkingGetterRepository"; +import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; -interface RepositoryHolder { - name: string; - factory(): Promise; - teardown(): Promise; -} - -class InMemoryParkingRepositoryHolder implements RepositoryHolder { +class InMemoryParkingRepositoryHolder implements RepositoryHolder { name = 'InMemoryParkingRepository'; factory = async () => { return new InMemoryParkingRepository(); @@ -19,19 +15,23 @@ class InMemoryParkingRepositoryHolder implements RepositoryHolder { teardown = async () => {}; } -class RedisParkingRepositoryHolder implements RepositoryHolder { +class RedisParkingRepositoryHolder implements RepositoryHolder { repo: RedisParkingRepository | undefined; + redisClient: RedisClientType | undefined; name = 'RedisParkingRepository'; factory = async () => { - this.repo = new RedisParkingRepository(); - await this.repo.connect(); + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisParkingRepository(this.redisClient); return this.repo; }; teardown = async () => { - if (this.repo) { - await this.repo.clearAllData(); - await this.repo.disconnect(); + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); } }; } @@ -151,7 +151,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { describe("getHistoricalAveragesOfParkingStructureCounts", () => { it("should return empty array for non-existent structure or no data", async () => { - const options: HistoricalParkingAverageQueryArguments = { + const options: HistoricalParkingAverageFilterArguments = { from: new Date(1000), to: new Date(2000), intervalMs: 500 @@ -182,7 +182,7 @@ describe.each(repositoryImplementations)('$name', (holder) => { } const now = Date.now(); - const options: HistoricalParkingAverageQueryArguments = { + const options: HistoricalParkingAverageFilterArguments = { from: new Date(now - 10000), // Look back 10 seconds to: new Date(now + 10000), // Look forward 10 seconds intervalMs: 20000 // Single large interval diff --git a/src/repositories/shuttle/RedisShuttleRepository.ts b/src/repositories/shuttle/RedisShuttleRepository.ts new file mode 100644 index 0000000..cd90147 --- /dev/null +++ b/src/repositories/shuttle/RedisShuttleRepository.ts @@ -0,0 +1,591 @@ +import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; +import { IEta, IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; +import { + ShuttleRepositoryEvent, + ShuttleRepositoryEventListener, + ShuttleRepositoryEventName, + ShuttleRepositoryEventPayloads, + ShuttleStopArrival, + ShuttleTravelTimeDataIdentifier, + ShuttleTravelTimeDateFilterArguments +} from "./ShuttleGetterRepository"; +import { BaseRedisRepository } from "../BaseRedisRepository"; + +export class RedisShuttleRepository extends BaseRedisRepository implements ShuttleGetterSetterRepository { + get isReady() { + return this.redisClient.isReady; + } + + public async connect() { + await this.redisClient.connect(); + } + + public async disconnect() { + await this.redisClient.disconnect(); + } + + // EventEmitter override methods for type safety + public override on( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + public override once( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + public override off( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + public override addListener( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + public override removeListener( + event: T, + listener: ShuttleRepositoryEventListener, + ): this; + public override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + public override emit( + event: T, + payload: ShuttleRepositoryEventPayloads[T], + ): boolean; + public override emit(event: string | symbol, ...args: any[]): boolean { + return super.emit(event, ...args); + } + + // Helper methods for Redis key generation + private createStopKey = (stopId: string) => `shuttle:stop:${stopId}`; + private createRouteKey = (routeId: string) => `shuttle:route:${routeId}`; + private createShuttleKey = (shuttleId: string) => `shuttle:shuttle:${shuttleId}`; + private createEtaKey = (shuttleId: string, stopId: string) => `shuttle:eta:${shuttleId}:${stopId}`; + private createOrderedStopKey = (routeId: string, stopId: string) => `shuttle:orderedstop:${routeId}:${stopId}`; + private createShuttleLastStopKey = (shuttleId: string) => `shuttle:laststop:${shuttleId}`; + private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { + return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; + } + + // Helper methods for converting entities to Redis hashes + private createRedisHashFromStop = (stop: IStop): Record => ({ + id: stop.id, + name: stop.name, + systemId: stop.systemId, + latitude: stop.coordinates.latitude.toString(), + longitude: stop.coordinates.longitude.toString(), + updatedTime: stop.updatedTime.toISOString(), + }); + + private createStopFromRedisData = (data: Record): IStop => ({ + id: data.id, + name: data.name, + systemId: data.systemId, + coordinates: { + latitude: parseFloat(data.latitude), + longitude: parseFloat(data.longitude), + }, + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromRoute = (route: IRoute): Record => ({ + id: route.id, + name: route.name, + color: route.color, + systemId: route.systemId, + polylineCoordinates: JSON.stringify(route.polylineCoordinates), + updatedTime: route.updatedTime.toISOString(), + }); + + private createRouteFromRedisData = (data: Record): IRoute => ({ + id: data.id, + name: data.name, + color: data.color, + systemId: data.systemId, + polylineCoordinates: JSON.parse(data.polylineCoordinates), + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromShuttle = (shuttle: IShuttle): Record => ({ + id: shuttle.id, + name: shuttle.name, + routeId: shuttle.routeId, + systemId: shuttle.systemId, + latitude: shuttle.coordinates.latitude.toString(), + longitude: shuttle.coordinates.longitude.toString(), + orientationInDegrees: shuttle.orientationInDegrees.toString(), + updatedTime: shuttle.updatedTime.toISOString(), + }); + + private createShuttleFromRedisData = (data: Record): IShuttle => ({ + id: data.id, + name: data.name, + routeId: data.routeId, + systemId: data.systemId, + coordinates: { + latitude: parseFloat(data.latitude), + longitude: parseFloat(data.longitude), + }, + orientationInDegrees: parseFloat(data.orientationInDegrees), + updatedTime: new Date(data.updatedTime), + }); + + private createEtaFromRedisData = (data: Record): IEta => ({ + secondsRemaining: parseFloat(data.secondsRemaining), + shuttleId: data.shuttleId, + stopId: data.stopId, + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }); + + private createRedisHashFromOrderedStop = (orderedStop: IOrderedStop): Record => { + const hash: Record = { + routeId: orderedStop.routeId, + stopId: orderedStop.stopId, + position: orderedStop.position.toString(), + systemId: orderedStop.systemId, + updatedTime: orderedStop.updatedTime.toISOString(), + }; + + if (orderedStop.nextStop) { + hash.nextStopRouteId = orderedStop.nextStop.routeId; + hash.nextStopStopId = orderedStop.nextStop.stopId; + } + + if (orderedStop.previousStop) { + hash.previousStopRouteId = orderedStop.previousStop.routeId; + hash.previousStopStopId = orderedStop.previousStop.stopId; + } + + return hash; + }; + + private createOrderedStopFromRedisData = (data: Record): IOrderedStop => { + const orderedStop: IOrderedStop = { + routeId: data.routeId, + stopId: data.stopId, + position: parseInt(data.position), + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }; + + // Note: We only store the IDs of next/previous stops, not full objects + // to avoid circular references in Redis. These would need to be + // resolved separately if needed. + if (data.nextStopRouteId && data.nextStopStopId) { + orderedStop.nextStop = { + routeId: data.nextStopRouteId, + stopId: data.nextStopStopId, + position: 0, // placeholder + systemId: data.systemId, + updatedTime: new Date(), + }; + } + + if (data.previousStopRouteId && data.previousStopStopId) { + orderedStop.previousStop = { + routeId: data.previousStopRouteId, + stopId: data.previousStopStopId, + position: 0, // placeholder + systemId: data.systemId, + updatedTime: new Date(), + }; + } + + return orderedStop; + }; + + // Getter methods + public async getStops(): Promise { + const keys = await this.redisClient.keys('shuttle:stop:*'); + const stops: IStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + stops.push(this.createStopFromRedisData(data)); + } + } + + return stops; + } + + public async getStopById(stopId: string): Promise { + const key = this.createStopKey(stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createStopFromRedisData(data); + } + + public async getRoutes(): Promise { + const keys = await this.redisClient.keys('shuttle:route:*'); + const routes: IRoute[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + routes.push(this.createRouteFromRedisData(data)); + } + } + + return routes; + } + + public async getRouteById(routeId: string): Promise { + const key = this.createRouteKey(routeId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createRouteFromRedisData(data); + } + + public async getShuttles(): Promise { + const keys = await this.redisClient.keys('shuttle:shuttle:*'); + const shuttles: IShuttle[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + shuttles.push(this.createShuttleFromRedisData(data)); + } + } + + return shuttles; + } + + public async getShuttlesByRouteId(routeId: string): Promise { + const allShuttles = await this.getShuttles(); + return allShuttles.filter(shuttle => shuttle.routeId === routeId); + } + + public async getShuttleById(shuttleId: string): Promise { + const key = this.createShuttleKey(shuttleId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createShuttleFromRedisData(data); + } + + public async getEtasForShuttleId(shuttleId: string): Promise { + const keys = await this.redisClient.keys(`shuttle:eta:${shuttleId}:*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + public async getEtasForStopId(stopId: string): Promise { + const keys = await this.redisClient.keys('shuttle:eta:*'); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const key = this.createEtaKey(shuttleId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createEtaFromRedisData(data); + } + + public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string): Promise { + const key = this.createOrderedStopKey(routeId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createOrderedStopFromRedisData(data); + } + + public async getOrderedStopsByStopId(stopId: string): Promise { + const keys = await this.redisClient.keys('shuttle:orderedstop:*'); + const orderedStops: IOrderedStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + orderedStops.push(this.createOrderedStopFromRedisData(data)); + } + } + + return orderedStops; + } + + public async getOrderedStopsByRouteId(routeId: string): Promise { + const keys = await this.redisClient.keys(`shuttle:orderedstop:${routeId}:*`); + const orderedStops: IOrderedStop[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + orderedStops.push(this.createOrderedStopFromRedisData(data)); + } + } + + return orderedStops; + } + + // Setter/update methods + public async addOrUpdateRoute(route: IRoute): Promise { + const key = this.createRouteKey(route.id); + await this.redisClient.hSet(key, this.createRedisHashFromRoute(route)); + } + + public async addOrUpdateShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ): Promise { + const key = this.createShuttleKey(shuttle.id); + await this.redisClient.hSet(key, this.createRedisHashFromShuttle(shuttle)); + + this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle); + + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); + } + + private async updateLastStopArrival( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ) { + const arrivedStop = await this.getArrivedStopIfExists(shuttle); + + if (arrivedStop) { + // stop if same stop + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop?.stopId === arrivedStop.id) return; + + const shuttleArrival = { + stopId: arrivedStop.id, + timestamp: new Date(travelTimeTimestamp), + shuttleId: shuttle.id, + }; + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, { + lastArrival: lastStop, + currentArrival: shuttleArrival, + }); + await this.updateShuttleLastStopArrival(shuttleArrival); + } + } + + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments, + ): Promise { + const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + const intervalMs = toTimestamp - fromTimestamp + 1; + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + fromTimestamp.toString(), + toTimestamp.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + return parseFloat(averageValue); + } + + return; + } catch (error) { + console.warn(`Failed to get average travel time: ${error instanceof Error ? error.message : String(error)}`); + return; + } + } + + /** + * Get the stop that the shuttle is currently at, if it exists. + * + * If the shuttle has a "last stop", it will only return the stop + * directly after the last stop. Otherwise, it may return any stop that + * is on the shuttle's route. + * + * @param shuttle + * @param delta + * @returns + */ + public async getArrivedStopIfExists( + shuttle: IShuttle, + delta = 0.001, + ): Promise { + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop) { + const lastOrderedStop = await this.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + const orderedStopAfter = lastOrderedStop?.nextStop; + if (orderedStopAfter) { + const stopAfter = await this.getStopById(orderedStopAfter.stopId); + if (stopAfter && shuttleHasArrivedAtStop(shuttle, stopAfter, delta)) { + return stopAfter; + } + } + } else { + const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId); + + for (const orderedStop of orderedStops) { + const stop = await this.getStopById(orderedStop.stopId); + if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { + return stop; + } + } + } + + return undefined; + } + + public async getShuttleLastStopArrival(shuttleId: string): Promise { + const key = this.createShuttleLastStopKey(shuttleId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return undefined; + } + + return { + shuttleId, + stopId: data.stopId, + timestamp: new Date(data.timestamp), + }; + } + + private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) { + const key = this.createShuttleLastStopKey(lastStopArrival.shuttleId); + await this.redisClient.hSet(key, { + stopId: lastStopArrival.stopId, + timestamp: lastStopArrival.timestamp.toISOString(), + }); + } + + public async addOrUpdateStop(stop: IStop): Promise { + const key = this.createStopKey(stop.id); + await this.redisClient.hSet(key, this.createRedisHashFromStop(stop)); + } + + public async addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise { + const key = this.createOrderedStopKey(orderedStop.routeId, orderedStop.stopId); + await this.redisClient.hSet(key, this.createRedisHashFromOrderedStop(orderedStop)); + } + + // Remove methods + public async removeRouteIfExists(routeId: string): Promise { + const route = await this.getRouteById(routeId); + if (route) { + const key = this.createRouteKey(routeId); + await this.redisClient.del(key); + return route; + } + return null; + } + + public async removeShuttleIfExists(shuttleId: string): Promise { + const shuttle = await this.getShuttleById(shuttleId); + if (shuttle) { + const key = this.createShuttleKey(shuttleId); + await this.redisClient.del(key); + this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle); + return shuttle; + } + return null; + } + + public async removeStopIfExists(stopId: string): Promise { + const stop = await this.getStopById(stopId); + if (stop) { + const key = this.createStopKey(stopId); + await this.redisClient.del(key); + return stop; + } + return null; + } + + public async removeOrderedStopIfExists(stopId: string, routeId: string): Promise { + const orderedStop = await this.getOrderedStopByRouteAndStopId(routeId, stopId); + if (orderedStop) { + const key = this.createOrderedStopKey(routeId, stopId); + await this.redisClient.del(key); + return orderedStop; + } + return null; + } + + + // Clear methods + public async clearShuttleData(): Promise { + const keys = await this.redisClient.keys('shuttle:shuttle:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearOrderedStopData(): Promise { + const keys = await this.redisClient.keys('shuttle:orderedstop:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearRouteData(): Promise { + const keys = await this.redisClient.keys('shuttle:route:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } + + public async clearStopData(): Promise { + const keys = await this.redisClient.keys('shuttle:stop:*'); + if (keys.length > 0) { + await this.redisClient.del(keys); + } + } +} diff --git a/src/repositories/shuttle/ShuttleGetterRepository.ts b/src/repositories/shuttle/ShuttleGetterRepository.ts index 86f3ec5..7fbab0c 100644 --- a/src/repositories/shuttle/ShuttleGetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterRepository.ts @@ -2,9 +2,9 @@ import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/Shut import type EventEmitter from "node:events"; export const ShuttleRepositoryEvent = { - ETA_UPDATED: "etaUpdated", - ETA_REMOVED: "etaRemoved", - ETA_DATA_CLEARED: "etaDataCleared", + SHUTTLE_UPDATED: "shuttleUpdated", + SHUTTLE_REMOVED: "shuttleRemoved", + SHUTTLE_WILL_ARRIVE_AT_STOP: "shuttleArrivedAtStop", } as const; export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typeof ShuttleRepositoryEvent]; @@ -12,16 +12,38 @@ export type ShuttleRepositoryEventName = typeof ShuttleRepositoryEvent[keyof typ export type EtaRemovedEventPayload = IEta; export type EtaDataClearedEventPayload = IEta[]; +export interface WillArriveAtStopPayload { + lastArrival?: ShuttleStopArrival; + currentArrival: ShuttleStopArrival; +}; + export interface ShuttleRepositoryEventPayloads { - [ShuttleRepositoryEvent.ETA_UPDATED]: IEta; - [ShuttleRepositoryEvent.ETA_REMOVED]: EtaRemovedEventPayload; - [ShuttleRepositoryEvent.ETA_DATA_CLEARED]: EtaDataClearedEventPayload; + [ShuttleRepositoryEvent.SHUTTLE_UPDATED]: IShuttle, + [ShuttleRepositoryEvent.SHUTTLE_REMOVED]: IShuttle, + [ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP]: WillArriveAtStopPayload, } export type ShuttleRepositoryEventListener = ( payload: ShuttleRepositoryEventPayloads[T], ) => void; +export interface ShuttleStopArrival { + shuttleId: string; + stopId: string; + timestamp: Date; +} + +export interface ShuttleTravelTimeDataIdentifier { + routeId: string; + fromStopId: string; + toStopId: string; +} + +export interface ShuttleTravelTimeDateFilterArguments { + from: Date; + to: Date; +} + /** * Shuttle getter repository to be linked to a system. */ @@ -36,10 +58,6 @@ export interface ShuttleGetterRepository extends EventEmitter { getShuttleById(shuttleId: string): Promise; getShuttlesByRouteId(routeId: string): Promise; - getEtasForShuttleId(shuttleId: string): Promise; - getEtasForStopId(stopId: string): Promise; - getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; - on(event: T, listener: ShuttleRepositoryEventListener): this; once(event: T, listener: ShuttleRepositoryEventListener): this; off(event: T, listener: ShuttleRepositoryEventListener): this; @@ -61,4 +79,19 @@ export interface ShuttleGetterRepository extends EventEmitter { * @param routeId */ getOrderedStopsByRouteId(routeId: string): Promise; + + /** + * Get the last stop arrival for a shuttle. + * Returns undefined if no last stop arrival has been recorded. + * @param shuttleId + */ + getShuttleLastStopArrival(shuttleId: string): Promise; + + /** + * Check if a shuttle has arrived at a stop within the given delta. + * Returns the stop if the shuttle is at a stop, otherwise undefined. + * @param shuttle + * @param delta - The coordinate delta tolerance (default 0.001) + */ + getArrivedStopIfExists(shuttle: IShuttle, delta?: number): Promise; } diff --git a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts index 24da9e1..3551d67 100644 --- a/src/repositories/shuttle/ShuttleGetterSetterRepository.ts +++ b/src/repositories/shuttle/ShuttleGetterSetterRepository.ts @@ -1,8 +1,8 @@ // If types match closely, we can use TypeScript "casting" // to convert from data repo to GraphQL schema -import { ShuttleGetterRepository } from "./ShuttleGetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { ShuttleGetterRepository, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "./ShuttleGetterRepository"; +import { IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; /** * ShuttleGetterRepository interface for data derived from Passio API. @@ -13,20 +13,28 @@ import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/Shut export interface ShuttleGetterSetterRepository extends ShuttleGetterRepository { // Setter methods addOrUpdateRoute(route: IRoute): Promise; - addOrUpdateShuttle(shuttle: IShuttle): Promise; + addOrUpdateShuttle(shuttle: IShuttle, travelTimeTimestamp?: number, referenceCurrentTime?: Date): Promise; addOrUpdateStop(stop: IStop): Promise; addOrUpdateOrderedStop(orderedStop: IOrderedStop): Promise; - addOrUpdateEta(eta: IEta): Promise; removeRouteIfExists(routeId: string): Promise; removeShuttleIfExists(shuttleId: string): Promise; removeStopIfExists(stopId: string): Promise; removeOrderedStopIfExists(stopId: string, routeId: string): Promise; - removeEtaIfExists(shuttleId: string, stopId: string): Promise; clearRouteData(): Promise; clearShuttleData(): Promise; clearStopData(): Promise; clearOrderedStopData(): Promise; - clearEtaData(): Promise; + + /** + * Get average travel time between two stops based on historical data. + * Returns undefined if no data exists for the specified time range. + * @param identifier - The route and stop IDs to query + * @param dateFilter - The date range to filter data + */ + getAverageTravelTimeSeconds( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilter: ShuttleTravelTimeDateFilterArguments + ): Promise; } diff --git a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts index 0d1c88c..14ba7d7 100644 --- a/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts +++ b/src/repositories/shuttle/UnoptimizedInMemoryShuttleRepository.ts @@ -1,12 +1,15 @@ import EventEmitter from "node:events"; import { ShuttleGetterSetterRepository } from "./ShuttleGetterSetterRepository"; -import { IEta, IOrderedStop, IRoute, IShuttle, IStop } from "../../entities/ShuttleRepositoryEntities"; +import { IOrderedStop, IRoute, IShuttle, IStop, shuttleHasArrivedAtStop } from "../../entities/ShuttleRepositoryEntities"; import { IEntityWithId } from "../../entities/SharedEntities"; import { ShuttleRepositoryEvent, ShuttleRepositoryEventListener, ShuttleRepositoryEventName, ShuttleRepositoryEventPayloads, + ShuttleStopArrival, + ShuttleTravelTimeDataIdentifier, + ShuttleTravelTimeDateFilterArguments, } from "./ShuttleGetterRepository"; /** @@ -68,8 +71,9 @@ export class UnoptimizedInMemoryShuttleRepository private stops: IStop[] = []; private routes: IRoute[] = []; private shuttles: IShuttle[] = []; - private etas: IEta[] = []; private orderedStops: IOrderedStop[] = []; + private shuttleLastStopArrivals: Map = new Map(); + private travelTimeData: Map> = new Map(); public async getStops(): Promise { return this.stops; @@ -99,18 +103,6 @@ export class UnoptimizedInMemoryShuttleRepository return this.findEntityById(shuttleId, this.shuttles); } - public async getEtasForShuttleId(shuttleId: string): Promise { - return this.etas.filter(eta => eta.shuttleId === shuttleId); - } - - public async getEtasForStopId(stopId: string) { - return this.etas.filter(eta => eta.stopId === stopId); - } - - public async getEtaForShuttleAndStopId(shuttleId: string, stopId: string) { - return this.findEntityByMatcher((value) => value.stopId === stopId && value.shuttleId === shuttleId, this.etas); - } - public async getOrderedStopByRouteAndStopId(routeId: string, stopId: string) { return this.findEntityByMatcher((value) => value.routeId === routeId && value.stopId === stopId, this.orderedStops) } @@ -144,13 +136,20 @@ export class UnoptimizedInMemoryShuttleRepository } } - public async addOrUpdateShuttle(shuttle: IShuttle): Promise { + public async addOrUpdateShuttle( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ): Promise { const index = this.shuttles.findIndex((s) => s.id === shuttle.id); if (index !== -1) { this.shuttles[index] = shuttle; } else { this.shuttles.push(shuttle); } + + this.emit(ShuttleRepositoryEvent.SHUTTLE_UPDATED, shuttle); + + await this.updateLastStopArrival(shuttle, travelTimeTimestamp); } public async addOrUpdateStop(stop: IStop): Promise { @@ -171,14 +170,78 @@ export class UnoptimizedInMemoryShuttleRepository } } - public async addOrUpdateEta(eta: IEta): Promise { - const index = this.etas.findIndex((e) => e.stopId === eta.stopId && e.shuttleId === eta.shuttleId); - if (index !== -1) { - this.etas[index] = eta; - } else { - this.etas.push(eta); + private async updateLastStopArrival( + shuttle: IShuttle, + travelTimeTimestamp = Date.now(), + ) { + const arrivedStop = await this.getArrivedStopIfExists(shuttle); + + if (arrivedStop != undefined) { + // stop if same stop + const lastStop = await this.getShuttleLastStopArrival(shuttle.id); + if (lastStop?.stopId === arrivedStop.id) return; + + const shuttleArrival = { + stopId: arrivedStop.id, + timestamp: new Date(travelTimeTimestamp), + shuttleId: shuttle.id, + }; + this.emit(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, { + lastArrival: lastStop, + currentArrival: shuttleArrival, + }); + await this.updateShuttleLastStopArrival(shuttleArrival); } - this.emit(ShuttleRepositoryEvent.ETA_UPDATED, eta); + } + + + private async updateShuttleLastStopArrival(lastStopArrival: ShuttleStopArrival) { + this.shuttleLastStopArrivals.set(lastStopArrival.shuttleId, lastStopArrival); + } + + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments, + ): Promise { + const key = `${routeId}:${fromStopId}:${toStopId}`; + const dataPoints = this.travelTimeData.get(key); + + if (!dataPoints || dataPoints.length === 0) { + return undefined; + } + + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + + const filteredPoints = dataPoints.filter( + (point) => point.timestamp >= fromTimestamp && point.timestamp <= toTimestamp + ); + + if (filteredPoints.length === 0) { + return undefined; + } + + const sum = filteredPoints.reduce((acc, point) => acc + point.seconds, 0); + return sum / filteredPoints.length; + } + + public async getArrivedStopIfExists( + shuttle: IShuttle, + delta = 0.001, + ): Promise { + const orderedStops = await this.getOrderedStopsByRouteId(shuttle.routeId); + + for (const orderedStop of orderedStops) { + const stop = await this.getStopById(orderedStop.stopId); + if (stop != null && shuttleHasArrivedAtStop(shuttle, stop, delta)) { + return stop; + } + } + return undefined; + } + + public async getShuttleLastStopArrival(shuttleId: string): Promise { + return this.shuttleLastStopArrivals.get(shuttleId); } private async removeEntityByMatcherIfExists(callback: (value: T) => boolean, arrayToSearchIn: T[]) { @@ -201,7 +264,11 @@ export class UnoptimizedInMemoryShuttleRepository } public async removeShuttleIfExists(shuttleId: string): Promise { - return await this.removeEntityByIdIfExists(shuttleId, this.shuttles); + const shuttle = await this.removeEntityByIdIfExists(shuttleId, this.shuttles); + if (shuttle != null) { + this.emit(ShuttleRepositoryEvent.SHUTTLE_REMOVED, shuttle); + } + return shuttle; } public async removeStopIfExists(stopId: string): Promise { @@ -215,27 +282,10 @@ export class UnoptimizedInMemoryShuttleRepository }, this.orderedStops); } - public async removeEtaIfExists(shuttleId: string, stopId: string): Promise { - const removedEta = await this.removeEntityByMatcherIfExists((eta) => { - return eta.stopId === stopId - && eta.shuttleId === shuttleId - }, this.etas); - if (removedEta) { - this.emit(ShuttleRepositoryEvent.ETA_REMOVED, removedEta); - } - return removedEta; - } - public async clearShuttleData(): Promise { this.shuttles = []; } - public async clearEtaData(): Promise { - const removedEtas = [...this.etas]; - this.etas = []; - this.emit(ShuttleRepositoryEvent.ETA_DATA_CLEARED, removedEtas); - } - public async clearOrderedStopData(): Promise { this.orderedStops = []; } diff --git a/src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts similarity index 57% rename from src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts rename to src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts index c5c8d7c..b45f2a3 100644 --- a/src/repositories/shuttle/__tests__/UnoptimizedInMemoryShuttleRepositoryTests.test.ts +++ b/src/repositories/shuttle/__tests__/ShuttleRepositorySharedTests.test.ts @@ -1,24 +1,63 @@ -import { beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; import { UnoptimizedInMemoryShuttleRepository } from "../UnoptimizedInMemoryShuttleRepository"; -import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; +import { ShuttleGetterSetterRepository } from "../ShuttleGetterSetterRepository"; +import { RedisShuttleRepository } from "../RedisShuttleRepository"; import { - generateMockEtas, generateMockOrderedStops, generateMockRoutes, generateMockShuttles, generateMockStops, } from "../../../../testHelpers/mockDataGenerators"; +import { RepositoryHolder } from "../../../../testHelpers/RepositoryHolder"; +import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; +import { ShuttleRepositoryEvent } from "../ShuttleGetterRepository"; -// For repositories created in the future, reuse core testing -// logic from here and differentiate setup (e.g. creating mocks) -// Do this by creating a function which takes a ShuttleGetterRepository -// or ShuttleGetterSetterRepository instance +class UnoptimizedInMemoryShuttleRepositoryHolder implements RepositoryHolder { + name = 'UnoptimizedInMemoryShuttleRepository'; + factory = async () => { + return new UnoptimizedInMemoryShuttleRepository(); + }; + teardown = async () => {}; +} -describe("UnoptimizedInMemoryRepository", () => { - let repository: UnoptimizedInMemoryShuttleRepository; +class RedisShuttleRepositoryHolder implements RepositoryHolder { + repo: RedisShuttleRepository | undefined; + redisClient: RedisClientType | undefined; - beforeEach(() => { - repository = new UnoptimizedInMemoryShuttleRepository(); + name = 'RedisShuttleRepository'; + factory = async () => { + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisShuttleRepository(this.redisClient); + return this.repo; + }; + teardown = async () => { + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); + } + }; +} + +const repositoryImplementations = [ + new UnoptimizedInMemoryShuttleRepositoryHolder(), + new RedisShuttleRepositoryHolder(), +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: ShuttleGetterSetterRepository; + + beforeEach(async () => { + repository = await holder.factory(); + jest.useRealTimers(); + }); + + afterEach(async () => { + await holder.teardown(); + jest.useRealTimers(); }); describe("getStops", () => { @@ -29,7 +68,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getStops(); - expect(result).toEqual(mockStops); + expect(result).toHaveLength(mockStops.length); + expect(result).toEqual(expect.arrayContaining(mockStops)); }); test("returns an empty list if there are no stops for the given system ID", async () => { @@ -62,7 +102,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getRoutes(); - expect(result).toEqual(mockRoutes); + expect(result).toHaveLength(mockRoutes.length); + expect(result).toEqual(expect.arrayContaining(mockRoutes)); }); test("returns an empty list if there are no routes for the system ID", async () => { @@ -86,6 +127,7 @@ describe("UnoptimizedInMemoryRepository", () => { expect(result).toBeNull(); }); }); + describe("getShuttles", () => { test("gets all shuttles for a specific system ID", async () => { const mockShuttles = generateMockShuttles(); @@ -94,7 +136,8 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getShuttles(); - expect(result).toEqual(mockShuttles); + expect(result).toHaveLength(mockShuttles.length); + expect(result).toEqual(expect.arrayContaining(mockShuttles)); }); test("returns an empty list if there are no shuttles for the system ID", async () => { @@ -137,118 +180,6 @@ describe("UnoptimizedInMemoryRepository", () => { }); }); - describe("getEtasForShuttleId", () => { - test("gets ETAs for a specific shuttle ID", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - const result = await repository.getEtasForShuttleId("sh1"); - expect(result).toEqual(mockEtas.filter((eta) => eta.shuttleId === "sh1")); - }); - - test("returns an empty list if there are no ETAs for the shuttle ID", async () => { - const result = await repository.getEtasForShuttleId("nonexistent-shuttle"); - expect(result).toEqual([]); - }); - }); - - describe("getEtasForStopId", () => { - test("gets ETAs for a specific stop ID", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - const result = await repository.getEtasForStopId("st1"); - expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1")); - }); - - test("returns an empty list if there are no ETAs for the stop ID", async () => { - const result = await repository.getEtasForStopId("nonexistent-stop"); - expect(result).toEqual([]); - }); - }); - - describe("getEtaForShuttleAndStopId", () => { - test("gets a single ETA for a specific shuttle and stop ID", async () => { - const mockEtas = generateMockEtas(); - const mockEta = mockEtas[0]; - await repository.addOrUpdateEta(mockEta); - - const result = await repository.getEtaForShuttleAndStopId("sh1", "st1"); - expect(result).toEqual(mockEta); - }); - - test("returns null if no ETA matches the shuttle and stop ID", async () => { - const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop"); - expect(result).toBeNull(); - }); - }); - - describe("on/addListener", () => { - test("notifies listeners if etas have been added or changed", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - expect(mockListener).toHaveBeenCalledTimes(mockEtas.length); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); // First notification - expect(mockListener).toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification - }); - - test("does not notify listener if removed", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - await repository.addOrUpdateEta(mockEtas[0]); - expect(mockListener).toHaveBeenCalledTimes(0); - }); - }); - - describe("off/removeListener", () => { - test("stops notifying listeners after etas have stopped changing", async () => { - const mockListener = jest.fn(); // Jest mock function to simulate a listener - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - await repository.addOrUpdateEta(mockEtas[0]); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - await repository.addOrUpdateEta(mockEtas[mockEtas.length - 1]); - - expect(mockListener).toHaveBeenCalledTimes(1); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); // First notification - expect(mockListener).not.toHaveBeenCalledWith(mockEtas[mockEtas.length - 1]); // Last notification - }); - - test("does not remove listener if wrong reference", async () => { - const mockListener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_UPDATED, mockListener); - - const mockEtas = generateMockEtas(); - - repository.off(ShuttleRepositoryEvent.ETA_UPDATED, () => {}); - - await repository.addOrUpdateEta(mockEtas[0]); - - expect(mockListener).toHaveBeenCalledTimes(1); - expect(mockListener).toHaveBeenCalledWith(mockEtas[0]); - }); - }) - - describe("ETA update events", () => { - }); - describe("getOrderedStopByRouteAndStopId", () => { test("gets an ordered stop by route ID and stop ID", async () => { const mockOrderedStops = generateMockOrderedStops(); @@ -277,7 +208,9 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getOrderedStopsByStopId("st1"); - expect(result).toEqual(mockOrderedStops.filter((os) => os.stopId === "st1")); + const expected = mockOrderedStops.filter((os) => os.stopId === "st1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); }); test("returns an empty list if there are no ordered stops for the stop ID", async () => { @@ -294,7 +227,9 @@ describe("UnoptimizedInMemoryRepository", () => { } const result = await repository.getOrderedStopsByRouteId("r1"); - expect(result).toEqual(mockOrderedStops.filter((os) => os.routeId === "r1")); + const expected = mockOrderedStops.filter((os) => os.routeId === "r1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); }); test("returns an empty list if there are no ordered stops for the route ID", async () => { @@ -403,30 +338,6 @@ describe("UnoptimizedInMemoryRepository", () => { }); }); - describe("addOrUpdateEta", () => { - test("adds a new ETA if nonexistent", async () => { - const mockEtas = generateMockEtas(); - const newEta = mockEtas[0]; - - await repository.addOrUpdateEta(newEta); - - const result = await repository.getEtasForShuttleId(newEta.shuttleId); - expect(result).toEqual([newEta]); - }); - - test("updates an existing ETA if it exists", async () => { - const mockEtas = generateMockEtas(); - const existingEta = mockEtas[0]; - const updatedEta = structuredClone(existingEta); - updatedEta.secondsRemaining = existingEta.secondsRemaining + 60; - - await repository.addOrUpdateEta(existingEta); - await repository.addOrUpdateEta(updatedEta); - - const result = await repository.getEtasForShuttleId(existingEta.shuttleId); - expect(result).toEqual([updatedEta]); - }); - }); describe("removeRouteIfExists", () => { test("removes route given ID", async () => { @@ -554,54 +465,6 @@ describe("UnoptimizedInMemoryRepository", () => { }); }); - describe("removeEtaIfExists", () => { - test("removes eta given shuttle ID and stop ID", async () => { - let mockEtas = generateMockEtas(); - const stopId = mockEtas[0].stopId; - mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); - - await Promise.all(mockEtas.map(async (eta) => { - eta.stopId = stopId; - await repository.addOrUpdateEta(eta); - })); - - const etaToRemove = mockEtas[0]; - await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); - - const remainingEtas = await repository.getEtasForStopId(stopId); - expect(remainingEtas).toHaveLength(mockEtas.length - 1); - }); - - test("does nothing if eta doesn't exist", async () => { - let mockEtas = generateMockEtas(); - const stopId = mockEtas[0].stopId; - mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); - - await Promise.all(mockEtas.map(async (eta) => { - eta.stopId = stopId; - await repository.addOrUpdateEta(eta); - })); - - await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id"); - - const remainingEtas = await repository.getEtasForStopId(stopId); - expect(remainingEtas).toHaveLength(mockEtas.length); - }); - - test("emits an eta removed event when an eta is removed", async () => { - const mockEtas = generateMockEtas(); - const etaToRemove = mockEtas[0]; - const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_REMOVED, listener); - - await repository.addOrUpdateEta(etaToRemove); - await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(etaToRemove); - }); - }); - describe("clearShuttleData", () => { test("clears all shuttles from the repository", async () => { const mockShuttles = generateMockShuttles(); @@ -616,38 +479,10 @@ describe("UnoptimizedInMemoryRepository", () => { }); }); - describe("clearEtaData", () => { - test("clears all ETAs from the repository", async () => { - const mockEtas = generateMockEtas(); - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - await repository.clearEtaData(); - - const result = await repository.getEtasForShuttleId("shuttle1"); - expect(result).toEqual([]); - }); - - test("emits an event with the cleared etas", async () => { - const mockEtas = generateMockEtas(); - const listener = jest.fn(); - repository.on(ShuttleRepositoryEvent.ETA_DATA_CLEARED, listener); - - for (const eta of mockEtas) { - await repository.addOrUpdateEta(eta); - } - - await repository.clearEtaData(); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(mockEtas); - }); - }); describe("clearOrderedStopData", () => { test("clears all ordered stops from the repository", async () => { - const mockOrderedStops = await generateMockOrderedStops(); + const mockOrderedStops = generateMockOrderedStops(); for (const system of mockOrderedStops) { await repository.addOrUpdateOrderedStop(system); } @@ -686,4 +521,285 @@ describe("UnoptimizedInMemoryRepository", () => { expect(result).toEqual([]); }); }); + + // Helper function for setting up routes and ordered stops for shuttle tracking tests + async function setupRouteAndOrderedStops() { + return await setupRouteAndOrderedStopsForShuttleRepository(repository); + } + + describe("addOrUpdateShuttle with shuttle tracking", () => { + test("updates the shuttle's last stop arrival if shuttle is at a stop", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + const lastStop = await repository.getShuttleLastStopArrival(shuttle.id); + expect(lastStop?.stopId).toEqual(stop2.id); + }); + }); + + describe("getArrivedStopIfExists", () => { + test("gets the stop that the shuttle is currently at, if exists", async () => { + const { route, systemId, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop2.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeDefined(); + expect(result?.id).toBe("st2"); + expect(result?.name).toBe("Stop 2"); + }); + + test("returns undefined if shuttle is not currently at a stop", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 12.5, longitude: 22.5 }, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const result = await repository.getArrivedStopIfExists(shuttle); + + expect(result).toBeUndefined(); + }); + }); + + describe("getShuttleLastStopArrival", () => { + test("gets the shuttle's last stop if existing in the data", async () => { + const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const stopArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, stopArrivalTime.getTime()); + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(stop1.id); + expect(result?.timestamp.getTime()).toBe(stopArrivalTime.getTime()); + }); + + test("returns undefined if the data has never been initialized", async () => { + const mockShuttles = generateMockShuttles(); + const shuttle = mockShuttles[0]; + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeUndefined(); + }); + + test("returns the most recent stop arrival when updated multiple times", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); + await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); + + const result = await repository.getShuttleLastStopArrival(shuttle.id); + + expect(result).toBeDefined(); + expect(result?.stopId).toBe(stop2.id); + expect(result?.timestamp.getTime()).toBe(secondArrivalTime.getTime()); + }); + }); + + describe("SHUTTLE_UPDATED event", () => { + test("emits SHUTTLE_UPDATED event when shuttles are added or updated", async () => { + const mockListener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + + const mockShuttles = generateMockShuttles(); + for (const shuttle of mockShuttles) { + await repository.addOrUpdateShuttle(shuttle); + } + + expect(mockListener).toHaveBeenCalledTimes(mockShuttles.length); + expect(mockListener).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + }); + + test("does not notify listener after it has been removed", async () => { + const mockListener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + + const mockShuttles = generateMockShuttles(); + + repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener); + await repository.addOrUpdateShuttle(mockShuttles[0]); + expect(mockListener).toHaveBeenCalledTimes(0); + }); + + test("stops notifying specific listener after removal but continues for others", async () => { + const mockListener1 = jest.fn(); + const mockListener2 = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1); + repository.on(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener2); + + const mockShuttles = generateMockShuttles(); + await repository.addOrUpdateShuttle(mockShuttles[0]); + + repository.off(ShuttleRepositoryEvent.SHUTTLE_UPDATED, mockListener1); + + await repository.addOrUpdateShuttle(mockShuttles[mockShuttles.length - 1]); + + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener1).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener1).not.toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + + expect(mockListener2).toHaveBeenCalledTimes(2); + expect(mockListener2).toHaveBeenCalledWith(mockShuttles[0]); + expect(mockListener2).toHaveBeenCalledWith(mockShuttles[mockShuttles.length - 1]); + }); + }); + + describe("SHUTTLE_REMOVED event", () => { + test("emits SHUTTLE_REMOVED event when a shuttle is removed", async () => { + const mockShuttles = generateMockShuttles(); + const shuttleToRemove = mockShuttles[0]; + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_REMOVED, listener); + + await repository.addOrUpdateShuttle(shuttleToRemove); + await repository.removeShuttleIfExists(shuttleToRemove.id); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(shuttleToRemove); + }); + }); + + describe("SHUTTLE_WILL_ARRIVE_AT_STOP event", () => { + test("emits SHUTTLE_WILL_ARRIVE_AT_STOP event before shuttle arrives at a stop", async () => { + const { route, systemId, stop1 } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const arrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, arrivalTime.getTime()); + + expect(listener).toHaveBeenCalledTimes(1); + const emittedPayload = listener.mock.calls[0][0] as any; + expect(emittedPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop1.id, + timestamp: arrivalTime, + }); + }); + + test("does not emit event when shuttle is not at a stop", async () => { + const { route, systemId } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: { latitude: 12.5, longitude: 22.5 }, // Not at any stop + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await repository.addOrUpdateShuttle(shuttle); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + test("emits multiple events as shuttle visits multiple stops", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + const listener = jest.fn(); + repository.on(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, listener); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstArrivalTime = new Date("2024-01-15T10:30:00Z"); + await repository.addOrUpdateShuttle(shuttle, firstArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondArrivalTime = new Date("2024-01-15T10:35:00Z"); + await repository.addOrUpdateShuttle(shuttle, secondArrivalTime.getTime()); + + expect(listener).toHaveBeenCalledTimes(2); + + const firstPayload = listener.mock.calls[0][0] as any; + expect(firstPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop1.id, + timestamp: firstArrivalTime, + }); + + const secondPayload = listener.mock.calls[1][0] as any; + expect(secondPayload.currentArrival).toEqual({ + shuttleId: shuttle.id, + stopId: stop2.id, + timestamp: secondArrivalTime, + }); + }); + }); }); diff --git a/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts new file mode 100644 index 0000000..c260331 --- /dev/null +++ b/src/repositories/shuttle/eta/BaseInMemoryETARepository.ts @@ -0,0 +1,76 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; +import EventEmitter from "node:events"; + +export abstract class BaseInMemoryETARepository extends EventEmitter implements ETAGetterRepository { + protected etas: IEta[] = []; + + async getEtasForShuttleId(shuttleId: string): Promise { + return this.etas.filter(eta => eta.shuttleId === shuttleId); + } + + async getEtasForStopId(stopId: string): Promise { + return this.etas.filter(eta => eta.stopId === stopId); + } + + async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const eta = this.etas.find(eta => eta.stopId === stopId && eta.shuttleId === shuttleId); + return eta ?? null; + } + + // Protected setter for internal use + protected async addOrUpdateEta(eta: IEta): Promise { + const index = this.etas.findIndex((e) => e.stopId === eta.stopId && e.shuttleId === eta.shuttleId); + if (index !== -1) { + this.etas[index] = eta; + } else { + this.etas.push(eta); + } + this.emit(ETARepositoryEvent.ETA_UPDATED, eta); + } + + // EventEmitter overrides for type safety + override on( + event: T, + listener: ETARepositoryEventListener, + ): this; + override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + override once( + event: T, + listener: ETARepositoryEventListener, + ): this; + override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + override off( + event: T, + listener: ETARepositoryEventListener, + ): this; + override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + override addListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + override removeListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + override removeAllListeners(eventName?: string | symbol): this { + return super.removeAllListeners(eventName); + } +} diff --git a/src/repositories/shuttle/eta/BaseRedisETARepository.ts b/src/repositories/shuttle/eta/BaseRedisETARepository.ts new file mode 100644 index 0000000..7fef6ee --- /dev/null +++ b/src/repositories/shuttle/eta/BaseRedisETARepository.ts @@ -0,0 +1,120 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { BaseRedisRepository } from "../../BaseRedisRepository"; +import { ETAGetterRepository, ETARepositoryEvent, ETARepositoryEventListener, ETARepositoryEventName } from "./ETAGetterRepository"; + +export abstract class BaseRedisETARepository extends BaseRedisRepository implements ETAGetterRepository { + private static readonly ETA_KEY_PREFIX = 'shuttle:eta:'; + + // Helper methods + protected createEtaKey = (shuttleId: string, stopId: string) => + `${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:${stopId}`; + + createRedisHashFromEta = (eta: IEta): Record => ({ + secondsRemaining: eta.secondsRemaining.toString(), + shuttleId: eta.shuttleId, + stopId: eta.stopId, + systemId: eta.systemId, + updatedTime: eta.updatedTime.toISOString(), + }); + + createEtaFromRedisData = (data: Record): IEta => ({ + secondsRemaining: parseFloat(data.secondsRemaining), + shuttleId: data.shuttleId, + stopId: data.stopId, + systemId: data.systemId, + updatedTime: new Date(data.updatedTime), + }); + + // Getter implementations + async getEtasForShuttleId(shuttleId: string): Promise { + const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}${shuttleId}:*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + async getEtasForStopId(stopId: string): Promise { + const keys = await this.redisClient.keys(`${BaseRedisETARepository.ETA_KEY_PREFIX}*`); + const etas: IEta[] = []; + + for (const key of keys) { + const data = await this.redisClient.hGetAll(key); + if (Object.keys(data).length > 0 && data.stopId === stopId) { + etas.push(this.createEtaFromRedisData(data)); + } + } + + return etas; + } + + async getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise { + const key = this.createEtaKey(shuttleId, stopId); + const data = await this.redisClient.hGetAll(key); + + if (Object.keys(data).length === 0) { + return null; + } + + return this.createEtaFromRedisData(data); + } + + // Protected setter for internal use + protected async addOrUpdateEta(eta: IEta): Promise { + const key = this.createEtaKey(eta.shuttleId, eta.stopId); + const hash = this.createRedisHashFromEta(eta); + await this.redisClient.hSet(key, hash); + this.emit(ETARepositoryEvent.ETA_UPDATED, eta); + } + + // EventEmitter override methods for type safety + override on( + event: T, + listener: ETARepositoryEventListener, + ): this; + override on(event: string | symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + override once( + event: T, + listener: ETARepositoryEventListener, + ): this; + override once(event: string | symbol, listener: (...args: any[]) => void): this { + return super.once(event, listener); + } + + override off( + event: T, + listener: ETARepositoryEventListener, + ): this; + override off(event: string | symbol, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + override addListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override addListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.addListener(event, listener); + } + + override removeListener( + event: T, + listener: ETARepositoryEventListener, + ): this; + override removeListener(event: string | symbol, listener: (...args: any[]) => void): this { + return super.removeListener(event, listener); + } + + override removeAllListeners(eventName?: string | symbol): this { + return super.removeAllListeners(eventName); + } +} diff --git a/src/repositories/shuttle/eta/ETAGetterRepository.ts b/src/repositories/shuttle/eta/ETAGetterRepository.ts new file mode 100644 index 0000000..a1f8255 --- /dev/null +++ b/src/repositories/shuttle/eta/ETAGetterRepository.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from "stream"; +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; + +// TODO: Remove these events in ShuttleGetterRepository + +export const ETARepositoryEvent = { + ETA_UPDATED: "etaUpdated", + ETA_REMOVED: "etaRemoved", + ETA_DATA_CLEARED: "etaDataCleared", +} as const; + +export type ETARepositoryEventName = typeof ETARepositoryEvent[keyof typeof ETARepositoryEvent]; + +export type EtaRemovedEventPayload = IEta; +export type EtaDataClearedEventPayload = IEta[]; + +export interface ETARepositoryEventPayloads { + [ETARepositoryEvent.ETA_UPDATED]: IEta; + [ETARepositoryEvent.ETA_REMOVED]: EtaRemovedEventPayload; + [ETARepositoryEvent.ETA_DATA_CLEARED]: EtaDataClearedEventPayload; +} + +export type ETARepositoryEventListener = ( + payload: ETARepositoryEventPayloads[T], +) => void; + +export interface ETAGetterRepository extends EventEmitter { + on(event: T, listener: ETARepositoryEventListener): this; + once(event: T, listener: ETARepositoryEventListener): this; + off(event: T, listener: ETARepositoryEventListener): this; + addListener(event: T, listener: ETARepositoryEventListener): this; + removeListener(event: T, listener: ETARepositoryEventListener): this; + + getEtasForShuttleId(shuttleId: string): Promise; + getEtasForStopId(stopId: string): Promise; + getEtaForShuttleAndStopId(shuttleId: string, stopId: string): Promise; +} diff --git a/src/repositories/shuttle/eta/ExternalSourceETARepository.ts b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts new file mode 100644 index 0000000..53dfe5d --- /dev/null +++ b/src/repositories/shuttle/eta/ExternalSourceETARepository.ts @@ -0,0 +1,11 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ETAGetterRepository } from "./ETAGetterRepository"; + +export interface ExternalSourceETARepository extends ETAGetterRepository { + /** + * Add or update an ETA from an external source (e.g., API or test data). + */ + addOrUpdateEtaFromExternalSource(eta: IEta): Promise; + + removeEtaIfExists(shuttleId: string, stopId: string): Promise; +} diff --git a/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts new file mode 100644 index 0000000..4054e4f --- /dev/null +++ b/src/repositories/shuttle/eta/InMemoryExternalSourceETARepository.ts @@ -0,0 +1,22 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; +import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; + +export class InMemoryExternalSourceETARepository extends BaseInMemoryETARepository implements ExternalSourceETARepository { + async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); + } + + async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId); + if (index === -1) { + return null; + } + + const removedEta = this.etas[index]; + this.etas.splice(index, 1); + this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta); + return removedEta; + } +} diff --git a/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts new file mode 100644 index 0000000..917e6bc --- /dev/null +++ b/src/repositories/shuttle/eta/InMemorySelfUpdatingETARepository.ts @@ -0,0 +1,205 @@ +import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, WillArriveAtStopPayload } from "../ShuttleGetterRepository"; +import { BaseInMemoryETARepository } from "./BaseInMemoryETARepository"; +import { IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; + +export class InMemorySelfUpdatingETARepository extends BaseInMemoryETARepository implements SelfUpdatingETARepository { + private referenceTime: Date | null = null; + private travelTimeData: Map> = new Map(); + + constructor( + readonly shuttleRepository: ShuttleGetterRepository + ) { + super(); + + this.setReferenceTime = this.setReferenceTime.bind(this); + this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this); + this.startListeningForUpdates = this.startListeningForUpdates.bind(this); + this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this); + this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this); + } + + setReferenceTime(referenceTime: Date): void { + this.referenceTime = referenceTime; + } + + async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments + ): Promise { + const key = `${routeId}:${fromStopId}:${toStopId}`; + const dataPoints = this.travelTimeData.get(key); + + if (!dataPoints || dataPoints.length === 0) { + return undefined; + } + + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + + const filteredPoints = dataPoints.filter( + (point) => point.timestamp >= fromTimestamp && point.timestamp <= toTimestamp + ); + + if (filteredPoints.length === 0) { + return undefined; + } + + const sum = filteredPoints.reduce((acc, point) => acc + point.seconds, 0); + return sum / filteredPoints.length; + } + + startListeningForUpdates(): void { + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); + } + + stopListeningForUpdates(): void { + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); + } + + private async getAverageTravelTimeSecondsWithFallbacks( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilters: ShuttleTravelTimeDateFilterArguments[] + ): Promise { + for (const dateFilter of dateFilters) { + const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter); + if (result !== undefined) { + return result; + } + } + return undefined; + } + + private async handleShuttleUpdate(shuttle: IShuttle): Promise { + const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); + if (!lastStop) return; + + const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + + await this.updateCascadingEta({ + shuttle, + currentStop: lastOrderedStop, + originalStopArrival: lastStop, + }); + } + + private async updateCascadingEta({ + shuttle, + currentStop, + originalStopArrival, + runningTravelTimeSeconds = 0 + }: { + shuttle: IShuttle; + currentStop: IOrderedStop | null; + originalStopArrival: ShuttleStopArrival; + runningTravelTimeSeconds?: number; + }) { + if (!currentStop) return; + const nextStop = currentStop?.nextStop; + if (!nextStop) return; + // In case the system we have loops around + if (nextStop.stopId === originalStopArrival.stopId) return; + + let referenceCurrentTime = new Date(); + if (this.referenceTime != null) { + referenceCurrentTime = this.referenceTime; + } + + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); + const oneHourAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 1000)); + + const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ + routeId: shuttle.routeId, + fromStopId: currentStop.stopId, + toStopId: nextStop.stopId, + }, [ + { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }, + { + from: oneDayAgo, + to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) + }, + { + from: oneHourAgo, + to: new Date(), + } + ]); + + if (travelTimeSeconds == undefined) return; + + const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds; + + await this.addOrUpdateEta({ + secondsRemaining: predictedTimeSeconds, + shuttleId: shuttle.id, + stopId: nextStop.stopId, + systemId: nextStop.systemId, + updatedTime: new Date(), + }); + + const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId); + await this.updateCascadingEta( + { + shuttle, + currentStop: nextStopWithNextNextStop, + originalStopArrival, + runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds, + }, + ) + } + + private async handleShuttleWillArriveAtStop({ + lastArrival, + currentArrival, + }: WillArriveAtStopPayload): Promise { + const etas = await this.getEtasForShuttleId(currentArrival.shuttleId); + for (const eta of etas) { + await this.removeEtaIfExists(eta.shuttleId, eta.stopId); + } + + if (lastArrival) { + // disallow cases where this gets triggered multiple times + if (lastArrival.stopId === currentArrival.stopId) return; + + const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId); + if (!shuttle) return; + + const routeId = shuttle.routeId; + const fromStopId = lastArrival.stopId; + const toStopId = currentArrival.stopId; + + const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId }, travelTimeSeconds, currentArrival.timestamp.getTime()); + } + } + + private async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const key = `${routeId}:${fromStopId}:${toStopId}`; + const dataPoints = this.travelTimeData.get(key) || []; + dataPoints.push({ timestamp, seconds: travelTimeSeconds }); + this.travelTimeData.set(key, dataPoints); + } + + private async removeEtaIfExists(shuttleId: string, stopId: string) { + const index = this.etas.findIndex((e) => e.stopId === stopId && e.shuttleId === shuttleId); + if (index === -1) { + return null; + } + + const removedEta = this.etas[index]; + this.etas.splice(index, 1); + this.emit(ETARepositoryEvent.ETA_REMOVED, removedEta); + return removedEta; + } +} diff --git a/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts new file mode 100644 index 0000000..298709b --- /dev/null +++ b/src/repositories/shuttle/eta/RedisExternalSourceETARepository.ts @@ -0,0 +1,22 @@ +import { IEta } from "../../../entities/ShuttleRepositoryEntities"; +import { BaseRedisETARepository } from "./BaseRedisETARepository"; +import { ExternalSourceETARepository } from "./ExternalSourceETARepository"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; + +export class RedisExternalSourceETARepository extends BaseRedisETARepository implements ExternalSourceETARepository { + async addOrUpdateEtaFromExternalSource(eta: IEta): Promise { + await this.addOrUpdateEta(eta); + } + + async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); + if (existingEta === null) { + return null; + } + + const key = this.createEtaKey(shuttleId, stopId); + await this.redisClient.del(key); + this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta); + return existingEta; + } +} diff --git a/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts new file mode 100644 index 0000000..60e07b4 --- /dev/null +++ b/src/repositories/shuttle/eta/RedisSelfUpdatingETARepository.ts @@ -0,0 +1,289 @@ +import { SelfUpdatingETARepository } from "./SelfUpdatingETARepository"; +import { BaseRedisETARepository } from "./BaseRedisETARepository"; +import { createClient, RedisClientType } from "redis"; +import { ShuttleGetterRepository, ShuttleRepositoryEvent, ShuttleStopArrival, ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments, WillArriveAtStopPayload } from "../ShuttleGetterRepository"; +import { REDIS_RECONNECT_INTERVAL } from "../../../environment"; +import { IEta, IOrderedStop, IShuttle } from "../../../entities/ShuttleRepositoryEntities"; +import { ETARepositoryEvent } from "./ETAGetterRepository"; + +export class RedisSelfUpdatingETARepository extends BaseRedisETARepository implements SelfUpdatingETARepository { + constructor( + readonly shuttleRepository: ShuttleGetterRepository, + redisClient: RedisClientType = createClient({ + url: process.env.REDIS_URL, + socket: { + tls: process.env.NODE_ENV === 'production', + rejectUnauthorized: false, + reconnectStrategy: REDIS_RECONNECT_INTERVAL, + }, + }), + private referenceTime: Date | null = null, + ) { + super(redisClient); + + this.setReferenceTime = this.setReferenceTime.bind(this); + this.getAverageTravelTimeSeconds = this.getAverageTravelTimeSeconds.bind(this); + this.startListeningForUpdates = this.startListeningForUpdates.bind(this); + this.handleShuttleWillArriveAtStop = this.handleShuttleWillArriveAtStop.bind(this); + this.handleShuttleUpdate = this.handleShuttleUpdate.bind(this); + this.updateCascadingEta = this.updateCascadingEta.bind(this); + this.getAverageTravelTimeSecondsWithFallbacks = this.getAverageTravelTimeSecondsWithFallbacks.bind(this); + this.removeEtaIfExists = this.removeEtaIfExists.bind(this); + } + + private createHistoricalEtaTimeSeriesKey = (routeId: string, fromStopId: string, toStopId: string) => { + return `shuttle:eta:historical:${routeId}:${fromStopId}:${toStopId}`; + } + + setReferenceTime(referenceTime: Date) { + this.referenceTime = referenceTime; + } + + public async getAverageTravelTimeSeconds( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + { from, to }: ShuttleTravelTimeDateFilterArguments + ): Promise { + const timeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + const fromTimestamp = from.getTime(); + const toTimestamp = to.getTime(); + const intervalMs = toTimestamp - fromTimestamp + 1; + + try { + const aggregationResult = await this.redisClient.sendCommand([ + 'TS.RANGE', + timeSeriesKey, + fromTimestamp.toString(), + toTimestamp.toString(), + 'AGGREGATION', + 'AVG', + intervalMs.toString() + ]) as [string, string][]; + + if (aggregationResult && aggregationResult.length > 0) { + const [, averageValue] = aggregationResult[0]; + return parseFloat(averageValue); + } + + return; + } catch (error) { + console.warn(`Failed to get average travel time for ${timeSeriesKey}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + } + + startListeningForUpdates() { + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.addListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop) + } + + stopListeningForUpdates() { + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_UPDATED, this.handleShuttleUpdate); + this.shuttleRepository.removeListener(ShuttleRepositoryEvent.SHUTTLE_WILL_ARRIVE_AT_STOP, this.handleShuttleWillArriveAtStop); + } + + private async getAverageTravelTimeSecondsWithFallbacks( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilters: ShuttleTravelTimeDateFilterArguments[] + ): Promise { + for (const dateFilter of dateFilters) { + const result = await this.getAverageTravelTimeSeconds(identifier, dateFilter); + if (result !== undefined) { + return result; + } + } + return undefined; + } + + private async handleShuttleUpdate(shuttle: IShuttle) { + const lastStop = await this.shuttleRepository.getShuttleLastStopArrival(shuttle.id); + if (!lastStop) return; + + const lastOrderedStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, lastStop.stopId); + + await this.updateCascadingEta({ + shuttle, + currentStop: lastOrderedStop, + originalStopArrival: lastStop, + }); + } + + private async updateCascadingEta({ + shuttle, + currentStop, + originalStopArrival, + runningTravelTimeSeconds = 0 + }: { + shuttle: IShuttle; + currentStop: IOrderedStop | null; + originalStopArrival: ShuttleStopArrival; + runningTravelTimeSeconds?: number; + }) { + if (!currentStop) return; + const nextStop = currentStop?.nextStop; + if (!nextStop) return; + // In case the system we have loops around + if (nextStop.stopId === originalStopArrival.stopId) return; + + let referenceCurrentTime = new Date(); + if (this.referenceTime != null) { + referenceCurrentTime = this.referenceTime; + } + + const oneWeekAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 7 * 1000)); + const oneDayAgo = new Date(referenceCurrentTime.getTime() - (60 * 60 * 24 * 1000)); + const twoHoursAgo = new Date(referenceCurrentTime.getTime() - (120 * 60 * 1000)); + + const travelTimeSeconds = await this.getAverageTravelTimeSecondsWithFallbacks({ + routeId: shuttle.routeId, + fromStopId: currentStop.stopId, + toStopId: nextStop.stopId, + }, [ + { + from: oneWeekAgo, + to: new Date(oneWeekAgo.getTime() + (60 * 60 * 1000)) + }, + { + from: oneDayAgo, + to: new Date(oneDayAgo.getTime() + (60 * 60 * 1000)) + }, + { + from: twoHoursAgo, + to: new Date(), + } + ]); + + if (travelTimeSeconds == undefined) return; + + const elapsedTimeMs = referenceCurrentTime.getTime() - originalStopArrival.timestamp.getTime(); + const predictedTimeSeconds = travelTimeSeconds - (elapsedTimeMs / 1000) + runningTravelTimeSeconds; + + await this.addOrUpdateEta({ + secondsRemaining: predictedTimeSeconds, + shuttleId: shuttle.id, + stopId: nextStop.stopId, + systemId: nextStop.systemId, + updatedTime: new Date(), + }); + + const nextStopWithNextNextStop = await this.shuttleRepository.getOrderedStopByRouteAndStopId(shuttle.routeId, nextStop.stopId); + await this.updateCascadingEta( + { + shuttle, + currentStop: nextStopWithNextNextStop, + originalStopArrival, + runningTravelTimeSeconds: runningTravelTimeSeconds + travelTimeSeconds, + }, + ) + } + + + private async handleShuttleWillArriveAtStop({ + lastArrival, + currentArrival, + }: WillArriveAtStopPayload) { + const etas = await this.getEtasForShuttleId(currentArrival.shuttleId); + for (const eta of etas) { + await this.removeEtaIfExists(eta.shuttleId, eta.stopId); + } + + // only update time traveled if last arrival exists + if (lastArrival) { + // disallow cases where this gets triggered multiple times + if (lastArrival.stopId === currentArrival.stopId) return; + + const shuttle = await this.shuttleRepository.getShuttleById(lastArrival.shuttleId); + if (!shuttle) return; + + const routeId = shuttle.routeId; + const fromStopId = lastArrival.stopId; + const toStopId = currentArrival.stopId; + + const travelTimeSeconds = (currentArrival.timestamp.getTime() - lastArrival.timestamp.getTime()) / 1000; + await this.addTravelTimeDataPoint({ routeId, fromStopId, toStopId, }, travelTimeSeconds, currentArrival.timestamp.getTime()); + } + } + + public async addTravelTimeDataPoint( + { routeId, fromStopId, toStopId }: ShuttleTravelTimeDataIdentifier, + travelTimeSeconds: number, + timestamp = Date.now(), + ): Promise { + const historicalEtaTimeSeriesKey = this.createHistoricalEtaTimeSeriesKey(routeId, fromStopId, toStopId); + + try { + await this.redisClient.sendCommand([ + 'TS.ADD', + historicalEtaTimeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString(), + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + } catch (error) { + await this.createHistoricalEtaTimeSeriesAndAddDataPoint( + historicalEtaTimeSeriesKey, + timestamp, + travelTimeSeconds, + routeId, + fromStopId, + toStopId + ); + } + } + + + private async createHistoricalEtaTimeSeriesAndAddDataPoint( + timeSeriesKey: string, + timestamp: number, + travelTimeSeconds: number, + routeId: string, + fromStopId: string, + toStopId: string, + ): Promise { + try { + await this.redisClient.sendCommand([ + 'TS.CREATE', + timeSeriesKey, + 'RETENTION', + '2678400000', // one month in milliseconds + 'LABELS', + 'routeId', + routeId, + 'fromStopId', + fromStopId, + 'toStopId', + toStopId + ]); + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } catch (createError) { + await this.redisClient.sendCommand([ + 'TS.ADD', + timeSeriesKey, + timestamp.toString(), + travelTimeSeconds.toString() + ]); + } + } + + private async removeEtaIfExists(shuttleId: string, stopId: string): Promise { + const existingEta = await this.getEtaForShuttleAndStopId(shuttleId, stopId); + if (existingEta === null) { + return null; + } + + const key = this.createEtaKey(shuttleId, stopId); + await this.redisClient.del(key); + this.emit(ETARepositoryEvent.ETA_REMOVED, existingEta); + return existingEta; + } +} diff --git a/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts new file mode 100644 index 0000000..4dae81a --- /dev/null +++ b/src/repositories/shuttle/eta/SelfUpdatingETARepository.ts @@ -0,0 +1,28 @@ +import { ShuttleTravelTimeDataIdentifier, ShuttleTravelTimeDateFilterArguments } from "../ShuttleGetterRepository"; +import { ETAGetterRepository } from "./ETAGetterRepository"; + +export interface SelfUpdatingETARepository extends ETAGetterRepository { + /** + * Attach a event listener to the shuttle repository, listening to + * shuttle updates + */ + startListeningForUpdates(): void; + + /** + * Get average travel time between two stops based on historical data. + * Returns undefined if no data exists for the specified time range. + * @param identifier - The route and stop IDs to query + * @param dateFilter - The date range to filter data + */ + getAverageTravelTimeSeconds( + identifier: ShuttleTravelTimeDataIdentifier, + dateFilter: ShuttleTravelTimeDateFilterArguments + ): Promise; + + /** + * Set the "current time" as the class knows it, in order to calculate + * ETAs based on past data. + * @param referenceTime + */ + setReferenceTime(referenceTime: Date): void; +} diff --git a/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts new file mode 100644 index 0000000..73aac3a --- /dev/null +++ b/src/repositories/shuttle/eta/__tests__/ExternalSourceETARepositorySharedTests.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; +import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; +import { ExternalSourceETARepository } from "../ExternalSourceETARepository"; +import { RedisExternalSourceETARepository } from "../RedisExternalSourceETARepository"; +import { InMemoryExternalSourceETARepository } from "../InMemoryExternalSourceETARepository"; +import { generateMockEtas } from "../../../../../testHelpers/mockDataGenerators"; + +class RedisExternalSourceETARepositoryHolder implements RepositoryHolder { + repo: RedisExternalSourceETARepository | undefined; + redisClient: RedisClientType | undefined; + + name = "RedisExternalSourceETARepository" + factory = async () => { + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + this.repo = new RedisExternalSourceETARepository(this.redisClient); + return this.repo; + } + teardown = async () => { + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); + } + } +} + +class InMemoryExternalSourceETARepositoryHolder implements RepositoryHolder { + repo: InMemoryExternalSourceETARepository | undefined; + + name = "InMemoryExternalSourceETARepository" + factory = async () => { + this.repo = new InMemoryExternalSourceETARepository(); + return this.repo; + } + teardown = async () => { + // No teardown needed for in-memory + } +} + +const repositoryImplementations = [ + new RedisExternalSourceETARepositoryHolder(), + new InMemoryExternalSourceETARepositoryHolder() +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: ExternalSourceETARepository; + + beforeEach(async () => { + repository = await holder.factory(); + }); + + afterEach(async () => { + await holder.teardown(); + }); + + describe("addOrUpdateEtaFromExternalSource", () => { + test("adds a new ETA if nonexistent", async () => { + const mockEtas = generateMockEtas(); + const newEta = mockEtas[0]; + + await repository.addOrUpdateEtaFromExternalSource(newEta); + + const result = await repository.getEtasForShuttleId(newEta.shuttleId); + expect(result).toEqual([newEta]); + }); + + test("updates an existing ETA if it exists", async () => { + const mockEtas = generateMockEtas(); + const existingEta = mockEtas[0]; + const updatedEta = structuredClone(existingEta); + updatedEta.secondsRemaining = existingEta.secondsRemaining + 60; + + await repository.addOrUpdateEtaFromExternalSource(existingEta); + await repository.addOrUpdateEtaFromExternalSource(updatedEta); + + const result = await repository.getEtasForShuttleId(existingEta.shuttleId); + expect(result).toEqual([updatedEta]); + }); + }); + + describe("getEtasForShuttleId", () => { + test("gets ETAs for a specific shuttle ID", async () => { + const mockEtas = generateMockEtas(); + for (const eta of mockEtas) { + await repository.addOrUpdateEtaFromExternalSource(eta); + } + + const result = await repository.getEtasForShuttleId("sh1"); + const expected = mockEtas.filter((eta) => eta.shuttleId === "sh1"); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expect.arrayContaining(expected)); + }); + + test("returns an empty list if there are no ETAs for the shuttle ID", async () => { + const result = await repository.getEtasForShuttleId("nonexistent-shuttle"); + expect(result).toEqual([]); + }); + }); + + describe("getEtasForStopId", () => { + test("gets ETAs for a specific stop ID", async () => { + const mockEtas = generateMockEtas(); + for (const eta of mockEtas) { + await repository.addOrUpdateEtaFromExternalSource(eta); + } + + const result = await repository.getEtasForStopId("st1"); + expect(result).toEqual(mockEtas.filter((eta) => eta.stopId === "st1")); + }); + + test("returns an empty list if there are no ETAs for the stop ID", async () => { + const result = await repository.getEtasForStopId("nonexistent-stop"); + expect(result).toEqual([]); + }); + }); + + describe("getEtaForShuttleAndStopId", () => { + test("gets a single ETA for a specific shuttle and stop ID", async () => { + const mockEtas = generateMockEtas(); + const mockEta = mockEtas[0]; + await repository.addOrUpdateEtaFromExternalSource(mockEta); + + const result = await repository.getEtaForShuttleAndStopId("sh1", "st1"); + expect(result).toEqual(mockEta); + }); + + test("returns null if no ETA matches the shuttle and stop ID", async () => { + const result = await repository.getEtaForShuttleAndStopId("nonexistent-shuttle", "nonexistent-stop"); + expect(result).toBeNull(); + }); + }); + + describe("removeEtaIfExists", () => { + test("removes eta given shuttle ID and stop ID", async () => { + let mockEtas = generateMockEtas(); + const stopId = mockEtas[0].stopId; + mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); + + await Promise.all(mockEtas.map(async (eta) => { + eta.stopId = stopId; + await repository.addOrUpdateEtaFromExternalSource(eta); + })); + + const etaToRemove = mockEtas[0]; + await repository.removeEtaIfExists(etaToRemove.shuttleId, etaToRemove.stopId); + + const remainingEtas = await repository.getEtasForStopId(stopId); + expect(remainingEtas).toHaveLength(mockEtas.length - 1); + }); + + test("does nothing if eta doesn't exist", async () => { + let mockEtas = generateMockEtas(); + const stopId = mockEtas[0].stopId; + mockEtas = mockEtas.filter((eta) => eta.stopId === stopId); + + await Promise.all(mockEtas.map(async (eta) => { + eta.stopId = stopId; + await repository.addOrUpdateEtaFromExternalSource(eta); + })); + + await repository.removeEtaIfExists("nonexistent-shuttle-id", "nonexistent-stop-id"); + + const remainingEtas = await repository.getEtasForStopId(stopId); + expect(remainingEtas).toHaveLength(mockEtas.length); + }); + }); +}) + diff --git a/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts new file mode 100644 index 0000000..f490b0f --- /dev/null +++ b/src/repositories/shuttle/eta/__tests__/SelfUpdatingETARepositorySharedTests.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { createClient, RedisClientType } from "redis"; +import { RepositoryHolder } from "../../../../../testHelpers/RepositoryHolder"; +import { SelfUpdatingETARepository } from "../SelfUpdatingETARepository"; +import { RedisSelfUpdatingETARepository } from "../RedisSelfUpdatingETARepository"; +import { InMemorySelfUpdatingETARepository } from "../InMemorySelfUpdatingETARepository"; +import { RedisShuttleRepository } from "../../RedisShuttleRepository"; +import { UnoptimizedInMemoryShuttleRepository } from "../../UnoptimizedInMemoryShuttleRepository"; +import { setupRouteAndOrderedStopsForShuttleRepository } from "../../../../../testHelpers/setupRouteAndOrderedStopsForShuttleRepository"; +import { ShuttleGetterSetterRepository } from "../../ShuttleGetterSetterRepository"; + +class RedisSelfUpdatingETARepositoryHolder implements RepositoryHolder { + repo: RedisSelfUpdatingETARepository | undefined; + shuttleRepo: RedisShuttleRepository | undefined; + redisClient: RedisClientType | undefined; + + name = "RedisSelfUpdatingETARepository" + factory = async () => { + this.redisClient = createClient({ + url: process.env.REDIS_URL, + }); + await this.redisClient.connect(); + await this.redisClient.flushAll(); + this.shuttleRepo = new RedisShuttleRepository(this.redisClient); + this.repo = new RedisSelfUpdatingETARepository( + this.shuttleRepo, + this.redisClient, + ); + return this.repo; + } + teardown = async () => { + if (this.redisClient) { + await this.redisClient.flushAll(); + await this.redisClient.disconnect(); + } + } +} + +class InMemorySelfUpdatingETARepositoryHolder implements RepositoryHolder { + repo: InMemorySelfUpdatingETARepository | undefined; + shuttleRepo: UnoptimizedInMemoryShuttleRepository | undefined; + + name = "InMemorySelfUpdatingETARepository" + factory = async () => { + this.shuttleRepo = new UnoptimizedInMemoryShuttleRepository(); + this.repo = new InMemorySelfUpdatingETARepository(this.shuttleRepo); + return this.repo; + } + teardown = async () => { + // No teardown needed for in-memory + } +} + +const repositoryImplementations = [ + new RedisSelfUpdatingETARepositoryHolder(), + new InMemorySelfUpdatingETARepositoryHolder() +]; + +describe.each(repositoryImplementations)('$name', (holder) => { + let repository: SelfUpdatingETARepository; + let shuttleRepository: ShuttleGetterSetterRepository; + + beforeEach(async () => { + repository = await holder.factory(); + shuttleRepository = holder.shuttleRepo!; + }); + + afterEach(async () => { + await holder.teardown(); + }); + + // Helper function for setting up routes and ordered stops + async function setupRouteAndOrderedStops() { + return await setupRouteAndOrderedStopsForShuttleRepository(shuttleRepository); + } + + describe("handleShuttleWillArriveAtStop", () => { + test("updates how long the shuttle took to get from one stop to another", async () => { + const { route, systemId, stop2, stop1 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + // Necessary to wait for the event emitter subscriber to execute + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + expect(travelTime).toEqual(15 * 60); + }); + + }); + + describe("handleShuttleUpdate", () => { + async function assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime: Date, + shuttleSecondArrivalTimeAtFirstStop: Date + ) { + const { route, systemId, stop1, stop2, stop3 } = await setupRouteAndOrderedStops(); + + // Populating travel time data + const firstStopArrivalTime = new Date(2025, 0, 1, 12, 0, 0); + const secondStopArrivalTime = new Date(2025, 0, 1, 12, 15, 0); + const thirdStopArrivalTime = new Date(2025, 0, 1, 12, 20, 0); + + repository.setReferenceTime(currentTime); + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopArrivalTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopArrivalTime.getTime()); + + shuttle.coordinates = stop3.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, thirdStopArrivalTime.getTime()); + + // Populating ETA data + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + shuttleSecondArrivalTimeAtFirstStop.getTime() + ); + + shuttle.coordinates = { latitude: 12.5, longitude: 22.5 }; + await shuttleRepository.addOrUpdateShuttle( + shuttle, + currentTime.getTime() + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const secondStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop2.id); + expect(secondStopEta?.secondsRemaining).toEqual(8 * 60); + + const thirdStopEta = await repository.getEtaForShuttleAndStopId(shuttle.id, stop3.id); + expect(thirdStopEta?.secondsRemaining).toEqual(13 * 60); + } + + test("adds ETA entries for stops based on historical data", async () => { + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 8, 12, 0, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); + + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop + ); + }, 60000); + + test("uses previous day fallback calculation when no data available from one week ago", async () => { + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 2, 12, 0, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop + ); + }, 60000); + + test("uses previous hour fallback calculation when no data available from one day ago", async () => { + const shuttleSecondArrivalTimeAtFirstStop = new Date(2025, 0, 1, 13, 5, 0); + const currentTime = new Date(shuttleSecondArrivalTimeAtFirstStop.getTime() + 7 * 60 * 1000); + await assertEtaIsValidGivenCurrentTimeAndSecondArrivalTime( + currentTime, shuttleSecondArrivalTimeAtFirstStop + ); + }); + }); + + describe("getAverageTravelTimeSeconds", () => { + test("returns the average travel time when historical data exists", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + const firstStopTime = new Date(2025, 0, 1, 12, 0, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, firstStopTime.getTime()); + + shuttle.coordinates = stop2.coordinates; + const secondStopTime = new Date(2025, 0, 1, 12, 15, 0); + await shuttleRepository.addOrUpdateShuttle(shuttle, secondStopTime.getTime()); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const travelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 13, 0, 0), + }); + + expect(travelTime).toEqual(15 * 60); + }); + + test("returns average of multiple data points", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + // First trip: 10 minutes travel time + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 10, 0).getTime()); + + // Second trip: 20 minutes travel time + shuttle.coordinates = stop1.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 30, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 50, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + // Average of 10 minutes and 20 minutes = 15 minutes = 900 seconds + expect(averageTravelTime).toBeDefined(); + }); + + test("returns undefined when no data exists", async () => { + const { route, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 1, 11, 0, 0), + to: new Date(2025, 0, 1, 14, 0, 0), + }); + + expect(averageTravelTime).toBeUndefined(); + }); + + test("returns undefined when querying outside the time range of data", async () => { + const { route, systemId, stop1, stop2 } = await setupRouteAndOrderedStops(); + + repository.startListeningForUpdates(); + + const shuttle = { + id: "sh1", + name: "Shuttle 1", + routeId: route.id, + systemId: systemId, + coordinates: stop1.coordinates, + orientationInDegrees: 0, + updatedTime: new Date(), + }; + + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 0, 0).getTime()); + shuttle.coordinates = stop2.coordinates; + await shuttleRepository.addOrUpdateShuttle(shuttle, new Date(2025, 0, 1, 12, 15, 0).getTime()); + + const averageTravelTime = await repository.getAverageTravelTimeSeconds({ + routeId: route.id, + fromStopId: stop1.id, + toStopId: stop2.id, + }, { + from: new Date(2025, 0, 2, 11, 0, 0), + to: new Date(2025, 0, 2, 13, 0, 0), + }); + expect(averageTravelTime).toBeUndefined(); + }); + }); +}) diff --git a/src/resolvers/ParkingStructureResolvers.ts b/src/resolvers/ParkingStructureResolvers.ts index a108daf..b417876 100644 --- a/src/resolvers/ParkingStructureResolvers.ts +++ b/src/resolvers/ParkingStructureResolvers.ts @@ -1,10 +1,10 @@ import { Resolvers } from "../generated/graphql"; import { ServerContext } from "../ServerContext"; -import { HistoricalParkingAverageQueryArguments } from "../repositories/parking/ParkingGetterRepository"; +import { HistoricalParkingAverageFilterArguments } from "../repositories/parking/ParkingGetterRepository"; import { GraphQLError } from "graphql/error"; import { - PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN, - PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL + PARKING_HISTORICAL_AVERAGE_MAXIMUM_TIMESPAN, + PARKING_HISTORICAL_AVERAGE_MINIMUM_INTERVAL } from "../environment"; export const ParkingStructureResolvers: Resolvers = { @@ -27,7 +27,7 @@ export const ParkingStructureResolvers: Resolvers = { throwBadUserInputError('No interval provided'); return null; } - const queryArguments: HistoricalParkingAverageQueryArguments = { + const queryArguments: HistoricalParkingAverageFilterArguments = { from: new Date(args.input.from), intervalMs: args.input.intervalMs, to: new Date(args.input.to), diff --git a/src/resolvers/ShuttleResolvers.ts b/src/resolvers/ShuttleResolvers.ts index b755e83..da276ae 100644 --- a/src/resolvers/ShuttleResolvers.ts +++ b/src/resolvers/ShuttleResolvers.ts @@ -9,7 +9,7 @@ export const ShuttleResolvers: Resolvers = { const system = contextValue.findSystemById(parent.systemId); if (!system) return null; - const etaForStopId = await system.shuttleRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId); + const etaForStopId = await system.etaRepository.getEtaForShuttleAndStopId(parent.id, args.forStopId); if (etaForStopId === null) return null; return { @@ -25,7 +25,7 @@ export const ShuttleResolvers: Resolvers = { const system = contextValue.findSystemById(parent.systemId); if (!system) return null; - const etasForShuttle = await system.shuttleRepository.getEtasForShuttleId(parent.id); + const etasForShuttle = await system.etaRepository.getEtasForShuttleId(parent.id); if (!etasForShuttle) return null; const computedEtas = await Promise.all( diff --git a/src/resolvers/StopResolvers.ts b/src/resolvers/StopResolvers.ts index 05d0cb9..45507e0 100644 --- a/src/resolvers/StopResolvers.ts +++ b/src/resolvers/StopResolvers.ts @@ -16,7 +16,7 @@ export const StopResolvers: Resolvers = { if (!system) { return []; } - const etas = await system.shuttleRepository.getEtasForStopId(parent.id); + const etas = await system.etaRepository.getEtasForStopId(parent.id); return etas.slice().sort((a, b) => a.secondsRemaining - b.secondsRemaining); }, }, diff --git a/src/resolvers/__tests__/EtaResolverTests.test.ts b/src/resolvers/__tests__/EtaResolverTests.test.ts index e6bbe20..f584b8c 100644 --- a/src/resolvers/__tests__/EtaResolverTests.test.ts +++ b/src/resolvers/__tests__/EtaResolverTests.test.ts @@ -6,7 +6,8 @@ import { addMockShuttleToRepository, addMockStopToRepository, } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("EtaResolvers", () => { const holder = setupTestServerHolder(); @@ -19,7 +20,7 @@ describe("EtaResolvers", () => { beforeEach(async () => { mockShuttle = await addMockShuttleToRepository(context.systems[0].shuttleRepository, context.systems[0].id); mockStop = await addMockStopToRepository(context.systems[0].shuttleRepository, context.systems[0].id); - expectedEta = await addMockEtaToRepository(context.systems[0].shuttleRepository, mockStop.id, mockShuttle.id); + expectedEta = await addMockEtaToRepository(context.systems[0].etaRepository as ExternalSourceETARepository, mockStop.id, mockShuttle.id); }); async function getResponseForEtaQuery(query: string) { diff --git a/src/resolvers/__tests__/ShuttleResolverTests.test.ts b/src/resolvers/__tests__/ShuttleResolverTests.test.ts index 0ab62d0..ed827a4 100644 --- a/src/resolvers/__tests__/ShuttleResolverTests.test.ts +++ b/src/resolvers/__tests__/ShuttleResolverTests.test.ts @@ -3,8 +3,9 @@ import { generateMockEtas, generateMockRoutes } from "../../../testHelpers/mockD import { IShuttle } from "../../entities/ShuttleRepositoryEntities"; import { setupTestServerContext, setupTestServerHolder } from "../../../testHelpers/apolloTestServerHelpers"; import { addMockShuttleToRepository } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); import { InterchangeSystem } from "../../entities/InterchangeSystem"; +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("ShuttleResolvers", () => { @@ -25,7 +26,7 @@ describe("ShuttleResolvers", () => { const etas = generateMockEtas(); await Promise.all(etas.map(async (eta) => { eta.shuttleId = shuttleId; - await context.systems[0].shuttleRepository.addOrUpdateEta(eta); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta); })); return etas; } @@ -146,9 +147,9 @@ describe("ShuttleResolvers", () => { const e1 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stA", secondsRemaining: 300 }; const e2 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stB", secondsRemaining: 30 }; const e3 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEta(e1); - await context.systems[0].shuttleRepository.addOrUpdateEta(e2); - await context.systems[0].shuttleRepository.addOrUpdateEta(e3); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3); const response = await holder.testServer.executeOperation({ query, diff --git a/src/resolvers/__tests__/StopResolverTests.test.ts b/src/resolvers/__tests__/StopResolverTests.test.ts index 4378df5..615a9c1 100644 --- a/src/resolvers/__tests__/StopResolverTests.test.ts +++ b/src/resolvers/__tests__/StopResolverTests.test.ts @@ -6,7 +6,8 @@ import { import { generateMockEtas, generateMockOrderedStops } from "../../../testHelpers/mockDataGenerators"; import { IStop } from "../../entities/ShuttleRepositoryEntities"; import { addMockStopToRepository } from "../../../testHelpers/repositorySetupHelpers"; -import assert = require("node:assert"); +import { ExternalSourceETARepository } from "../../repositories/shuttle/eta/ExternalSourceETARepository"; +import assert from "node:assert"; describe("StopResolvers", () => { const holder = setupTestServerHolder(); @@ -106,7 +107,7 @@ describe("StopResolvers", () => { mockEtas = mockEtas.filter((eta) => eta.stopId === mockEtas[0].stopId); await Promise.all(mockEtas.map(async eta => { eta.stopId = mockStop.id; - await context.systems[0].shuttleRepository.addOrUpdateEta(eta); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(eta); })); const response = await getResponseForQuery(query); @@ -128,9 +129,9 @@ describe("StopResolvers", () => { const e1 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shA", secondsRemaining: 240 }; const e2 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shB", secondsRemaining: 60 }; const e3 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shC", secondsRemaining: 120 }; - await context.systems[0].shuttleRepository.addOrUpdateEta(e1); - await context.systems[0].shuttleRepository.addOrUpdateEta(e2); - await context.systems[0].shuttleRepository.addOrUpdateEta(e3); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e1); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e2); + await (context.systems[0].etaRepository as ExternalSourceETARepository).addOrUpdateEtaFromExternalSource(e3); const response = await getResponseForQuery(query); diff --git a/testHelpers/RepositoryHolder.ts b/testHelpers/RepositoryHolder.ts new file mode 100644 index 0000000..4871993 --- /dev/null +++ b/testHelpers/RepositoryHolder.ts @@ -0,0 +1,5 @@ +export interface RepositoryHolder { + name: string; + factory(): Promise; + teardown(): Promise; +} diff --git a/testHelpers/apolloTestServerHelpers.ts b/testHelpers/apolloTestServerHelpers.ts index ea192c2..60a8355 100644 --- a/testHelpers/apolloTestServerHelpers.ts +++ b/testHelpers/apolloTestServerHelpers.ts @@ -3,7 +3,7 @@ import { ApolloServer } from "@apollo/server"; import { MergedResolvers } from "../src/MergedResolvers"; import { beforeEach } from "@jest/globals"; import { ServerContext } from "../src/ServerContext"; -import { InterchangeSystem } from "../src/entities/InterchangeSystem"; +import { InterchangeSystem, InterchangeSystemBuilderArguments } from "../src/entities/InterchangeSystem"; import { ChapmanApiBasedParkingRepositoryLoader } from "../src/loaders/parking/ChapmanApiBasedParkingRepositoryLoader"; @@ -19,11 +19,12 @@ function setUpTestServer() { }); } -const systemInfoForTesting = { +const systemInfoForTesting: InterchangeSystemBuilderArguments = { id: "1", name: "Chapman University", passioSystemId: "263", parkingSystemId: ChapmanApiBasedParkingRepositoryLoader.id, + useSelfUpdatingEtas: false, }; export function buildSystemForTesting() { diff --git a/testHelpers/repositorySetupHelpers.ts b/testHelpers/repositorySetupHelpers.ts index 54c5b7e..657fd4d 100644 --- a/testHelpers/repositorySetupHelpers.ts +++ b/testHelpers/repositorySetupHelpers.ts @@ -5,6 +5,7 @@ import { generateMockStops, } from "./mockDataGenerators"; import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository"; +import { ExternalSourceETARepository } from "../src/repositories/shuttle/eta/ExternalSourceETARepository"; export async function addMockRouteToRepository(repository: ShuttleGetterSetterRepository, systemId: string) { const mockRoutes = generateMockRoutes(); @@ -32,12 +33,12 @@ export async function addMockShuttleToRepository(repository: ShuttleGetterSetter return mockShuttle; } -export async function addMockEtaToRepository(repository: ShuttleGetterSetterRepository, stopId: string, shuttleId: string) { +export async function addMockEtaToRepository(repository: ExternalSourceETARepository, stopId: string, shuttleId: string) { const etas = generateMockEtas(); const expectedEta = etas[0]; expectedEta.stopId = stopId; expectedEta.shuttleId = shuttleId; - await repository.addOrUpdateEta(expectedEta); + await repository.addOrUpdateEtaFromExternalSource(expectedEta); return expectedEta; } diff --git a/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts new file mode 100644 index 0000000..7dd80e9 --- /dev/null +++ b/testHelpers/setupRouteAndOrderedStopsForShuttleRepository.ts @@ -0,0 +1,81 @@ +import { IOrderedStop, IStop } from "../src/entities/ShuttleRepositoryEntities"; +import { ShuttleGetterSetterRepository } from "../src/repositories/shuttle/ShuttleGetterSetterRepository"; + +export async function setupRouteAndOrderedStopsForShuttleRepository( + shuttleRepository: ShuttleGetterSetterRepository +) { + const systemId = "sys1"; + const route = { + id: "r1", + name: "Route 1", + color: "red", + systemId: systemId, + polylineCoordinates: [], + updatedTime: new Date(), + }; + await shuttleRepository.addOrUpdateRoute(route); + + const stop1: IStop = { + id: "st1", + name: "Stop 1", + systemId, + coordinates: { latitude: 10.0, longitude: 20.0 }, + updatedTime: new Date(), + }; + const stop2: IStop = { + id: "st2", + name: "Stop 2", + systemId, + coordinates: { latitude: 15.0, longitude: 25.0 }, + updatedTime: new Date(), + }; + const stop3: IStop = { + id: "st3", + name: "Stop 3", + systemId, + coordinates: { latitude: 20.0, longitude: 30.0 }, + updatedTime: new Date(), + } + await shuttleRepository.addOrUpdateStop(stop1); + await shuttleRepository.addOrUpdateStop(stop2); + await shuttleRepository.addOrUpdateStop(stop3); + + const orderedStop1: IOrderedStop = { + routeId: route.id, + stopId: stop1.id, + position: 1, + systemId, + updatedTime: new Date(), + }; + const orderedStop2: IOrderedStop = { + routeId: route.id, + stopId: stop2.id, + position: 2, + systemId, + updatedTime: new Date(), + }; + const orderedStop3: IOrderedStop = { + routeId: route.id, + stopId: stop3.id, + position: 3, + systemId, + updatedTime: new Date(), + } + orderedStop1.nextStop = orderedStop2; + orderedStop1.previousStop = orderedStop3; + orderedStop2.nextStop = orderedStop3; + orderedStop2.previousStop = orderedStop1; + orderedStop3.nextStop = orderedStop1; + orderedStop3.previousStop = orderedStop2; + await shuttleRepository.addOrUpdateOrderedStop(orderedStop1); + await shuttleRepository.addOrUpdateOrderedStop(orderedStop2); + await shuttleRepository.addOrUpdateOrderedStop(orderedStop3); + + return { + route, + systemId, + stop1, + stop2, + stop3, + }; +}