UNPKG

minotor

Version:

A lightweight client-side transit routing library.

1,423 lines (1,283 loc) 37.2 kB
import assert from 'node:assert'; import { Readable } from 'node:stream'; import { describe, it } from 'node:test'; import { Route } from '../../timetable/route.js'; import { durationFromSeconds, timeFromHM } from '../../timetable/time.js'; import { Timetable, TransferTypes } from '../../timetable/timetable.js'; import { encode } from '../../timetable/tripStopId.js'; import { GtfsStopsMap } from '../stops.js'; import { addGeneratedTransfers, addMissingSiblingTransfers, buildTripTransfers, ForbiddenTransfersMap, GtfsTripTransfer, parseTransfers, TransfersMap, } from '../transfers.js'; import { TripsMapping } from '../trips.js'; describe('GTFS transfers parser', () => { it('should correctly parse valid transfers', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","2","180"\n'); mockedStream.push('"1100097","8014447","0","240"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedTransfers = new Map([ [ 0, // Internal ID for stop '1100084' [ { destination: 1, // Internal ID for stop '8014440:0:1' type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: durationFromSeconds(180), }, ], ], [ 2, // Internal ID for stop '1100097' [ { destination: 3, // Internal ID for stop '8014447' type: TransferTypes.RECOMMENDED, minTransferTime: durationFromSeconds(240), }, ], ], ]); assert.deepEqual(result.transfers, expectedTransfers); assert.deepEqual(result.tripContinuations, []); }); it('should only retain unscoped impossible transfers as forbidden', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,from_route_id,to_route_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","","","","","3","180"\n'); mockedStream.push('"8014440:0:1","1100084","trip-a","","","","3","180"\n'); mockedStream.push('"1100097","8014447","","","","route-b","3","240"\n'); mockedStream.push('"1100097","8014447","","","","","5","240"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.forbiddenTransfers, new Map([[0, new Set([1])]])); assert.deepEqual(result.tripContinuations, []); }); it('should ignore transfers with missing stop IDs', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,transfer_type,min_transfer_time\n', ); mockedStream.push(',"8014440:0:1","2","180"\n'); mockedStream.push('"1100097",,"0","240"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); }); it('should correctly parse in-seat transfers (type 4)', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","trip1","trip2","4","0"\n'); mockedStream.push('"1100097","8014447","trip3","trip4","4","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedTripContinuations = [ { fromStop: 0, fromTrip: 'trip1', toStop: 1, toTrip: 'trip2', }, { fromStop: 2, fromTrip: 'trip3', toStop: 3, toTrip: 'trip4', }, ]; assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, expectedTripContinuations); assert.deepEqual(result.guaranteedTripTransfers, []); }); it('should correctly parse guaranteed transfers (type 1) with trip IDs', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","tripA","tripB","1","0"\n'); mockedStream.push('"1100097","8014447","tripC","tripD","1","60"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedGuaranteedTripTransfers = [ { fromStop: 0, fromTrip: 'tripA', toStop: 1, toTrip: 'tripB', }, { fromStop: 2, fromTrip: 'tripC', toStop: 3, toTrip: 'tripD', }, ]; // Guaranteed transfers with trip IDs should go to guaranteedTripTransfers, not transfers assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); assert.deepEqual( result.guaranteedTripTransfers, expectedGuaranteedTripTransfers, ); }); it('should differentiate guaranteed transfers with and without trip IDs', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); // Type 1 with trip IDs -> goes to guaranteedTripTransfers mockedStream.push('"1100084","8014440:0:1","tripA","tripB","1","0"\n'); // Type 1 without trip IDs -> goes to transfers as stop-to-stop mockedStream.push('"1100097","8014447","","","1","120"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); // Type 1 with trip IDs -> guaranteedTripTransfers const expectedGuaranteedTripTransfers = [ { fromStop: 0, fromTrip: 'tripA', toStop: 1, toTrip: 'tripB', }, ]; // Type 1 without trip IDs -> transfers (stop-to-stop) const expectedTransfers = new Map([ [ 2, [ { destination: 3, type: TransferTypes.GUARANTEED, minTransferTime: durationFromSeconds(120), }, ], ], ]); assert.deepEqual(result.transfers, expectedTransfers); assert.deepEqual(result.tripContinuations, []); assert.deepEqual( result.guaranteedTripTransfers, expectedGuaranteedTripTransfers, ); }); it('should ignore in-seat transfers with missing trip IDs', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1",,"trip2","4","0"\n'); mockedStream.push('"1100097","8014447","trip3",,"4","0"\n'); mockedStream.push('"1100098","8014448","","trip5","4","0"\n'); mockedStream.push('"1100099","8014449","trip6","","4","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); }); it('should ignore unsupported transfer types between trips', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","trip1","trip2","1","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); }); it('should ignore unsupported transfer types between routes', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_route_id,to_route_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","route1","route2","1","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); }); it('should handle transfers without minimum transfer time', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","2"\n'); mockedStream.push('"1100097","8014447","1","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedTransfers = new Map([ [ 0, [ { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, }, ], ], [ 2, [ { destination: 3, type: TransferTypes.GUARANTEED, minTransferTime: durationFromSeconds(0), }, ], ], ]); assert.deepEqual(result.transfers, expectedTransfers); assert.deepEqual(result.tripContinuations, []); }); it('should handle mixed transfers and trip continuations', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"1100084","8014440:0:1","","","1","120"\n'); mockedStream.push('"1100097","8014447","trip1","trip2","4","0"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '1100097', { id: 2, sourceStopId: '1100097', name: 'Test Stop 3', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014447', { id: 3, sourceStopId: '8014447', name: 'Test Stop 4', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedTransfers = new Map([ [ 0, [ { destination: 1, type: TransferTypes.GUARANTEED, minTransferTime: durationFromSeconds(120), }, ], ], ]); const expectedTripContinuations = [ { fromStop: 2, fromTrip: 'trip1', toStop: 3, toTrip: 'trip2', }, ]; assert.deepEqual(result.transfers, expectedTransfers); assert.deepEqual(result.tripContinuations, expectedTripContinuations); }); it('should filter service-scoped transfers by active service regardless of type', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,from_trip_id,to_trip_id,service_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"from","to","","","","0",""\n'); mockedStream.push('"from","to","","","","2","120"\n'); mockedStream.push('"from","to","","","active","1",""\n'); mockedStream.push('"from","to","","","inactive","2","180"\n'); mockedStream.push('"from","to","tripA","tripB","active","1",""\n'); mockedStream.push('"from","to","tripC","tripD","inactive","1",""\n'); mockedStream.push('"from","to","tripE","tripF","active","4",""\n'); mockedStream.push('"from","to","tripG","tripH","inactive","4",""\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ 'from', { id: 0, sourceStopId: 'from', name: 'From', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ 'to', { id: 1, sourceStopId: 'to', name: 'To', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers( mockedStream, stopsMap, new Set(['active']), ); assert.deepEqual( result.transfers, new Map([ [ 0, [ { destination: 1, type: TransferTypes.RECOMMENDED, minTransferTime: durationFromSeconds(0), }, { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: durationFromSeconds(120), }, { destination: 1, type: TransferTypes.GUARANTEED, minTransferTime: durationFromSeconds(0), }, ], ], ]), ); assert.deepEqual(result.guaranteedTripTransfers, [ { fromStop: 0, fromTrip: 'tripA', toStop: 1, toTrip: 'tripB', }, ]); assert.deepEqual(result.tripContinuations, [ { fromStop: 0, fromTrip: 'tripE', toStop: 1, toTrip: 'tripF', }, ]); }); it('should handle empty transfers file', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,transfer_type,min_transfer_time\n', ); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map(); const result = await parseTransfers(mockedStream, stopsMap, new Set()); assert.deepEqual(result.transfers, new Map()); assert.deepEqual(result.tripContinuations, []); }); it('should ignore transfers with non-existent stops', async () => { const mockedStream = new Readable(); mockedStream.push( 'from_stop_id,to_stop_id,transfer_type,min_transfer_time\n', ); mockedStream.push('"unknown_stop","8014440:0:1","0","120"\n'); mockedStream.push('"1100084","unknown_stop","1","60"\n'); mockedStream.push('"1100084","8014440:0:1","2","180"\n'); mockedStream.push(null); const stopsMap: GtfsStopsMap = new Map([ [ '1100084', { id: 0, sourceStopId: '1100084', name: 'Test Stop 1', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ '8014440:0:1', { id: 1, sourceStopId: '8014440:0:1', name: 'Test Stop 2', children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); const result = await parseTransfers(mockedStream, stopsMap, new Set()); const expectedTransfers = new Map([ [ 0, [ { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: durationFromSeconds(180), }, ], ], ]); assert.deepEqual(result.transfers, expectedTransfers); assert.deepEqual(result.tripContinuations, []); }); }); describe('generated transfers', () => { const stopsMap: GtfsStopsMap = new Map([ [ 'station', { id: 0, sourceStopId: 'station', name: 'Interchange', children: [1, 2], locationType: 'STATION', }, ], [ 'platform-a', { id: 1, sourceStopId: 'platform-a', name: 'Interchange', parent: 0, children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], [ 'platform-b', { id: 2, sourceStopId: 'platform-b', name: 'Interchange', parent: 0, children: [], locationType: 'SIMPLE_STOP_OR_PLATFORM', }, ], ]); it('adds directed fallback transfers between active sibling platforms', () => { const transfers: TransfersMap = new Map(); const added = addMissingSiblingTransfers( stopsMap, new Set([1, 2]), transfers, ); assert.strictEqual(added, 2); assert.deepStrictEqual( transfers, new Map([ [ 1, [ { destination: 2, type: TransferTypes.REQUIRES_MINIMAL_TIME, }, ], ], [ 2, [ { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, }, ], ], ]), ); }); it('preserves explicit transfers and excludes forbidden sibling directions', () => { const transfers: TransfersMap = new Map([ [ 1, [ { destination: 2, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 7, }, ], ], ]); const forbiddenTransfers: ForbiddenTransfersMap = new Map([ [2, new Set([1])], ]); const added = addMissingSiblingTransfers( stopsMap, new Set([1, 2]), transfers, forbiddenTransfers, ); assert.strictEqual(added, 0); assert.deepStrictEqual(transfers.get(1), [ { destination: 2, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 7, }, ]); assert.strictEqual(transfers.has(2), false); }); it('excludes forbidden directions from generated transfers', () => { const transfers: TransfersMap = new Map(); const generatedTransfers: TransfersMap = new Map([ [ 1, [ { destination: 2, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 3, }, ], ], [ 2, [ { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 3, }, ], ], ]); const forbiddenTransfers: ForbiddenTransfersMap = new Map([ [1, new Set([2])], ]); const added = addGeneratedTransfers( generatedTransfers, new Set([1, 2]), transfers, forbiddenTransfers, ); assert.strictEqual(added, 1); assert.strictEqual(transfers.has(1), false); assert.deepStrictEqual(transfers.get(2), [ { destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 3, }, ]); }); }); describe('buildTripTransfers', () => { it('should build trip transfers for valid data', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, ]; const mockFromRoute = { stopRouteIndices: () => [0], arrivalAt: () => timeFromHM(1, 0), // 1:00 } as unknown as Route; const mockToRoute = { stopRouteIndices: () => [1], departureFrom: () => timeFromHM(1, 15), // 1:15 } as unknown as Route; const mockTimetable = { getRoute: (routeId: number) => routeId === 0 ? mockFromRoute : mockToRoute, } as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); const expectedTripBoardingId = encode(0, 0, 0); const transfers = result.get(expectedTripBoardingId); assert(transfers); assert.strictEqual(transfers.length, 1); assert.deepEqual(transfers[0], { stopIndex: 1, routeId: 1, tripIndex: 0, }); }); it('should ignore transfers with inactive stops', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, // inactive stop fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, ]; const mockTimetable = {} as unknown as Timetable; const activeStopIds = new Set([200]); // only toStop is active const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); assert.strictEqual(result.size, 0); }); it('should ignore transfers with unknown trip IDs', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'unknown_trip', // not in tripsMapping toStop: 200, toTrip: 'trip1', }, ]; const mockTimetable = {} as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); assert.strictEqual(result.size, 0); }); it('should ignore transfers with unknown routes', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, ]; const mockTimetable = { getRoute: () => undefined, // no routes found } as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); assert.strictEqual(result.size, 0); }); it('should ignore transfers with no valid timing', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, ]; const mockFromRoute = { stopRouteIndices: () => [0], arrivalAt: () => timeFromHM(1, 15), // 1:15 - arrives AFTER departure } as unknown as Route; const mockToRoute = { stopRouteIndices: () => [1], departureFrom: () => timeFromHM(1, 0), // 1:00 - departs BEFORE arrival } as unknown as Route; const mockTimetable = { getRoute: (routeId: number) => routeId === 0 ? mockFromRoute : mockToRoute, } as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); assert.strictEqual(result.size, 0); }); it('should handle multiple transfers from same trip boarding', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ['trip3', { routeId: 2, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, { fromStop: 100, fromTrip: 'trip1', toStop: 300, toTrip: 'trip3', }, ]; const mockFromRoute = { stopRouteIndices: () => [0], arrivalAt: () => timeFromHM(1, 0), } as unknown as Route; const mockToRoute1 = { stopRouteIndices: () => [1], departureFrom: () => timeFromHM(1, 10), } as unknown as Route; const mockToRoute2 = { stopRouteIndices: () => [2], departureFrom: () => timeFromHM(1, 20), } as unknown as Route; const mockTimetable = { getRoute: (routeId: number) => { if (routeId === 0) return mockFromRoute; if (routeId === 1) return mockToRoute1; if (routeId === 2) return mockToRoute2; return undefined; }, } as unknown as Timetable; const activeStopIds = new Set([100, 200, 300]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); const expectedTripBoardingId = encode(0, 0, 0); const transfers = result.get(expectedTripBoardingId); assert(transfers); assert.strictEqual(transfers.length, 2); assert.deepEqual(transfers[0], { stopIndex: 1, routeId: 1, tripIndex: 0, }); assert.deepEqual(transfers[1], { stopIndex: 2, routeId: 2, tripIndex: 0, }); }); it('should handle empty input gracefully', () => { const tripsMapping: TripsMapping = new Map(); const tripTransfers: GtfsTripTransfer[] = []; const mockTimetable = {} as unknown as Timetable; const activeStopIds = new Set<number>(); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); assert.strictEqual(result.size, 0); }); it('should disambiguate transfers when routes visit same stop multiple times', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, // This stop appears multiple times in the route fromTrip: 'trip1', toStop: 200, // This stop also appears multiple times toTrip: 'trip2', }, ]; // Mock route that visits stop 100 at indices 0 and 3 (circular route) const mockFromRoute = { stopRouteIndices: () => [0, 3], // Stop 100 appears twice arrivalAt: (stopIndex: number) => { // First visit at 1:00, second visit at 2:00 return stopIndex === 0 ? timeFromHM(1, 0) : timeFromHM(2, 0); }, } as unknown as Route; // Mock route that visits stop 200 at indices 1 and 4 const mockToRoute = { stopRouteIndices: () => [1, 4], // Stop 200 appears twice departureFrom: (stopIndex: number) => { // First departure at 1:10, second departure at 2:30 return stopIndex === 1 ? timeFromHM(1, 10) : timeFromHM(2, 30); }, } as unknown as Route; const mockTimetable = { getRoute: (routeId: number) => routeId === 0 ? mockFromRoute : mockToRoute, } as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); // Should pick the best timing: arrive at stop 0 (1:00) -> depart from stop 1 (1:10) // This is better than arrive at stop 3 (2:00) -> depart from stop 4 (2:30) const expectedTripBoardingId = encode(0, 0, 0); // stopIndex=0, routeId=0, tripIndex=0 const transfers = result.get(expectedTripBoardingId); assert(transfers); assert.strictEqual(transfers.length, 1); assert.deepEqual(transfers[0], { stopIndex: 1, // Best to-stop index routeId: 1, tripIndex: 0, }); }); it('should handle case where no valid transfer timing exists between duplicate stops', () => { const tripsMapping: TripsMapping = new Map([ ['trip1', { routeId: 0, tripRouteIndex: 0 }], ['trip2', { routeId: 1, tripRouteIndex: 0 }], ]); const tripTransfers: GtfsTripTransfer[] = [ { fromStop: 100, fromTrip: 'trip1', toStop: 200, toTrip: 'trip2', }, ]; // Mock route where all arrivals are AFTER all departures (impossible transfer) const mockFromRoute = { stopRouteIndices: () => [0, 3], // Stop 100 appears twice arrivalAt: (stopIndex: number) => { // Both arrivals are late: 2:00 and 3:00 return stopIndex === 0 ? timeFromHM(2, 0) : timeFromHM(3, 0); }, } as unknown as Route; const mockToRoute = { stopRouteIndices: () => [1, 4], // Stop 200 appears twice departureFrom: (stopIndex: number) => { // Both departures are early: 1:00 and 1:30 return stopIndex === 1 ? timeFromHM(1, 0) : timeFromHM(1, 30); }, } as unknown as Route; const mockTimetable = { getRoute: (routeId: number) => routeId === 0 ? mockFromRoute : mockToRoute, } as unknown as Timetable; const activeStopIds = new Set([100, 200]); const result = buildTripTransfers( tripsMapping, tripTransfers, mockTimetable, activeStopIds, ); // Should find no valid transfers since all departures are before arrivals assert.strictEqual(result.size, 0); }); });