diff --git a/src/raptor/McRaptorAlgorithm.ts b/src/raptor/McRaptorAlgorithm.ts index 164b1f8..43d1bba 100644 --- a/src/raptor/McRaptorAlgorithm.ts +++ b/src/raptor/McRaptorAlgorithm.ts @@ -15,23 +15,32 @@ export interface Journey { } } -/** - * Represents a single segment of a journey, either a transit trip or a walking transfer. - */ -export interface JourneyLeg { - type: 'Trip' | 'Transfer'; +interface JourneyLegCommon { origin: StopID; destination: StopID; startTime: number; endTime: number; - trip?: Trip; - transfer?: Transfer; duration: number; originID: StopID; destinationID: StopID; +}; + +export interface JourneyLegTrip extends JourneyLegCommon { + type: 'Trip'; + trip: Trip; rt?: string; - stopTimes?: StopTime[]; -} + stopTimes: StopTime[]; +}; + +export interface JourneyLegTransfer extends JourneyLegCommon { + type: 'Transfer', + transfer: Transfer, +}; + +/** + * Represents a single segment of a journey, either a transit trip or a walking transfer. + */ +export type JourneyLeg = JourneyLegTransfer | JourneyLegTrip; /** * Implementation of the McRAPTOR (Multi-Criteria Round-Based Public Transit Routing) algorithm. @@ -360,8 +369,8 @@ export class McRaptorAlgorithm { for (const j of allJourneys) { const tripsSignature = j.legs - .filter(l => l.type === 'Trip' && l.trip) - .map(l => l.trip!.tripId) + .filter((l) => l.type === 'Trip') + .map(l => l.trip.tripId) .join('|'); if (!tripsSignature) { diff --git a/src/routes/api.ts b/src/routes/api.ts index 1bd8f8d..436fdda 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -9,6 +9,7 @@ import * as journeyService from '../services/journey'; import * as reminderService from '../services/reminder'; import * as graphBuilder from '../services/graphBuilder'; import { startBackgroundJobs } from '../jobs'; +import { BusRouteLineSchema } from "../services/bustimeCommon"; import * as documented from "./documented"; /** @@ -188,27 +189,19 @@ export function getRidePositions(req: express.Request, res: express.Response) { } router.get('/getRidePositions', getRidePositions); -/** - * Returns all cached route patterns. - * @param req - Express request - * @param res - Express response - * @returns JSON object with `routes` mapping route IDs to patterns. - */ -export function getAllRoutes(req: express.Request, res: express.Response) { - res.json({ routes: state.cachedRoutes }); -} -router.get('/getAllRoutes', getAllRoutes); +documented.addGetRoute( + documented.globalContext, router, '/getAllRoutes', + { ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) }, + () => documented.makeSuccessResponse(Object.values(state.cachedRoutes).flat(1)), + { description: 'get all cached route patterns' } +); -/** - * Returns all cached ride route patterns. - * @param req - Express request - * @param res - Express response - * @returns JSON object with `routes` mapping route IDs to patterns. - */ -export function getAllRideRoutes(req: express.Request, res: express.Response) { - res.json({ routes: state.cachedRideRoutes }); -} -router.get('/getAllRideRoutes', getAllRideRoutes); +documented.addGetRoute( + documented.globalContext, router, '/getAllRideRoutes', + { ...documented.emptyFormat, resBody: z.array(BusRouteLineSchema) }, + () => documented.makeSuccessResponse(Object.values(state.cachedRideRoutes).flat(1)), + { description: 'get all cached ride route patterns' }, +) /** * Returns the route timing cache used for extrapolation. diff --git a/src/services/bustimeCommon.ts b/src/services/bustimeCommon.ts new file mode 100644 index 0000000..d5d6596 --- /dev/null +++ b/src/services/bustimeCommon.ts @@ -0,0 +1,144 @@ +import z from "zod"; + +const PatternPtSchema = z.object({ + seq: z.int(), + typ: z.string(), + stpid: z.optional(z.string()), + stpnm: z.optional(z.string()), + pdist: z.optional(z.number()), + lat: z.number(), + lon: z.number(), +}).meta({ id: 'PatternPt' }); + +export const PatternSchema = z.object({ + pid: z.int(), + ln: z.number(), + rtdir: z.string(), + pt: z.array(PatternPtSchema), + dtrid: z.optional(z.string()), + dtrpt: z.optional(z.array(PatternPtSchema)), +}).meta({ id: 'Pattern' }); +export type Pattern = z.infer + +export const PatternsArraySchema = z.array(PatternSchema); + +export const LatLonSchema = z.object({ lat: z.number(), lon: z.number() }).meta({ id: 'LatLon' }); + +export const BusStopSchema = z.object({ + id: z.string(), + name: z.string(), + location: LatLonSchema, + routeId: z.string(), + rotation: z.number(), + isRide: z.boolean(), +}).meta({ id: 'BusStop' }); +export type BusStop = z.infer; + +export function makeBusStop( + { id, name, lat, lon }: { id?: string, name?: string, lat?: number, lon?: number }, + routeId: string, rotation: number, isRide: boolean +): BusStop { + return { + id: id ?? '', + name: name ? normalizeStopName(name) : '', + location: { lat: lat ?? 0, lon: lon ?? 0 }, + routeId, rotation, isRide, + }; +} + +/** doesn't include color or image url, which are still handled by the frontend */ +export const BusRouteLineSchema = z.object({ + routeId: z.string(), + routeDirection: z.string(), + points: z.array(LatLonSchema), + stops: z.array(z.object({ index: z.int(), stop: BusStopSchema })), +}).meta({ id: 'BusRouteLine' }); +export type BusRouteLine = z.infer; + +export function makeBusRouteLines(rt: string, pattern: Pattern, isRide: boolean): BusRouteLine[] { + + const process = (pointList: Pattern['pt']): { + points: BusRouteLine['points'], + stops: BusRouteLine['stops'] + } => { + const points = []; + const stops = []; + for (let i = 0; i < pointList.length; i++) { + const point = pointList[i]; + const isLast = i == pointList.length - 1; // bool to check if last + points.push({ lat: point.lat, lon: point.lon }); + if (point.typ === 'S') { + // get rotation of stop + let stopRotation; + if (isLast) { + // use the previous 2 points to calculate rotation + stopRotation = pointRotation( + pointList[i - 2]?.lat ?? 0, + pointList[i - 2]?.lon ?? 0, + pointList[i - 1]?.lat ?? 0, + pointList[i - 1]?.lon ?? 0, + ); + } else { + // use the next 2 points to calculate rotation + stopRotation = pointRotation( + pointList[i + 1]?.lat ?? 0, + pointList[i + 1]?.lon ?? 0, + pointList[i + 2]?.lat ?? 0, + pointList[i + 2]?.lon ?? 0, + ); + } + stops.push({ + index: i, + stop : makeBusStop( + { id: point.stpid, name: point.stpnm, lat: point.lat, lon: point.lon }, + rt, stopRotation, isRide + ) + }); + } + } + return { points, stops }; + } + + const lines: BusRouteLine[] = []; + { + const { points, stops } = process(pattern.pt); + lines.push({ routeId: rt, points, stops, routeDirection: pattern.rtdir }); + } + + // Handle detour points if present + if (pattern.dtrpt) { + const { points, stops } = process(pattern.dtrpt); + lines.push({ routeId: rt, points, stops, routeDirection: pattern.rtdir }); + } + + return lines; +} + +/** + * Function to calculate rotation angle between two geographical points + * (used for bus stop icon orientation) + */ +export function pointRotation(lat1: number, lon1: number, lat2: number, lon2: number): number { + const dLat = lat2 - lat1; + const dLon = lon2 - lon1; + + // Scale longitude by cos(lat) to correct for east-west distance + const x = dLon * (Math.cos(lat1 * Math.PI / 180.0)); + const y = dLat; + + let angle = Math.atan2(x, y) * 180.0 / Math.PI; + + // Normalize to [0, 360) + if (angle < 0) angle += 360; + + return angle; +} + +// KEEP THIS IN SYNC WITH THE CORRESPONDING FUNCTION IN THE FRONTEND +function normalizeStopName(rawStopName: string): string { + return rawStopName + .replaceAll('%', '') + .replaceAll(/\s+/g, ' ') + .trim(); +} + diff --git a/src/services/graphBuilder.ts b/src/services/graphBuilder.ts index 68ac957..ba72e83 100644 --- a/src/services/graphBuilder.ts +++ b/src/services/graphBuilder.ts @@ -7,6 +7,7 @@ import * as process from "node:process"; import { MaxPriorityQueue } from '@datastructures-js/priority-queue'; import * as fs from 'fs'; import * as path from 'path'; +import { makeBusRouteLines } from './bustimeCommon'; const DEFAULT_ROUTES = ["BB", "CN", "CS", "CSX", "DD", "MX", "NE", "NW", "NX", "OS", "NES", "WS", "WX"]; const DEFAULT_RIDE_ROUTES = ["3", "4", "5", "6", "22", "23", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "42", "43", "44", "45", "46", "47", "61", "62", "63", "64", "65", "66", "67", "68", "104"]; @@ -32,13 +33,13 @@ export async function initializeRoutes() { await Promise.all(routesData.map(async (r: any) => { state.validRoutes.add(r.rt); const patterns = await mbus.fetchPatterns(r.rt); - if (patterns) state.cachedRoutes[r.rt] = patterns; + state.cachedRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, false)).flat(1); })); await Promise.all(rideRoutesData.map(async (r: any) => { state.validRideRoutes.add(r.rt); const patterns = await rideBus.fetchPatterns(r.rt); - if (patterns) state.cachedRideRoutes[r.rt] = patterns; + state.cachedRideRoutes[r.rt] = patterns.map((p) => makeBusRouteLines(r.rt, p, true)).flat(1); })); buildStopLocationMap(); @@ -57,11 +58,10 @@ export async function rebuildGraph() { try { console.log(`Rebuilding graph...`); const allStopIds = new Set(); - Object.values(state.cachedRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid) allStopIds.add(pt.stpid); - })); - }); + Object.values(state.cachedRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => allStopIds.add(stop.id)); const rawPreds = await mbus.fetchPredictions(Array.from(allStopIds), DEFAULT_ROUTES); const formattedPreds = processPredictions(rawPreds); @@ -74,11 +74,10 @@ export async function rebuildGraph() { // extra stuff to update the busses for the ride const rideStopIds = new Set(); - Object.values(state.cachedRideRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid) rideStopIds.add(pt.stpid); - })); - }); + Object.values(state.cachedRideRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => rideStopIds.add(stop.id)); const rawRidePreds = await rideBus.fetchPredictions(Array.from(rideStopIds), DEFAULT_RIDE_ROUTES); const formattedRidePreds = processRidePredictions(rawRidePreds); populateRideLookupMaps(formattedRidePreds); @@ -100,13 +99,10 @@ export async function rebuildGraph() { * @param preds List of processed predictions */ function populateLookupMaps(preds: any[]) { - Object.values(state.cachedRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid && pt.stpnm) { - state.stopIdToName[pt.stpid] = pt.stpnm; - } - })); - }); + Object.values(state.cachedRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => state.stopIdToName[stop.id] = stop.name); preds.forEach((trip: any) => { trip.stops.forEach((stop: any) => { if (stop.stpid && stop.stpnm) { @@ -130,13 +126,10 @@ function populateLookupMaps(preds: any[]) { * @param preds List of processed predictions from the ride */ function populateRideLookupMaps(preds: any[]) { - Object.values(state.cachedRideRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid && pt.stpnm) { - state.rideStopIdToName[pt.stpid] = pt.stpnm; - } - })); - }); + Object.values(state.cachedRideRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => state.rideStopIdToName[stop.id] = stop.name); preds.forEach((trip: any) => { trip.stops.forEach((stop: any) => { if (stop.stpid && stop.stpnm) { @@ -152,13 +145,12 @@ function populateRideLookupMaps(preds: any[]) { */ function buildStopLocationMap() { const locs: Record = {}; - Object.values(state.cachedRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid && pt.lat) { - locs[pt.stpid] = { name: pt.stpnm, lat: parseFloat(pt.lat), lon: parseFloat(pt.lon) }; - } - })); - }); + Object.values(state.cachedRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => + locs[stop.id] = { name: stop.name, lat: stop.location.lat, lon: stop.location.lon } + ); state.setCachedStopLocations(locs); walking.buildStopNodeMap(locs); } @@ -168,13 +160,11 @@ function buildStopLocationMap() { */ function buildRideStops() { const locs: Record = {}; - Object.values(state.cachedRideRoutes).forEach((patterns: any) => { - patterns?.forEach((p: any) => p.pt?.forEach((pt: any) => { - if (pt.stpid && pt.lat) { - locs[pt.stpid] = { name: pt.stpnm, lat: parseFloat(pt.lat), lon: parseFloat(pt.lon) }; - } - })); - }); + Object.values(state.cachedRideRoutes) + .flat(1) + .flatMap((line) => line.stops) + .forEach(({ index: _, stop }) => + locs[stop.id] = { name: stop.name, lat: stop.location.lat, lon: stop.location.lon }); state.setCachedRideStopLocations(locs); } @@ -242,15 +232,13 @@ function processPredictions(rawChunks: any[]) { // build index maps const routeInfoFilter: Record = {}; - for (const [routeName, routeList] of Object.entries(state.cachedRoutes as Record)) { + for (const [routeName, routeList] of Object.entries(state.cachedRoutes)) { for (const route of routeList) { - const rtdir = route.rtdir; + const rtdir = route.routeDirection; const routeKey = routeName + rtdir; if (!routeInfoFilter[routeKey]) routeInfoFilter[routeKey] = []; - for (const point of route.pt) { - if (point.typ !== "W" && point.stpid) { - routeInfoFilter[routeKey].push({ stpid: point.stpid, rtdir }); - } + for (const { index: _, stop } of route.stops) { + routeInfoFilter[routeKey].push({ stpid: stop.id, rtdir }); } } } diff --git a/src/services/journey.ts b/src/services/journey.ts index f5a0776..a9c3a3a 100644 --- a/src/services/journey.ts +++ b/src/services/journey.ts @@ -1,6 +1,7 @@ import * as state from '../state/transitState'; import * as walking from '../walking/walkingMap'; import { McRaptorAlgorithm, Journey, JourneyLeg } from "../raptor/McRaptorAlgorithm"; +import { StopTime, Trip } from '@/raptor/types'; /** * Plans a journey between two coordinates using the McRaptor algorithm. @@ -75,12 +76,44 @@ export async function planJourney( return processJourneys(journeys, oLat, oLon, dLat, dLon); } +interface FormattedLegCommon { + origin_id: string, + origin: string, + destination_id: string, + destination: string, + destinationName: string, + startTime: number, + endTime: number, + duration: number, + originID: string, + destinationID: string, +}; + +export interface FormattedLegWalk extends + FormattedLegCommon, + Partial> // leaves just the path_coords field for now +{ + mode: 'walk' +}; + +export interface FormattedLegBus extends FormattedLegCommon { + mode: 'bus', + stopTimes: StopTime[], + trip: Trip, + tripId: string, + rt: string, + vid: string | null, +}; + +export type FormattedLeg = FormattedLegWalk | FormattedLegBus + async function processJourneys(journeys: Journey[], oLat: number, oLon: number, dLat: number, dLon: number) { const processLeg = async (leg: JourneyLeg) => { - const isWalk = !leg.trip; + const isWalk = leg.type === 'Transfer'; - const formattedLeg: any = { + let formattedLeg: FormattedLeg; + const formattedLegCommon: FormattedLegCommon = { origin_id: leg.origin, origin: leg.origin === 'VIRTUAL_ORIGIN' ? 'Start' : (leg.origin === 'VIRTUAL_DESTINATION' ? 'End' : (state.stopIdToName[leg.origin] || leg.origin)), destination_id: leg.destination, @@ -89,28 +122,26 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, startTime: Math.round(leg.startTime), endTime: Math.round(leg.endTime), duration: Math.round(leg.duration), - mode: isWalk ? 'walk' : 'bus', originID: leg.originID, destinationID: leg.destinationID, - stopTimes: leg.stopTimes, - trip: leg.trip, - rt: leg.rt }; - if (leg.trip) { - formattedLeg.tripId = leg.trip.tripId; - formattedLeg.vid = leg.trip.vid; - if (!formattedLeg.rt) { - const firstStop = leg.trip.stopTimes[0]; - formattedLeg.rt = firstStop.rt || state.tatripidToRt[leg.trip.tripId] || 'UNKNOWN'; - } - } - - if (isWalk) { + if (!isWalk) { + formattedLeg = { + ...formattedLegCommon, + mode: 'bus', + stopTimes: leg.stopTimes, + trip: leg.trip, + tripId: leg.trip.tripId, + vid: leg.trip.vid, + // fallback to route of the first stop or the route associated with the trip id + rt: leg.rt || leg.trip.stopTimes[0].rt || state.tatripidToRt[leg.trip.tripId] || 'UNKNOWN' + }; + } else { const cached = walking.getCachedWalk(leg.origin, leg.destination); if (cached) { - Object.assign(formattedLeg, cached); + formattedLeg = {...formattedLegCommon, ...cached, mode: 'walk'} } else { const l1 = leg.origin === 'VIRTUAL_ORIGIN' ? { lat: oLat, lon: oLon } : state.cachedStopLocations[leg.origin]; const l2 = leg.destination === 'VIRTUAL_DESTINATION' ? { lat: dLat, lon: dLon } : state.cachedStopLocations[leg.destination]; @@ -119,10 +150,12 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, try { const data = await walking.getWalkingResponse(l1.lat, l1.lon, l2.lat, l2.lon); data.duration = Math.round(data.duration); - Object.assign(formattedLeg, data); + formattedLeg = {...formattedLegCommon, ...data, mode: 'walk'} } catch (e) { - formattedLeg.path_coords = []; + formattedLeg = {...formattedLegCommon, path_coords: [], mode: 'walk'} } + } else { + formattedLeg = {...formattedLegCommon, mode: 'walk'} } } } @@ -144,8 +177,8 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, })); return processedList - .filter((j: any) => j !== null) - .sort((a: any, b: any) => + .filter((j) => j !== null) + .sort((a, b) => a.arrivalTime - b.arrivalTime || a.criteria.walkingDistance - b.criteria.walkingDistance ); diff --git a/src/services/mbus.ts b/src/services/mbus.ts index a479f5a..e163c9d 100644 --- a/src/services/mbus.ts +++ b/src/services/mbus.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; +import { Pattern, PatternsArraySchema } from './bustimeCommon'; dotenv.config(); @@ -44,13 +45,16 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); - return res.data['bustime-response']?.ptr || []; + const resData = res.data['bustime-response']?.ptr as unknown; + const patterns = PatternsArraySchema.parse(resData); + return patterns; } catch (e) { + console.error("Fetch Patterns failed", e); return []; } } diff --git a/src/services/ride.ts b/src/services/ride.ts index bd8fd31..cb7a08b 100644 --- a/src/services/ride.ts +++ b/src/services/ride.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; +import { Pattern, PatternsArraySchema } from './bustimeCommon'; dotenv.config(); @@ -46,13 +47,15 @@ export async function fetchRoutes() { } /** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { +export async function fetchPatterns(rt: string): Promise { try { const res = await client.get('/getpatterns', { params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } }); - return res.data['bustime-response']?.ptr || []; + const resData = res.data['bustime-response']?.ptr as unknown; + return PatternsArraySchema.parse(resData); } catch (e) { + console.error("Fetch Patterns failed", e); return []; } } diff --git a/src/state/transitState.ts b/src/state/transitState.ts index 6686a27..b5760b3 100644 --- a/src/state/transitState.ts +++ b/src/state/transitState.ts @@ -1,13 +1,14 @@ import { Trip, TransfersByOrigin, Interchange } from "../raptor/types"; +import { BusRouteLine, Pattern } from "@/services/bustimeCommon"; /** Current positions of all buses. */ export const curBusPositions = { buses: [] as any[] }; /** Current positions of all ride buses. */ export const curRidePositions = { buses: [] as any[] }; /** Cache of route patterns and static data. */ -export const cachedRoutes: Record = {}; +export const cachedRoutes: Record = {}; /** Cache of route patterns and static data for the ride. */ -export const cachedRideRoutes: Record = {}; +export const cachedRideRoutes: Record = {}; /** Represents a bus prediction. */ export type Prediction = { @@ -51,7 +52,8 @@ export let cachedStopLocations: Record = {}; /** Cache of timing differences between stops for extrapolation. */ -export const routeTimingCache: Record>> = { +export const routeTimingCache: Record>> = { "CN": { "N434NORTHBOUND": { "N500": { "diff": 5, "rtdir": "SOUTHBOUND", "rtNext": "CS" } diff --git a/test/api.test.ts b/test/api.test.ts index fc586fc..8f8d595 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -44,9 +44,8 @@ describe('API Endpoints', () => { it('should get all cached routes and confirm structure', async () => { const response = await axios.get(`${BASE_URL}/getAllRoutes`); expect(response.status).toBe(200); - expect(response.data).toHaveProperty('routes'); - expect(typeof response.data.routes).toBe('object'); // cachedRoutes is an object, not array - console.log(`GET /getAllRoutes: ${Object.keys(response.data.routes).length} cached routes found.`); + expect(typeof response.data).toBe('object'); // should be array + console.log(`GET /getAllRoutes: ${Object.keys(response.data).length} cached routes found.`); }); it('should get all bus predictions and log stop IDs', async () => { @@ -171,13 +170,13 @@ describe('API Endpoints', () => { expect(response.status).toBe(200); expect(response.data).toHaveProperty('journeys'); expect(Array.isArray(response.data.journeys)).toBe(true); - console.log('GET /plan-journey (test 1):', JSON.stringify(response.data.journeys, null, 2)); + // console.log('GET /plan-journey (test 1):', JSON.stringify(response.data.journeys, null, 2)); const response2 = await axios.get(`${BASE_URL}/plan-journey?originLat=42.27389558&originLon=-83.73739576&destLat=42.29303061&destLon=-83.7163671`); expect(response2.status).toBe(200); expect(response2.data).toHaveProperty('journeys'); expect(Array.isArray(response2.data.journeys)).toBe(true); - console.log('GET /plan-journey (test 2):', JSON.stringify(response2.data.journeys, null, 2)); + // console.log('GET /plan-journey (test 2):', JSON.stringify(response2.data.journeys, null, 2)); } catch (error) { if (axios.isAxiosError(error)) { console.error('Error fetching path:', error.message); diff --git a/test/bustimeCommon.test.ts b/test/bustimeCommon.test.ts new file mode 100644 index 0000000..3449f97 --- /dev/null +++ b/test/bustimeCommon.test.ts @@ -0,0 +1,99 @@ +import { makeBusRouteLines, Pattern } from "@/services/bustimeCommon"; +import { describe, expect, it } from "vitest"; + +describe('makeBusRouteLines', () => { + + it('should handle short routes', () => { + const pointsSingleStop: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + ]; + const pattern = makePattern(0, 0, '', pointsSingleStop, '', pointsSingleStop); + const lines = makeBusRouteLines('', pattern, false); + expect(lines.length).toBe(2); + expect(lines[0]).toEqual(lines[1]); + const line = lines[0]; + expect(line.points).toEqual([{ lat: 45.0, lon: 46.0 }]); + const stop = line.stops[0].stop; + expect(stop.id).toEqual('C1'); + expect(stop.name).toEqual('Central'); + expect(stop.location.lat).toEqual(45); + expect(stop.location.lon).toEqual(46); + }); + + it('should pass through isRide and rt', () => { + for (const rt of ["BB", "CN"]) { + for (const isRide of [true, false]) { + const pointsSingleStop: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + ]; + const pattern = makePattern(0, 0, '', pointsSingleStop, null, null); + const lines = makeBusRouteLines(rt, pattern, isRide); + const line = lines[0]; + expect(line.routeId).toEqual(rt); + for (const stop of line.stops) { + expect(stop.stop.routeId).toBe(rt); + expect(stop.stop.isRide).toBe(isRide); + } + } + } + }); + + it('should handle both route and detour', () => { + const points1: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + makeWaypoint(1, 45.1, 45.9), + makeStop(2, 45.2, 45.8, 'C2', 'Community'), + ]; + const points2: Pattern['pt'] = [ + makeStop(0, 45.0, 46.0, 'C1', 'Central'), + makeWaypoint(1, 0.0, 0.0), + makeWaypoint(1, 2.0, 2.0), + makeStop(2, 45.2, 45.8, 'C2', 'Community'), + ]; + + const positions = (points: Pattern['pt']) => + points.map((p) => { return { lat: p.lat, lon: p.lon}; }); + + for (const [points, detourPts] of [[points1, points2], [points2, points1]]) { + const pattern = makePattern(0, 0, '', points, '', detourPts); + const lines = makeBusRouteLines('', pattern, false); + expect(lines[0].points).toEqual(positions(points)); + expect(lines[1].points).toEqual(positions(detourPts)); + } + }); +}); + +function makeStop(seq: number, lat: number, lon: number, stpid: string, stpnm: string): Pattern['pt'][0] { + return { + seq, + typ: "S", + lat, + lon, + pdist: 0.0, + stpid, + stpnm, + }; +} + +function makeWaypoint(seq: number, lat: number, lon: number): Pattern['pt'][0] { + return { + seq, + typ: "W", + lat, + lon, + }; +} + +function makePattern( + pid: number, ln: number, rtdir: string, points: Pattern['pt'], + dtrid: string | null, dtrpt: Pattern['pt'] | null, +): Pattern { + return { + pid: pid, + ln: ln, + rtdir: rtdir, + pt: points, + dtrid: dtrid ?? undefined, + dtrpt: dtrpt ?? undefined, + }; +} diff --git a/test/ride.test.ts b/test/ride.test.ts index b58d451..2da1bd0 100644 --- a/test/ride.test.ts +++ b/test/ride.test.ts @@ -27,10 +27,9 @@ describe('The Ride (AAATA) API Endpoints', () => { it('should get all Ride routes', async () => { const response = await axios.get(`${BASE_URL}/getAllRideRoutes`); expect(response.status).toBe(200); - expect(response.data).toHaveProperty('routes'); - expect(typeof response.data.routes).toBe('object'); + expect(typeof response.data).toBe('object'); - const routeCount = Object.keys(response.data.routes).length; + const routeCount = Object.keys(response.data).length; console.log(`GET /getAllRideRoutes: ${routeCount} Ride routes found.`); expect(routeCount).toBeGreaterThanOrEqual(0); });