diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4654e91..f18f815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,19 +17,45 @@ jobs: run: | export MBUS_URL=https://mbus.bustime.mock.mb.thething.fyi/ export RIDE_URL=https://ride.bustime.mock.mb.thething.fyi/ - npm start & - until curl localhost:3000 > /dev/null 2>&1 - do - sleep 1 # waits for initial startup + npm start > /tmp/server.log 2>&1 & + # The server binds and serves 404s long before its data is ready and + # a bare `curl` exits 0 on a 404, so poll data endpoints with -sf and + # check the content we actually need. + wait_for() { + endpoint="$1"; needle="$2"; tries="$3" + for i in $(seq 1 "$tries"); do + if curl -sf "localhost:3000$endpoint" | grep -q "$needle"; then + echo "ready: $endpoint has $needle (after $((i * 2))s)" + return 0 + fi + sleep 2 + done + echo "::warning::timed out after $((tries * 2))s waiting for $endpoint to contain $needle" + tail -30 /tmp/server.log + return 1 + } + # Stops appear once route patterns are fetched. Required: without + # them nothing else can be built. + if ! wait_for /mbus/api/v3/getAllStops stpid 150; then + echo "::error::server never loaded stop data" + exit 1 + fi + # walkingCache.json is gitignored, so CI always starts cold and the + # server computes every stop-pair path (~10k, several minutes), + # persisting them in one atomic write at the end. Wait for that file: + # otherwise the vitest process recomputes the whole matrix in-process + # and the live reminder test races its own timeout. Warn-only - the + # tests are the authoritative verdict. + # ~4 min on a dev laptop; allow generous headroom for slower runners. + for i in $(seq 1 450); do + [ -f src/assets/walkingCache.json ] && { echo "walking cache ready (after $((i * 2))s)"; break; } + sleep 2 done - sleep 10 - until curl localhost:3000 > /dev/null 2>&1 - do - sleep 1 # waits for the walking cache to populate - done - sleep 10 # waits for the graph/predictions to be built + [ -f src/assets/walkingCache.json ] || echo "::warning::walking cache never persisted; tests will recompute it in-process" + # The routing graph is rebuilt on an interval, so predictions can lag + # the cache slightly. + wait_for /mbus/api/v3/getAllPredictions stpid 60 || true npx vitest run test - # npm test working-directory: ${{ github.workspace }} Typecheck: @@ -37,14 +63,13 @@ jobs: steps: - name: Check out repository code uses: actions/checkout@v6 - # - name: Placeholder - # run: echo hi - # working-directory: ${{ github.workspace }} - name: NPM Install run: npm i working-directory: ${{ github.workspace }} - name: Typecheck - run: tsc --noEmit + # npx: use the project-pinned TypeScript, not whatever the runner + # image happens to ship globally. + run: npx tsc --noEmit working-directory: ${{ github.workspace }} Typedoc: @@ -59,6 +84,9 @@ jobs: run: npx typedoc --entryPointStrategy expand ./src --treatWarningsAsErrors working-directory: ${{ github.workspace }} - name: Sync files + # Deploy only from pushes to main: fork PRs have no secrets (the step + # would always fail), and unreviewed branches must not publish docs. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: SamKirkland/FTP-Deploy-Action@v4.4.0 with: server: ${{ secrets.FTP_SERVER }} diff --git a/package.json b/package.json index 69aa221..ff34928 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "tsx src/app.ts", "test": "vitest run test", + "test:search": "vitest run test/raptor-core.test.ts test/raptor-property.test.ts test/ingestion.test.ts test/journey-plan.test.ts test/api-handlers.test.ts", "stress-test": "vitest run test/search-stress.test.ts", "docs": "typedoc --entryPointStrategy expand ./src" }, @@ -21,7 +22,6 @@ "fast-xml-parser": "^5.3.2", "firebase-admin": "^13.6.0", "lru-cache": "^11.2.5", - "ts-array-utils": "^0.5.0", "tsx": "^4.11.0", "zod": "^4.3.6" }, @@ -30,6 +30,7 @@ "@types/node": "^20.12.12", "@vitest/coverage-v8": "^4.1.10", "typedoc": "^0.28.15", + "typescript": "^5.9.3", "vitest": "^4.1.10" } } diff --git a/src/app.ts b/src/app.ts index 7690451..af9f2ac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,25 @@ import express from "express"; +import { existsSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; import mbus from "./routes/api" const app = express(); +// Module-relative so the server works regardless of the launch directory. +const DOCS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../docs"); +if (!existsSync(DOCS_DIR)) { + console.warn(`docs/ not found at ${DOCS_DIR} — /docs will 404 until \`npm run docs\` is run`); +} + app.use(express.json()); app.use("/mbus/api/v3", mbus); -app.use("/docs", express.static("docs")); +app.use("/docs", express.static(DOCS_DIR)); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); -}); \ No newline at end of file +}); diff --git a/src/jobs.ts b/src/jobs.ts index a337238..4c333ec 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -1,21 +1,52 @@ import { updateBusPositions, initializeRoutes, rebuildGraph } from './services/graphBuilder'; import { initializeReminders, processRideReminders, processUniversityReminders } from './services/reminder'; +/** + * Wraps an async job so overlapping runs are skipped: if the previous tick is + * still in flight (e.g. a slow upstream API), the new tick is dropped instead + * of piling up requests and letting a stale run overwrite fresher data. + */ +function nonOverlapping(name: string, job: () => Promise): () => Promise { + let running = false; + return async () => { + if (running) { + console.warn(`Job ${name} still running; skipping this tick`); + return; + } + running = true; + try { + await job(); + } catch (e) { + console.error(`Job ${name} failed`, e); + } finally { + running = false; + } + }; +} + /** * Starts background jobs for updating bus positions, initializing routes, and rebuilding the graph. */ export function startBackgroundJobs() { initializeReminders(); - initializeRoutes().then(() => { + + const guardedUpdatePositions = nonOverlapping('updateBusPositions', updateBusPositions); + const guardedInitRoutes = nonOverlapping('initializeRoutes', initializeRoutes); + const guardedRebuild = nonOverlapping('rebuildGraph', rebuildGraph); + + // The boot runs share the same guards as the interval ticks, so a slow + // boot (e.g. cold walking cache) can never overlap — and stale-overwrite — + // an interval run of the same job. + guardedInitRoutes().then(() => { console.log("Routes initialized. Building initial graph..."); - rebuildGraph(); + return guardedRebuild(); }); - setInterval(updateBusPositions, 7500); - setInterval(initializeRoutes, 60000); - setInterval(rebuildGraph, 60 * 1000); + setInterval(guardedUpdatePositions, 7500); + setInterval(guardedInitRoutes, 60000); + setInterval(guardedRebuild, 60 * 1000); setInterval(processUniversityReminders, 7500); setInterval(processRideReminders, 7500); console.log("Background jobs started."); -} \ No newline at end of file +} diff --git a/src/raptor/McRaptorAlgorithm.ts b/src/raptor/McRaptorAlgorithm.ts index 164b1f8..56013b2 100644 --- a/src/raptor/McRaptorAlgorithm.ts +++ b/src/raptor/McRaptorAlgorithm.ts @@ -44,9 +44,23 @@ export class McRaptorAlgorithm { private stops: StopID[]; private routes: Record; private routeStops: Record; + private stopToRoutes: Record; private walkingPenalty: number = 1; + /** + * Route index (FIFO chains + inverted stop index) per trips array. + * planJourney passes the same cached trips array for every request between + * graph rebuilds, so the index is built once per rebuild instead of once + * per request. Keyed by array identity: callers must not mutate a trips + * array after first constructing an algorithm with it. + */ + private static indexCache = new WeakMap, + routeStops: Record, + stopToRoutes: Record, + }>(); + /** * Initializes the routing engine with transit data. * @param trips - List of all transit trips. @@ -63,21 +77,82 @@ export class McRaptorAlgorithm { this.interchange = interchange; this.stops = Object.keys(interchange); + const cached = McRaptorAlgorithm.indexCache.get(trips); + if (cached) { + this.routes = cached.routes; + this.routeStops = cached.routeStops; + this.stopToRoutes = cached.stopToRoutes; + return; + } + this.routes = {}; this.routeStops = {}; + this.stopToRoutes = {}; + const tripsBySeq: Record = {}; for (const trip of trips) { const stopSeq = trip.stopTimes.map(st => st.stop).join(','); - if (!this.routes[stopSeq]) { - this.routes[stopSeq] = []; - this.routeStops[stopSeq] = trip.stopTimes.map(st => st.stop); + if (!tripsBySeq[stopSeq]) tripsBySeq[stopSeq] = []; + tripsBySeq[stopSeq].push(trip); + } + + // RAPTOR requires that within a route, trips never overtake each other + // (otherwise the earliest catchable trip is not always the best one). + // Split each stop sequence into FIFO-ordered chains of trips. + for (const stopSeq in tripsBySeq) { + const sorted = tripsBySeq[stopSeq].sort((a, b) => a.stopTimes[0].departureTime - b.stopTimes[0].departureTime); + + const chains: Trip[][] = []; + for (const trip of sorted) { + const chain = chains.find(c => McRaptorAlgorithm.followsFifo(c[c.length - 1], trip)); + if (chain) chain.push(trip); + else chains.push([trip]); } - this.routes[stopSeq].push(trip); + + chains.forEach((chain, i) => { + const routeId = `${stopSeq}#${i}`; + this.routes[routeId] = chain; + this.routeStops[routeId] = chain[0].stopTimes.map(st => st.stop); + }); } - for (const routeId in this.routes) { - this.routes[routeId].sort((a, b) => a.stopTimes[0].departureTime - b.stopTimes[0].departureTime); + // Inverted index so route scans are O(markedStops) instead of scanning + // every route's stop list per marked stop per round. + for (const [routeId, stops] of Object.entries(this.routeStops)) { + for (const stop of new Set(stops)) { + if (!this.stopToRoutes[stop]) this.stopToRoutes[stop] = []; + this.stopToRoutes[stop].push(routeId); + } } + + McRaptorAlgorithm.indexCache.set(trips, { + routes: this.routes, + routeStops: this.routeStops, + stopToRoutes: this.stopToRoutes, + }); + } + + /** + * Returns true if `later` departs and arrives STRICTLY later than `earlier` + * at every stop and offers the same pickUp/dropOff availability. Only then + * is boarding the earliest catchable trip of a chain always optimal. + * + * Strictness matters: if two trips tie at one stop but diverge afterwards, + * on-board labels of the slower trip can tie-dominate boardings of the + * faster one inside the shared route bag and lose Pareto-optimal journeys + * (ties are common in production because countdowns are quantized to whole + * minutes). Tied trips are simply scanned as separate chains. + */ + private static followsFifo(earlier: Trip, later: Trip): boolean { + for (let i = 0; i < earlier.stopTimes.length; i++) { + const a = earlier.stopTimes[i]; + const b = later.stopTimes[i]; + if (b.departureTime <= a.departureTime) return false; + if (b.arrivalTime <= a.arrivalTime) return false; + if ((a.pickUp ?? true) !== (b.pickUp ?? true)) return false; + if ((a.dropOff ?? true) !== (b.dropOff ?? true)) return false; + } + return true; } /** @@ -126,6 +201,7 @@ export class McRaptorAlgorithm { if (!bags[0][dest]) bags[0][dest] = new Bag(); for (const label of stopBag.labels) { + if (label.arrivalTime < transfer.startTime || label.arrivalTime > transfer.endTime) continue; const arrTime = label.arrivalTime + walkTime; const totalWalk = label.walkingDistance + walkCost; @@ -156,10 +232,8 @@ export class McRaptorAlgorithm { const routesToVisit = new Set(); for (const stop of markedStops) { - for (const [routeId, stops] of Object.entries(this.routeStops)) { - if (stops.includes(stop)) { - routesToVisit.add(routeId); - } + for (const routeId of this.stopToRoutes[stop] ?? []) { + routesToVisit.add(routeId); } } @@ -177,10 +251,24 @@ export class McRaptorAlgorithm { const stop = stops[i]; if (!bags[k][stop]) bags[k][stop] = new Bag(); + // Advance on-board labels to this stop so dominance compares + // positions along the route rather than boarding times; a trip + // boarded later upstream may still be ahead here. + if (i > 0 && !routeBag.isEmpty()) { + const advanced = new Bag(); + for (const label of routeBag.labels) { + if (!label.trip) continue; + const moved = label.clone(); + moved.arrivalTime = label.trip.stopTimes[i].departureTime; + advanced.add(moved); + } + routeBag = advanced; + } + for (const label of routeBag.labels) { if (!label.trip) continue; const stopTime = label.trip.stopTimes[i]; - if (!stopTime.dropOff && stopTime.dropOff !== undefined) continue; + if (stopTime.dropOff === false) continue; const arrivalTime = stopTime.arrivalTime; const newLabel = new Label( arrivalTime, @@ -206,8 +294,6 @@ export class McRaptorAlgorithm { const catchTrip = this.findEarliestTrip(trips, i, label.arrivalTime + buffer); if (catchTrip) { - if (catchTrip.stopTimes[i].pickUp === false) continue; - const departureTime = catchTrip.stopTimes[i].departureTime; const onBoardLabel = new Label( departureTime, @@ -229,10 +315,17 @@ export class McRaptorAlgorithm { const footPathMarked = new Set(); + // Snapshot the trip-arrival labels before relaxing footpaths so a + // walk label added at one stop is not walked onward from another + // stop in the same pass (walks must not chain). + const footPathSources = new Map(); for (const stop of newMarkedStops) { const stopBag = bags[k][stop]; if (!stopBag || stopBag.isEmpty()) continue; + footPathSources.set(stop, [...stopBag.labels]); + } + for (const [stop, sourceLabels] of footPathSources) { const transfers = this.transfers[stop] || []; for (const transfer of transfers) { @@ -242,7 +335,8 @@ export class McRaptorAlgorithm { if (!bags[k][dest]) bags[k][dest] = new Bag(); - for (const label of stopBag.labels) { + for (const label of sourceLabels) { + if (label.arrivalTime < transfer.startTime || label.arrivalTime > transfer.endTime) continue; const arrTime = label.arrivalTime + walkTime; const totalWalk = label.walkingDistance + walkCost; @@ -282,7 +376,9 @@ export class McRaptorAlgorithm { private findEarliestTrip(trips: Trip[], stopIndex: number, minTime: number): Trip | null { for (const trip of trips) { - if (trip.stopTimes[stopIndex].departureTime >= minTime) { + const stopTime = trip.stopTimes[stopIndex]; + if (stopTime.pickUp === false) continue; + if (stopTime.departureTime >= minTime) { return trip; } } @@ -329,23 +425,38 @@ export class McRaptorAlgorithm { const significantTimes = new Set(); significantTimes.add(startTime); - - const startStops = [origin, ...(this.transfers[origin] || []).map(t => t.destination)]; - - for (const stop of startStops) { - for (const routeId in this.routeStops) { - if (this.routeStops[routeId].includes(stop)) { - const params = this.routes[routeId]; - for (const trip of params) { - const stopTime = trip.stopTimes.find(st => st.stop === stop); - if (stopTime && stopTime.departureTime >= startTime && stopTime.departureTime <= endTime) { - significantTimes.add(stopTime.departureTime); - } + // Always sample the window end too: a bus whose latest-catchable seed + // falls just past endTime can be the ONLY option for riders departing + // in the window's tail, and no interior seed would surface it once an + // earlier faster bus dominates those runs. + significantTimes.add(endTime); + + // A seed is the LATEST origin departure that can still catch a trip: + // the trip's departure minus the walk to the stop and that stop's + // interchange buffer. Seeding raw departure times would start runs + // that arrive after their own trip has left. + const reachable: { stop: StopID, cost: number }[] = [ + { stop: origin, cost: this.interchange[origin] || 0 }, + ...(this.transfers[origin] || []).map(t => ({ + stop: t.destination, + cost: t.duration + (this.interchange[t.destination] || 0), + })), + ]; + + for (const { stop, cost } of reachable) { + for (const routeId of this.stopToRoutes[stop] ?? []) { + for (const trip of this.routes[routeId]) { + for (const stopTime of trip.stopTimes) { + if (stopTime.stop !== stop) continue; + const seed = stopTime.departureTime - cost; + if (seed >= startTime && seed <= endTime) significantTimes.add(seed); } } } } + // Latest-first, so when two seeds yield the same journey criteria the + // latest-departure variant is the one that survives dedup. const sortedTimes = Array.from(significantTimes).sort((a, b) => b - a); for (const depTime of sortedTimes) { @@ -355,41 +466,45 @@ export class McRaptorAlgorithm { allJourneys.push(j); } } - const bestJourneysBySignature = new Map(); + + // Identity-based trip keys: tripId can legitimately be undefined + // (vid-only feed rows), and joining undefined yields '' which would + // misclassify bus journeys as walking-only. + let anonCounter = 0; + const tripKeys = new Map(); + const keyOf = (trip: Trip): string => { + let key = tripKeys.get(trip); + if (key === undefined) { + key = trip.tripId ?? trip.vid ?? `anon_${anonCounter++}`; + tripKeys.set(trip, key); + } + return key; + }; + + const dominatesOrEqual = (a: Journey, b: Journey) => + a.criteria.arrivalTime <= b.criteria.arrivalTime && + a.criteria.walkingDistance <= b.criteria.walkingDistance && + a.criteria.transferCount <= b.criteria.transferCount; + + // Keep the full Pareto set per trip signature: journeys on the same + // trips can still trade arrival time against walking distance. + const journeysBySignature = new Map(); const walkingJourneys: Journey[] = []; for (const j of allJourneys) { - const tripsSignature = j.legs - .filter(l => l.type === 'Trip' && l.trip) - .map(l => l.trip!.tripId) - .join('|'); - - if (!tripsSignature) { + const busLegs = j.legs.filter(l => l.type === 'Trip' && l.trip); + if (busLegs.length === 0) { walkingJourneys.push(j); continue; } + const signature = busLegs.map(l => keyOf(l.trip!)).join('|'); - if (!bestJourneysBySignature.has(tripsSignature)) { - bestJourneysBySignature.set(tripsSignature, j); - } else { - const existing = bestJourneysBySignature.get(tripsSignature)!; - - let better = false; - if (j.criteria.arrivalTime < existing.criteria.arrivalTime) better = true; - else if (j.criteria.arrivalTime === existing.criteria.arrivalTime) { - if (j.criteria.walkingDistance < existing.criteria.walkingDistance) better = true; - else if (j.criteria.walkingDistance === existing.criteria.walkingDistance) { - if (j.criteria.transferCount < existing.criteria.transferCount) better = true; - } - } - - if (better) { - bestJourneysBySignature.set(tripsSignature, j); - } - } + const group = journeysBySignature.get(signature) ?? []; + if (group.some(existing => dominatesOrEqual(existing, j))) continue; + journeysBySignature.set(signature, [...group.filter(existing => !dominatesOrEqual(j, existing)), j]); } - const uniqueJourneys: Journey[] = Array.from(bestJourneysBySignature.values()); + const uniqueJourneys: Journey[] = Array.from(journeysBySignature.values()).flat(); if (walkingJourneys.length > 0) { walkingJourneys.sort((a, b) => a.criteria.arrivalTime - b.criteria.arrivalTime); diff --git a/src/raptor/McStructs.ts b/src/raptor/McStructs.ts index 45c1e32..31b9520 100644 --- a/src/raptor/McStructs.ts +++ b/src/raptor/McStructs.ts @@ -5,7 +5,10 @@ import { Transfer, Trip, StopID, Time, StopTime } from "./types"; */ export type Criteria = { arrivalTime: number; + /** Penalty-weighted walking time: seconds of walking x walkingPenalty + * (NOT meters). With penalty 0 this is 0 regardless of walk length. */ walkingDistance: number; + /** Number of boardings (a direct ride counts as 1). */ transferCount: number; }; @@ -16,8 +19,8 @@ export type Criteria = { export class Label { /** * @param arrivalTime - Time of arrival at this stop. - * @param walkingDistance - Cumulative walking distance in meters. - * @param transferCount - Number of transfers taken so far. + * @param walkingDistance - Cumulative penalty-weighted walking time (seconds x walkingPenalty, not meters). + * @param transferCount - Number of boardings taken so far. * @param parent - The previous label in the chain (used for backtracking). * @param trip - The trip taken to reach this state (if applicable). * @param transfer - The transfer used to reach this state (if applicable). diff --git a/src/routes/api.ts b/src/routes/api.ts index cc03750..11c67dd 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -1,5 +1,6 @@ import express from "express"; import * as path from "path"; +import { fileURLToPath } from "url"; import * as process from "node:process"; import axios from "axios"; import * as z from "zod"; @@ -17,7 +18,37 @@ import { startBackgroundJobs } from '../jobs'; const router = express.Router(); const API_KEY = process.env.MBUS_API_KEY; -startBackgroundJobs(); +// Under vitest, tests import the handlers directly and must not spin up the +// polling loops (network fetches, Firebase init). +if (process.env.VITEST !== 'true') { + startBackgroundJobs(); +} + +// Assets resolve relative to this module, not process.cwd(), so the server +// works regardless of the directory it is launched from. +const ASSETS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets'); + +/** Parses a request body against a zod schema, answering 400 on failure. */ +function parseBody(schema: S, req: express.Request, res: express.Response): z.infer | null { + const result = schema.safeParse(req.body); + if (!result.success) { + res.status(400).send(result.error.message); + return null; + } + return result.data; +} + +/** Answers a route-lookup miss. When either feed's route set is still empty + * (startup window, a single-feed outage, or DEV_CACHE mode) an unknown rtid + * cannot be distinguished from a not-yet-loaded one, so signal a retryable + * 503; only with both feeds loaded is it a genuine 400. */ +function respondUnknownRoute(rtid: string, res: express.Response): void { + if (state.validRoutes.size === 0 || state.validRideRoutes.size === 0) { + res.status(503).send(`Route data for ${rtid} is unavailable right now; retry shortly`); + } else { + res.status(400).send(`Invalid route ${rtid}`); + } +} /** * Returns static route metadata including names, images, and colors. @@ -129,11 +160,15 @@ router.get('/getFrontendData', getFrontendData); */ export function getVehicleImage(req: express.Request, res: express.Response) { const { route } = req.params; - const assetPath = path.resolve(process.cwd(), 'src/assets/main2025'); + const assetPath = path.join(ASSETS_DIR, 'main2025'); const image = meta.getRouteImage(route); if (!image) { - res.status(400).sendFile(path.join(assetPath, 'bus_CN.png')); + // 400 marks the route as unknown (clients rely on the status; see + // test/api.test.ts) while still sending the default icon as the body. + res.status(400).sendFile(path.join(assetPath, 'bus_CN.png'), (err) => { + if (err && !res.headersSent) res.status(404).send('Image file not found.'); + }); return; } res.sendFile(path.join(assetPath, image), (err) => { @@ -149,7 +184,7 @@ router.get('/getVehicleImage/:route', getVehicleImage); * @returns JSON file containing building data. */ export function getBuildingLocations(req: express.Request, res: express.Response) { - res.sendFile(path.resolve(process.cwd(), 'src/assets/building-data.json')); + res.sendFile(path.join(ASSETS_DIR, 'building-data.json')); } router.get('/getBuildingLocations', getBuildingLocations); @@ -251,8 +286,20 @@ router.get('/getRidePredictions/:busId', getRidePredictions); * @param res - Express response */ export function getBusPredictionsLegacy(req: express.Request, res: express.Response) { - const url = `https://mbus.ltp.umich.edu/bustime/api/v3/getpredictions?requestType=getpredictions&locale=en&vid=${req.params.busId}&top=4&tmres=s&rtpidatafeed=bustime&key=${API_KEY}&format=json&xtime=1626028950462`; - axios.get(url).then(apiRes => { + // Params go through URLSearchParams: the path param is user-controlled and + // must not be interpolated into a query string that carries the API key. + const base = process.env.MBUS_URL || 'https://mbus.ltp.umich.edu/bustime/api/v3/'; + const params = new URLSearchParams({ + requestType: 'getpredictions', + locale: 'en', + vid: req.params.busId, + top: '4', + tmres: 's', + rtpidatafeed: 'bustime', + key: API_KEY ?? '', + format: 'json', + }); + axios.get(`${base.replace(/\/$/, '')}/getpredictions?${params.toString()}`).then(apiRes => { res.send(apiRes.data); }).catch(err => { console.log(err); @@ -292,8 +339,9 @@ router.get('/getRideStopPredictions/:stopId', getRideStopPredictions); * @returns JSON array of objects, each with `vid` and `stops` (predictions). */ export function getAllPredictions(req: express.Request, res: express.Response) { - const now = new Date(); - const currentTime = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); + // Use the graph's own time frame so countdowns stay correct in the window + // after 00:00 UTC while the cached graph still holds pre-midnight times. + const currentTime = graphBuilder.currentGraphTimeSeconds(); const reconstructed = state.cachedGraph.trips .filter(t => t.vid) @@ -353,7 +401,8 @@ export function getNearestStops(req: express.Request, res: express.Response) { const { lat, lon, k = '2' } = req.query; const originLat = parseFloat(lat as string); const originLon = parseFloat(lon as string); - const numStops = parseInt(k as string); + const parsedK = parseInt(k as string); + const numStops = Number.isFinite(parsedK) && parsedK > 0 ? parsedK : 2; const nearest = graphBuilder.findNearestStops(originLat, originLon, numStops); res.json({ nearestStops: nearest }); @@ -379,17 +428,34 @@ export async function planJourney(req: express.Request, res: express.Response) { return; } - const now = new Date(); - const secondsSinceMidnight = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); + const oLat = parseFloat(originLat as string); + const oLon = parseFloat(originLon as string); + const dLat = parseFloat(destLat as string); + const dLon = parseFloat(destLon as string); + if (![oLat, oLon, dLat, dLon].every(Number.isFinite)) { + res.status(400).json({ error: 'coordinates must be numeric' }); + return; + } + + const penalty = walkingPenalty !== undefined ? parseFloat(walkingPenalty as string) : undefined; + if (penalty !== undefined && (!Number.isFinite(penalty) || penalty < 0)) { + res.status(400).json({ error: 'walkingPenalty must be a non-negative number' }); + return; + } + + const rangeSeconds = range !== undefined ? parseInt(range as string) : undefined; + if (rangeSeconds !== undefined && (!Number.isFinite(rangeSeconds) || rangeSeconds < 0)) { + res.status(400).json({ error: 'range must be a non-negative number of seconds' }); + return; + } + + // Time in the cached graph's frame (see currentGraphTimeSeconds). + const secondsSinceMidnight = graphBuilder.currentGraphTimeSeconds(); const results = await journeyService.planJourney( - parseFloat(originLat as string), parseFloat(originLon as string), - parseFloat(destLat as string), parseFloat(destLon as string), + oLat, oLon, dLat, dLon, secondsSinceMidnight, - { - walkingPenalty: walkingPenalty ? parseFloat(walkingPenalty as string) : undefined, - range: range ? parseInt(range as string) : undefined - } + { walkingPenalty: penalty, range: rangeSeconds } ); res.json({ journeys: results }); @@ -429,7 +495,8 @@ router.get('/save-graph', saveGraph); */ export function getStartupInfo(req: express.Request, res: express.Response) { res.json({ - min_supported_version: "2.0.0", + // Matches main: 2.0.2 locks out client versions with a shipped bug. + min_supported_version: "2.0.2", why_update_message: { title: "Update Needed", subtitle: "You need to update to the latest version for the app to work properly." }, persistant_message: { title: "", subtitle: ""}, one_time_message: { title: "", subtitle: "" }, @@ -486,35 +553,33 @@ router.get('/get-key-stops', getKeyStops); // Notifications / Reminders -const SetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: z.string(), thresh: z.number() }); +// thresh is minutes: unbounded values (negative, 0, absurd) create reminders +// whose trigger condition can never be satisfied. +const ReminderThresh = z.number().min(1).max(120); +const SetReminderBody = z.object({ token: z.string(), stpid: z.string().min(1), rtid: z.string().min(1), thresh: ReminderThresh }); /** * @param req - Express request, expects `SetReminderBody` in the body * @param res - Express response, error message as string if error occurs */ export function setReminder(req: express.Request, res: express.Response) { - const result = SetReminderBody.safeParse(req.body); - if (!result.success) { - res.status(400); - res.send(result.error.message); - } else { - const { token, stpid, rtid, thresh } = result.data; - const info = reminderService.infoToUseForRoute(rtid); - if (info === null) { - res.status(400); - res.send(`Invalid route ${rtid}`); - return; - } - const { reminderSubscriptions, predsByStopId } = info; - reminderSubscriptions.add( - reminderService.baseEvent({ stpid, rtid }), - thresh, - reminderService.registrationToken(token), - predsByStopId, - Date.now(), - ); - res.sendStatus(200); - } + const body = parseBody(SetReminderBody, req, res); + if (body === null) return; + const { token, stpid, rtid, thresh } = body; + const info = reminderService.infoToUseForRoute(rtid); + if (info === null) { + respondUnknownRoute(rtid, res); + return; + } + const { reminderSubscriptions, predsByStopId } = info; + reminderSubscriptions.add( + reminderService.baseEvent({ stpid, rtid }), + thresh, + reminderService.registrationToken(token), + predsByStopId, + Date.now(), + ); + res.sendStatus(200); } router.post('/setReminder', setReminder); @@ -524,24 +589,20 @@ const UnsetReminderBody = z.object({ token: z.string(), stpid: z.string(), rtid: * @param res - Express response, error message as string if error occurs */ export function unsetReminder(req: express.Request, res: express.Response) { - const result = UnsetReminderBody.safeParse(req.body); - if (!result.success) { - res.status(400); - res.send(result.error.message); - } else { - const { token ,stpid, rtid } = result.data; - const info = reminderService.infoToUseForRoute(rtid); - if (info === null) { - res.status(400); - res.send(`Invalid route ${rtid}`); - return; - } - const { reminderSubscriptions } = info; - reminderSubscriptions.remove( - reminderService.baseEvent({ stpid, rtid }), reminderService.registrationToken(token) - ); - res.sendStatus(200); + const body = parseBody(UnsetReminderBody, req, res); + if (body === null) return; + + const { token, stpid, rtid } = body; + const info = reminderService.infoToUseForRoute(rtid); + if (info === null) { + respondUnknownRoute(rtid, res); + return; } + const { reminderSubscriptions } = info; + reminderSubscriptions.remove( + reminderService.baseEvent({ stpid, rtid }), reminderService.registrationToken(token) + ); + res.sendStatus(200); } router.post('/unsetReminder', unsetReminder); @@ -554,16 +615,15 @@ const SwapTokenBody = z.object({ oldTok: z.string(), newTok: z.string() }); * will need the new token */ export function swapToken(req: express.Request, res: express.Response) { - const result = SwapTokenBody.safeParse(req.body) - if (!result.success) { - res.status(400); - res.send(result.error.message); - } else { - const { oldTok, newTok } = req.body; - reminderService.universityReminderSubscriptions.swapToken(oldTok, newTok); - reminderService.rideReminderSubscriptions.swapToken(oldTok, newTok); - res.sendStatus(200); - } + const body = parseBody(SwapTokenBody, req, res); + if (body === null) return; + + // From the validated data, not the raw req.body. + const from = reminderService.registrationToken(body.oldTok); + const to = reminderService.registrationToken(body.newTok); + reminderService.universityReminderSubscriptions.swapToken(from, to); + reminderService.rideReminderSubscriptions.swapToken(from, to); + res.sendStatus(200); } router.post('/swapToken', swapToken); @@ -613,7 +673,7 @@ const ModifyRemindersBody = z.object({ token: z.string(), modifications: z.array( z.union([ - z.object({ action: z.literal("set"), stpid: z.string(), rtid: z.string(), thresh: z.number() }), + z.object({ action: z.literal("set"), stpid: z.string().min(1), rtid: z.string().min(1), thresh: ReminderThresh }), z.object({ action: z.literal("unset"), stpid: z.string(), rtid: z.string() }) ]) ) @@ -623,21 +683,22 @@ const ModifyRemindersBody = z.object({ * @param res - Express response */ export function modifyReminders(req: express.Request, res: express.Response) { - const result = ModifyRemindersBody.safeParse(req.body); - if (!result.success) { - res.status(400); - res.send(result.error.message); - } else { - const { token, modifications } = result.data; - for (const modification of modifications) { + const parsed = parseBody(ModifyRemindersBody, req, res); + if (parsed === null) return; + { + const { token, modifications } = parsed; + // Validate every route before applying anything, so a bad entry cannot + // leave the batch half-applied. + const infos = modifications.map(m => reminderService.infoToUseForRoute(m.rtid)); + const badIndex = infos.findIndex(info => info === null); + if (badIndex !== -1) { + respondUnknownRoute(modifications[badIndex].rtid, res); + return; + } + for (let i = 0; i < modifications.length; i++) { + const modification = modifications[i]; const event = reminderService.baseEvent({ stpid: modification.stpid, rtid: modification.rtid }); - const info = reminderService.infoToUseForRoute(modification.rtid); - if (info === null) { - res.status(400); - res.send(`Invalid route ${modification.rtid}`); - return; - } - const { reminderSubscriptions, predsByStopId } = info; + const { reminderSubscriptions, predsByStopId } = infos[i]!; if (modification.action == "set") { reminderSubscriptions.add( event, @@ -664,14 +725,16 @@ export function notifyMeLater(req: express.Request, res: express.Response) { if (registrationToken === undefined) { console.log("got request with no token"); console.log(req.body); - res.send("registration token missing"); - res.status(400); + // status must be set before send: send() flushes the response. + res.status(400).send("registration token missing"); return; } setTimeout(() => { console.log(`sending test push notification to ${registrationToken}`); reminderService.sendNotifToAll({ title: "hi", body: "hello world!"}, new Set([registrationToken])); - }, 0); + // 10s so the tester can background/close the app first: the endpoint + // exists to exercise background push delivery. + }, 10_000); res.sendStatus(200); } router.post('/notifyMeLater', notifyMeLater); diff --git a/src/routes/documented_design.md b/src/routes/documented_design.md index 5d1be13..a6b1c1b 100644 --- a/src/routes/documented_design.md +++ b/src/routes/documented_design.md @@ -2,7 +2,7 @@ **link to flowchart on figjam or alternative:** none **Developer(s):** Edward Zhang **Feature / Algorithm Name:** documented -* *Status:* Implemented +* *Status:* Proposed — NOT yet implemented in this branch (src/routes/documented.ts and test/documented.test.ts do not exist here; api.ts still uses raw Express handlers) * *Date:* 2026-08-15 * *Reviewers:* none (this doc was created after the fact, which isn't ideal) diff --git a/src/services/bustimeClient.ts b/src/services/bustimeClient.ts new file mode 100644 index 0000000..2c0d301 --- /dev/null +++ b/src/services/bustimeClient.ts @@ -0,0 +1,127 @@ +import axios from 'axios'; + +/** + * BusTime reports many failures as HTTP 200 with a bustime-response.error + * array. Per-stop/per-route entries ("No arrival times", "No data found for + * parameter") carry an identifying field (stpid/vid/rt) and are normal parts + * of a healthy response. A SYSTEM error (invalid API key, daily transaction + * limit exceeded) has only a msg and means the whole request failed — treating + * it as an empty-but-successful response would wipe live caches downstream. + * + * Any HTTP-200 body that is not a well-formed BusTime envelope at all (a + * proxy maintenance HTML page, an empty object, a non-array error field) is + * also a system failure: an out-of-protocol response must never be mistaken + * for "no buses". + */ +export function hasBusTimeSystemError(data: any): boolean { + const envelope = data?.['bustime-response']; + if (envelope === null || typeof envelope !== 'object') return true; + const errors = envelope.error; + if (errors === undefined) return false; + if (!Array.isArray(errors)) return true; + return errors.some((e: any) => e && !e.stpid && !e.vid && !e.rt); +} + +export interface BusTimeClient { + /** Returns null when any chunk fails (network or system error), so callers + * keep their previous data instead of mistaking failure for "no vehicles". */ + fetchVehicles(routes: string[]): Promise; + fetchRoutes(): Promise; + fetchPatterns(rt: string): Promise; + /** Failed chunks resolve to null so callers can detect partial failures. */ + fetchPredictions(stopIds: string[], routes: string[]): Promise; +} + +/** + * Shared BusTime v3 API client. The UM (mbus) and TheRide feeds speak the same + * protocol; parametrizing here keeps chunking, timeouts, and failure contracts + * in exactly one place instead of two drifting copies. + */ +export function createBusTimeClient(options: { baseURL: string, apiKey: string | undefined, label: string }): BusTimeClient { + const { baseURL, apiKey, label } = options; + const client = axios.create({ + baseURL, + params: { key: apiKey, format: 'json' }, + timeout: 15000 + }); + + const chunked = (items: T[], size: number): T[][] => { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); + return chunks; + }; + + return { + async fetchVehicles(routes: string[]): Promise { + const promises = chunked(routes, 10).map(async chunk => { + try { + const res = await client.get('/getvehicles', { + params: { requestType: 'getvehicles', rt: chunk.join(',') }, + }); + if (hasBusTimeSystemError(res.data)) { + console.warn(`[${label}] getvehicles system error`, JSON.stringify(res.data?.['bustime-response']?.error ?? 'malformed response body')); + return null; + } + return res.data['bustime-response']?.vehicle || []; + } catch (e) { + console.warn(`[${label}] fetch vehicles failed`, e instanceof Error ? e.message : e); + return null; + } + }); + const results = await Promise.all(promises); + if (results.some(r => r === null)) return null; + return (results as any[][]).flat(); + }, + + async fetchRoutes(): Promise { + try { + const res = await client.get('/getroutes', { params: { requestType: 'getroutes' } }); + if (hasBusTimeSystemError(res.data)) { + console.warn(`[${label}] getroutes system error`, JSON.stringify(res.data?.['bustime-response']?.error ?? 'malformed response body')); + return []; + } + return res.data['bustime-response']?.routes || []; + } catch (e) { + console.error(`[${label}] fetch routes failed`, e instanceof Error ? e.message : e); + return []; + } + }, + + async fetchPatterns(rt: string): Promise { + try { + const res = await client.get('/getpatterns', { + params: { requestType: 'getpatterns', rt, rtpidatafeed: 'bustime' } + }); + if (hasBusTimeSystemError(res.data)) return []; + return res.data['bustime-response']?.ptr || []; + } catch (e) { + return []; + } + }, + + async fetchPredictions(stopIds: string[], routes: string[]): Promise { + const promises = chunked(stopIds, 10).map(async chunk => { + try { + const res = await client.get('/getpredictions', { + params: { + requestType: 'getpredictions', + stpid: chunk.join(','), + rt: routes.join(','), + tmres: 's', + unixTime: true, + } + }); + if (hasBusTimeSystemError(res.data)) { + console.warn(`[${label}] getpredictions system error`, JSON.stringify(res.data?.['bustime-response']?.error ?? 'malformed response body')); + return null; + } + return res.data; + } catch (e) { + console.warn(`[${label}] fetch predictions chunk failed`, e instanceof Error ? e.message : e); + return null; + } + }); + return Promise.all(promises); + }, + }; +} diff --git a/src/services/graphBuilder.ts b/src/services/graphBuilder.ts index 68ac957..6de0295 100644 --- a/src/services/graphBuilder.ts +++ b/src/services/graphBuilder.ts @@ -2,6 +2,7 @@ import * as state from '../state/transitState'; import * as mbus from './mbus'; import * as rideBus from './ride'; import * as walking from '../walking/walkingMap'; +import { haversine } from '../walking/loadMap'; import { Trip, StopTime } from "../raptor/types"; import * as process from "node:process"; import { MaxPriorityQueue } from '@datastructures-js/priority-queue'; @@ -11,35 +12,71 @@ import * as path from 'path'; 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"]; -/** Fetches and updates current bus positions in state. */ +/** Routes to poll: the live feed's route set once known (so reminders accepted + * for any valid route are actually tracked), falling back to the static + * defaults before the first successful getroutes fetch. */ +function activeRoutes(): string[] { + return state.validRoutes.size > 0 ? Array.from(state.validRoutes) : DEFAULT_ROUTES; +} +function activeRideRoutes(): string[] { + return state.validRideRoutes.size > 0 ? Array.from(state.validRideRoutes) : DEFAULT_RIDE_ROUTES; +} + +/** Fetches and updates current bus positions in state. + * Keeps the previous positions when a fetch fails (null), so a transient + * API error does not blank the live map. */ export async function updateBusPositions() { - const buses = await mbus.fetchVehicles(DEFAULT_ROUTES); - state.curBusPositions.buses = buses; - const ridesBusses = await rideBus.fetchVehicles(DEFAULT_RIDE_ROUTES); - state.curRidePositions.buses = ridesBusses; + const [buses, ridesBusses] = await Promise.all([ + mbus.fetchVehicles(activeRoutes()), + rideBus.fetchVehicles(activeRideRoutes()), + ]); + if (buses !== null) state.curBusPositions.buses = buses; + if (ridesBusses !== null) state.curRidePositions.buses = ridesBusses; } -/** Initializes route data, caching patterns and stop locations. */ +/** Initializes route data, caching patterns and stop locations. + * All network fetches happen first; shared state is only swapped in + * synchronously afterwards, so concurrent requests never observe cleared + * route sets, and transient failures keep the previous data. */ export async function initializeRoutes() { if (process.env.DEV_CACHE === 'true') return; try { const routesData = await mbus.fetchRoutes(); - state.validRoutes.clear(); const rideRoutesData = await rideBus.fetchRoutes(); - state.validRideRoutes.clear(); - 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; - })); + const umPatterns = await Promise.all(routesData.map(async (r: any) => ({ + rt: r.rt, patterns: await mbus.fetchPatterns(r.rt) + }))); + const ridePatterns = await Promise.all(rideRoutesData.map(async (r: any) => ({ + rt: r.rt, patterns: await rideBus.fetchPatterns(r.rt) + }))); + + if (routesData.length > 0) { + state.validRoutes.clear(); + for (const r of routesData) state.validRoutes.add(r.rt); + // Evict routes the feed no longer reports so their stops stop + // appearing in nearest-stop results and the transfer matrix. + for (const rt of Object.keys(state.cachedRoutes)) { + if (!state.validRoutes.has(rt)) delete state.cachedRoutes[rt]; + } + for (const { rt, patterns } of umPatterns) { + // An empty result is what a failed fetch looks like; keep the + // previously cached patterns rather than wiping the route. + if (patterns && patterns.length > 0) state.cachedRoutes[rt] = patterns; + } + } - 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; - })); + if (rideRoutesData.length > 0) { + state.validRideRoutes.clear(); + for (const r of rideRoutesData) state.validRideRoutes.add(r.rt); + for (const rt of Object.keys(state.cachedRideRoutes)) { + if (!state.validRideRoutes.has(rt)) delete state.cachedRideRoutes[rt]; + } + for (const { rt, patterns } of ridePatterns) { + if (patterns && patterns.length > 0) state.cachedRideRoutes[rt] = patterns; + } + } buildStopLocationMap(); buildRideStops(); @@ -63,15 +100,28 @@ export async function rebuildGraph() { })); }); - const rawPreds = await mbus.fetchPredictions(Array.from(allStopIds), DEFAULT_ROUTES); - const formattedPreds = processPredictions(rawPreds); - - // Populate the lookup maps needed for Journey formatting - populateLookupMaps(formattedPreds); + const rawPreds = await mbus.fetchPredictions(Array.from(allStopIds), activeRoutes()); + if (rawPreds.some((chunk: any) => chunk === null)) { + // A failed chunk would make vehicles look vanished, mass-firing + // "disappeared" reminders and blanking routing data. Keep the + // previous graph and predictions until the next successful cycle. + console.warn('Prediction fetch partially failed; keeping previous graph and predictions'); + } else { + const formattedPreds = processPredictions(rawPreds); + + // Populate the lookup maps needed for Journey formatting + populateLookupMaps(formattedPreds); + + updatePredictionLookups(formattedPreds); + const trips = convertToTrips(formattedPreds); + + state.setCachedGraph({ + trips, + transfers: state.cachedGraph.transfers, + interchange: state.cachedGraph.interchange + }); + } - updatePredictionLookups(formattedPreds); - const trips = convertToTrips(formattedPreds); - // extra stuff to update the busses for the ride const rideStopIds = new Set(); Object.values(state.cachedRideRoutes).forEach((patterns: any) => { @@ -79,16 +129,14 @@ export async function rebuildGraph() { if (pt.stpid) rideStopIds.add(pt.stpid); })); }); - const rawRidePreds = await rideBus.fetchPredictions(Array.from(rideStopIds), DEFAULT_RIDE_ROUTES); - const formattedRidePreds = processRidePredictions(rawRidePreds); - populateRideLookupMaps(formattedRidePreds); - updateRideLookups(formattedRidePreds); - - state.setCachedGraph({ - trips, - transfers: state.cachedGraph.transfers, - interchange: state.cachedGraph.interchange - }); + const rawRidePreds = await rideBus.fetchPredictions(Array.from(rideStopIds), activeRideRoutes()); + if (rawRidePreds.some((chunk: any) => chunk === null)) { + console.warn('Ride prediction fetch partially failed; keeping previous ride predictions'); + } else { + const formattedRidePreds = processRidePredictions(rawRidePreds); + populateRideLookupMaps(formattedRidePreds); + updateRideLookups(formattedRidePreds); + } } catch (error) { console.error('Error rebuilding graph:', error); @@ -155,7 +203,12 @@ function buildStopLocationMap() { 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) }; + const lat = parseFloat(pt.lat); + const lon = parseFloat(pt.lon); + // A garbled coordinate poisons every distance computed from it. + if (Number.isFinite(lat) && Number.isFinite(lon)) { + locs[pt.stpid] = { name: pt.stpnm, lat, lon }; + } } })); }); @@ -171,7 +224,11 @@ function buildRideStops() { 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) }; + const lat = parseFloat(pt.lat); + const lon = parseFloat(pt.lon); + if (Number.isFinite(lat) && Number.isFinite(lon)) { + locs[pt.stpid] = { name: pt.stpnm, lat, lon }; + } } })); }); @@ -184,25 +241,38 @@ function buildRideStops() { */ async function buildWalkingTransfers() { const stops = Object.keys(state.cachedStopLocations); - stops.forEach(s => { - state.cachedGraph.transfers[s] = []; - state.cachedGraph.interchange[s] = 30; - }); + // ensureCacheForStops yields to the event loop while computing, so the + // shared graph must NOT be cleared before it: build into local structures + // and swap them in synchronously afterwards, or concurrent /plan-journey + // requests would observe an empty transfer table mid-rebuild. await walking.ensureCacheForStops(new Set(stops), state.cachedStopLocations); + const transfers: typeof state.cachedGraph.transfers = {}; + const interchange: typeof state.cachedGraph.interchange = {}; + stops.forEach(s => { + transfers[s] = []; + interchange[s] = 30; + }); + stops.forEach(origin => { stops.forEach(dest => { if (origin === dest) return; const walk = walking.getCachedWalk(origin, dest); if (walk) { - state.cachedGraph.transfers[origin].push({ + transfers[origin].push({ origin, destination: dest, duration: walk.duration, startTime: 0, endTime: Number.MAX_SAFE_INTEGER }); } }); }); + + state.setCachedGraph({ + trips: state.cachedGraph.trips, + transfers, + interchange + }); } /** @@ -210,11 +280,20 @@ async function buildWalkingTransfers() { * Handles flattening, sorting, and extrapolating predictions. * @param rawChunks Raw API response chunks */ -function processPredictions(rawChunks: any[]) { - const formattedPredictions = rawChunks.flat().reduce((acc: any[], chunk: any) => { - if (chunk['bustime-response']?.['prd']) { +/** + * Shared grouping step for both feeds: folds raw prediction chunks into + * per-vehicle trips with one stop event per (stop, pass). The feeds differ + * only in how a usable prdtm timestamp is derived, so that is a parameter — + * keeping the tatripid/vid matching and loop-pass logic in exactly one place. + */ +function groupPredictions(rawChunks: any[], prdtmOf: (prd: any, prdctdn: string) => number): any[] { + return rawChunks.flat().reduce((acc: any[], chunk: any) => { + if (chunk?.['bustime-response']?.['prd']) { chunk['bustime-response']['prd'].forEach((prd: any) => { - let trip = acc.find((t: any) => t.tatripid === prd.tatripid); + // Only match by tatripid when one is present: matching + // undefined === undefined would merge every tatripid-less + // prediction from DIFFERENT vehicles into one trip. + let trip = prd.tatripid ? acc.find((t: any) => t.tatripid === prd.tatripid) : undefined; // If no tatripid, try to match by vid (mbus API specifics) if (!trip && prd.vid) trip = acc.find((t: any) => t.vid === prd.vid); // If no match, create new trip @@ -225,20 +304,32 @@ function processPredictions(rawChunks: any[]) { if (!trip.tatripid) trip.tatripid = prd.tatripid; if (!trip.vid && prd.vid) trip.vid = prd.vid; } - // If no stop, create new stop - let stop = trip.stops.find((s: any) => s.stpid === prd.stpid); + // A looping bus predicts the same stop more than once (e.g. in + // 1 min and again in 11 min). Only merge entries for the same + // pass (same stop AND same countdown); different passes must be + // kept as separate stop events. + const prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; + let stop = trip.stops.find((s: any) => s.stpid === prd.stpid && s.prdctdn === prdctdn); if (!stop) { stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: null, rt: null, rtdir: null }; trip.stops.push(stop); } stop.rtdir = prd.rtdir; stop.rt = prd.rt; - stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; - stop.prdtm = parseInt(prd.prdtm); + stop.prdctdn = prdctdn; + stop.prdtm = prdtmOf(prd, prdctdn); }); } return acc; }, []); +} + +export function processPredictions(rawChunks: any[]) { + // prdtm sorts the prediction caches; keep it a finite number. + const formattedPredictions = groupPredictions(rawChunks, (prd) => { + const prdtm = parseInt(prd.prdtm); + return Number.isFinite(prdtm) ? prdtm : Number.MAX_SAFE_INTEGER; + }); // build index maps const routeInfoFilter: Record = {}; @@ -264,18 +355,31 @@ function processPredictions(rawChunks: any[]) { // sort predictions based on route (oddly complicated) formattedPredictions.forEach((trip: any) => { if (trip.stops.length == 0) return; - const minPrdctdn = Math.min(...trip.stops.map((s: any) => parseInt(s.prdctdn, 10))); - const firstRoute = trip.stops.find((s: any) => parseInt(s.prdctdn, 10) === minPrdctdn)?.rt; + // The feed reports "DLY" (delayed) instead of a countdown for some + // stops; those cannot be ordered by time, so sort them last. + const countdown = (s: any) => { + const v = parseInt(s.prdctdn, 10); + return Number.isFinite(v) ? v : Infinity; + }; + const finiteCountdowns = trip.stops.map(countdown).filter((v: number) => v !== Infinity); + if (finiteCountdowns.length === 0) return; + const minPrdctdn = Math.min(...finiteCountdowns); + const firstRoute = trip.stops.find((s: any) => countdown(s) === minPrdctdn)?.rt; if (!firstRoute) return; trip.stops.sort((a: any, b: any) => { - const diffTime = parseInt(a.prdctdn, 10) - parseInt(b.prdctdn, 10); - if (diffTime !== 0) return diffTime; + const diffTime = countdown(a) - countdown(b); + if (diffTime !== 0 && !Number.isNaN(diffTime)) return diffTime; if (a.rt + a.rtdir !== b.rt + b.rtdir) { - if (a.rt === firstRoute) return -1; - if (b.rt === firstRoute) return 1; - return a.rt.localeCompare(b.rt); + // Antisymmetry matters: both stops can be on firstRoute in + // different directions, so that tie must also be broken + // deterministically (returning -1 for both orders corrupts + // the sort and the timing cache learned from it). + const aFirst = a.rt === firstRoute; + const bFirst = b.rt === firstRoute; + if (aFirst !== bFirst) return aFirst ? -1 : 1; + return String(a.rt + (a.rtdir ?? '')).localeCompare(String(b.rt + (b.rtdir ?? ''))); } const aMap = routeStopIndexMaps.get(a.rt + a.rtdir); const bMap = routeStopIndexMaps.get(b.rt + b.rtdir); @@ -289,6 +393,7 @@ function processPredictions(rawChunks: any[]) { const from = trip.stops[i]; const to = trip.stops[i + 1]; const diff = parseInt(to.prdctdn, 10) - parseInt(from.prdctdn, 10); + if (!Number.isFinite(diff)) continue; // delayed neighbor: no usable timing const rt = from.rt; const stopIndexMap = routeStopIndexMaps.get(from.rt + from.rtdir); @@ -305,12 +410,14 @@ function processPredictions(rawChunks: any[]) { if (!state.routeTimingCache[rt]) state.routeTimingCache[rt] = {}; const fromKey = from.stpid + (from.rtdir || ""); if (!state.routeTimingCache[rt][fromKey]) state.routeTimingCache[rt][fromKey] = {}; - state.routeTimingCache[rt][fromKey] = { - [to.stpid]: { - diff: diff, - rtdir: to.rtdir, - rtNext: to.rt - } + // Merge rather than replace: the hand-seeded interlining entries in + // transitState (e.g. CN -> CS at the loop end) must survive live + // learning. Extrapolation follows the FIRST entry, so earlier + // (seeded) successors keep priority over later learned ones. + state.routeTimingCache[rt][fromKey][to.stpid] = { + diff: diff, + rtdir: to.rtdir, + rtNext: to.rt }; } }); @@ -331,8 +438,11 @@ function processPredictions(rawChunks: any[]) { const nextEntries = Object.entries(nextStops); if (nextEntries.length === 0) break; + const lastCountdown = parseInt(lastStop.prdctdn, 10); + if (!Number.isFinite(lastCountdown)) break; // cannot extrapolate from a delayed stop + const [nextStopId, { diff, rtdir, rtNext }] = nextEntries[0]; - const nextPrdctdn = (parseInt(lastStop.prdctdn, 10) + diff).toString(); + const nextPrdctdn = (lastCountdown + diff).toString(); trip.stops.push({ stpnm: state.cachedStopLocations[nextStopId]?.name || nextStopId, @@ -354,41 +464,17 @@ function processPredictions(rawChunks: any[]) { * COPIED FROM PROCESS PREDICTIONS AND MODIFIED TO WORK WITH THE RIDE * @param rawChunks Raw API response chunks */ -function processRidePredictions(rawChunks: any[]) { - const formattedPredictions = rawChunks.flat().reduce((acc: any[], chunk: any) => { - if (chunk['bustime-response']?.['prd']) { - chunk['bustime-response']['prd'].forEach((prd: any) => { - let trip = acc.find((t: any) => t.tatripid === prd.tatripid); - // If no tatripid, try to match by vid (mbus API specifics) - if (!trip && prd.vid) trip = acc.find((t: any) => t.vid === prd.vid); - // If no match, create new trip - if (!trip) { - trip = { tatripid: prd.tatripid, vid: prd.vid, des: prd.des, stops: [] }; - acc.push(trip); - } else { - if (!trip.tatripid) trip.tatripid = prd.tatripid; - if (!trip.vid && prd.vid) trip.vid = prd.vid; - } - // If no stop, create new stop - let stop = trip.stops.find((s: any) => s.stpid === prd.stpid); - if (!stop) { - stop = { stpnm: prd.stpnm, stpid: prd.stpid, prdctdn: null, rt: null, rtdir: null }; - trip.stops.push(stop); - } - stop.rtdir = prd.rtdir; - stop.rt = prd.rt; - stop.prdctdn = prd.prdctdn === "DUE" ? "1" : prd.prdctdn; - // console.log(prd.prdtm); - // prdtm is in format YYYYMMDD HH:MM:SS - // stop.prdtm = parseInt(prd.prdtm); - // TODO: use actual timestamp - stop.prdtm = Date.now() + (parseInt(stop.prdctdn) + 0.5) * 60 * 1000; - }); - } - return acc; - }, []); - - return formattedPredictions; +export function processRidePredictions(rawChunks: any[]) { + // TheRide's prdtm is "YYYYMMDD HH:MM:SS" (no unix timestamps), so derive a + // usable epoch from the countdown instead. TODO: parse the actual timestamp. + // "DLY" has no countdown; a NaN prdtm would corrupt the sorted prediction + // caches, so pin it to the far future instead. + return groupPredictions(rawChunks, (_prd, prdctdn) => { + const rideCountdown = parseInt(prdctdn, 10); + return Number.isFinite(rideCountdown) + ? Date.now() + (rideCountdown + 0.5) * 60 * 1000 + : Number.MAX_SAFE_INTEGER; + }); } /** @@ -401,7 +487,10 @@ function updatePredictionLookups(preds: any[]) { preds.forEach((trip: any) => { trip.stops.forEach((stop: any) => { - if (stop.isExtrapolated) return; + if (stop.isExtrapolated) return; + // "DLY" entries are kept so the frontend can show delayed buses; + // their prdtm is pinned far in the future so they sort last, and + // the reminder pipeline skips non-numeric countdowns itself. const predObj = { ...stop, vid: trip.vid, tatripid: trip.tatripid, des: trip.des }; @@ -431,6 +520,8 @@ function updateRideLookups(preds: any[]) { preds.forEach((trip: any) => { trip.stops.forEach((stop: any) => { + // Same as updatePredictionLookups: "DLY" entries stay visible to + // the frontend; the reminder pipeline ignores them itself. const predObj = { ...stop, vid: trip.vid, tatripid: trip.tatripid, des: trip.des }; if (!state.cachedRidePredsByStopId[stop.stpid]) state.cachedRidePredsByStopId[stop.stpid] = []; @@ -458,20 +549,33 @@ export function sortPreds(x: Record) { * Converts processed predictions into the Trip format used by the Raptor algorithm. * @param preds List of processed predictions */ -function convertToTrips(preds: any[]): Trip[] { +export function convertToTrips(preds: any[]): Trip[] { const trips: Trip[] = []; const now = new Date(); - const currentTime = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); + // Anchor all trip times to this build's UTC midnight and remember the + // anchor, so request handlers can compute "now" in the same frame even + // when the clock crosses midnight before the next rebuild. + const baseMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + state.setCachedGraphTimeBase(baseMs); + const currentTime = Math.floor((now.getTime() - baseMs) / 1000); preds.forEach((p: any) => { - const stopTimes: StopTime[] = p.stops.map((s: any) => ({ - stop: s.stpid, - arrivalTime: currentTime + (parseInt(s.prdctdn) * 60), - departureTime: currentTime + (parseInt(s.prdctdn) * 60), - pickUp: true, - dropOff: true, - rt: s.rt - })).sort((a: StopTime, b: StopTime) => a.arrivalTime - b.arrivalTime); + const stopTimes: StopTime[] = p.stops + // Delayed stops ("DLY") have no countdown and would produce NaN times. + .filter((s: any) => Number.isFinite(parseInt(s.prdctdn, 10))) + .map((s: any) => ({ + stop: s.stpid, + arrivalTime: currentTime + (parseInt(s.prdctdn) * 60), + departureTime: currentTime + (parseInt(s.prdctdn) * 60), + pickUp: true, + dropOff: true, + rt: s.rt, + // Carried through so journey legs can flag guessed stop times. + isExtrapolated: s.isExtrapolated + })).sort((a: StopTime, b: StopTime) => a.arrivalTime - b.arrivalTime); + + // A vehicle whose every prediction is delayed has no usable schedule. + if (stopTimes.length === 0) return; trips.push({ tripId: p.tatripid, @@ -492,18 +596,31 @@ function convertToTrips(preds: any[]): Trip[] { return trips; } +/** + * Returns "now" in the cached graph's time frame: seconds since the UTC + * midnight the graph's stop times are anchored to. Just after 00:00 UTC this + * exceeds 86400 until the graph is rebuilt, matching the stop times instead + * of wrapping to ~0 while the graph still holds pre-midnight values. + */ +export function currentGraphTimeSeconds(): number { + const now = new Date(); + const base = state.cachedGraphTimeBase + || Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return Math.floor((now.getTime() - base) / 1000); +} + /** Finds the nearest k stops to a given coordinate. */ export function findNearestStops(lat: number, lon: number, k: number = 2) { if (isNaN(lat) || isNaN(lon)) throw new Error("Invalid Coordinates"); + // Root of the heap is the FARTHEST of the k stops kept so far, so it is + // the one to evict when a nearer stop shows up. const heap = new MaxPriorityQueue<{ stpid: string; name: string; lat: number; lon: number; distance: number }>({ - compare: (a, b) => a.distance - b.distance + compare: (a, b) => b.distance - a.distance }); for (const [stpid, stop] of Object.entries(state.cachedStopLocations)) { - const latDiff = (stop.lat - lat) * 111320; - const lonDiff = (stop.lon - lon) * 111320 * Math.cos(lat * Math.PI / 180); - const distance = Math.sqrt(latDiff ** 2 + lonDiff ** 2); + const distance = haversine(lat, lon, stop.lat, stop.lon); const stopWithDist = { stpid, diff --git a/src/services/journey.ts b/src/services/journey.ts index f5a0776..5b923ac 100644 --- a/src/services/journey.ts +++ b/src/services/journey.ts @@ -43,24 +43,14 @@ export async function planJourney( }); }); - const requestTrips = state.cachedGraph.trips.map(trip => { - if (trip.tripId === 'VIRTUAL_ORIGIN_TRIP') { - return { - ...trip, - stopTimes: [{ stop: V_ORIGIN, arrivalTime: time, departureTime: time, pickUp: true, dropOff: true }] - }; - } - if (trip.tripId === 'VIRTUAL_DESTINATION_TRIP') { - return { - ...trip, - stopTimes: [{ stop: V_DEST, arrivalTime: time, departureTime: time, pickUp: true, dropOff: true }] - }; - } - return trip; - }); - - const mcRaptor = new McRaptorAlgorithm(requestTrips, transferData, state.cachedGraph.interchange); - mcRaptor.setWalkingPenalty(options.walkingPenalty || 1); + // Use the cached trips array directly: it keeps the same identity for + // every request between graph rebuilds, so the algorithm's per-graph route + // index is built once per rebuild instead of once per request. The + // VIRTUAL_*_TRIP placeholders are single-stop trips that can never be + // ridden, so they need no per-request patching. + const mcRaptor = new McRaptorAlgorithm(state.cachedGraph.trips, transferData, state.cachedGraph.interchange); + // ?? not ||: an explicit walkingPenalty of 0 (walking is free) is valid. + mcRaptor.setWalkingPenalty(options.walkingPenalty ?? 1); const range = options.range; const journeys = range === undefined @@ -118,8 +108,11 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, if (l1 && l2) { try { const data = await walking.getWalkingResponse(l1.lat, l1.lon, l2.lat, l2.lon); - data.duration = Math.round(data.duration); Object.assign(formattedLeg, data); + // Keep the duration the journey was routed with so the + // leg stays consistent with its start/end times; the + // fresh response only contributes geometry/distance. + formattedLeg.duration = Math.round(leg.duration); } catch (e) { formattedLeg.path_coords = []; } @@ -137,7 +130,7 @@ async function processJourneys(journeys: Journey[], oLat: number, oLon: number, return { legs, - departureTime: journey.criteria.arrivalTime - (legs.reduce((acc, leg) => acc + leg.duration, 0)), + departureTime: legs.length > 0 ? legs[0].startTime : journey.criteria.arrivalTime, arrivalTime: journey.criteria.arrivalTime, criteria: journey.criteria }; diff --git a/src/services/mbus.ts b/src/services/mbus.ts index a479f5a..6366e79 100644 --- a/src/services/mbus.ts +++ b/src/services/mbus.ts @@ -1,81 +1,16 @@ -import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; +import { createBusTimeClient } from './bustimeClient'; dotenv.config(); -const API_KEY = process.env.MBUS_API_KEY; -const BASE_URL = process.env.MBUS_URL || 'https://mbus.ltp.umich.edu/bustime/api/v3/'; - -const client = axios.create({ - baseURL: BASE_URL, - params: { key: API_KEY, format: 'json' } +const api = createBusTimeClient({ + baseURL: process.env.MBUS_URL || 'https://mbus.ltp.umich.edu/bustime/api/v3/', + apiKey: process.env.MBUS_API_KEY, + label: 'mbus', }); -/** Fetches vehicle positions for the given routes. */ -export async function fetchVehicles(routes: string[]) { - const chunks = []; - for (let i = 0; i < routes.length; i += 10) chunks.push(routes.slice(i, i + 10)); - - const promises = chunks.map(async chunk => { - try { - const res = await client.get('/getvehicles', { - params: { requestType: 'getvehicles', rt: chunk.join(',') }, - }); - return res.data['bustime-response']?.vehicle || []; - } catch (e) { - console.warn('Fetch vehicles failed', e); - return []; - } - }); - const results = await Promise.all(promises); - return results.flat(); -} - -/** Fetches all available routes. */ -export async function fetchRoutes() { - try { - const res = await client.get('/getroutes', { params: { requestType: 'getroutes' } }); - return res.data['bustime-response']?.routes || []; - } catch (e) { - console.error("Fetch Routes failed", e); - return []; - } -} - -/** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { - try { - const res = await client.get('/getpatterns', { - params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } - }); - return res.data['bustime-response']?.ptr || []; - } catch (e) { - return []; - } -} - -/** Fetches predictions for multiple stop IDs. */ -export async function fetchPredictions(stopIds: string[], routes: string[]) { - const chunks = []; - for (let i = 0; i < stopIds.length; i += 10) chunks.push(stopIds.slice(i, i + 10)); - - const promises = chunks.map(async chunk => { - try { - const res = await client.get('/getpredictions', { - params: { - requestType: 'getpredictions', - stpid: chunk.join(','), - rt: routes.join(','), - tmres: 's', - unixTime: true, - } - }); - return res.data; - } catch (e) { - return []; - } - }); - - return Promise.all(promises); -} \ No newline at end of file +export const fetchVehicles = api.fetchVehicles; +export const fetchRoutes = api.fetchRoutes; +export const fetchPatterns = api.fetchPatterns; +export const fetchPredictions = api.fetchPredictions; diff --git a/src/services/reminder.ts b/src/services/reminder.ts index 509db08..ef20252 100644 --- a/src/services/reminder.ts +++ b/src/services/reminder.ts @@ -9,8 +9,12 @@ export * from "./reminderTypes"; dotenv.config() +let firebaseInitialized = false; export function initializeReminders() { - // Initialize Firebase + // Initialize Firebase. Idempotent: initializeApp throws on a second call, + // and callers (entry points, tests) must be able to invoke this safely. + if (firebaseInitialized) return; + firebaseInitialized = true; initializeApp({ credential: applicationDefault() }); } @@ -29,13 +33,16 @@ export type PreThreshold = { as things are updated and such a change won't trigger a disappeared notification */ candidateVid: string | null, /** minutes */ - candidateVidPredPrev: number | null + candidateVidPredPrev: number | null, + /** unix epoch milliseconds of the candidate's predicted arrival */ + candidatePrdtm: number | null }; /** factory */ function preThreshold(event: BaseEvent, thresh: number, candidateVid: string | null, now: number): PreThreshold { return { - stage: 0, event, thresh, mustBeAfter: now + thresh * 60 * 1000, candidateVid, candidateVidPredPrev: null + stage: 0, event, thresh, mustBeAfter: now + thresh * 60 * 1000, + candidateVid, candidateVidPredPrev: null, candidatePrdtm: null }; } @@ -49,12 +56,24 @@ function firstPredAfter(timestamp: number, preds: state.Prediction[]): state.Pre /** Waiting for the bus indicated by `vid` to be at the stop indicated by `stpid`. Logic for what notification to send * is complicated by arrival times sometimes skipping DUE, see `ReminderSubscriptons.process` for details. */ -export type PostThreshold = { stage: 1, event: BaseEvent, vid: string, vidPredPrev: number | null }; +export type PostThreshold = { + stage: 1, + event: BaseEvent, + vid: string, + vidPredPrev: number | null, + /** predicted arrival timestamp (epoch ms) of the tracked pass. Predictions + * are matched by PROXIMITY to this: an early-running bus stays matched + * (a frozen lower cutoff would drop it and fire a false "disappeared"), + * while the same looping vehicle's other passes stay excluded. */ + expectedPrdtm: number +}; /** factory */ function postThreshold(prev: PreThreshold, vid: string): PostThreshold { return { - stage: 1, event: prev.event, vid, vidPredPrev: prev.candidateVid === vid ? prev.candidateVidPredPrev : null + stage: 1, event: prev.event, vid, + vidPredPrev: prev.candidateVid === vid ? prev.candidateVidPredPrev : null, + expectedPrdtm: prev.candidatePrdtm ?? prev.mustBeAfter }; } @@ -62,6 +81,12 @@ function prdctdnToNum(prdctdn: string): number { return prdctdn === 'DUE' ? 1 : parseInt(prdctdn); } +/** Delayed buses ("DLY") are surfaced to prediction endpoints but carry no + * usable countdown, so the reminder pipeline must never track them. */ +function hasUsableCountdown(p: state.Prediction): boolean { + return Number.isFinite(prdctdnToNum(p.prdctdn)); +} + /** result of ReminderSubscriptons.process */ export type RemindersToTrigger = { /** can be sent in bulk based off of route, stop, and threshold (or route, stop, and prdctdn) */ @@ -78,7 +103,7 @@ export type RemindersToTrigger = { /** Subscriptions go through a pipeline, see types above for details. */ export class ReminderSubscriptions { subscriptions: Array<{ - token: RegistrationToken, subscription: PreThreshold | PostThreshold + token: RegistrationToken, subscription: PreThreshold | PostThreshold, createdAt: number }>; pure: boolean; @@ -105,9 +130,15 @@ export class ReminderSubscriptions { const subscription = preThreshold(event, thresh, null, now); if (predictions) { const relevant = predictions - .filter((p) => p.rt === event.rtid); - const candidate = firstPredAfter(subscription.mustBeAfter, relevant); - subscription.candidateVid = candidate?.vid ?? null; + .filter((p) => p.rt === event.rtid && hasUsableCountdown(p)); + // Prefer a trackable (vid-assigned) bus over a vid-less schedule + // row, which would hold the threshold indefinitely. + const candidate = firstPredAfter(subscription.mustBeAfter, relevant.filter((p) => p.vid)) + ?? firstPredAfter(subscription.mustBeAfter, relevant); + // || null: schedule-based feed rows carry vid "" until a vehicle + // is assigned; those cannot be tracked in stage 1. + subscription.candidateVid = candidate?.vid || null; + subscription.candidatePrdtm = candidate?.prdtm ?? null; if (candidate?.prdctdn) { subscription.candidateVidPredPrev = prdctdnToNum(candidate?.prdctdn); } else { @@ -116,7 +147,7 @@ export class ReminderSubscriptions { } // remove existing this.remove(event, token, { noUpdate: true }); - this.subscriptions.push({ token, subscription }); + this.subscriptions.push({ token, subscription, createdAt: now }); if (options?.noUpdate || this.pure) return; sendReminderUpdateToAll(new Set([token])); } @@ -170,8 +201,17 @@ export class ReminderSubscriptions { updated: new Set() }; + // Reminders are short-lived by nature: expire stale subscriptions so + // no-candidate zombies and dead-token entries cannot accumulate + // forever (or fire a bogus stale reminder the next service day). + const SUBSCRIPTION_TTL_MS = 3 * 60 * 60 * 1000; + const newSubscriptions: typeof this.subscriptions = []; for (const s of this.subscriptions) { + if (now - s.createdAt > SUBSCRIPTION_TTL_MS) { + notifications.updated.add(s.token); + continue; + } // only one is sent, variable order is priority let disappeared: BaseEvent | null = null; let delayed: DelayEvent | null = null; @@ -184,11 +224,11 @@ export class ReminderSubscriptions { // did the candidate vehicle change? // PERF: caching these filter results might be good const relevantPreds = (predsByStopId[s.subscription.event.stpid] ?? []) - .filter((p) => p.rt === s.subscription.event.rtid); - const newCandidate = firstPredAfter( - s.subscription.mustBeAfter, - relevantPreds - ); + .filter((p) => p.rt === s.subscription.event.rtid && hasUsableCountdown(p)); + // Same preference as add(): a trackable vid'd bus beats a + // vid-less schedule row. + const newCandidate = firstPredAfter(s.subscription.mustBeAfter, relevantPreds.filter((p) => p.vid)) + ?? firstPredAfter(s.subscription.mustBeAfter, relevantPreds); if (newCandidate === null) { if (s.subscription.candidateVid !== null) { // no candidate now + existed before @@ -199,14 +239,18 @@ export class ReminderSubscriptions { if (s.subscription.candidateVidPredPrev !== pred) { timeChanged = true; } - if (newCandidate.vid !== s.subscription.candidateVid) { + // || null: schedule-based feed rows carry vid "" (or none) + // until a vehicle is assigned; an empty-string candidate + // would slip past === null guards into untrackable stage 1. + if ((newCandidate.vid || null) !== s.subscription.candidateVid) { // new candidate console.log(`stage 0: new candidate, time is now ${pred}`); - s.subscription.candidateVid = newCandidate.vid; + s.subscription.candidateVid = newCandidate.vid || null; } else { // same candidate console.log(`stage 0: same candidate, time is now ${pred}`); } + s.subscription.candidatePrdtm = newCandidate.prdtm; if (s.subscription.candidateVidPredPrev !== null && pred > s.subscription.candidateVidPredPrev ) { @@ -216,7 +260,11 @@ export class ReminderSubscriptions { ); } s.subscription.candidateVidPredPrev = pred; - if (newCandidate.prdtm > s.subscription.mustBeAfter && pred <= s.subscription.thresh) { + // A vid-less (schedule-based) candidate cannot be tracked + // in stage 1: hold the threshold until a vehicle is + // assigned, which happens as the bus enters service. + if (newCandidate.prdtm > s.subscription.mustBeAfter && pred <= s.subscription.thresh + && s.subscription.candidateVid !== null) { threshold = thresholdEvent({ ...s.subscription.event, threshold: s.subscription.thresh, @@ -233,19 +281,67 @@ export class ReminderSubscriptions { const shouldBeArrivingThresh = 3; const maxDelayWhenShouldBeArriving = 1; - const prdctdn = (predsByVid[s.subscription.vid] ?? []) - .find((p) => p.stpid === s.subscription.event.stpid)?.prdctdn ?? null; - const currArrivalTime = prdctdn == null ? null : prdctdnToNum(prdctdn); + // A vanished pass is only inferred as "arrived" when the bus + // was already this close (minutes); farther out, a big + // prediction jump is far more likely a delay than an arrival. + const arrivalInferenceMax = 5; + + // Track the pass by PROXIMITY to its expected arrival: a bus + // running a few minutes early stays matched, while the same + // looping vehicle's other passes (typically >= 8 minutes away) + // stay excluded and cannot masquerade as a huge delay. + const PASS_MATCH_SLACK_MS = 5 * 60 * 1000; + const expected = s.subscription.expectedPrdtm; + const allStopPreds = (predsByVid[s.subscription.vid] ?? []) + .filter((p) => p.stpid === s.subscription.event.stpid); + const stopPreds = allStopPreds.filter(hasUsableCountdown); const prevArrivalTime = s.subscription.vidPredPrev; - console.log(`stage 1: time is now ${currArrivalTime}`); + let tracked = stopPreds.find((p) => Math.abs(p.prdtm - expected) <= PASS_MATCH_SLACK_MS) ?? null; + let inferredArrival = false; + let delayedHold = false; + if (tracked === null) { + const laterPred = stopPreds.find((p) => p.prdtm > expected + PASS_MATCH_SLACK_MS) ?? null; + if (allStopPreds.some((p) => !hasUsableCountdown(p))) { + // The vehicle reports "DLY" for this stop: its position + // in the loop is unknowable this tick, so hold the + // subscription rather than guess arrival/disappearance. + delayedHold = true; + } else if (laterPred !== null) { + if (prevArrivalTime !== null && prevArrivalTime <= arrivalInferenceMax) { + // The tracked pass vanished close to arrival while a + // LATER pass of the looping vehicle is still listed: + // the bus completed this pass, i.e. it arrived. + inferredArrival = true; + } else { + // The prediction jumped past the window in a single + // tick while the bus was still far out: treat it as + // the tracked pass being delayed and follow it. + tracked = laterPred; + } + } + } + if (tracked) { + // Follow gradual drift/delay so the window moves with the bus. + s.subscription.expectedPrdtm = tracked.prdtm; + } - if (currArrivalTime !== prevArrivalTime) { + const prdctdn = tracked?.prdctdn ?? null; + const currArrivalTime = prdctdn == null ? null : prdctdnToNum(prdctdn); + + console.log(`stage 1: time is now ${delayedHold ? 'unknown (DLY)' : currArrivalTime}`); + + if (currArrivalTime !== prevArrivalTime && !delayedHold) { timeChanged = true; } - if (currArrivalTime === null) { - // disappeared - if (prevArrivalTime !== null && prevArrivalTime <= shouldBeArrivingThresh + if (delayedHold) { + // no notification this tick; the subscription is retained + // with its state unchanged until the countdown recovers + } else if (currArrivalTime === null) { + if (inferredArrival) { + console.log("tracked pass completed (later loop pass still listed): at the stop"); + ats = s.subscription.event; + } else if (prevArrivalTime !== null && prevArrivalTime <= shouldBeArrivingThresh ) { // override console.log("disappeared notification was overriden!"); @@ -272,7 +368,9 @@ export class ReminderSubscriptions { ); } } - s.subscription.vidPredPrev = currArrivalTime; + if (!delayedHold) { + s.subscription.vidPredPrev = currArrivalTime; + } } if (disappeared || delayed || threshold || ats || delayed || timeChanged) { @@ -293,7 +391,7 @@ export class ReminderSubscriptions { throw Error("A threshold notification was triggered without a corresponding vid"); } newSubscriptions.push( - { token: s.token, subscription: postThreshold(s.subscription, s.subscription.candidateVid) } + { token: s.token, subscription: postThreshold(s.subscription, s.subscription.candidateVid), createdAt: s.createdAt } ); } else if (ats) { addHelper(notifications.atTheStop, toKey(ats), s.token); @@ -318,13 +416,18 @@ export class ReminderSubscriptions { } swapToken(from: RegistrationToken, to: RegistrationToken) { - this.subscriptions = this.subscriptions.map((s) => { - if (s.token === from) { - return { ...s, token: to }; - } else { - return s; - } - }); + // A same-token swap must be a no-op (the dedup below would otherwise + // see every event as "already held" and delete all subscriptions). + if (from === to) return; + // Drop source subscriptions whose event the new token already holds: + // renaming them would leave two live subscriptions (possibly in + // different stages) for one (event, token) pair. + const existingEvents = this.subscriptions + .filter((s) => s.token === to) + .map((s) => s.subscription.event); + this.subscriptions = this.subscriptions + .filter((s) => !(s.token === from && existingEvents.some((e) => eventsEqual(e, s.subscription.event)))) + .map((s) => s.token === from ? { ...s, token: to } : s); } activeRemindersFor(id: RegistrationToken): Array { @@ -348,8 +451,9 @@ export function processUniversityReminders() { state.stopIdToName ); } catch (e) { - console.log("Processing university reminders failed"); - console.log(`${JSON.stringify(e)}`); + // Not JSON.stringify: Errors serialize to '{}' (message/stack are + // non-enumerable), which made recurring failures undiagnosable. + console.error("Processing university reminders failed", e); } } @@ -362,8 +466,7 @@ export function processRideReminders() { state.rideStopIdToName, ); } catch (e) { - console.log("Processing ride reminders failed"); - console.log(`${JSON.stringify(e)}`); + console.error("Processing ride reminders failed", e); } } @@ -470,10 +573,25 @@ function sendToAll(msg: any, tokens: Set) { sendToAllHelper(msg, group); } +/** Removes every subscription for a token FCM reports as dead, so the + * in-memory lists cannot grow forever with uninstalled clients. */ +function pruneDeadToken(token: string) { + for (const subs of [universityReminderSubscriptions, rideReminderSubscriptions]) { + const before = subs.subscriptions.length; + subs.subscriptions = subs.subscriptions.filter((s) => s.token !== token); + if (subs.subscriptions.length !== before) { + console.log(`Pruned ${before - subs.subscriptions.length} subscription(s) for a dead registration token`); + } + } +} + // REQUIRES: tokens.size <= 500 function sendToAllHelper(msg: any, tokens: Set) { console.log(`helper sending to ${tokens.size}`); - const payload = { tokens: Array.from(tokens), ...msg }; + // Snapshot: the caller reuses and refills the Set for the next batch, so + // the async callbacks below must not read it by reference. + const sentTokens = Array.from(tokens); + const payload = { tokens: sentTokens, ...msg }; console.log(`payload: ${JSON.stringify(payload)}`); getMessaging().sendEachForMulticast(payload) .then((res) => { @@ -483,10 +601,19 @@ function sendToAllHelper(msg: any, tokens: Set) { if (!res.success) { console.log(`message send ${idx} failed`); console.log(res.error); - console.log(`tokens was: ${JSON.stringify(Array.from(tokens))}`); + console.log(`token was: ${JSON.stringify(sentTokens[idx])}`); + const code = (res.error as any)?.code; + if (code === 'messaging/registration-token-not-registered' + || code === 'messaging/invalid-registration-token') { + pruneDeadToken(sentTokens[idx]); + } } }) } + }) + .catch((err) => { + // An unhandled rejection here would crash the whole server. + console.error('sendEachForMulticast failed', err); }); } diff --git a/src/services/ride.ts b/src/services/ride.ts index bd8fd31..67ea5dd 100644 --- a/src/services/ride.ts +++ b/src/services/ride.ts @@ -1,84 +1,16 @@ -// COPY OF MBUS.TS BUT MODIFIED FOR THE RIDE - -import axios from 'axios'; import * as process from "node:process"; import dotenv from "dotenv"; +import { createBusTimeClient } from './bustimeClient'; dotenv.config(); -const RIDE_API_KEY = process.env.RIDE_API_KEY; -const BASE_URL = process.env.RIDE_URL || 'https://rt.theride.org/bustime/api/v3/'; - -const client = axios.create({ - baseURL: BASE_URL, - params: { key: RIDE_API_KEY, format: 'json' } +const api = createBusTimeClient({ + baseURL: process.env.RIDE_URL || 'https://rt.theride.org/bustime/api/v3/', + apiKey: process.env.RIDE_API_KEY, + label: 'ride', }); -/** Fetches vehicle positions for the given routes. */ -export async function fetchVehicles(routes: string[]) { - const chunks = []; - for (let i = 0; i < routes.length; i += 10) chunks.push(routes.slice(i, i + 10)); - - const promises = chunks.map(async chunk => { - try { - const res = await client.get('/getvehicles', { - params: { requestType: 'getvehicles', rt: chunk.join(',') }, - }); - return res.data['bustime-response']?.vehicle || []; - } catch (e) { - console.warn('Fetch vehicles failed', e); - return []; - } - }); - const results = await Promise.all(promises); - return results.flat(); -} - -/** Fetches all available routes. */ -export async function fetchRoutes() { - try { - const res = await client.get('/getroutes', { params: { requestType: 'getroutes' } }); - return res.data['bustime-response']?.routes || []; - } catch (e) { - console.error("Fetch Routes failed", e); - return []; - } -} - -/** Fetches route patterns (path points) for a specific route. */ -export async function fetchPatterns(rt: string) { - try { - const res = await client.get('/getpatterns', { - params: { requestType: 'getpatterns', rt: rt, rtpidatafeed: 'bustime' } - }); - return res.data['bustime-response']?.ptr || []; - } catch (e) { - return []; - } -} - -/** Fetches predictions for multiple stop IDs. */ -export async function fetchPredictions(stopIds: string[], routes: string[]) { - const chunks = []; - for (let i = 0; i < stopIds.length; i += 10) chunks.push(stopIds.slice(i, i + 10)); - - const promises = chunks.map(async chunk => { - try { - const res = await client.get('/getpredictions', { - params: { - requestType: 'getpredictions', - stpid: chunk.join(','), - rt: routes.join(','), - tmres: 's', - // theride doesn't seem to support unix timestamps so this doesn't do anything - unixTime: true, - } - }); - return res.data; - } catch (e) { - return []; - } - }); - - return Promise.all(promises); -} \ No newline at end of file +export const fetchVehicles = api.fetchVehicles; +export const fetchRoutes = api.fetchRoutes; +export const fetchPatterns = api.fetchPatterns; +export const fetchPredictions = api.fetchPredictions; diff --git a/src/state/transitState.ts b/src/state/transitState.ts index 6686a27..6c37059 100644 --- a/src/state/transitState.ts +++ b/src/state/transitState.ts @@ -23,14 +23,18 @@ export type Prediction = { prdctdn: string } & Record; +// The prediction caches are looked up with USER-CONTROLLED keys from route +// params, so they must be prototype-less: with a plain {} a request for +// "constructor" or "__proto__" would return inherited Object members and +// break the response shape. /** Predictions indexed by vehicle ID. */ -export const cachedPredsByVid: Record = {}; +export const cachedPredsByVid: Record = Object.create(null); /** Predictions indexed by ride vehicle ID. */ -export const cachedRidePredsByVid: Record = {}; +export const cachedRidePredsByVid: Record = Object.create(null); /** Predictions indexed by stop ID. */ -export const cachedPredsByStopId: Record = {}; +export const cachedPredsByStopId: Record = Object.create(null); /** Predictions indexed by ride stop ID. */ -export const cachedRidePredsByStopId: Record = {}; +export const cachedRidePredsByStopId: Record = Object.create(null); /** Map of stop IDs to their human-readable names. */ export const stopIdToName: Record = {}; @@ -50,7 +54,14 @@ export let cachedGraph: { export let cachedStopLocations: Record = {}; export let cachedRideStopLocations: Record = {}; -/** Cache of timing differences between stops for extrapolation. */ +/** Cache of timing differences between stops for extrapolation. + * + * The pre-seeded entries below encode route INTERLINING (a CN bus becomes a + * CS bus at the loop end, and vice versa) with hardcoded stop IDs. They are + * configuration, not derivable state: revisit whenever the university + * renumbers these stops or changes the CN/CS pairing. Live learning MERGES + * into this map (it must never replace whole entries), and extrapolation + * prefers the first (i.e. seeded) successor for a given stop. */ export const routeTimingCache: Record>> = { "CN": { "N434NORTHBOUND": { @@ -69,6 +80,19 @@ export const validRoutes = new Set(); /** Set of currently valid ride route IDs. */ export const validRideRoutes = new Set(); +/** + * Epoch milliseconds of the UTC midnight that the cached graph's + * seconds-since-midnight stop times are relative to. Request handlers must + * compute "now" against this base so a graph built just before 00:00 UTC is + * not queried with a wrapped-around clock. + */ +export let cachedGraphTimeBase = 0; + +/** Updates the graph time base (set when trips are converted). */ +export function setCachedGraphTimeBase(baseMs: number) { + cachedGraphTimeBase = baseMs; +} + /** Updates the cached graph. */ export function setCachedGraph(newGraph: typeof cachedGraph) { cachedGraph = newGraph; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 9f93b59..0000000 --- a/src/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -type Route = { - rt: string -} - -export { - Route -}; \ No newline at end of file diff --git a/src/walking/loadMap.ts b/src/walking/loadMap.ts index 5f088f1..2597680 100644 --- a/src/walking/loadMap.ts +++ b/src/walking/loadMap.ts @@ -1,9 +1,11 @@ import fs from 'fs'; import { XMLParser } from 'fast-xml-parser'; import path from 'path'; +import { fileURLToPath } from 'url'; import { GraphMLNode, GraphMLEdge } from './types'; -const MAP_FILE = path.resolve(process.cwd(), 'src/assets/ann_arbor.graphml'); +// Module-relative so the server works regardless of the launch directory. +export const MAP_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets/ann_arbor.graphml'); const DEBUG = false; /** @@ -206,6 +208,21 @@ export function loadMap() { } } + // The source data is a multigraph: parallel edges can connect the same + // pair of nodes with different lengths. Only the shortest is ever useful, + // and path reconstruction looks edges up by (from, to), so keep just one. + for (const [id, edges] of graph) { + if (edges.length < 2) continue; + const bestByTarget = new Map(); + for (const e of edges) { + const existing = bestByTarget.get(e.to); + if (!existing || e.dist < existing.dist) bestByTarget.set(e.to, e); + } + if (bestByTarget.size !== edges.length) { + graph.set(id, Array.from(bestByTarget.values())); + } + } + // prune disconnected components, keep only largest const visited = new Set(); let maxComponentSize = 0; diff --git a/src/walking/walkingMap.ts b/src/walking/walkingMap.ts index b105667..b0f72a7 100644 --- a/src/walking/walkingMap.ts +++ b/src/walking/walkingMap.ts @@ -1,9 +1,11 @@ import fs from 'fs'; import path from 'path'; -import { writeFileSync, readFileSync, existsSync } from "fs"; +import { fileURLToPath } from 'url'; +import { readFileSync, existsSync } from "fs"; import { GraphMLNode, GraphMLEdge, LandmarkDef } from './types'; -import { haversine, loadMap } from './loadMap'; -import { LRUCache } from 'lru-cache'; +import { haversine, loadMap, MAP_FILE } from './loadMap'; +import { MinPriorityQueue } from '@datastructures-js/priority-queue'; +import { LRUCache } from 'lru-cache'; /** * Standard response for a single point-to-point walking query. @@ -29,9 +31,12 @@ export interface BatchWalkingResult { nodeDistances: Map; } -const CACHE_FILE = path.resolve(process.cwd(), 'src/assets/landmark_dist.json'); +// Resolve assets relative to this module, not process.cwd(): the server must +// work no matter which directory it is launched from. +const ASSETS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets'); +const CACHE_FILE = path.join(ASSETS_DIR, 'landmark_dist.json'); const WALKING_SPEED_M_S = 5000 / 3600; -const WALKING_CACHE_PATH = "src/assets/walkingCache.json"; +const WALKING_CACHE_PATH = path.join(ASSETS_DIR, 'walkingCache.json'); const DEBUG = false; const LANDMARK_DISTANCES = new Map>(); const LANDMARKS: LandmarkDef[] = [ @@ -50,8 +55,13 @@ let relevantStopNodes = new Set(); let walkingCache: { [key: string]: WalkingResponse } = {}; +// Bounded by total retained MAP ENTRIES, not map count: a single Dijkstra +// result can cover ~99% of the ~52k-node street graph (~2.6 MB retained), so +// a count-only bound would permit multi-GB heap growth and an eventual OOM. const networkDistanceCache = new LRUCache>({ - max: 5000, + max: 500, + maxSize: 2_000_000, + sizeCalculation: (distances) => Math.max(1, distances.size), }); /** @@ -86,16 +96,9 @@ function reconstructPath(cameFrom: Map, current: string) { return total.reverse(); } -/** - * Minimal MinHeap implementation for priority queueing in A* and Dijkstra. - */ -class MinHeap { - private arr: { id: string; f: number }[] = []; - push(item: { id: string; f: number }) { this.arr.push(item); this._siftUp(); } - pop() { if (this.arr.length === 0) return null; const top = this.arr[0]; const last = this.arr.pop()!; if (this.arr.length) { this.arr[0] = last; this._siftDown(); } return top; } - size() { return this.arr.length; } - private _siftUp() { let i = this.arr.length - 1; while (i > 0) { const p = Math.floor((i - 1) / 2); if (this.arr[i].f >= this.arr[p].f) break;[this.arr[i], this.arr[p]] = [this.arr[p], this.arr[i]]; i = p; } } - private _siftDown() { let i = 0; const n = this.arr.length; while (true) { const l = 2 * i + 1; const r = 2 * i + 2; let smallest = i; if (l < n && this.arr[l].f < this.arr[smallest].f) smallest = l; if (r < n && this.arr[r].f < this.arr[smallest].f) smallest = r; if (smallest === i) break;[this.arr[i], this.arr[smallest]] = [this.arr[smallest], this.arr[i]]; i = smallest; } } +/** Min-heap over {id, f} entries, backed by the shared priority-queue dependency. */ +function makeMinHeap() { + return new MinPriorityQueue<{ id: string; f: number }>((item) => item.f); } /** @@ -107,16 +110,16 @@ class MinHeap { */ function computeDijkstraAll(startId: string, targets?: Set): Map { const distances = new Map(); - const minHeap = new MinHeap(); + const minHeap = makeMinHeap(); let targetsFound = 0; const totalTargets = targets ? targets.size : 0; distances.set(startId, 0); - minHeap.push({ id: startId, f: 0 }); + minHeap.enqueue({ id: startId, f: 0 }); while (minHeap.size() > 0) { - const { id: u, f: d } = minHeap.pop()!; + const { id: u, f: d } = minHeap.dequeue()!; if (d > (distances.get(u) ?? Infinity)) continue; if (targets && targets.has(u)) { targetsFound++; @@ -129,7 +132,7 @@ function computeDijkstraAll(startId: string, targets?: Set): Map): Map(); const fScore = new Map(); - const inOpen = new Set(); const cameFrom = new Map(); let explored = 0; @@ -172,14 +174,17 @@ async function aStar(startId: string, goalId: string) { const initialH = getHeuristic(startId); fScore.set(startId, initialH); - openHeap.push({ id: startId, f: initialH }); - inOpen.add(startId); + openHeap.enqueue({ id: startId, f: initialH }); while (openHeap.size() > 0) { explored++; - const cur = openHeap.pop()!; + const cur = openHeap.dequeue()!; const current = cur.id; + // Stale heap entry: this node was re-pushed with a better score after + // this entry was queued (the heap has no decrease-key). + if (cur.f > (fScore.get(current) ?? Infinity)) continue; + if (current === goalId) { const pathIds = reconstructPath(cameFrom, current); let totalDist = 0; @@ -192,7 +197,6 @@ async function aStar(startId: string, goalId: string) { return { pathIds, totalDist, explored }; } - inOpen.delete(current); const neighbors = graphAdjacency.get(current) ?? []; for (const edge of neighbors) { const tentative_g = (gScore.get(current) ?? Infinity) + edge.dist; @@ -204,10 +208,8 @@ async function aStar(startId: string, goalId: string) { const f = tentative_g + h; fScore.set(edge.to, f); - if (!inOpen.has(edge.to)) { - openHeap.push({ id: edge.to, f }); - inOpen.add(edge.to); - } + // Always re-push on improvement; stale entries are skipped on pop. + openHeap.enqueue({ id: edge.to, f }); } } } @@ -226,7 +228,11 @@ function saveLandmarkDistances(data: Map>) { } output[landmarkId] = distObj; } - fs.writeFileSync(CACHE_FILE, JSON.stringify(output)); + // Temp-file + rename so a crash mid-write can never leave a truncated + // cache that would fail to parse on the next boot. + const tmpPath = `${CACHE_FILE}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(output)); + fs.renameSync(tmpPath, CACHE_FILE); console.log(`Saved cache to ${CACHE_FILE}`); } @@ -257,11 +263,31 @@ function initializeGraph() { graphAdjacency = graph; console.log(`Graph initialized with ${graphNodes.size} nodes.`); - if (fs.existsSync(CACHE_FILE)) { + // The landmark cache is only valid for the graph it was computed from: a + // stale cache makes the ALT heuristic inadmissible (A* silently returns + // non-shortest paths), so recompute whenever the map file is newer. + // The tolerance absorbs checkout/copy jitter (a fresh git clone writes both + // files within milliseconds of each other, in arbitrary order); a genuine + // map update is newer by far more than this. + const STALE_TOLERANCE_MS = 60_000; + const landmarksFresh = fs.existsSync(CACHE_FILE) + && fs.statSync(CACHE_FILE).mtimeMs >= fs.statSync(MAP_FILE).mtimeMs - STALE_TOLERANCE_MS; + + let landmarksLoaded = false; + if (landmarksFresh) { console.log('--- Cache Found: Loading Precomputed Distances ---'); - loadLandmarkDistances(); - } else { - console.log('--- No Cache Found: Starting Computation ---'); + // A corrupt cache must never prevent boot: fall through to recompute. + try { + loadLandmarkDistances(); + landmarksLoaded = true; + } catch (err) { + console.error('landmark_dist.json is corrupt — recomputing', err instanceof Error ? err.message : err); + } + } + if (!landmarksLoaded) { + console.log(fs.existsSync(CACHE_FILE) + ? '--- Landmark cache is stale or corrupt: Recomputing ---' + : '--- No Cache Found: Starting Computation ---'); const t0 = performance.now(); for (const lm of LANDMARKS) { @@ -285,11 +311,27 @@ function initializeGraph() { * This optimizes future lookups by caching the StopID -> NodeID relationship. * @param locations - A map of StopID to {lat, lon}. */ +let stopNodeMapSignature: string | null = null; + export function buildStopNodeMap(locations: Record) { if (graphNodes.size === 0) initializeGraph(); + // The stop->node mapping is a pure function of (locations, static graph): + // skip the O(stops x nodes) rebuild AND the Dijkstra cache wipe when the + // stop set hasn't changed (initializeRoutes calls this every 60s). + const signature = Object.keys(locations).sort() + .map(id => `${id}:${locations[id].lat},${locations[id].lon}`).join(';'); + // Only short-circuit on a non-empty prior mapping: recomputing an empty + // one is free, and this avoids preserving an empty map across an + // in-process graph re-initialization. + if (signature === stopNodeMapSignature && Object.keys(stopNodeMap).length > 0) return; + stopNodeMapSignature = signature; + stopNodeMap = {}; relevantStopNodes.clear(); + // Cached Dijkstra results were computed with early exit against the old + // target set and may lack distances for stops added by this rebuild. + networkDistanceCache.clear(); let mappedCount = 0; Object.entries(locations).forEach(([stopId, loc]) => { const nearest = nearestNode(graphNodes, loc.lat, loc.lon); @@ -476,52 +518,57 @@ export async function ensureCacheForStops( stopIds: Set, stopLocations: Record ): Promise { - - const fetchPromises: Promise[] = []; - let cacheWasUpdated = false; - console.log(`Verifying cache for ${stopIds.size} stops...`); + const missing: Array<{ cacheKey: string, loc1: { lat: number, lon: number }, loc2: { lat: number, lon: number } }> = []; for (const id1 of stopIds) { for (const id2 of stopIds) { if (id1 === id2) continue; - const cacheKey = `${id1}_TO_${id2}`; - - if (!walkingCache[cacheKey]) { - const loc1 = stopLocations[id1]; - const loc2 = stopLocations[id2]; - - if (loc1 && loc2) { - cacheWasUpdated = true; - - const p = (async () => { - try { - const data = await getWalkingResponse(loc1.lat, loc1.lon, loc2.lat, loc2.lon); - walkingCache[cacheKey] = data; - } catch (err) { - walkingCache[cacheKey] = { duration: 60000, distance: 0, path_coords: [] }; - } - })(); - - fetchPromises.push(p); - } - } + if (walkingCache[cacheKey]) continue; + const loc1 = stopLocations[id1]; + const loc2 = stopLocations[id2]; + if (loc1 && loc2) missing.push({ cacheKey, loc1, loc2 }); } } - if (fetchPromises.length > 0) { - console.log(`Computing ${fetchPromises.length} new paths...`); - await Promise.all(fetchPromises); - } + if (missing.length === 0) return; + console.log(`Computing ${missing.length} new paths...`); - if (cacheWasUpdated) { + let computed = 0; + for (let i = 0; i < missing.length; i++) { + const { cacheKey, loc1, loc2 } = missing[i]; try { - writeFileSync(WALKING_CACHE_PATH, JSON.stringify(walkingCache, null, 2)); - console.log(`WalkingManager: Cache updated on disk. Total entries: ${Object.keys(walkingCache).length}`); + walkingCache[cacheKey] = await getWalkingResponse(loc1.lat, loc1.lon, loc2.lat, loc2.lon); + computed++; } catch (err) { - console.error("WalkingManager: Failed to write cache to disk", err); + // Leave the pair uncached so it is retried on the next cycle; a + // poisoned sentinel would otherwise be persisted forever. + console.warn(`WalkingManager: failed to compute ${cacheKey}, will retry later`); } + // The path searches are pure CPU: yield to the event loop regularly so + // HTTP requests and interval jobs are not starved for minutes when the + // cache is cold. + if ((i + 1) % 20 === 0) await new Promise(resolve => setImmediate(resolve)); + } + + if (computed > 0) await persistWalkingCache(); +} + +async function persistWalkingCache(): Promise { + // Never write from tests: a plain `npm test` must not dirty the tracked + // cache file. + if (process.env.VITEST === 'true') return; + try { + // Compact JSON (the file is hundreds of MB) written to a temp file and + // renamed, so a crash mid-write can never leave a truncated cache that + // would fail to parse on the next boot. + const tmpPath = `${WALKING_CACHE_PATH}.tmp`; + await fs.promises.writeFile(tmpPath, JSON.stringify(walkingCache)); + await fs.promises.rename(tmpPath, WALKING_CACHE_PATH); + console.log(`WalkingManager: cache persisted (${Object.keys(walkingCache).length} entries)`); + } catch (err) { + console.error("WalkingManager: failed to write cache to disk", err); } } @@ -537,9 +584,25 @@ export function getCachedWalk(originId: string, destId: string): WalkingResponse initializeGraph(); if (existsSync(WALKING_CACHE_PATH)) { - const file = readFileSync(WALKING_CACHE_PATH, "utf8"); - Object.assign(walkingCache, JSON.parse(file)); - console.log("Loaded walkingCache.json"); + // A corrupt cache must never prevent boot (this runs at module import); + // fall back to an empty cache and let it rebuild. + try { + const file = readFileSync(WALKING_CACHE_PATH, "utf8"); + const loaded: Record = JSON.parse(file); + // Drop entries poisoned by the old error sentinel or otherwise invalid so + // they get recomputed instead of routing around a 16-hour "walk". + let dropped = 0; + for (const [key, value] of Object.entries(loaded)) { + if (!Number.isFinite(value?.duration) || value.duration >= 60000) { + dropped++; + continue; + } + walkingCache[key] = value; + } + console.log(`Loaded walkingCache.json${dropped > 0 ? ` (dropped ${dropped} invalid entries)` : ''}`); + } catch (err) { + console.error("walkingCache.json is corrupt — starting with an empty cache and rebuilding", err instanceof Error ? err.message : err); + } } else { console.log("walkingCache.json does not exist — using empty cache"); } \ No newline at end of file diff --git a/test/api-handlers.test.ts b/test/api-handlers.test.ts new file mode 100644 index 0000000..d84eab6 --- /dev/null +++ b/test/api-handlers.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type express from 'express'; + +// Heavy modules replaced before the router module loads. startBackgroundJobs +// itself is skipped by api.ts under vitest (process.env.VITEST === 'true'). +vi.mock('../src/walking/walkingMap', () => ({ + buildStopNodeMap: vi.fn(), + ensureCacheForStops: vi.fn().mockResolvedValue(undefined), + getCachedWalk: vi.fn().mockReturnValue(undefined), + getWalkingDistancesFrom: vi.fn().mockReturnValue([]), + getWalkingResponse: vi.fn().mockResolvedValue({ duration: 0, distance: 0, path_coords: [] }), +})); +vi.mock('../src/services/mbus', () => ({ + fetchVehicles: vi.fn().mockResolvedValue([]), + fetchRoutes: vi.fn().mockResolvedValue([]), + fetchPatterns: vi.fn().mockResolvedValue([]), + fetchPredictions: vi.fn().mockResolvedValue([]), +})); +vi.mock('../src/services/ride', () => ({ + fetchVehicles: vi.fn().mockResolvedValue([]), + fetchRoutes: vi.fn().mockResolvedValue([]), + fetchPatterns: vi.fn().mockResolvedValue([]), + fetchPredictions: vi.fn().mockResolvedValue([]), +})); + +import * as api from '../src/routes/api'; +import * as state from '../src/state/transitState'; +import * as reminderService from '../src/services/reminder'; +import { makeTrip } from './helpers/network'; + +function mockRes() { + const res: any = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { this.statusCode = code; return this; }, + sendStatus(code: number) { this.statusCode = code; return this; }, + json(payload: unknown) { this.body = payload; return this; }, + send(payload: unknown) { this.body = payload; return this; }, + }; + return res as express.Response & { statusCode: number, body: any }; +} + +const req = (query: Record = {}, body: unknown = {}) => + ({ query, body, params: {} }) as unknown as express.Request; + +beforeEach(() => { + state.setCachedGraph({ trips: [], transfers: {}, interchange: {} }); + state.setCachedGraphTimeBase(0); + state.setCachedStopLocations({}); + state.validRoutes.clear(); + state.validRideRoutes.clear(); + reminderService.universityReminderSubscriptions.subscriptions = []; + reminderService.rideReminderSubscriptions.subscriptions = []; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('/plan-journey parameter validation', () => { + const goodCoords = { originLat: '42.27', originLon: '-83.74', destLat: '42.29', destLon: '-83.71' }; + + it('rejects missing coordinates with 400', async () => { + const res = mockRes(); + await api.planJourney(req({ originLat: '42.27' }), res); + expect(res.statusCode).toBe(400); + }); + + it('rejects non-numeric coordinates with 400 instead of a 500', async () => { + const res = mockRes(); + await api.planJourney(req({ ...goodCoords, originLat: 'abc' }), res); + expect(res.statusCode).toBe(400); + expect(res.body.error).toMatch(/numeric/); + }); + + it('rejects a negative or non-numeric walkingPenalty with 400', async () => { + const negative = mockRes(); + await api.planJourney(req({ ...goodCoords, walkingPenalty: '-1' }), negative); + expect(negative.statusCode).toBe(400); + + const garbage = mockRes(); + await api.planJourney(req({ ...goodCoords, walkingPenalty: 'fast' }), garbage); + expect(garbage.statusCode).toBe(400); + }); + + it('rejects a non-numeric range with 400', async () => { + const res = mockRes(); + await api.planJourney(req({ ...goodCoords, range: 'soon' }), res); + expect(res.statusCode).toBe(400); + }); + + it('accepts walkingPenalty=0 (free walking) and returns 200', async () => { + const res = mockRes(); + await api.planJourney(req({ ...goodCoords, walkingPenalty: '0' }), res); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ journeys: [] }); + }); +}); + +describe('/getAllPredictions time frame', () => { + it('computes countdowns in the graph frame after the clock crosses UTC midnight', () => { + vi.useFakeTimers(); + // Graph built just before midnight: bus due at 86670s in that frame. + state.setCachedGraphTimeBase(Date.UTC(2026, 7, 26)); + state.setCachedGraph({ + trips: [makeTrip('888001', '4001', [{ stop: 'T1', arr: 86670, rt: 'TT' }])], + transfers: {}, interchange: {}, + }); + state.stopIdToName['T1'] = 'Central Campus Transit Center'; + + // Request 30s after midnight: (86670 - 86430) / 60 = 4 minutes, not ~1444. + vi.setSystemTime(new Date('2026-08-27T00:00:30Z')); + const res = mockRes(); + api.getAllPredictions(req(), res); + expect(res.body).toHaveLength(1); + expect(res.body[0].stops[0].prdctdn).toBe('4'); + }); +}); + +describe('/modifyReminders atomicity', () => { + it('applies nothing when any modification names an invalid route', () => { + state.validRoutes.add('BB'); + state.validRideRoutes.add('4'); // both feeds loaded -> a miss is a real 400 + const res = mockRes(); + api.modifyReminders(req({}, { + token: 'tok1', + modifications: [ + { action: 'set', stpid: 'C250', rtid: 'BB', thresh: 3 }, + { action: 'set', stpid: 'C250', rtid: 'NOPE', thresh: 3 }, + ], + }), res); + + expect(res.statusCode).toBe(400); + // The valid first entry must NOT have been applied before the abort. + const active = reminderService.universityReminderSubscriptions + .activeRemindersFor(reminderService.registrationToken('tok1')); + expect(active).toHaveLength(0); + }); +}); + +describe('reminder endpoint validation', () => { + it('rejects out-of-range thresh values with 400', () => { + state.validRoutes.add('BB'); + for (const thresh of [-1, 0, 1e12]) { + const res = mockRes(); + api.setReminder(req({}, { token: 'tok1', stpid: 'C250', rtid: 'BB', thresh }), res); + expect(res.statusCode).toBe(400); + } + expect(reminderService.universityReminderSubscriptions.subscriptions).toHaveLength(0); + }); + + it('answers 503 (retryable) instead of 400 while route data is still loading', () => { + // validRoutes/validRideRoutes are empty until the first getroutes + // fetch completes; a 400 would make clients discard valid reminders. + const res = mockRes(); + api.setReminder(req({}, { token: 'tok1', stpid: 'C250', rtid: 'BB', thresh: 5 }), res); + expect(res.statusCode).toBe(503); + + // Same when only ONE feed is down: an unknown rtid could belong to it. + state.validRoutes.add('BB'); + const oneFeedDown = mockRes(); + api.setReminder(req({}, { token: 'tok1', stpid: 'X', rtid: '4', thresh: 5 }), oneFeedDown); + expect(oneFeedDown.statusCode).toBe(503); + }); + + it('startup info restores the 2.0.2 minimum supported version gate', () => { + const res = mockRes(); + api.getStartupInfo(req(), res); + expect(res.body.min_supported_version).toBe('2.0.2'); + }); +}); + +describe('prototype-key robustness', () => { + it('returns an empty prediction list for prototype-named IDs', () => { + for (const key of ['constructor', '__proto__', 'hasOwnProperty']) { + const res = mockRes(); + api.getBusPredictions({ params: { busId: key }, query: {}, body: {} } as any, res); + expect(res.body).toEqual({ 'bustime-response': { prd: [] } }); + + const res2 = mockRes(); + api.getStopPredictions({ params: { stopId: key }, query: {}, body: {} } as any, res2); + expect(res2.body).toEqual({ 'bustime-response': { prd: [] } }); + } + }); +}); + +describe('misc endpoint fixes', () => { + it('notifyMeLater returns HTTP 400 (not 200) when the token is missing', () => { + const res = mockRes(); + api.notifyMeLater(req({}, {}), res); + expect(res.statusCode).toBe(400); + }); + + it('nearest-stops falls back to k=2 for garbage k instead of returning nothing', () => { + state.setCachedStopLocations({ + A: { name: 'A', lat: 42.28, lon: -83.73 }, + B: { name: 'B', lat: 42.28, lon: -83.72 }, + C: { name: 'C', lat: 42.28, lon: -83.70 }, + }); + const res = mockRes(); + api.getNearestStops(req({ lat: '42.28', lon: '-83.735', k: 'abc' }), res); + expect(res.statusCode).toBe(200); + expect(res.body.nearestStops.map((s: any) => s.stpid)).toEqual(['A', 'B']); + }); +}); diff --git a/test/api.test.ts b/test/api.test.ts index fc586fc..45d03f0 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -10,8 +10,9 @@ describe('API Endpoints', () => { const response = await axios.get(`${BASE_URL}/getBusPositions`); console.log(`Server responded to /getBusPositions with status: ${response.status}`); } catch (error) { - console.error('Server is not running! Please start the server with: `npm start` (or your equivalent command).'); - process.exit(1); // Exit if the server isn't reachable + // throw, not process.exit: exiting kills the whole vitest worker + // and silently masks the results of other queued test files. + throw new Error('Server is not running on :3000 - start it with: npm start'); } }); diff --git a/test/helpers/mockBusApi.ts b/test/helpers/mockBusApi.ts new file mode 100644 index 0000000..40483c3 --- /dev/null +++ b/test/helpers/mockBusApi.ts @@ -0,0 +1,83 @@ +/** + * Builders for realistic BusTime v3 API payloads, shaped like the real + * responses from mbus.ltp.umich.edu (see src/services/mbus.ts). + */ + +export interface PrdOverrides { + stpid: string; + stpnm: string; + rt: string; + rtdir: string; + /** Minutes until arrival as the API reports it: a number string or "DUE". */ + prdctdn: string; + vid?: string; + tatripid?: string; + des?: string; + prdtm?: string; + [key: string]: unknown; +} + +/** + * One prediction entry as returned inside bustime-response.prd. + * Fields not under test are filled with realistic constants. + */ +export function prd(overrides: PrdOverrides): Record { + const minutes = overrides.prdctdn === 'DUE' ? 1 : parseInt(overrides.prdctdn, 10); + const base: Record = { + tmstmp: '20260826 12:00:00', + typ: 'A', + dstp: 1200, + vid: '4001', + tatripid: '999001', + origtatripno: '999001', + tablockid: 'BB -401', + des: 'Bursley-Baits', + dly: false, + zone: '', + // The route rebuild requests unixTime, so prdtm parses as epoch millis. + prdtm: String(Date.parse('2026-08-26T12:00:00Z') + minutes * 60_000), + }; + return { ...base, ...overrides }; +} + +/** Wraps predictions in the chunked response shape fetchPredictions returns. */ +export function predictionChunk(prds: Record[]): Record { + return { 'bustime-response': { prd: prds } }; +} + +export interface PatternStop { + stpid: string; + stpnm: string; + lat: number; + lon: number; +} + +/** + * A route pattern in the shape of bustime-response.ptr, as cached in + * state.cachedRoutes by initializeRoutes. Interleaves waypoints ("W") between + * stops the way the real feed does; processPredictions must skip them. + */ +export function pattern(rtdir: string, stops: PatternStop[]): Record { + const pt: Record[] = []; + stops.forEach((s, i) => { + pt.push({ + seq: pt.length + 1, + typ: 'S', + stpid: s.stpid, + stpnm: s.stpnm, + lat: String(s.lat), + lon: String(s.lon), + pdist: i * 800, + }); + if (i < stops.length - 1) { + pt.push({ + seq: pt.length + 1, + typ: 'W', + lat: String(s.lat + 0.001), + lon: String(s.lon + 0.001), + pdist: i * 800 + 400, + }); + } + }); + return { pid: 1000 + pt.length, ln: stops.length * 800, rtdir, pt }; +} diff --git a/test/helpers/network.ts b/test/helpers/network.ts new file mode 100644 index 0000000..f65eb7f --- /dev/null +++ b/test/helpers/network.ts @@ -0,0 +1,96 @@ +import { Trip, StopTime, Transfer, TransfersByOrigin, Interchange, StopID } from '../../src/raptor/types'; + +/** + * Compact spec for a single stop event when hand-building trips in tests. + */ +export interface StopTimeSpec { + stop: StopID; + /** Arrival time in seconds since midnight. */ + arr: number; + /** Departure time; defaults to the arrival time. */ + dep?: number; + pickUp?: boolean; + dropOff?: boolean; + rt?: string; +} + +/** Builds a Trip from compact stop time specs. */ +export function makeTrip(tripId: string, vid: string | null, specs: StopTimeSpec[]): Trip { + const stopTimes: StopTime[] = specs.map(s => ({ + stop: s.stop, + arrivalTime: s.arr, + departureTime: s.dep ?? s.arr, + pickUp: s.pickUp ?? true, + dropOff: s.dropOff ?? true, + rt: s.rt + })); + return { tripId, vid, stopTimes }; +} + +/** Builds a walking transfer. Window defaults to "always usable" like the production graph. */ +export function walkTransfer( + origin: StopID, + destination: StopID, + duration: number, + startTime: number = 0, + endTime: number = Number.MAX_SAFE_INTEGER +): Transfer { + return { origin, destination, duration, startTime, endTime }; +} + +/** Indexes a flat list of transfers by origin stop, as the algorithm expects. */ +export function transferMap(transfers: Transfer[]): TransfersByOrigin { + const map: TransfersByOrigin = {}; + for (const t of transfers) { + if (!map[t.origin]) map[t.origin] = []; + map[t.origin].push(t); + } + return map; +} + +/** Uniform minimum-transfer-time map, mirroring the production 30s default. */ +export function uniformInterchange(stops: StopID[], buffer: number = 30): Interchange { + const interchange: Interchange = {}; + for (const s of stops) interchange[s] = buffer; + return interchange; +} + +/** + * A scheduled route: an ordered stop list served by several timed runs, + * the shape real M-Bus routes (BB, CN, CS, ...) take after ingestion. + */ +export interface ScheduledRoute { + rt: string; + rtdir: string; + stops: StopID[]; + /** Seconds of travel between consecutive stops (length = stops.length - 1). */ + travelTimes: number[]; + /** Departure time of the first run from stops[0], seconds since midnight. */ + firstDeparture: number; + /** Seconds between consecutive runs. */ + headway: number; + runs: number; + /** Seconds a bus waits at each intermediate stop. Default 0. */ + dwell?: number; +} + +/** Expands a scheduled route into one Trip per run. */ +export function buildScheduledTrips(route: ScheduledRoute): Trip[] { + const trips: Trip[] = []; + const dwell = route.dwell ?? 0; + + for (let run = 0; run < route.runs; run++) { + const specs: StopTimeSpec[] = []; + let arr = route.firstDeparture + run * route.headway; + + route.stops.forEach((stop, i) => { + const isLast = i === route.stops.length - 1; + const dep = isLast ? arr : arr + dwell; + specs.push({ stop, arr, dep, rt: route.rt }); + if (!isLast) arr = dep + route.travelTimes[i]; + }); + + trips.push(makeTrip(`${route.rt}_${route.rtdir}_${run}`, `${4000 + run}`, specs)); + } + return trips; +} diff --git a/test/helpers/oracle.ts b/test/helpers/oracle.ts new file mode 100644 index 0000000..bc72fbf --- /dev/null +++ b/test/helpers/oracle.ts @@ -0,0 +1,220 @@ +import { Trip, TransfersByOrigin, Interchange, StopID } from '../../src/raptor/types'; +import { Journey } from '../../src/raptor/McRaptorAlgorithm'; + +/** + * The three optimization criteria of the McRaptor search. + * transferCount counts boardings (a direct ride has transferCount 1). + */ +export interface Criteria { + arrivalTime: number; + walkingDistance: number; + transferCount: number; +} + +export interface Scenario { + trips: Trip[]; + transfers: TransfersByOrigin; + interchange: Interchange; + origin: StopID; + destination: StopID; + departureTime: number; + walkingPenalty?: number; +} + +/** Sorts criteria lexicographically so two frontiers can be compared with toEqual. */ +export function sortCriteria(list: Criteria[]): Criteria[] { + return [...list].sort((a, b) => + a.arrivalTime - b.arrivalTime || + a.walkingDistance - b.walkingDistance || + a.transferCount - b.transferCount + ); +} + +function dominates(a: Criteria, b: Criteria): boolean { + if (a.arrivalTime > b.arrivalTime) return false; + if (a.walkingDistance > b.walkingDistance) return false; + if (a.transferCount > b.transferCount) return false; + return a.arrivalTime < b.arrivalTime || a.walkingDistance < b.walkingDistance || a.transferCount < b.transferCount; +} + +function sameCriteria(a: Criteria, b: Criteria): boolean { + return a.arrivalTime === b.arrivalTime + && a.walkingDistance === b.walkingDistance + && a.transferCount === b.transferCount; +} + +interface SearchState { + stop: StopID; + time: number; + walk: number; + rides: number; + /** Walking legs may not chain: transfers are assumed transitively closed upstream. */ + lastWasWalk: boolean; +} + +/** + * Brute-force reference implementation of the intended journey semantics: + * exhaustively enumerates every feasible journey (up to maxRides boardings) + * and returns the exact Pareto frontier of (arrivalTime, walkingDistance, + * transferCount) at the destination. Deliberately independent of the + * McRaptor implementation so the two can be compared. + * + * Semantics mirrored: + * - The interchange buffer applies before every boarding, including the first. + * - pickUp === false forbids boarding, dropOff === false forbids alighting. + * - A transfer is usable only if the walk starts within [startTime, endTime]. + * - walkingPenalty scales the walking criterion, not the arrival time. + * - Two walking legs never follow each other. + */ +export function bruteForceParetoCriteria(scenario: Scenario, maxRides: number = 8): Criteria[] { + const { trips, transfers, interchange, origin, destination, departureTime } = scenario; + const penalty = scenario.walkingPenalty ?? 1; + + const boardings = new Map(); + for (const trip of trips) { + trip.stopTimes.forEach((st, index) => { + if (!boardings.has(st.stop)) boardings.set(st.stop, []); + boardings.get(st.stop)!.push({ trip, index }); + }); + } + + const best = new Map(); + const atDestination: Criteria[] = []; + const queue: SearchState[] = []; + + const offer = (state: SearchState) => { + const key = `${state.stop}|${state.lastWasWalk ? 1 : 0}`; + const labels = best.get(key) ?? []; + for (const l of labels) { + if (l.time <= state.time && l.walk <= state.walk && l.rides <= state.rides) return; + } + const kept = labels.filter(l => !(state.time <= l.time && state.walk <= l.walk && state.rides <= l.rides)); + kept.push({ time: state.time, walk: state.walk, rides: state.rides }); + best.set(key, kept); + + if (state.stop === destination) { + atDestination.push({ arrivalTime: state.time, walkingDistance: state.walk, transferCount: state.rides }); + } + queue.push(state); + }; + + offer({ stop: origin, time: departureTime, walk: 0, rides: 0, lastWasWalk: false }); + + while (queue.length > 0) { + const s = queue.pop()!; + + if (s.rides < maxRides) { + const buffer = interchange[s.stop] || 0; + for (const { trip, index } of boardings.get(s.stop) ?? []) { + const board = trip.stopTimes[index]; + if (board.pickUp === false) continue; + if (board.departureTime < s.time + buffer) continue; + for (let j = index + 1; j < trip.stopTimes.length; j++) { + const alight = trip.stopTimes[j]; + if (alight.dropOff === false) continue; + offer({ + stop: alight.stop, + time: alight.arrivalTime, + walk: s.walk, + rides: s.rides + 1, + lastWasWalk: false + }); + } + } + } + + if (!s.lastWasWalk) { + for (const t of transfers[s.stop] ?? []) { + if (s.time < t.startTime || s.time > t.endTime) continue; + offer({ + stop: t.destination, + time: s.time + t.duration, + walk: s.walk + t.duration * penalty, + rides: s.rides, + lastWasWalk: true + }); + } + } + } + + const unique: Criteria[] = []; + for (const c of atDestination) { + if (!unique.some(u => sameCriteria(u, c))) unique.push(c); + } + return sortCriteria(unique.filter(c => !unique.some(u => dominates(u, c)))); +} + +/** + * Checks that a journey returned by the algorithm is internally consistent + * and actually executable against the scenario's data: legs connect, times + * come from real trips/transfers, buffers and pickUp/dropOff/window rules are + * respected, and the reported criteria match a replay of the legs. + */ +export function validateJourney(journey: Journey, scenario: Scenario): void { + const penalty = scenario.walkingPenalty ?? 1; + const { legs, criteria } = journey; + + const describe = () => legs + .map(l => `${l.type}:${l.origin}->${l.destination}@${l.startTime}-${l.endTime}`) + .join(', '); + const fail = (msg: string): never => { + throw new Error(`Invalid journey (${msg}); criteria=${JSON.stringify(criteria)}; legs=[${describe()}]`); + }; + + if (legs.length === 0) { + if (scenario.origin !== scenario.destination) fail('no legs but origin differs from destination'); + if (criteria.arrivalTime !== scenario.departureTime || criteria.walkingDistance !== 0 || criteria.transferCount !== 0) { + fail('trivial journey criteria mismatch'); + } + return; + } + + if (legs[0].origin !== scenario.origin) fail('first leg does not start at the origin'); + if (legs[legs.length - 1].destination !== scenario.destination) fail('last leg does not end at the destination'); + + let cursor = scenario.departureTime; + let walkCost = 0; + let rides = 0; + + for (let i = 0; i < legs.length; i++) { + const leg = legs[i]; + if (i > 0 && legs[i - 1].destination !== leg.origin) fail(`leg ${i} does not start where leg ${i - 1} ended`); + + if (leg.type === 'Transfer') { + if (i > 0 && legs[i - 1].type === 'Transfer') fail('two consecutive walking legs'); + const match = (scenario.transfers[leg.origin] ?? []) + .find(t => t.destination === leg.destination && t.duration === leg.duration); + if (!match) return fail(`no transfer ${leg.origin}->${leg.destination} of duration ${leg.duration}`); + if (leg.startTime !== cursor) fail(`walk leg ${i} does not start when the previous leg ended`); + if (cursor < match.startTime || cursor > match.endTime) fail(`walk leg ${i} starts outside the transfer window`); + if (leg.endTime !== leg.startTime + match.duration) fail(`walk leg ${i} end time mismatch`); + walkCost += match.duration * penalty; + cursor = leg.endTime; + } else { + const trip = scenario.trips.find(t => t.tripId === leg.trip?.tripId); + if (!trip) return fail(`ride leg ${i} references unknown trip ${leg.trip?.tripId}`); + const buffer = scenario.interchange[leg.origin] || 0; + if (leg.startTime < cursor + buffer) fail(`ride leg ${i} boards before the ${buffer}s interchange buffer`); + + let consistent = false; + for (let bi = 0; bi < trip.stopTimes.length && !consistent; bi++) { + const board = trip.stopTimes[bi]; + if (board.stop !== leg.origin || board.departureTime !== leg.startTime || board.pickUp === false) continue; + for (let ai = bi + 1; ai < trip.stopTimes.length; ai++) { + const alight = trip.stopTimes[ai]; + if (alight.stop === leg.destination && alight.arrivalTime === leg.endTime && alight.dropOff !== false) { + consistent = true; + break; + } + } + } + if (!consistent) fail(`ride leg ${i} cannot be executed on trip ${trip.tripId}`); + rides++; + cursor = leg.endTime; + } + } + + if (criteria.arrivalTime !== cursor) fail(`criteria.arrivalTime ${criteria.arrivalTime} != replayed arrival ${cursor}`); + if (Math.abs(criteria.walkingDistance - walkCost) > 1e-6) fail(`criteria.walkingDistance ${criteria.walkingDistance} != replayed ${walkCost}`); + if (criteria.transferCount !== rides) fail(`criteria.transferCount ${criteria.transferCount} != boarding count ${rides}`); +} diff --git a/test/ingestion.test.ts b/test/ingestion.test.ts new file mode 100644 index 0000000..6238b6a --- /dev/null +++ b/test/ingestion.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { prd, predictionChunk, pattern } from './helpers/mockBusApi'; + +// The walking module loads the full street graph at import time and the API +// modules talk to the real feeds; both are replaced before graphBuilder loads. +vi.mock('../src/walking/walkingMap', () => ({ + buildStopNodeMap: vi.fn(), + ensureCacheForStops: vi.fn().mockResolvedValue(undefined), + getCachedWalk: vi.fn().mockReturnValue(undefined), + getWalkingDistancesFrom: vi.fn().mockReturnValue([]), + getWalkingResponse: vi.fn().mockResolvedValue({ duration: 0, distance: 0, path_coords: [] }), +})); +vi.mock('../src/services/mbus', () => ({ + fetchVehicles: vi.fn().mockResolvedValue([]), + fetchRoutes: vi.fn().mockResolvedValue([]), + fetchPatterns: vi.fn().mockResolvedValue([]), + fetchPredictions: vi.fn().mockResolvedValue([]), +})); +vi.mock('../src/services/ride', () => ({ + fetchVehicles: vi.fn().mockResolvedValue([]), + fetchRoutes: vi.fn().mockResolvedValue([]), + fetchPatterns: vi.fn().mockResolvedValue([]), + fetchPredictions: vi.fn().mockResolvedValue([]), +})); + +import * as state from '../src/state/transitState'; +import * as mbus from '../src/services/mbus'; +import * as rideBus from '../src/services/ride'; +import { hasBusTimeSystemError } from '../src/services/bustimeClient'; +import { + processPredictions, processRidePredictions, convertToTrips, rebuildGraph, + findNearestStops, updateBusPositions, initializeRoutes, currentGraphTimeSeconds, +} from '../src/services/graphBuilder'; + +const TT_STOPS = [ + { stpid: 'T1', stpnm: 'Central Campus Transit Center', lat: 42.2783, lon: -83.7354 }, + { stpid: 'T2', stpnm: 'Rackham Bldg', lat: 42.2801, lon: -83.7382 }, + { stpid: 'T3', stpnm: 'Pierpont Commons', lat: 42.2910, lon: -83.7176 }, +]; + +function clearRecord(record: Record) { + for (const key of Object.keys(record)) delete record[key]; +} + +beforeEach(() => { + clearRecord(state.cachedRoutes); + clearRecord(state.cachedRideRoutes); + clearRecord(state.routeTimingCache); + clearRecord(state.stopIdToName); + clearRecord(state.tatripidToRt); + clearRecord(state.cachedPredsByVid); + clearRecord(state.cachedPredsByStopId); + state.setCachedGraph({ trips: [], transfers: {}, interchange: {} }); + state.setCachedGraphTimeBase(0); + state.setCachedStopLocations({}); + state.validRoutes.clear(); + state.validRideRoutes.clear(); + state.curBusPositions.buses = []; + state.curRidePositions.buses = []; + + state.cachedRoutes['TT'] = [pattern('NORTHBOUND', TT_STOPS)]; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +function ttPrd(stpid: string, prdctdn: string, overrides: Record = {}) { + const stop = TT_STOPS.find(s => s.stpid === stpid)!; + return prd({ + stpid, stpnm: stop.stpnm, rt: 'TT', rtdir: 'NORTHBOUND', prdctdn, + vid: '4001', tatripid: '888001', des: 'Northbound', ...overrides, + }); +} + +describe('processPredictions with realistic BusTime payloads', () => { + it('groups predictions into trips, converts DUE to 1 minute, and matches missing tatripid by vid', () => { + const chunks = [predictionChunk([ + ttPrd('T1', 'DUE'), + ttPrd('T2', '4'), + // Real feed sometimes omits tatripid; the entry must fold into the same vehicle. + ttPrd('T3', '9', { tatripid: undefined }), + ])]; + + const trips = processPredictions(chunks); + expect(trips).toHaveLength(1); + expect(trips[0].tatripid).toBe('888001'); + expect(trips[0].vid).toBe('4001'); + expect(trips[0].stops.map((s: any) => [s.stpid, s.prdctdn])).toEqual([ + ['T1', '1'], // DUE -> 1 + ['T2', '4'], + ['T3', '9'], + ]); + }); + + it('sorts stops by countdown, breaking ties by position along the route pattern', () => { + // T1 and T2 both at 2 minutes; the pattern order (T1 before T2) decides. + const chunks = [predictionChunk([ + ttPrd('T3', '7'), + ttPrd('T2', '2'), + ttPrd('T1', '2'), + ])]; + + const trips = processPredictions(chunks); + expect(trips[0].stops.map((s: any) => s.stpid)).toEqual(['T1', 'T2', 'T3']); + }); + + it('learns stop-to-stop timing diffs into the route timing cache', () => { + processPredictions([predictionChunk([ + ttPrd('T1', '1'), + ttPrd('T2', '4'), + ttPrd('T3', '9'), + ])]); + + expect(state.routeTimingCache['TT']['T1NORTHBOUND']).toEqual({ + T2: { diff: 3, rtdir: 'NORTHBOUND', rtNext: 'TT' }, + }); + expect(state.routeTimingCache['TT']['T2NORTHBOUND']).toEqual({ + T3: { diff: 5, rtdir: 'NORTHBOUND', rtNext: 'TT' }, + }); + }); + + it('extrapolates future stops from the timing cache, capped at 20 added stops', () => { + state.setCachedStopLocations(Object.fromEntries( + TT_STOPS.map(s => [s.stpid, { name: s.stpnm, lat: s.lat, lon: s.lon }]) + )); + // Close the loop so extrapolation can continue T3 -> T1 like a circulating bus. + state.routeTimingCache['TT'] = { + T3NORTHBOUND: { T1: { diff: 4, rtdir: 'NORTHBOUND', rtNext: 'TT' } }, + }; + + const trips = processPredictions([predictionChunk([ + ttPrd('T1', '1'), + ttPrd('T2', '4'), + ttPrd('T3', '9'), + ])]); + + const stops = trips[0].stops; + expect(stops).toHaveLength(3 + 20); // hard cap of 20 extrapolated stops + expect(stops.slice(3).every((s: any) => s.isExtrapolated)).toBe(true); + // First extrapolated hop: T3 at 9 min + learned diff 4 -> T1 at 13 min, with the cached name. + expect(stops[3]).toMatchObject({ stpid: 'T1', prdctdn: '13', stpnm: 'Central Campus Transit Center' }); + // The cycle continues with the diffs learned from this very payload (T1->T2: 3, T2->T3: 5). + expect(stops[4]).toMatchObject({ stpid: 'T2', prdctdn: '16' }); + expect(stops[5]).toMatchObject({ stpid: 'T3', prdctdn: '21' }); + }); + + it('keeps each pass of a looping bus as a separate stop event', () => { + // A looping bus reports T1 twice (in 1 min and again in 11 min after + // the loop); both passes must survive, in countdown order. + const trips = processPredictions([predictionChunk([ + ttPrd('T1', '1'), + ttPrd('T2', '5'), + ttPrd('T1', '11'), + ])]); + + expect(trips[0].stops.map((s: any) => [s.stpid, s.prdctdn, !!s.isExtrapolated])).toEqual([ + ['T1', '1', false], + ['T2', '5', false], + ['T1', '11', false], + // Extrapolation continues the loop using the T1->T2 timing (diff 4) + // learned from this very payload. + ['T2', '15', true], + ]); + }); + + it('still merges duplicate reports of the same pass', () => { + // Same stop, same pass ("DUE" normalizes to "1"): one stop event. + const trips = processPredictions([predictionChunk([ + ttPrd('T1', 'DUE'), + ttPrd('T1', '1'), + ttPrd('T2', '5'), + ])]); + + expect(trips[0].stops.map((s: any) => [s.stpid, s.prdctdn])).toEqual([ + ['T1', '1'], + ['T2', '5'], + ]); + }); + + it('learns the loop-closure timing from a looping bus (last pattern stop back to first)', () => { + processPredictions([predictionChunk([ + ttPrd('T1', '2'), + ttPrd('T2', '5'), + ttPrd('T3', '9'), + ttPrd('T1', '15'), + ])]); + + expect(state.routeTimingCache['TT']['T3NORTHBOUND']).toEqual({ + T1: { diff: 6, rtdir: 'NORTHBOUND', rtNext: 'TT' }, + }); + }); + + it('does not merge tatripid-less predictions from different vehicles', () => { + // Matching undefined === undefined used to fold every tatripid-less + // prediction into the first such vehicle's trip. + const trips = processPredictions([predictionChunk([ + ttPrd('T1', '2', { tatripid: undefined, vid: '4001' }), + ttPrd('T2', '5', { tatripid: undefined, vid: '4002' }), + ])]); + + expect(trips).toHaveLength(2); + expect(trips.map((t: any) => [t.vid, t.stops.length])).toEqual([ + ['4001', 1], + ['4002', 1], + ]); + }); + + it('sorts delayed ("DLY") stops last and never learns timing from them', () => { + const trips = processPredictions([predictionChunk([ + ttPrd('T2', 'DLY'), + ttPrd('T1', '2'), + ttPrd('T3', '8'), + ])]); + + expect(trips[0].stops.map((s: any) => s.stpid)).toEqual(['T1', 'T3', 'T2']); + // T1->T3 is not consecutive in the pattern and T3->T2 has no countdown, + // so nothing valid can be learned. + expect(state.routeTimingCache['TT']).toBeUndefined(); + }); +}); + +describe('convertToTrips', () => { + it('converts countdowns to seconds-since-UTC-midnight stop times and appends virtual trips', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-26T12:00:00Z')); // 43200s since midnight UTC + + const trips = convertToTrips([{ + tatripid: '888001', vid: '4001', + stops: [ + { stpid: 'T2', prdctdn: '5', rt: 'TT' }, + { stpid: 'T1', prdctdn: '2', rt: 'TT' }, + { stpid: 'T3', prdctdn: '9', rt: 'TT' }, + ], + }]); + + expect(trips).toHaveLength(3); // 1 real + 2 virtual + const real = trips[0]; + expect(real.tripId).toBe('888001'); + // Sorted by arrival time regardless of input order. + expect(real.stopTimes.map(st => [st.stop, st.arrivalTime])).toEqual([ + ['T1', 43200 + 120], + ['T2', 43200 + 300], + ['T3', 43200 + 540], + ]); + expect(real.stopTimes.every(st => st.pickUp && st.dropOff)).toBe(true); + expect(real.stopTimes.every(st => st.arrivalTime === st.departureTime)).toBe(true); + + expect(trips[1].tripId).toBe('VIRTUAL_ORIGIN_TRIP'); + expect(trips[2].tripId).toBe('VIRTUAL_DESTINATION_TRIP'); + }); + + it('produces a loop trip whose stop appears twice in the routing graph', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-26T12:00:00Z')); + + const preds = processPredictions([predictionChunk([ + ttPrd('T1', '1'), + ttPrd('T2', '5'), + ttPrd('T1', '11'), + ])]); + const trips = convertToTrips(preds); + + expect(trips[0].stopTimes.map(st => [st.stop, st.arrivalTime])).toEqual([ + ['T1', 43200 + 60], + ['T2', 43200 + 300], + ['T1', 43200 + 660], + ['T2', 43200 + 900], // extrapolated continuation of the loop + ]); + }); + + it('excludes delayed stops from routing trips and drops fully delayed vehicles', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-26T12:00:00Z')); + + const preds = processPredictions([predictionChunk([ + ttPrd('T1', '2'), + ttPrd('T2', 'DLY'), + ttPrd('T3', '8'), + ttPrd('T1', 'DLY', { vid: '4002', tatripid: '888002' }), + ])]); + const trips = convertToTrips(preds); + + // The delayed stop is dropped from the schedule; the delayed stop still + // surfaces in the prediction lists shown to users. + expect(trips[0].stopTimes.map(st => st.stop)).toEqual(['T1', 'T3']); + expect(trips[0].stopTimes.every(st => Number.isFinite(st.arrivalTime))).toBe(true); + expect(preds[0].stops.some((s: any) => s.prdctdn === 'DLY')).toBe(true); + + // The all-delayed vehicle contributes no trip at all (only virtuals follow). + expect(trips.map(t => t.tripId)).toEqual(['888001', 'VIRTUAL_ORIGIN_TRIP', 'VIRTUAL_DESTINATION_TRIP']); + }); +}); + +describe('delayed ("DLY") predictions surface to the frontend', () => { + it('keeps DLY entries in the prediction caches, sorted last, but out of the routing graph', async () => { + vi.mocked(mbus.fetchPredictions).mockResolvedValue([predictionChunk([ + ttPrd('T1', '3'), + ttPrd('T2', 'DLY'), + ])]); + await rebuildGraph(); + + // Surfaced to the prediction endpoints with a finite far-future prdtm. + expect(state.cachedPredsByStopId['T2']).toHaveLength(1); + expect(state.cachedPredsByStopId['T2'][0].prdctdn).toBe('DLY'); + expect(Number.isFinite(state.cachedPredsByStopId['T2'][0].prdtm)).toBe(true); + // Sorted after real countdowns in the per-vehicle list. + expect(state.cachedPredsByVid['4001'].map((p: any) => p.prdctdn)).toEqual(['3', 'DLY']); + // Routing still cannot use a stop with no usable time. + expect(state.cachedGraph.trips[0].stopTimes.map(st => st.stop)).toEqual(['T1']); + }); +}); + +describe('graph time frame across UTC midnight', () => { + it('keeps request time in the graph frame after the clock wraps', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-26T23:59:30Z')); + + const trips = convertToTrips([{ + tatripid: '888001', vid: '4001', + stops: [{ stpid: 'T1', prdctdn: '5', rt: 'TT' }], + }]); + // 23:59:30 = 86370s into the day; bus due 5 min later. + expect(trips[0].stopTimes[0].arrivalTime).toBe(86370 + 300); + + // 60s later the wall clock has crossed UTC midnight, but the graph has + // not been rebuilt: request time must stay in the graph's frame + // instead of wrapping to ~30 while trip times sit near 86400. + vi.setSystemTime(new Date('2026-08-27T00:00:30Z')); + expect(currentGraphTimeSeconds()).toBe(86430); + }); +}); + +describe('resilience to transient feed failures', () => { + it('keeps the previous graph and predictions when a prediction chunk fails', async () => { + vi.mocked(mbus.fetchPredictions).mockResolvedValue([predictionChunk([ttPrd('T1', '2')])]); + await rebuildGraph(); + const goodTrips = state.cachedGraph.trips; + expect(state.cachedPredsByStopId['T1']).toBeDefined(); + + // One chunk errored: everything from the previous cycle must survive. + vi.mocked(mbus.fetchPredictions).mockResolvedValue([null]); + await rebuildGraph(); + expect(state.cachedGraph.trips).toBe(goodTrips); + expect(state.cachedPredsByStopId['T1']).toBeDefined(); + }); + + it('keeps previous bus positions when the vehicles fetch fails, but accepts a real empty result', async () => { + state.curBusPositions.buses = [{ vid: '4001' }]; + + vi.mocked(mbus.fetchVehicles).mockResolvedValue(null); + vi.mocked(rideBus.fetchVehicles).mockResolvedValue(null); + await updateBusPositions(); + expect(state.curBusPositions.buses).toEqual([{ vid: '4001' }]); + + vi.mocked(mbus.fetchVehicles).mockResolvedValue([]); + vi.mocked(rideBus.fetchVehicles).mockResolvedValue([]); + await updateBusPositions(); + expect(state.curBusPositions.buses).toEqual([]); + }); + + it('keeps existing valid routes and patterns when the routes fetch fails', async () => { + state.validRoutes.add('TT'); + + vi.mocked(mbus.fetchRoutes).mockResolvedValue([]); + vi.mocked(rideBus.fetchRoutes).mockResolvedValue([]); + await initializeRoutes(); + + expect(state.validRoutes.has('TT')).toBe(true); + expect(state.cachedRoutes['TT']).toHaveLength(1); + }); + + it('keeps previously cached patterns when a pattern fetch fails', async () => { + vi.mocked(mbus.fetchRoutes).mockResolvedValue([{ rt: 'TT' }]); + vi.mocked(rideBus.fetchRoutes).mockResolvedValue([]); + vi.mocked(mbus.fetchPatterns).mockResolvedValue([]); // what a failed fetch returns + + await initializeRoutes(); + + expect(state.validRoutes.has('TT')).toBe(true); + expect(state.cachedRoutes['TT']).toHaveLength(1); // pre-existing pattern kept + }); +}); + +describe('processRidePredictions', () => { + function ridePrd(stpid: string, prdctdn: string, overrides: Record = {}) { + return prd({ stpid, stpnm: stpid, rt: '4', rtdir: 'EASTBOUND', prdctdn, vid: '2201', tatripid: '77001', ...overrides }); + } + + it('keeps ride "DLY" prdtm finite so sorted prediction lists stay valid', () => { + const trips = processRidePredictions([predictionChunk([ + ridePrd('R1', '4'), + ridePrd('R2', 'DLY'), + ])]); + + const stops = trips[0].stops; + expect(stops.every((s: any) => Number.isFinite(s.prdtm))).toBe(true); + const delayed = stops.find((s: any) => s.prdctdn === 'DLY'); + const normal = stops.find((s: any) => s.prdctdn === '4'); + expect(delayed.prdtm).toBeGreaterThan(normal.prdtm); // sorts after real predictions + }); + + it('keeps loop passes separate and does not merge tatripid-less vehicles (ride)', () => { + const trips = processRidePredictions([predictionChunk([ + ridePrd('R1', '1'), + ridePrd('R1', '11'), + ridePrd('R2', '3', { tatripid: undefined, vid: '2202' }), + ridePrd('R3', '6', { tatripid: undefined, vid: '2203' }), + ])]); + + expect(trips).toHaveLength(3); + expect(trips[0].stops.map((s: any) => [s.stpid, s.prdctdn])).toEqual([['R1', '1'], ['R1', '11']]); + expect(trips.slice(1).map((t: any) => t.vid)).toEqual(['2202', '2203']); + }); +}); + +describe('hasBusTimeSystemError', () => { + it('flags system errors and out-of-protocol bodies, but not per-stop errors', () => { + // Benign: per-stop/per-route "no data" entries are part of a healthy response. + expect(hasBusTimeSystemError({ 'bustime-response': { error: [{ stpid: 'C250', msg: 'No arrival times' }] } })).toBe(false); + expect(hasBusTimeSystemError({ 'bustime-response': { error: [{ rt: 'BB', msg: 'No data found for parameter' }] } })).toBe(false); + expect(hasBusTimeSystemError({ 'bustime-response': { prd: [] } })).toBe(false); + + // System errors: whole request failed despite the HTTP 200. + expect(hasBusTimeSystemError({ 'bustime-response': { error: [{ msg: 'Transaction limit for current day has been exceeded.' }] } })).toBe(true); + + // Out-of-protocol 200 bodies (proxy maintenance page, junk) must be + // failures too, or they would wipe live caches as "no buses". + expect(hasBusTimeSystemError('maintenance')).toBe(true); + expect(hasBusTimeSystemError({})).toBe(true); + expect(hasBusTimeSystemError({ 'bustime-response': { error: { msg: 'non-array error' } } })).toBe(true); + expect(hasBusTimeSystemError(undefined)).toBe(true); + }); +}); + +describe('findNearestStops', () => { + it('returns the k nearest stops sorted by distance regardless of iteration order', () => { + // Listed farthest-first to exercise the heap eviction path. + state.setCachedStopLocations({ + FAR: { name: 'Far', lat: 42.28, lon: -83.70 }, + MID: { name: 'Mid', lat: 42.28, lon: -83.72 }, + NEAR: { name: 'Near', lat: 42.28, lon: -83.73 }, + NEAREST: { name: 'Nearest', lat: 42.28, lon: -83.735 }, + }); + + const result = findNearestStops(42.28, -83.7354, 2); + expect(result.map(r => r.stpid)).toEqual(['NEAREST', 'NEAR']); + expect(result[0].distance).toBeLessThan(result[1].distance); + }); + + it('returns all stops when k exceeds the stop count', () => { + state.setCachedStopLocations({ + A: { name: 'A', lat: 42.28, lon: -83.73 }, + B: { name: 'B', lat: 42.28, lon: -83.72 }, + }); + + expect(findNearestStops(42.28, -83.7354, 5).map(r => r.stpid)).toEqual(['A', 'B']); + }); + + it('rejects invalid coordinates', () => { + expect(() => findNearestStops(NaN, -83.7, 2)).toThrow(); + }); +}); + +describe('rebuildGraph end-to-end with a mocked feed', () => { + it('builds the routing graph and lookup maps from raw prediction chunks', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-26T12:00:00Z')); + + vi.mocked(mbus.fetchPredictions).mockResolvedValue([predictionChunk([ + ttPrd('T1', '2'), + ttPrd('T2', '6'), + ])]); + + await rebuildGraph(); + + // Predictions were requested for exactly the stops in the cached patterns. + const [stopIds] = vi.mocked(mbus.fetchPredictions).mock.calls[0]; + expect([...stopIds].sort()).toEqual(['T1', 'T2', 'T3']); + + const graphTrips = state.cachedGraph.trips; + expect(graphTrips.map(t => t.tripId)).toEqual(['888001', 'VIRTUAL_ORIGIN_TRIP', 'VIRTUAL_DESTINATION_TRIP']); + expect(graphTrips[0].stopTimes.map(st => [st.stop, st.arrivalTime])).toEqual([ + ['T1', 43200 + 120], + ['T2', 43200 + 360], + ]); + + // Lookup maps used by journey formatting and the prediction endpoints. + expect(state.stopIdToName['T1']).toBe('Central Campus Transit Center'); + expect(state.stopIdToName['T3']).toBe('Pierpont Commons'); + expect(state.tatripidToRt['888001']).toBe('TT'); + expect(state.cachedPredsByVid['4001']).toHaveLength(2); + expect(state.cachedPredsByStopId['T1'][0]).toMatchObject({ vid: '4001', rt: 'TT' }); + }); +}); diff --git a/test/journey-plan.test.ts b/test/journey-plan.test.ts new file mode 100644 index 0000000..63c8fe9 --- /dev/null +++ b/test/journey-plan.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { makeTrip } from './helpers/network'; + +// Replace the walking layer: the real module loads the Ann Arbor street graph +// at import time and its durations would make expected values opaque. +vi.mock('../src/walking/walkingMap', () => ({ + buildStopNodeMap: vi.fn(), + ensureCacheForStops: vi.fn().mockResolvedValue(undefined), + getCachedWalk: vi.fn().mockReturnValue(undefined), + getWalkingDistancesFrom: vi.fn().mockReturnValue([]), + getWalkingResponse: vi.fn().mockResolvedValue({ duration: 77, distance: 100, path_coords: [{ lat: 42.28, lon: -83.73 }] }), +})); + +import * as walking from '../src/walking/walkingMap'; +import * as state from '../src/state/transitState'; +import { planJourney } from '../src/services/journey'; + +const ORIGIN = { lat: 42.2645, lon: -83.7443 }; +const DEST = { lat: 42.2910, lon: -83.7176 }; +const TIME = 36000; // 10:00 + +// planJourney's return type includes the nulls it filters out; the tests +// assert on the actual shape. +async function plan(time: number, options: { walkingPenalty?: number, range?: number }): Promise { + return await planJourney(ORIGIN.lat, ORIGIN.lon, DEST.lat, DEST.lon, time, options) as any[]; +} + +function setWalks( + fromOrigin: { stopId: string, duration: number }[], + toDest: { stopId: string, duration: number }[] +) { + vi.mocked(walking.getWalkingDistancesFrom).mockImplementation( + (_lat, _lon, destLat) => destLat === undefined ? toDest : fromOrigin + ); +} + +function setGraph(trips: ReturnType[]) { + state.setCachedGraph({ + trips: [ + ...trips, + makeTrip('VIRTUAL_ORIGIN_TRIP', null, [{ stop: 'VIRTUAL_ORIGIN', arr: 0 }]), + makeTrip('VIRTUAL_DESTINATION_TRIP', null, [{ stop: 'VIRTUAL_DESTINATION', arr: 0 }]), + ], + transfers: {}, + interchange: { C250: 30, N551: 30 }, + }); +} + +beforeEach(() => { + setGraph([]); + state.setCachedStopLocations({ + C250: { name: 'Central Campus Transit Center', lat: 42.2783, lon: -83.7354 }, + N551: { name: 'Pierpont Commons', lat: 42.2910, lon: -83.7176 }, + }); + state.stopIdToName['C250'] = 'Central Campus Transit Center'; + state.stopIdToName['N551'] = 'Pierpont Commons'; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('planJourney end-to-end (mocked walking layer)', () => { + it('plans walk -> bus -> walk and a direct-walk alternative, formatted for the frontend', async () => { + setGraph([makeTrip('trip_bb', '4001', [ + { stop: 'C250', arr: TIME + 300, rt: 'BB' }, + { stop: 'N551', arr: TIME + 600, rt: 'BB' }, + ])]); + setWalks( + [{ stopId: 'C250', duration: 120 }, { stopId: 'DIRECT_WALK', duration: 3600 }], + [{ stopId: 'N551', duration: 60 }] + ); + + const journeys = await plan(TIME, {}); + expect(journeys).toHaveLength(2); + + const [bus, walkOnly] = journeys; + expect(bus.criteria).toEqual({ arrivalTime: TIME + 660, walkingDistance: 180, transferCount: 1 }); + expect(bus.legs.map((l: any) => l.mode)).toEqual(['walk', 'bus', 'walk']); + expect(bus.legs[0]).toMatchObject({ + origin: 'Start', + origin_id: 'VIRTUAL_ORIGIN', + destination: 'Central Campus Transit Center', + destination_id: 'C250', + startTime: TIME, + endTime: TIME + 120, + }); + expect(bus.legs[1]).toMatchObject({ + tripId: 'trip_bb', + vid: '4001', + rt: 'BB', + startTime: TIME + 300, + endTime: TIME + 600, + }); + expect(bus.legs[2]).toMatchObject({ destination: 'End', destination_id: 'VIRTUAL_DESTINATION' }); + + expect(walkOnly.legs).toHaveLength(1); + expect(walkOnly.legs[0].mode).toBe('walk'); + expect(walkOnly.criteria).toEqual({ arrivalTime: TIME + 3600, walkingDistance: 3600, transferCount: 0 }); + }); + + it('reports the true departure time even when there is wait time before boarding', async () => { + setGraph([makeTrip('trip_bb', '4001', [ + { stop: 'C250', arr: TIME + 300, rt: 'BB' }, + { stop: 'N551', arr: TIME + 600, rt: 'BB' }, + ])]); + setWalks([{ stopId: 'C250', duration: 120 }], [{ stopId: 'N551', duration: 60 }]); + + const journeys = await plan(TIME, {}); + // The walk ends at TIME+120 but the bus leaves at TIME+300: the journey + // still starts at TIME. The old arrival-minus-durations math reported a + // departure inside the waiting gap. + expect(journeys[0].departureTime).toBe(TIME); + expect(journeys[0].arrivalTime).toBe(TIME + 660); + }); + + it('returns only the direct walk when no bus is available', async () => { + setWalks([{ stopId: 'DIRECT_WALK', duration: 3600 }], []); + + const journeys = await plan(TIME, {}); + expect(journeys).toHaveLength(1); + expect(journeys[0].legs).toHaveLength(1); + expect(journeys[0].legs[0]).toMatchObject({ mode: 'walk', origin: 'Start', destination: 'End' }); + expect(journeys[0].departureTime).toBe(TIME); + }); + + it('applies the walkingPenalty option to the walking criterion', async () => { + setGraph([makeTrip('trip_bb', '4001', [ + { stop: 'C250', arr: TIME + 300, rt: 'BB' }, + { stop: 'N551', arr: TIME + 600, rt: 'BB' }, + ])]); + setWalks([{ stopId: 'C250', duration: 120 }], [{ stopId: 'N551', duration: 60 }]); + + const journeys = await plan(TIME, { walkingPenalty: 8 }); + expect(journeys[0].criteria.walkingDistance).toBe((120 + 60) * 8); + expect(journeys[0].criteria.arrivalTime).toBe(TIME + 660); // penalty never slows the clock + }); + + it('returns one journey per catchable departure when a range is given', async () => { + setGraph([ + makeTrip('run0', '4001', [ + { stop: 'C250', arr: TIME + 300, rt: 'BB' }, + { stop: 'N551', arr: TIME + 600, rt: 'BB' }, + ]), + makeTrip('run1', '4002', [ + { stop: 'C250', arr: TIME + 1500, rt: 'BB' }, + { stop: 'N551', arr: TIME + 1800, rt: 'BB' }, + ]), + ]); + setWalks( + [{ stopId: 'C250', duration: 120 }, { stopId: 'DIRECT_WALK', duration: 3600 }], + [{ stopId: 'N551', duration: 60 }] + ); + + const journeys = await plan(TIME, { range: 3600 }); + expect(journeys.map((j: any) => j.arrivalTime)).toEqual([TIME + 660, TIME + 1860, TIME + 3600]); + const tripIds = journeys.flatMap((j: any) => j.legs.filter((l: any) => l.mode === 'bus').map((l: any) => l.tripId)); + expect(tripIds).toEqual(['run0', 'run1']); + }); +}); diff --git a/test/path.test.ts b/test/path.test.ts index 7812b40..091fee1 100644 --- a/test/path.test.ts +++ b/test/path.test.ts @@ -9,8 +9,7 @@ describe('API Endpoints', () => { try { await axios.get(`${BASE_URL}/getAllPredictions`); } catch (error) { - console.error('Server is not running! Please start the server with: npm start'); - process.exit(1); + throw new Error('Server is not running on :3000 - start it with: npm start'); } }, 20000); diff --git a/test/raptor-core.test.ts b/test/raptor-core.test.ts new file mode 100644 index 0000000..3b1cb69 --- /dev/null +++ b/test/raptor-core.test.ts @@ -0,0 +1,589 @@ +import { describe, it, expect } from 'vitest'; +import { McRaptorAlgorithm, Journey } from '../src/raptor/McRaptorAlgorithm'; +import { makeTrip, walkTransfer, transferMap, uniformInterchange, buildScheduledTrips } from './helpers/network'; +import { Scenario, bruteForceParetoCriteria, sortCriteria, validateJourney } from './helpers/oracle'; + +/** + * Runs the algorithm on a scenario, validates every returned journey against + * the raw data, and asserts the returned criteria set equals the exact Pareto + * frontier computed by the brute-force oracle. + */ +function runScenario(scenario: Scenario): Journey[] { + const algo = new McRaptorAlgorithm(scenario.trips, scenario.transfers, scenario.interchange); + if (scenario.walkingPenalty !== undefined) algo.setWalkingPenalty(scenario.walkingPenalty); + + const journeys = algo.getOptimizedJourneys(scenario.origin, scenario.destination, scenario.departureTime); + for (const j of journeys) validateJourney(j, scenario); + + const got = sortCriteria(journeys.map(j => j.criteria)); + const want = bruteForceParetoCriteria(scenario); + expect(got).toEqual(want); + return journeys; +} + +describe('McRaptor core: single rides and boarding rules', () => { + it('finds a direct single-bus journey', () => { + const scenario: Scenario = { + trips: [makeTrip('BB_NORTH_0', '4001', [ + { stop: 'C250', arr: 1000 }, + { stop: 'M310', arr: 1200 }, + { stop: 'N551', arr: 1400 }, + ])], + transfers: {}, + interchange: uniformInterchange(['C250', 'M310', 'N551']), + origin: 'C250', destination: 'N551', departureTime: 900, + }; + const journeys = runScenario(scenario); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1400, walkingDistance: 0, transferCount: 1 }); + expect(journeys[0].legs).toHaveLength(1); + expect(journeys[0].legs[0]).toMatchObject({ type: 'Trip', origin: 'C250', destination: 'N551', startTime: 1000, endTime: 1400 }); + }); + + it('does not board a bus that departs before the request time plus buffer', () => { + const trips = [ + makeTrip('run0', '4001', [{ stop: 'C250', arr: 1000 }, { stop: 'N551', arr: 1400 }]), + makeTrip('run1', '4002', [{ stop: 'C250', arr: 1600 }, { stop: 'N551', arr: 2000 }]), + ]; + const scenario: Scenario = { + trips, transfers: {}, + interchange: uniformInterchange(['C250', 'N551']), + origin: 'C250', destination: 'N551', departureTime: 980, // 980 + 30 > 1000: first run missed + }; + const journeys = runScenario(scenario); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria.arrivalTime).toBe(2000); + }); + + it('treats the interchange buffer boundary as inclusive', () => { + const trips = [ + makeTrip('run0', '4001', [{ stop: 'C250', arr: 1000 }, { stop: 'N551', arr: 1400 }]), + makeTrip('run1', '4002', [{ stop: 'C250', arr: 1600 }, { stop: 'N551', arr: 2000 }]), + ]; + const interchange = uniformInterchange(['C250', 'N551']); + + // 970 + 30 = 1000 exactly: catchable. + const caught = runScenario({ trips, transfers: {}, interchange, origin: 'C250', destination: 'N551', departureTime: 970 }); + expect(caught[0].criteria.arrivalTime).toBe(1400); + + // 971 + 30 = 1001 > 1000: missed. + const missed = runScenario({ trips, transfers: {}, interchange, origin: 'C250', destination: 'N551', departureTime: 971 }); + expect(missed[0].criteria.arrivalTime).toBe(2000); + }); + + it('skips a trip whose boarding stop has pickUp=false and boards the next one', () => { + // Both trips share a stop sequence, so they end up in the same FIFO chain; + // the earliest catchable trip forbids boarding and must be passed over. + const trips = [ + makeTrip('noPickup', '4001', [{ stop: 'C250', arr: 1000, pickUp: false }, { stop: 'N551', arr: 1200 }]), + makeTrip('okPickup', '4002', [{ stop: 'C250', arr: 1100 }, { stop: 'N551', arr: 1300 }]), + ]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['C250', 'N551']), + origin: 'C250', destination: 'N551', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria.arrivalTime).toBe(1300); + expect(journeys[0].legs[0].trip?.tripId).toBe('okPickup'); + }); + + it('does not alight at a stop with dropOff=false but can ride past it', () => { + const trips = [makeTrip('t', '4001', [ + { stop: 'X', arr: 1000 }, + { stop: 'Y', arr: 1200, dropOff: false }, + { stop: 'Z', arr: 1400 }, + ])]; + const interchange = uniformInterchange(['X', 'Y', 'Z']); + + const toY = runScenario({ trips, transfers: {}, interchange, origin: 'X', destination: 'Y', departureTime: 900 }); + expect(toY).toHaveLength(0); + + const toZ = runScenario({ trips, transfers: {}, interchange, origin: 'X', destination: 'Z', departureTime: 900 }); + expect(toZ).toHaveLength(1); + expect(toZ[0].criteria.arrivalTime).toBe(1400); + }); + + it('handles trips running past midnight (times above 86400)', () => { + const trips = [makeTrip('late', '4001', [{ stop: 'C250', arr: 86300 }, { stop: 'N551', arr: 86800 }])]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['C250', 'N551']), + origin: 'C250', destination: 'N551', departureTime: 86200, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria.arrivalTime).toBe(86800); + }); + + it('returns a single trivial journey when origin equals destination', () => { + const trips = [makeTrip('t', '4001', [{ stop: 'C250', arr: 1000 }, { stop: 'N551', arr: 1400 }])]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['C250', 'N551']), + origin: 'C250', destination: 'C250', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].legs).toHaveLength(0); + expect(journeys[0].criteria).toEqual({ arrivalTime: 900, walkingDistance: 0, transferCount: 0 }); + }); + + it('returns no journeys when the destination is unreachable', () => { + const trips = [makeTrip('t', '4001', [{ stop: 'A', arr: 1000 }, { stop: 'B', arr: 1400 }])]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['A', 'B', 'ISLAND']), + origin: 'A', destination: 'ISLAND', departureTime: 900, + }); + expect(journeys).toHaveLength(0); + }); +}); + +describe('McRaptor core: transfers and walking', () => { + it('finds a two-bus journey transferring at a shared stop', () => { + const trips = [ + makeTrip('leg1', '4001', [{ stop: 'S1', arr: 1000 }, { stop: 'S2', arr: 1300 }]), + makeTrip('leg2', '4002', [{ stop: 'S2', arr: 1400 }, { stop: 'S3', arr: 1700 }]), + ]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['S1', 'S2', 'S3']), + origin: 'S1', destination: 'S3', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1700, walkingDistance: 0, transferCount: 2 }); + expect(journeys[0].legs.map(l => l.type)).toEqual(['Trip', 'Trip']); + }); + + it('finds a journey that walks between stops to transfer', () => { + const trips = [ + makeTrip('leg1', '4001', [{ stop: 'S1', arr: 1000 }, { stop: 'S2', arr: 1300 }]), + makeTrip('leg2', '4002', [{ stop: 'S3', arr: 1500 }, { stop: 'S4', arr: 1800 }]), + ]; + const journeys = runScenario({ + trips, + transfers: transferMap([walkTransfer('S2', 'S3', 120)]), + interchange: uniformInterchange(['S1', 'S2', 'S3', 'S4']), + origin: 'S1', destination: 'S4', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1800, walkingDistance: 120, transferCount: 2 }); + expect(journeys[0].legs.map(l => l.type)).toEqual(['Trip', 'Transfer', 'Trip']); + }); + + it('walks from the origin to reach a better stop (round-0 footpath)', () => { + const trips = [ + makeTrip('viaA', '4001', [{ stop: 'A', arr: 1100 }, { stop: 'D', arr: 1400 }]), + makeTrip('fromO', '4002', [{ stop: 'O', arr: 1300 }, { stop: 'D', arr: 1800 }]), + ]; + const journeys = runScenario({ + trips, + transfers: transferMap([walkTransfer('O', 'A', 100)]), + interchange: uniformInterchange(['O', 'A', 'D']), + origin: 'O', destination: 'D', departureTime: 900, + }); + // Walk to A then ride (faster, some walking) vs ride from O (slower, no walking). + expect(sortCriteria(journeys.map(j => j.criteria))).toEqual([ + { arrivalTime: 1400, walkingDistance: 100, transferCount: 1 }, + { arrivalTime: 1800, walkingDistance: 0, transferCount: 1 }, + ]); + }); + + it('walks after the final bus to reach the destination', () => { + const trips = [makeTrip('t', '4001', [{ stop: 'O', arr: 1000 }, { stop: 'A', arr: 1300 }])]; + const journeys = runScenario({ + trips, + transfers: transferMap([walkTransfer('A', 'D', 150)]), + interchange: uniformInterchange(['O', 'A', 'D']), + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1450, walkingDistance: 150, transferCount: 1 }); + }); + + it('finds a walk-only journey when no bus helps', () => { + const journeys = runScenario({ + trips: [], + transfers: transferMap([walkTransfer('O', 'D', 600)]), + interchange: uniformInterchange(['O', 'D']), + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1500, walkingDistance: 600, transferCount: 0 }); + expect(journeys[0].legs).toHaveLength(1); + expect(journeys[0].legs[0].type).toBe('Transfer'); + }); + + it('does not chain two walking legs (transfer table is assumed transitively closed)', () => { + // O->M and M->D exist but O->D does not. The graph builder always + // produces all-pairs transfers, so single-hop walking is the contract. + const journeys = runScenario({ + trips: [], + transfers: transferMap([walkTransfer('O', 'M', 100), walkTransfer('M', 'D', 100)]), + interchange: uniformInterchange(['O', 'M', 'D']), + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(journeys).toHaveLength(0); + }); +}); + +describe('McRaptor core: transfer time windows', () => { + it('ignores a transfer whose window has expired', () => { + const transfers = transferMap([walkTransfer('O', 'D', 300, 0, 900)]); + const journeys = runScenario({ + trips: [], transfers, + interchange: uniformInterchange(['O', 'D']), + origin: 'O', destination: 'D', departureTime: 1000, + }); + expect(journeys).toHaveLength(0); + }); + + it('treats the transfer window end as inclusive', () => { + const transfers = transferMap([walkTransfer('O', 'D', 300, 0, 1000)]); + const journeys = runScenario({ + trips: [], transfers, + interchange: uniformInterchange(['O', 'D']), + origin: 'O', destination: 'D', departureTime: 1000, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria.arrivalTime).toBe(1300); + }); + + it('ignores a transfer whose window has not opened yet (no waiting to walk)', () => { + const transfers = transferMap([walkTransfer('O', 'D', 300, 1200, Number.MAX_SAFE_INTEGER)]); + const journeys = runScenario({ + trips: [], transfers, + interchange: uniformInterchange(['O', 'D']), + origin: 'O', destination: 'D', departureTime: 1000, + }); + expect(journeys).toHaveLength(0); + }); + + it('applies the window to mid-journey transfers based on when the walk starts', () => { + const trips = [makeTrip('t', '4001', [{ stop: 'O', arr: 1100 }, { stop: 'A', arr: 1400 }])]; + const interchange = uniformInterchange(['O', 'A', 'D']); + + const closed = runScenario({ + trips, transfers: transferMap([walkTransfer('A', 'D', 100, 0, 1399)]), + interchange, origin: 'O', destination: 'D', departureTime: 900, + }); + expect(closed).toHaveLength(0); + + const open = runScenario({ + trips, transfers: transferMap([walkTransfer('A', 'D', 100, 0, 1400)]), + interchange, origin: 'O', destination: 'D', departureTime: 900, + }); + expect(open).toHaveLength(1); + expect(open[0].criteria).toEqual({ arrivalTime: 1500, walkingDistance: 100, transferCount: 1 }); + }); +}); + +describe('McRaptor core: Pareto optimality', () => { + it('returns all mutually non-dominated journeys and drops dominated ones', () => { + const trips = [ + makeTrip('direct', '4001', [{ stop: 'O', arr: 960 }, { stop: 'D', arr: 1400 }]), + makeTrip('viaA', '4002', [{ stop: 'A', arr: 1080 }, { stop: 'D', arr: 1300 }]), + makeTrip('slowDirect', '4003', [{ stop: 'O', arr: 1000 }, { stop: 'D', arr: 1450 }]), // dominated by 'direct' + ]; + const journeys = runScenario({ + trips, + transfers: transferMap([walkTransfer('O', 'A', 100), walkTransfer('O', 'D', 600)]), + interchange: uniformInterchange(['O', 'A', 'D']), + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(sortCriteria(journeys.map(j => j.criteria))).toEqual([ + { arrivalTime: 1300, walkingDistance: 100, transferCount: 1 }, // walk + fast bus + { arrivalTime: 1400, walkingDistance: 0, transferCount: 1 }, // direct bus + { arrivalTime: 1500, walkingDistance: 600, transferCount: 0 }, // pure walk + ]); + expect(journeys.some(j => j.legs.some(l => l.trip?.tripId === 'slowDirect'))).toBe(false); + }); + + it('drops a two-bus journey dominated by a direct bus, but keeps it when it is faster', () => { + const interchange = uniformInterchange(['O', 'M', 'D']); + const direct = makeTrip('direct', '4001', [{ stop: 'O', arr: 1000 }, { stop: 'D', arr: 1500 }]); + const first = makeTrip('first', '4002', [{ stop: 'O', arr: 950 }, { stop: 'M', arr: 1050 }]); + + // Second leg arrives later than the direct bus: dominated (more boardings, later). + const slowSecond = makeTrip('slowSecond', '4003', [{ stop: 'M', arr: 1100 }, { stop: 'D', arr: 1600 }]); + const dominated = runScenario({ + trips: [direct, first, slowSecond], transfers: {}, interchange, + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(dominated.map(j => j.criteria)).toEqual([ + { arrivalTime: 1500, walkingDistance: 0, transferCount: 1 }, + ]); + + // Second leg beats the direct bus: both survive (earlier vs fewer boardings). + const fastSecond = makeTrip('fastSecond', '4004', [{ stop: 'M', arr: 1100 }, { stop: 'D', arr: 1400 }]); + const both = runScenario({ + trips: [direct, first, fastSecond], transfers: {}, interchange, + origin: 'O', destination: 'D', departureTime: 900, + }); + expect(sortCriteria(both.map(j => j.criteria))).toEqual([ + { arrivalTime: 1400, walkingDistance: 0, transferCount: 2 }, + { arrivalTime: 1500, walkingDistance: 0, transferCount: 1 }, + ]); + }); + + it('applies the walking penalty to the walking criterion only', () => { + const trips = [makeTrip('bus', '4001', [{ stop: 'O', arr: 1000 }, { stop: 'D', arr: 1600 }])]; + const transfers = transferMap([walkTransfer('O', 'D', 600)]); + const interchange = uniformInterchange(['O', 'D']); + + // Penalty 8: the walk still arrives at 1500 but costs 4800. + const penalized = runScenario({ + trips, transfers, interchange, + origin: 'O', destination: 'D', departureTime: 900, walkingPenalty: 8, + }); + expect(sortCriteria(penalized.map(j => j.criteria))).toEqual([ + { arrivalTime: 1500, walkingDistance: 4800, transferCount: 0 }, + { arrivalTime: 1600, walkingDistance: 0, transferCount: 1 }, + ]); + + // Penalty 0: walking is free, so the earlier walk dominates the bus entirely. + const free = runScenario({ + trips, transfers, interchange, + origin: 'O', destination: 'D', departureTime: 900, walkingPenalty: 0, + }); + expect(free.map(j => j.criteria)).toEqual([ + { arrivalTime: 1500, walkingDistance: 0, transferCount: 0 }, + ]); + }); +}); + +describe('McRaptor core: route structure edge cases', () => { + it('boards the faster overtaking trip even when a slower one departs first', () => { + // Same stop sequence, but the express overtakes the local: before the + // FIFO-split fix the algorithm always boarded the local and reported 2000. + const trips = [ + makeTrip('local', '4001', [{ stop: 'X', arr: 1000 }, { stop: 'Y', arr: 2000 }]), + makeTrip('express', '4002', [{ stop: 'X', arr: 1100 }, { stop: 'Y', arr: 1500 }]), + ]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['X', 'Y']), + origin: 'X', destination: 'Y', departureTime: 900, + }); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1500, walkingDistance: 0, transferCount: 1 }); + expect(journeys[0].legs[0].trip?.tripId).toBe('express'); + }); + + it('does not lose a Pareto-optimal journey when two trips of a route tie at a stop', () => { + // run1 trails run0 but ties with it at S3 and falls behind afterwards. + // With non-strict FIFO chaining, run1's on-board label tie-dominated + // the walk+board-run0 option inside the shared route bag, losing the + // journey that arrives 120s earlier. Ties like this are routine in + // production because countdowns are quantized to whole minutes. + const trips = [ + makeTrip('run0', '4001', [ + { stop: 'S5', arr: 36300 }, { stop: 'S4', arr: 36360 }, + { stop: 'S6', arr: 36420 }, { stop: 'S3', arr: 36660 }, { stop: 'S0', arr: 36960 }, + ]), + makeTrip('run1', '4002', [ + { stop: 'S5', arr: 36420 }, { stop: 'S4', arr: 36480 }, + { stop: 'S6', arr: 36540 }, { stop: 'S3', arr: 36660 }, { stop: 'S0', arr: 37080 }, + ]), + ]; + const journeys = runScenario({ + trips, + transfers: transferMap([walkTransfer('S6', 'S3', 118)]), + interchange: uniformInterchange(['S5', 'S4', 'S6', 'S3', 'S0']), + origin: 'S6', destination: 'S0', departureTime: 36480, + }); + expect(sortCriteria(journeys.map(j => j.criteria))).toEqual([ + { arrivalTime: 36960, walkingDistance: 118, transferCount: 1 }, // walk to S3, catch run0 + { arrivalTime: 37080, walkingDistance: 0, transferCount: 1 }, // board run1 at S6 + ]); + }); + + it('handles loop routes where a trip serves the same stop twice', () => { + const trips = [makeTrip('loop', '4001', [ + { stop: 'A', arr: 1000, dep: 1000 }, + { stop: 'B', arr: 1200, dep: 1210 }, + { stop: 'A', arr: 1400, dep: 1410 }, + { stop: 'C', arr: 1600 }, + ])]; + const interchange = uniformInterchange(['A', 'B', 'C']); + + // Board at B, alight at the second visit to A. + const bToA = runScenario({ trips, transfers: {}, interchange, origin: 'B', destination: 'A', departureTime: 900 }); + expect(bToA).toHaveLength(1); + expect(bToA[0].criteria).toEqual({ arrivalTime: 1400, walkingDistance: 0, transferCount: 1 }); + + // Board at the first visit to A, ride through the loop to C. + const aToC = runScenario({ trips, transfers: {}, interchange, origin: 'A', destination: 'C', departureTime: 900 }); + expect(aToC).toHaveLength(1); + expect(aToC[0].criteria).toEqual({ arrivalTime: 1600, walkingDistance: 0, transferCount: 1 }); + }); + + it('rides two routes sharing a corridor of stops', () => { + const trips = [ + ...buildScheduledTrips({ + rt: 'R1', rtdir: 'EAST', stops: ['A', 'B', 'C'], + travelTimes: [200, 200], firstDeparture: 1000, headway: 600, runs: 1, + }), + ...buildScheduledTrips({ + rt: 'R2', rtdir: 'EAST', stops: ['B', 'C', 'D'], + travelTimes: [200, 200], firstDeparture: 1300, headway: 600, runs: 1, + }), + ]; + const journeys = runScenario({ + trips, transfers: {}, + interchange: uniformInterchange(['A', 'B', 'C', 'D']), + origin: 'A', destination: 'D', departureTime: 900, + }); + // Transferring at B or at C yields the identical criteria; exactly one survives. + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 1700, walkingDistance: 0, transferCount: 2 }); + }); + + it('finds journeys needing all 8 rounds but not more (documented round cap)', () => { + // A chain of 9 single-hop routes: S0 -> S1 -> ... -> S9, one boarding each. + const stops = Array.from({ length: 10 }, (_, i) => `S${i}`); + const trips = stops.slice(1).map((stop, i) => + makeTrip(`hop${i + 1}`, `40${i}`, [ + { stop: `S${i}`, arr: 1000 + i * 200 }, + { stop, arr: 1000 + i * 200 + 100 }, + ]) + ); + const base = { + trips, transfers: {}, interchange: uniformInterchange(stops), departureTime: 900, + }; + + const eightRides = runScenario({ ...base, origin: 'S0', destination: 'S8' }); + expect(eightRides).toHaveLength(1); + expect(eightRides[0].criteria.transferCount).toBe(8); + + // Nine boardings exceed the 8-round cap: the algorithm finds nothing, + // even though the journey physically exists (the oracle sees it at maxRides=9). + const algo = new McRaptorAlgorithm(trips, {}, uniformInterchange(stops)); + expect(algo.getOptimizedJourneys('S0', 'S9', 900)).toHaveLength(0); + expect(bruteForceParetoCriteria({ ...base, origin: 'S0', destination: 'S9', transfers: {} }, 9)).toHaveLength(1); + }); +}); + +describe('McRaptor range search (getOptimizedJourneysInRange)', () => { + // Interchange 0 keeps window boundaries exact in these tests. + const stops = ['X', 'Y']; + const interchange = uniformInterchange(stops, 0); + + function rangeJourneys(trips: ReturnType[], transfers = {}, start = 900, range = 1500) { + const algo = new McRaptorAlgorithm(trips, transfers, interchange); + return algo.getOptimizedJourneysInRange('X', 'Y', start, range); + } + + it('returns one journey per departure in the window plus one walking option', () => { + const trips = buildScheduledTrips({ + rt: 'RR', rtdir: 'EAST', stops: ['X', 'Y'], + travelTimes: [400], firstDeparture: 1000, headway: 600, runs: 3, // departs 1000, 1600, 2200 + }); + const journeys = rangeJourneys(trips, transferMap([walkTransfer('X', 'Y', 5000)])); + + const busArrivals = journeys.filter(j => j.legs.some(l => l.type === 'Trip')).map(j => j.criteria.arrivalTime).sort((a, b) => a - b); + expect(busArrivals).toEqual([1400, 2000, 2600]); + + // Every seed produces a walking journey; only the earliest survives. + const walks = journeys.filter(j => j.legs.every(l => l.type === 'Transfer')); + expect(walks).toHaveLength(1); + expect(walks[0].criteria).toEqual({ arrivalTime: 5900, walkingDistance: 5000, transferCount: 0 }); + }); + + it('includes a departure exactly at the end of the window and dedupes repeated trips', () => { + const trips = buildScheduledTrips({ + rt: 'RR', rtdir: 'EAST', stops: ['X', 'Y'], + travelTimes: [400], firstDeparture: 1200, headway: 600, runs: 4, // departs 1200, 1800, 2400, 3000 + }); + const journeys = rangeJourneys(trips); // window [900, 2400] + + // The 2400 departure is included (inclusive end); the 3000 one is not + // Pareto-relevant from any seed. Each trip appears exactly once. + const tripIds = journeys.map(j => j.legs[0].trip?.tripId).sort(); + expect(tripIds).toEqual(['RR_EAST_0', 'RR_EAST_1', 'RR_EAST_2']); + expect(journeys.map(j => j.criteria.arrivalTime).sort((a, b) => a - b)).toEqual([1600, 2200, 2800]); + }); + + it('keeps Pareto alternates that share the same trips (different alight/walk tradeoffs)', () => { + // One trip, two ways off it: earlier arrival with more walking vs + // later arrival with less. Both are non-dominated and must survive + // the per-signature dedup. + const trips = [makeTrip('R1', '4001', [ + { stop: 'O', arr: 1000 }, + { stop: 'B', arr: 1100 }, + { stop: 'A', arr: 1200 }, + ])]; + const algo = new McRaptorAlgorithm( + trips, + transferMap([walkTransfer('B', 'D', 150), walkTransfer('A', 'D', 60)]), + uniformInterchange(['O', 'B', 'A', 'D']) + ); + const journeys = algo.getOptimizedJourneysInRange('O', 'D', 900, 600); + expect(sortCriteria(journeys.map(j => j.criteria))).toEqual([ + { arrivalTime: 1250, walkingDistance: 150, transferCount: 1 }, + { arrivalTime: 1260, walkingDistance: 60, transferCount: 1 }, + ]); + }); + + it('seeds account for walking and buffer, producing the latest catchable departure', () => { + // Trip departs N at 2000; reaching N takes a 100s walk plus the 30s + // buffer, so the latest origin departure that catches it is 1870. + const trips = [makeTrip('T', '4001', [{ stop: 'N', arr: 2000 }, { stop: 'Z', arr: 2400 }])]; + const algo = new McRaptorAlgorithm( + trips, + transferMap([walkTransfer('O', 'N', 100)]), + uniformInterchange(['O', 'N', 'Z']) + ); + const journeys = algo.getOptimizedJourneysInRange('O', 'Z', 0, 3600); + expect(journeys).toHaveLength(1); + // The latest-departure variant survives dedup (seeds are run latest-first). + expect(journeys[0].legs[0].startTime).toBe(1870); + expect(journeys[0].criteria.arrivalTime).toBe(2400); + }); + + it('keeps a slower bus that is the only option in the window tail', () => { + // FAST's latest-catchable seed (1900) is inside the window; SLOW's + // (2500) falls past its end. Riders leaving in (1900, 2400] can only + // catch SLOW, so it must be returned even though FAST dominates every + // interior seed — the window end is always sampled. + const trips = [ + makeTrip('FAST', '4001', [{ stop: 'A', arr: 2000 }, { stop: 'D', arr: 2200 }]), + makeTrip('SLOW', '4002', [{ stop: 'A', arr: 2600 }, { stop: 'D', arr: 3200 }]), + ]; + const algo = new McRaptorAlgorithm( + trips, + transferMap([walkTransfer('O', 'A', 100)]), + uniformInterchange(['O', 'A', 'D'], 0) + ); + const journeys = algo.getOptimizedJourneysInRange('O', 'D', 900, 1500); + const tripsUsed = journeys.map(j => j.legs.find(l => l.type === 'Trip')?.trip?.tripId).sort(); + expect(tripsUsed).toEqual(['FAST', 'SLOW']); + }); + + it('does not misclassify journeys on tripId-less trips as walking journeys', () => { + // Real feed rows can lack tatripid, leaving Trip.tripId undefined. + const anonTrip = { ...makeTrip('x', '4001', [{ stop: 'X', arr: 1000 }, { stop: 'Y', arr: 1400 }]), tripId: undefined as any }; + const algo = new McRaptorAlgorithm( + [anonTrip], + transferMap([walkTransfer('X', 'Y', 5000)]), + uniformInterchange(['X', 'Y'], 0) + ); + const journeys = algo.getOptimizedJourneysInRange('X', 'Y', 900, 1500); + // Both the bus journey AND the walking option must survive; previously + // the bus journey's empty signature landed it in the walking bucket + // where only the earliest of the two was kept. + const busArrivals = journeys.filter(j => j.legs.some(l => l.type === 'Trip')).map(j => j.criteria.arrivalTime); + const walkArrivals = journeys.filter(j => j.legs.every(l => l.type === 'Transfer')).map(j => j.criteria.arrivalTime); + expect(busArrivals).toEqual([1400]); + expect(walkArrivals).toEqual([5900]); + }); + + it('still returns a bus that departs after the window when nothing departs inside it', () => { + // Characterization: the window bounds the *seed* departure times, not the + // first boarding. From the start-of-window seed the search runs + // unbounded into the future, so a later bus is still reported. + const trips = [makeTrip('late', '4001', [{ stop: 'X', arr: 5000 }, { stop: 'Y', arr: 5400 }])]; + const journeys = rangeJourneys(trips); + expect(journeys).toHaveLength(1); + expect(journeys[0].criteria).toEqual({ arrivalTime: 5400, walkingDistance: 0, transferCount: 1 }); + }); +}); diff --git a/test/raptor-property.test.ts b/test/raptor-property.test.ts new file mode 100644 index 0000000..e32bba1 --- /dev/null +++ b/test/raptor-property.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { McRaptorAlgorithm } from '../src/raptor/McRaptorAlgorithm'; +import { Trip, Transfer, Interchange } from '../src/raptor/types'; +import { walkTransfer, transferMap, makeTrip, StopTimeSpec } from './helpers/network'; +import { Scenario, bruteForceParetoCriteria, sortCriteria, validateJourney } from './helpers/oracle'; + +/** + * Differential test: generates hundreds of small random-but-realistic transit + * networks (loops, overtaking expresses, pickUp/dropOff restrictions, windowed + * transfers, varied interchange buffers and walking penalties) and checks that + * the McRaptor result set exactly equals the brute-force Pareto frontier, and + * that every returned journey is executable against the raw data. + * + * Deterministic: seeded PRNG, so failures reproduce by seed. + */ + +function mulberry32(seed: number): () => number { + let t = seed; + return () => { + t += 0x6D2B79F5; + let r = Math.imul(t ^ (t >>> 15), t | 1); + r ^= r + Math.imul(r ^ (r >>> 7), r | 61); + return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rand: () => number, maxExclusive: number): number { + return Math.floor(rand() * maxExclusive); +} + +function shuffled(rand: () => number, list: T[]): T[] { + const arr = [...list]; + for (let i = arr.length - 1; i > 0; i--) { + const j = randInt(rand, i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +const BASE_TIME = 36000; // 10:00 + +function generateScenario(rand: () => number): Scenario { + const numStops = 4 + randInt(rand, 7); + const stops = Array.from({ length: numStops }, (_, i) => `S${i}`); + + const transfers: Transfer[] = []; + for (const o of stops) { + for (const d of stops) { + if (o === d || rand() >= 0.25) continue; + const duration = 60 + randInt(rand, 540); + let endTime = Number.MAX_SAFE_INTEGER; + if (rand() < 0.15) { + // Only expiring windows. A window that opens in the future is + // never produced by the graph builder and breaks the Pareto + // monotonicity both the algorithm and the oracle rely on; the + // not-yet-open behavior is pinned in raptor-core.test.ts. + endTime = BASE_TIME + randInt(rand, 3000); + } + transfers.push(walkTransfer(o, d, duration, 0, endTime)); + } + } + + const interchange: Interchange = {}; + for (const s of stops) interchange[s] = [0, 15, 30, 60][randInt(rand, 4)]; + + const trips: Trip[] = []; + const numRoutes = 2 + randInt(rand, 3); + for (let r = 0; r < numRoutes; r++) { + const len = 3 + randInt(rand, Math.min(4, numStops - 2)); + const routeStops = shuffled(rand, stops).slice(0, len); + if (rand() < 0.15) routeStops.push(routeStops[0]); // loop route + + const travelTimes = Array.from({ length: routeStops.length - 1 }, () => 60 + randInt(rand, 420)); + const runs = 1 + randInt(rand, 3); + const headway = 300 + randInt(rand, 900); + const firstDeparture = BASE_TIME + randInt(rand, 1800); + const dwell = rand() < 0.5 ? 0 : 20; + + const buildRun = (tripId: string, dep0: number, times: number[]): Trip => { + const specs: StopTimeSpec[] = []; + let arr = dep0; + routeStops.forEach((stop, i) => { + const isLast = i === routeStops.length - 1; + const dep = isLast ? arr : arr + dwell; + specs.push({ stop, arr, dep, rt: `R${r}` }); + if (!isLast) arr = dep + times[i]; + }); + return makeTrip(tripId, null, specs); + }; + + const routeTrips: Trip[] = []; + for (let run = 0; run < runs; run++) { + routeTrips.push(buildRun(`R${r}_run${run}`, firstDeparture + run * headway, travelTimes)); + } + + // Occasionally add an overtaking express on the same stop sequence. + if (rand() < 0.25) { + const expressTimes = travelTimes.map(t => Math.max(30, Math.floor(t / 2))); + routeTrips.push(buildRun(`R${r}_express`, firstDeparture + 60 + randInt(rand, headway), expressTimes)); + } + + // A closely trailing duplicate makes same-chain time ties likely once + // times are quantized below. + if (rand() < 0.3) { + routeTrips.push(buildRun(`R${r}_bunched`, firstDeparture + 60 + randInt(rand, 180), travelTimes)); + } + + // Production countdowns are whole minutes, so bunched buses routinely + // tie at stops; quantizing recreates that (regression: tied trips must + // be scanned as separate chains, not tie-dominated in one route bag). + if (rand() < 0.35) { + for (const trip of routeTrips) { + for (const st of trip.stopTimes) { + st.arrivalTime = Math.floor(st.arrivalTime / 60) * 60; + st.departureTime = Math.floor(st.departureTime / 60) * 60; + } + } + } + + trips.push(...routeTrips); + } + + // Sprinkle boarding/alighting restrictions. + for (const trip of trips) { + for (const st of trip.stopTimes) { + if (rand() < 0.06) st.pickUp = false; + if (rand() < 0.06) st.dropOff = false; + } + } + + const origin = stops[randInt(rand, numStops)]; + let destination = stops[randInt(rand, numStops)]; + if (destination === origin) destination = stops[(stops.indexOf(origin) + 1) % numStops]; + + return { + trips, + transfers: transferMap(transfers), + interchange, + origin, + destination, + departureTime: BASE_TIME + randInt(rand, 1500), + walkingPenalty: [0, 1, 1, 2, 8][randInt(rand, 5)], + }; +} + +describe('McRaptor vs brute-force oracle on random networks', () => { + const ITERATIONS = 500; + + it(`matches the exact Pareto frontier on ${ITERATIONS} seeded random networks`, () => { + for (let seed = 1; seed <= ITERATIONS; seed++) { + const scenario = generateScenario(mulberry32(seed)); + + const algo = new McRaptorAlgorithm(scenario.trips, scenario.transfers, scenario.interchange); + algo.setWalkingPenalty(scenario.walkingPenalty!); + const journeys = algo.getOptimizedJourneys(scenario.origin, scenario.destination, scenario.departureTime); + + for (const journey of journeys) { + try { + validateJourney(journey, scenario); + } catch (e) { + throw new Error(`seed ${seed}: ${(e as Error).message}`); + } + } + + const got = sortCriteria(journeys.map(j => j.criteria)); + const want = bruteForceParetoCriteria(scenario); + expect(got, `seed ${seed} (${scenario.origin} -> ${scenario.destination} @ ${scenario.departureTime}, penalty ${scenario.walkingPenalty})`).toEqual(want); + } + }, 30000); +}); diff --git a/test/reminder.test.ts b/test/reminder.test.ts index 2b58f9c..6365ce1 100644 --- a/test/reminder.test.ts +++ b/test/reminder.test.ts @@ -200,6 +200,204 @@ describe('Reminders', () => { expect(reminders.atTheStop.size).toBe(1); }); + it('should track the correct pass of a looping bus in stage 1', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + // A looping bus serves the stop twice: an earlier pass and the pass the + // user is actually tracking (loop passes are preserved as separate + // prediction entries since the looping-bus ingestion fix). + const passes = (p1: string, p2: string) => createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 5 * 60 * 1000, prdctdn: p1 }, + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 12 * 60 * 1000, prdctdn: p2 }, + ]); + + // Threshold 8 min: the candidate must arrive after now+8min, i.e. the + // 12-minute pass, not the 5-minute one. + const initial = passes("5", "12"); + subs.add(testEvent, 8, testToken, initial.byStop, now); + + // Later: the earlier pass is DUE while the tracked pass hits the threshold. + const later = passes("1", "8"); + const atThreshold = subs.process(later.byStop, later.byVid, now + 4 * 60 * 1000); + expect(atThreshold.reminder.size).toBe(1); + expect(subs.subscriptions[0].subscription.stage).toBe(1); + + // The earlier pass being DUE must NOT fire "at the stop" for the + // tracked pass, which is still 8 minutes out. + const afterwards = subs.process(later.byStop, later.byVid, now + 4.5 * 60 * 1000); + expect(afterwards.atTheStop.size).toBe(0); + expect(afterwards.disappeared.size).toBe(0); + expect(subs.subscriptions).toHaveLength(1); // still tracking + }); + + it('should keep tracking a bus that runs a few minutes early in stage 1', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 4 * 60 * 1000, prdctdn: "2" }, + ]); + subs.add(testEvent, 3, testToken, byStop, now); + const first = subs.process(byStop, byVid, now); + expect(first.reminder.size).toBe(1); // -> stage 1 + + // The bus gains 1.5 minutes: its prediction now sits EARLIER than the + // original expectation. It must stay matched (a frozen lower cutoff + // used to drop it and fire a false "Bus Disappeared"). + const early = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 2.5 * 60 * 1000, prdctdn: "2" }, + ]); + const next = subs.process(early.byStop, early.byVid, now + 30 * 1000); + expect(next.disappeared.size).toBe(0); + expect(subs.subscriptions).toHaveLength(1); // still tracking + }); + + it('should report arrival (not a huge delay) when the tracked pass completes on a looping bus', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + // prev countdown 4 (> shouldBeArrivingThresh) so the old stpid-only + // lookup could not sneak through its arrival override — this test must + // fail on the pre-fix code (which reported "delayed by 16 minutes"). + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 6 * 60 * 1000, prdctdn: "4" }, + ]); + subs.add(testEvent, 5, testToken, byStop, now); + const toStage1 = subs.process(byStop, byVid, now); + expect(toStage1.reminder.size).toBe(1); // -> stage 1 + + // The tracked pass's prediction vanishes as the bus arrives, but the + // looping vehicle's NEXT pass (20 min out) is still listed: that must + // read as "arrived", not "delayed by 16 minutes". + const nextPassOnly = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 20 * 60 * 1000, prdctdn: "20" }, + ]); + const result = subs.process(nextPassOnly.byStop, nextPassOnly.byVid, now + 60 * 1000); + expect(result.delayed.size).toBe(0); + expect(result.disappeared.size).toBe(0); + expect(result.atTheStop.size).toBe(1); + }); + + it('should treat a big single-tick prediction jump as a delay while the bus is still far out', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 9 * 60 * 1000, prdctdn: "8" }, + ]); + subs.add(testEvent, 8, testToken, byStop, now); + expect(subs.process(byStop, byVid, now).reminder.size).toBe(1); // -> stage 1, prev=8 + + // Countdown jumps 8 -> 15 in one tick (7-min move, past the matching + // window). The bus was 8 minutes out, so this is a delay to follow — + // not an arrival, not a disappearance. + const jumped = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 16 * 60 * 1000, prdctdn: "15" }, + ]); + const result = subs.process(jumped.byStop, jumped.byVid, now + 60 * 1000); + expect(result.atTheStop.size).toBe(0); + expect(result.disappeared.size).toBe(0); + expect(result.delayed.size).toBe(1); + expect(subs.subscriptions).toHaveLength(1); // still tracking the moved pass + }); + + it('should hold (not guess) when the tracked pass flips to DLY on a looping bus', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 6 * 60 * 1000, prdctdn: "4" }, + ]); + subs.add(testEvent, 5, testToken, byStop, now); + subs.process(byStop, byVid, now); // -> stage 1, prev=4 + + // The tracked pass now reports DLY (no usable countdown) while the + // next loop pass is also listed: the bus's position is unknowable, so + // neither arrival nor disappearance may be inferred this tick. + const dlyTick = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: Number.MAX_SAFE_INTEGER, prdctdn: "DLY" }, + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 20 * 60 * 1000, prdctdn: "20" }, + ]); + const result = subs.process(dlyTick.byStop, dlyTick.byVid, now + 60 * 1000); + expect(result.atTheStop.size).toBe(0); + expect(result.disappeared.size).toBe(0); + expect(result.delayed.size).toBe(0); + expect(subs.subscriptions).toHaveLength(1); + }); + + it('should expire stale subscriptions instead of keeping zombies forever', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + // No prediction ever matches (e.g. last bus of the day was too close): + // candidateVid stays null and nothing can ever fire. + subs.add(testEvent, 3, testToken, {}, now); + const during = subs.process({}, {}, now + 60 * 1000); + expect(during.disappeared.size).toBe(0); + expect(subs.subscriptions).toHaveLength(1); + + // Past the TTL the zombie is dropped (and would otherwise have fired a + // bogus stale reminder the next service day). + const after = subs.process({}, {}, now + 4 * 60 * 60 * 1000); + expect(after.disappeared.size).toBe(0); + expect(subs.subscriptions).toHaveLength(0); + }); + + it('should not adopt a vid-less (schedule-based) prediction as a trackable candidate', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + // Real overnight feed rows carry vid "" until a vehicle is assigned. + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "", stpid: testEvent.stpid, prdtm: now + 5 * 60 * 1000, prdctdn: "2" }, + ]); + subs.add(testEvent, 3, testToken, byStop, now); + expect(subs.activeRemindersFor(testToken)[0]).toMatchObject({ stage: 0, candidateVid: null }); + + // The threshold must HOLD (not fire into an untrackable stage 1, and + // certainly not throw) until a vehicle is assigned. + const result = subs.process(byStop, byVid, now + 30 * 1000); + expect(result.reminder.size).toBe(0); + expect(subs.subscriptions[0].subscription.stage).toBe(0); + }); + + it('should not duplicate subscriptions when swapping onto a token that already has the event', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + const tokenB = r.registrationToken("tokenB"); + subs.add(testEvent, 3, tokenB, {}, now); + subs.add(testEvent, 3, testToken, {}, now); + expect(subs.subscriptions).toHaveLength(2); + + subs.swapToken(testToken, tokenB); + expect(subs.activeRemindersFor(tokenB)).toHaveLength(1); + expect(subs.subscriptions).toHaveLength(1); + }); + + it('should ignore delayed (DLY) predictions when selecting candidates', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + // DLY entries are surfaced to the prediction endpoints (with a + // far-future prdtm) but must never be tracked by reminders. + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: now + 6 * 60 * 1000, prdctdn: "6" }, + { rt: testEvent.rtid, vid: "vid2", stpid: testEvent.stpid, prdtm: Number.MAX_SAFE_INTEGER, prdctdn: "DLY" }, + ]); + subs.add(testEvent, 3, testToken, byStop, now); + expect(subs.activeRemindersFor(testToken)[0]).toMatchObject({ stage: 0, candidateVid: "vid1" }); + + const reminders = subs.process(byStop, byVid, now); + expect(reminders.disappeared.size).toBe(0); + expect(reminders.delayed.size).toBe(0); + }); + + it('should not adopt a DLY-only prediction as a candidate', () => { + const subs = new t.ReminderSubscriptions({ mock: true }); + const now = Date.now(); + const { byStop, byVid } = createCaches([ + { rt: testEvent.rtid, vid: "vid1", stpid: testEvent.stpid, prdtm: Number.MAX_SAFE_INTEGER, prdctdn: "DLY" }, + ]); + subs.add(testEvent, 3, testToken, byStop, now); + expect(subs.activeRemindersFor(testToken)[0]).toMatchObject({ stage: 0, candidateVid: null }); + + const reminders = subs.process(byStop, byVid, now); + expect(reminders.disappeared.size).toBe(0); + }); + it('should get unix timestamp from ride bus api', async () => { configDotenv(); const RIDE_API_KEY = process.env.RIDE_API_KEY; @@ -212,8 +410,16 @@ describe('Reminders', () => { params: { key: RIDE_API_KEY, format: 'json' } }); const res = await client.get('/gettime', { params: { unixTime: true } }); - expect(Math.abs(parseInt(res.data["bustime-response"]["tm"]) - Date.now())) - .toBeLessThan(2 * 24 * 60 * 60 * 1000); + const tm = parseInt(res.data["bustime-response"]["tm"]); + + // The invariant under test is the FORMAT: with unixTime=true the feed + // must return epoch milliseconds, not its default "YYYYMMDD HH:MM:SS" + // (which parseInt turns into ~2e7) and not epoch seconds (~1.8e9). + // Proximity to Date.now() is deliberately NOT asserted: CI points + // RIDE_URL at a mock that replays a recorded fixture, so its clock is + // legitimately hours or days stale. + expect(tm).toBeGreaterThan(1_000_000_000_000); // after 2001 in ms + expect(tm).toBeLessThan(10_000_000_000_000); // before 2286 in ms }); it('should have cached preds in a good state', async () => { @@ -221,9 +427,16 @@ describe('Reminders', () => { await initializeRoutes(); await rebuildGraph(); await updateBusPositions(); - // are there preds? - expect(Object.keys(state.cachedPredsByStopId).length).toBeGreaterThan(0); - expect(Object.keys(state.cachedPredsByVid).length).toBeGreaterThan(0); + // Overnight and during breaks no vehicles run: the feed may still + // serve schedule-based predictions (with vid "") or nothing at all, + // so the count assertions only make sense while vehicles are out. + // The shape checks below always run on whatever is cached. + if (state.curBusPositions.buses.length === 0) { + console.warn('No M-Bus vehicles in service right now; skipping live prediction count checks.'); + } else { + expect(Object.keys(state.cachedPredsByStopId).length).toBeGreaterThan(0); + expect(Object.keys(state.cachedPredsByVid).length).toBeGreaterThan(0); + } // expect(Object.keys(state.cachedRidePredsByStopId).length).toBeGreaterThan(0); // expect(Object.keys(state.cachedRidePredsByVid).length).toBeGreaterThan(0); // are the expected fields all there? @@ -239,9 +452,13 @@ describe('Reminders', () => { [state.cachedPredsByStopId, state.cachedPredsByVid, state.cachedRidePredsByStopId, state.cachedRidePredsByVid] .forEach((preds) => { for (const k in preds) { - preds[k].every(allThere); + // forEach, not every: allThere returns undefined, so + // every() would stop after the first prediction. + preds[k].forEach(allThere); } }); - }, 30000); + // Generous timeout: on a cold walking cache (fresh checkout / CI) the + // pipeline computes stop-pair paths before this test can proceed. + }, 120000); }); diff --git a/test/search-stress.test.ts b/test/search-stress.test.ts index 6b9ea92..d3af661 100644 --- a/test/search-stress.test.ts +++ b/test/search-stress.test.ts @@ -9,8 +9,7 @@ describe('Stress Test Pathing Endpoint', () => { try { await axios.get(`${BASE_URL}/getAllPredictions`); } catch (error) { - console.error('Server is not running! Please start the server with: npm start'); - process.exit(1); + throw new Error('Server is not running on :3000 - start it with: npm start'); } }); diff --git a/vitest.config.ts b/vitest.config.ts index 67aa345..90fca64 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ import {defineConfig} from "vitest/config"; export default defineConfig({ test: { alias: [ - { find: "@", replacement: resolve(__dirname, "./src") } + { find: "@", replacement: resolve(import.meta.dirname, "./src") } ] - } + } })