minotor
Version:
A lightweight client-side transit routing library.
401 lines (352 loc) • 13.9 kB
text/typescript
import assert from 'node:assert';
import { Readable } from 'node:stream';
import { describe, it } from 'node:test';
import { durationFromSeconds, timeFromHMS } from '../../timetable/time.js';
import { FrequenciesMap, parseFrequencies } from '../frequencies.js';
import { GtfsStopsMap } from '../stops.js';
import { GtfsTripIdsMap, parseStopTimes } from '../trips.js';
/** Builds a minimal GtfsStopsMap for the two stops used across tests. */
const buildTwoStopsMap = (): GtfsStopsMap =>
new Map([
[
'stop1',
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM' as const,
lat: 0,
lon: 0,
},
],
[
'stop2',
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM' as const,
lat: 0,
lon: 0,
},
],
]);
describe('GTFS frequencies parser', () => {
it('should correctly parse a single frequency window', async () => {
const stream = new Readable();
stream.push('trip_id,start_time,end_time,headway_secs\n');
stream.push('"tripA","06:00:00","08:00:00","1800"\n');
stream.push(null);
const result = await parseFrequencies(stream, new Set(['tripA']));
assert.strictEqual(result.size, 1);
const windows = result.get('tripA');
assert(windows !== undefined);
assert.strictEqual(windows.length, 1);
assert.strictEqual(windows[0]?.startTime, timeFromHMS(6, 0, 0));
assert.strictEqual(windows[0].endTime, timeFromHMS(8, 0, 0));
assert.strictEqual(windows[0].headwayMins, durationFromSeconds(1800));
});
it('should ignore trips not in activeTripIds', async () => {
const stream = new Readable();
stream.push('trip_id,start_time,end_time,headway_secs\n');
stream.push('"tripA","06:00:00","08:00:00","1800"\n');
stream.push('"tripB","10:00:00","12:00:00","600"\n');
stream.push(null);
const result = await parseFrequencies(stream, new Set(['tripA']));
assert.strictEqual(result.size, 1);
assert(result.has('tripA'));
assert(!result.has('tripB'));
});
it('should return an empty map when no trips match activeTripIds', async () => {
const stream = new Readable();
stream.push('trip_id,start_time,end_time,headway_secs\n');
stream.push('"tripA","06:00:00","08:00:00","1800"\n');
stream.push(null);
const result = await parseFrequencies(stream, new Set(['tripB']));
assert.strictEqual(result.size, 0);
});
it('should collect multiple frequency windows for the same trip', async () => {
const stream = new Readable();
stream.push('trip_id,start_time,end_time,headway_secs\n');
stream.push('"tripA","06:00:00","08:00:00","1800"\n');
stream.push('"tripA","08:00:00","10:00:00","600"\n');
stream.push(null);
const result = await parseFrequencies(stream, new Set(['tripA']));
assert.strictEqual(result.size, 1);
const windows = result.get('tripA');
assert(windows !== undefined);
assert.strictEqual(windows.length, 2);
assert.strictEqual(windows[0]?.startTime, timeFromHMS(6, 0, 0));
assert.strictEqual(windows[0].headwayMins, durationFromSeconds(1800));
assert.strictEqual(windows[1]?.startTime, timeFromHMS(8, 0, 0));
assert.strictEqual(windows[1].headwayMins, durationFromSeconds(600));
});
it('should parse multiple trips each with their own window', async () => {
const stream = new Readable();
stream.push('trip_id,start_time,end_time,headway_secs\n');
stream.push('"tripA","06:00:00","22:00:00","1800"\n');
stream.push('"tripB","08:00:00","20:00:00","600"\n');
stream.push(null);
const result = await parseFrequencies(stream, new Set(['tripA', 'tripB']));
assert.strictEqual(result.size, 2);
assert.strictEqual(result.get('tripA')?.length, 1);
assert.strictEqual(result.get('tripB')?.length, 1);
});
});
describe('GTFS stop times parser with frequency expansion', () => {
it('should expand a frequency trip into the correct number of concrete trips', async () => {
// Template trip: stop1 @ 06:00, stop2 @ 06:20
// Window: 06:00 – 07:00 (exclusive), headway 30 min → 2 departures (06:00, 06:30)
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","06:00:00","06:00:00","stop1","1"\n');
stream.push('"tripA","06:20:00","06:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
const frequenciesMap: FrequenciesMap = new Map([
[
'tripA',
[
{
startTime: timeFromHMS(6, 0, 0),
endTime: timeFromHMS(7, 0, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
],
],
]);
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
assert.strictEqual(result.routes.length, 1);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 2);
});
it('should produce correctly shifted stop times for expanded trips', async () => {
// Template: stop1 @ 06:00/06:00, stop2 @ 06:20/06:20
// Window: 06:00 – 07:00, headway 30 min → trips at 06:00 and 06:30
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","06:00:00","06:00:00","stop1","1"\n');
stream.push('"tripA","06:20:00","06:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
const frequenciesMap: FrequenciesMap = new Map([
[
'tripA',
[
{
startTime: timeFromHMS(6, 0, 0),
endTime: timeFromHMS(7, 0, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
],
],
]);
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
const route = result.routes[0];
assert(route !== undefined);
// Trips are sorted by firstDeparture inside finalizeRouteFromBuilder.
// Trip 0 departs at 06:00
assert.strictEqual(route.arrivalAt(0, 0), timeFromHMS(6, 0, 0));
assert.strictEqual(route.departureFrom(0, 0), timeFromHMS(6, 0, 0));
assert.strictEqual(route.arrivalAt(1, 0), timeFromHMS(6, 20, 0));
assert.strictEqual(route.departureFrom(1, 0), timeFromHMS(6, 20, 0));
// Trip 1 departs at 06:30 (offset +30 min)
assert.strictEqual(route.arrivalAt(0, 1), timeFromHMS(6, 30, 0));
assert.strictEqual(route.departureFrom(0, 1), timeFromHMS(6, 30, 0));
assert.strictEqual(route.arrivalAt(1, 1), timeFromHMS(6, 50, 0));
assert.strictEqual(route.departureFrom(1, 1), timeFromHMS(6, 50, 0));
});
it('should treat end_time as exclusive (last departure strictly before end_time)', async () => {
// Window: 06:00 – 06:30, headway 30 min → only 1 trip (06:00); 06:30 is excluded
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","06:00:00","06:00:00","stop1","1"\n');
stream.push('"tripA","06:20:00","06:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
const frequenciesMap: FrequenciesMap = new Map([
[
'tripA',
[
{
startTime: timeFromHMS(6, 0, 0),
endTime: timeFromHMS(6, 30, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
],
],
]);
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 1);
assert.strictEqual(route.arrivalAt(0, 0), timeFromHMS(6, 0, 0));
});
it('should expand trips across multiple frequency windows', async () => {
// Two windows for the same trip:
// Window 1: 06:00 – 07:00, headway 30 min → 2 trips
// Window 2: 10:00 – 11:00, headway 30 min → 2 trips
// Total: 4 trips
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","06:00:00","06:00:00","stop1","1"\n');
stream.push('"tripA","06:20:00","06:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
const frequenciesMap: FrequenciesMap = new Map([
[
'tripA',
[
{
startTime: timeFromHMS(6, 0, 0),
endTime: timeFromHMS(7, 0, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
{
startTime: timeFromHMS(10, 0, 0),
endTime: timeFromHMS(11, 0, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
],
],
]);
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 4);
// Trips are sorted by firstDeparture: 06:00, 06:30, 10:00, 10:30
assert.strictEqual(route.arrivalAt(0, 0), timeFromHMS(6, 0, 0));
assert.strictEqual(route.arrivalAt(0, 1), timeFromHMS(6, 30, 0));
assert.strictEqual(route.arrivalAt(0, 2), timeFromHMS(10, 0, 0));
assert.strictEqual(route.arrivalAt(0, 3), timeFromHMS(10, 30, 0));
});
it('should not expand a trip that has no frequency entry', async () => {
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","08:00:00","08:00:00","stop1","1"\n');
stream.push('"tripA","08:20:00","08:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
// Provide a frequenciesMap but with no entry for tripA
const frequenciesMap: FrequenciesMap = new Map();
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 1);
assert.strictEqual(route.arrivalAt(0, 0), timeFromHMS(8, 0, 0));
assert.strictEqual(route.arrivalAt(1, 0), timeFromHMS(8, 20, 0));
});
it('should leave non-frequency trips unchanged when frequenciesMap is undefined', async () => {
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","09:00:00","09:05:00","stop1","1"\n');
stream.push('"tripA","09:30:00","09:35:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([['tripA', 'routeA']]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
// No frequenciesMap passed → original behavior
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 1);
assert.strictEqual(route.arrivalAt(0, 0), timeFromHMS(9, 0, 0));
assert.strictEqual(route.departureFrom(0, 0), timeFromHMS(9, 5, 0));
});
it('should mix frequency and non-frequency trips sharing the same stop sequence into one route', async () => {
// tripA is a frequency trip (2 expansions); tripB is a regular trip.
// Both serve the same GTFS route with the same stop sequence → same route.
const stream = new Readable();
stream.push('trip_id,arrival_time,departure_time,stop_id,stop_sequence\n');
stream.push('"tripA","06:00:00","06:00:00","stop1","1"\n');
stream.push('"tripA","06:20:00","06:20:00","stop2","2"\n');
stream.push('"tripB","08:00:00","08:00:00","stop1","1"\n');
stream.push('"tripB","08:20:00","08:20:00","stop2","2"\n');
stream.push(null);
const activeTripIds: GtfsTripIdsMap = new Map([
['tripA', 'routeA'],
['tripB', 'routeA'],
]);
const activeStopIds = new Set([0, 1]);
const stopsMap = buildTwoStopsMap();
const frequenciesMap: FrequenciesMap = new Map([
[
'tripA',
[
{
startTime: timeFromHMS(6, 0, 0),
endTime: timeFromHMS(7, 0, 0),
headwayMins: durationFromSeconds(1800),
exactTimes: false,
},
],
],
]);
const result = await parseStopTimes(
stream,
stopsMap,
activeTripIds,
activeStopIds,
frequenciesMap,
);
// All three trips (2 from tripA expansion + 1 from tripB) share the same
// stop sequence, so they should be grouped into a single route.
assert.strictEqual(result.routes.length, 1);
const route = result.routes[0];
assert(route !== undefined);
assert.strictEqual(route.getNbTrips(), 3);
});
});