UNPKG

gtfs-utils

Version:
681 lines (618 loc) 19.5 kB
'use strict' const {DateTime} = require('luxon') const test = require('tape') const {createReadStream} = require('fs') const {join: pathJoin} = require('path') const {readJSON5Sync, readFilesFromFixture} = require('./lib') const readCsv = require('../read-csv') const inMemStore = require('../lib/in-memory-store') const formatDate = require('../format-date') const datesBetween = require('../lib/dates-between') const resolveTime = require('../lib/resolve-time') const readStopTimezones = require('../lib/read-stop-timezones') const readServicesAndExceptions = require('../read-services-and-exceptions') const computeStopovers = require('../compute-stopovers') const computeSortedConnections = require('../compute-sorted-connections') const computeServiceBreaks = require('../compute-service-breaks') const {extendedToBasic} = require('../route-types') const optimiseServicesAndExceptions = require('../optimise-services-and-exceptions') const testWithFixtures = (fn, fixtures, prefix = '') => { fixtures.forEach((f) => { const title = [prefix, f.title].filter(s => !!s).join(' – ') const args = f.args.map(a => a[1]) // select values const testFn = f.fails ? (t) => { t.plan(1) t.throws(() => fn(...args)) } : (t) => { t.plan(1) t.deepEqual(fn(...args), f.result) } test(title, testFn) }) } testWithFixtures( require('../parse-date'), readJSON5Sync(require.resolve('./fixtures/parse-date.json5')), 'parse-date', ) testWithFixtures( require('../parse-time'), readJSON5Sync(require.resolve('./fixtures/parse-time.json5')), 'parse-time', ) testWithFixtures( require('../lib/resolve-time'), readJSON5Sync(require.resolve('./fixtures/resolve-time.json5')), 'resolve-time', ) require('./iterate-matching') // const data = { // services: require('sample-gtfs-feed/json/calendar.json'), // exceptions: require('sample-gtfs-feed/json/calendar_dates.json'), // trips: require('sample-gtfs-feed/json/trips.json'), // stopovers: require('sample-gtfs-feed/json/stop_times.json') // } const readFile = (file) => { return readCsv(require.resolve('sample-gtfs-feed/gtfs/' + file + '.txt')) } const utc = 'Etc/UTC' const berlin = 'Europe/Berlin' test('read-csv: accept a readable stream as input', async (t) => { const readable = createReadStream(require.resolve('sample-gtfs-feed/gtfs/stops.txt')) const src = await readCsv(readable) const stop = await new Promise(res => src.once('data', res)) t.ok(stop) t.ok(stop.stop_id) src.destroy() }) test('read-csv: rejects on ENOENT', async (t) => { let rejected = false const p = readCsv(pathJoin(__dirname, '_non-existent_')) await p.catch((err) => { rejected = true t.ok(err, 'err') t.equal(err.code, 'ENOENT', 'err.code') }) t.equal(rejected, true, 'did not reject') }) test('format-date', (t) => { t.plan(3) t.equal(formatDate(1551571200, utc), '20190303') t.equal(formatDate(1551567600, berlin), '20190303') t.equal(formatDate(1551546000, 'Asia/Bangkok'), '20190303') }) test('lib/dates-between', (t) => { const march3rd = 1551567600 // Europe/Berlin const march4th = 1551654000 // Europe/Berlin const march5th = 1551740400 // Europe/Berlin const allWeekdays = { monday: true, tuesday: true, wednesday: true, thursday: true, friday: true, saturday: true, sunday: true } t.deepEqual(datesBetween('20190313', '20190303', allWeekdays, berlin), []) t.deepEqual(datesBetween('20190303', '20190303', allWeekdays, berlin), [ '2019-03-03', ]) t.deepEqual(datesBetween('20190303', '20190305', allWeekdays, berlin), [ '2019-03-03', '2019-03-04', '2019-03-05', ]) t.equal(datesBetween('20190303', '20190313', allWeekdays, berlin).length, 11) t.end() }) test('lib/resolve-time', (t) => { const r = resolveTime const _ = iso => Date.parse(iso) / 1000 const tzA = 'Europe/Berlin' const dateA = '2021-02-02' const tzB = 'Asia/Bangkok' const dateB = '2021-03-03' const time1 = 3 * 3600 + 2 * 60 + 1 // 03:02:01 const time2 = 26 * 3600 // 26:00 t.equal(r(tzA, dateA, time1), _('2021-02-02T03:02:01+01:00')) t.equal(r(tzA, dateA, time2), _('2021-02-03T02:00+01:00')) t.equal(r(tzB, dateA, time1), _('2021-02-02T03:02:01+07:00')) t.equal(r(tzB, dateA, time2), _('2021-02-03T02:00+07:00')) t.equal(r(tzB, dateB, time1), _('2021-03-03T03:02:01+07:00')) t.equal(r(tzB, dateB, time2), _('2021-03-04T02:00+07:00')) t.end() }) test('lib/read-stop-timezones', async (t) => { const readFile = readFilesFromFixture('timezones') const filters = {stop: () => true} const tzs = await readStopTimezones(readFile, filters, inMemStore) const actual = Object.fromEntries(Array.from(tzs.raw.entries())) t.deepEqual(actual, { 's2': 'Europe/Berlin', 's3': 'Europe/London', 's3a': 'Europe/London', 's3b': 'Europe/London', }) }) require('./read-stop-times') const servicesFixtures = readJSON5Sync(require.resolve('./fixtures/services.json5')) test('read-services-and-exceptions: works', async (t) => { const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} for await (const [id, dates] of services) res[id] = dates t.deepEqual(res, servicesFixtures) }) test('read-services-and-exceptions: works with calendar only', async (t) => { const readFile = readFilesFromFixture('calendar-only') const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} for await (const [id, dates] of services) res[id] = dates t.deepEqual(res, { a: [ '2021-05-01', '2021-05-02', '2021-05-03', '2021-05-04', '2021-05-05', '2021-05-06', '2021-05-07', '2021-05-08', '2021-05-09', '2021-05-10', ], b: ['2021-06-06'], c: ['2021-05-04', '2021-05-11'], }) }) // todo: what if readFile throws ENOENT synchronously? test('read-services-and-exceptions: works with calendar_dates only', async (t) => { const readFile = readFilesFromFixture('calendar-dates-only') const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} for await (const [id, dates] of services) res[id] = dates t.deepEqual(res, { a: ['2021-05-31', '2021-06-01', '2021-06-11', '2021-07-19'], b: [ '2021-05-31', '2021-06-01', '2021-06-22', '2021-06-28', '2021-06-29', '2021-07-16', '2021-08-02', ], }) }) test('read-services-and-exceptions: works with calendar_dates rows "before" first calendar row', async (t) => { const readFile = readFilesFromFixture('leading-exceptions') const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} for await (const [id, dates] of services) res[id] = dates t.deepEqual(res, { // leading exceptions a: ['2021-06-06'], b: ['2021-06-08'], c: [ '2021-06-05', '2021-06-06', '2021-06-07', ], }) }) test('read-services-and-exceptions: works with calendar_dates rows "after" last calendar row', async (t) => { const readFile = readFilesFromFixture('trailing-exceptions') const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} for await (const [id, dates] of services) res[id] = dates t.deepEqual(res, { c: [ '2021-06-05', '2021-06-06', '2021-06-07', ], // trailing exceptions d: ['2021-06-06'], e: ['2021-06-08'], }) }) test('read-services-and-exceptions: exposes service correctly', async (t) => { const readFile = readFilesFromFixture('optimise-services-and-exceptions') const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} const baseSvc = { monday: '0', tuesday: '0', wednesday: '0', thursday: '0', friday: '0', saturday: '0', sunday: '0', start_date: '20220301', end_date: '20220410', } const expected = { 'more-exceptions-than-regular': { ...baseSvc, service_id: 'more-exceptions-than-regular', wednesday: '1', thursday: '1', }, 'more-regular-than-exceptions': { ...baseSvc, service_id: 'more-regular-than-exceptions', friday: '1', }, 'should-stay-unchanged': { ...baseSvc, service_id: 'should-stay-unchanged', tuesday: '1', saturday: '1', }, } for await (const [id, _, svc] of services) { t.deepEqual(svc, expected[id], id) } }) test('lib/dates-between mutation bug', async (t) => { const readFile = (file) => { return readCsv(pathJoin(__dirname, 'fixtures', 'services-and-exceptions', file + '.txt')) } const services = readServicesAndExceptions(readFile, 'Europe/Berlin') const res = Object.create(null) for await (const [svcId, dates] of services) { if (svcId === 'T2#122' || svcId === 'T0#133') console.log(svcId, dates) res[svcId] = dates } t.deepEqual(res['T2#122'], [ '2021-06-05', '2021-06-12', '2021-06-19', '2021-06-26', '2021-07-03', '2021-07-10', '2021-07-17', '2021-07-24', '2021-07-31', '2021-08-07', '2021-08-14', '2021-08-21', '2021-08-28', ]) t.deepEqual(res['T0#133'], [ '2021-05-31', '2021-06-01', '2021-06-02', '2021-06-04', '2021-06-07', '2021-06-08', '2021-06-09', '2021-06-10', '2021-06-11', '2021-06-14', '2021-06-15', '2021-06-16', '2021-06-17', '2021-06-18', '2021-06-21', '2021-06-22', '2021-06-23', '2021-06-24', '2021-06-25', '2021-06-28', '2021-06-29', '2021-06-30', '2021-07-01', '2021-07-02', '2021-07-05', '2021-07-06', '2021-07-07', '2021-07-08', '2021-07-09', '2021-07-12', '2021-07-13', '2021-07-14', '2021-07-15', '2021-07-16', '2021-07-19', '2021-07-20', '2021-07-21', '2021-07-22', '2021-07-23', '2021-07-26', '2021-07-27', '2021-07-28', '2021-07-29', '2021-07-30', '2021-08-02', '2021-08-03', '2021-08-04', '2021-08-05', '2021-08-06', '2021-08-09', '2021-08-10', '2021-08-11', '2021-08-12', '2021-08-13', '2021-08-16', '2021-08-17', '2021-08-18', '2021-08-19', '2021-08-20', '2021-08-23', '2021-08-24', '2021-08-25', '2021-08-26', '2021-08-27', '2021-08-30', ]) }) const stopoversFixtures = readJSON5Sync(require.resolve('./fixtures/stopovers.json5')) test('compute-stopovers: works', async (t) => { const stopovers = computeStopovers(readFile, 'Europe/Berlin', { trip: t => t.trip_id === 'b-downtown-on-working-days', }) const res = [] for await (const s of stopovers) res.push(s) t.deepEqual(res, stopoversFixtures) }) test('compute-stopovers: handles DST switch properly', async (t) => { const readFile = readFilesFromFixture('daylight-saving-time') const stopovers = computeStopovers(readFile, 'Europe/Berlin') const res = [] for await (const s of stopovers) res.push(s) t.deepEqual(res, [{ stop_id: '1', trip_id: 'A1', service_id: 'sA', route_id: 'A', shape_id: undefined, start_of_trip: '2019-10-27', arrival: 1572137940, // 2019-10-27T02:59:00+02:00 departure: 1572138060, // 2019-10-27T02:01:00+01:00 }, { stop_id: '2', trip_id: 'A1', service_id: 'sA', route_id: 'A', shape_id: undefined, start_of_trip: '2019-10-27', arrival: 1572141540, // 2019-10-27T02:59:00+01:00 departure: 1572141660, // 2019-10-27T03:01:00+01:00 }, { stop_id: '2', trip_id: 'B1', service_id: 'sB', route_id: 'B', shape_id: undefined, start_of_trip: '2019-03-31', arrival: 1553990340, // 2019-03-31T00:59:00+01:00 departure: 1553990460, // 2019-03-31T01:01:00+01:00 }, { stop_id: '1', trip_id: 'B1', service_id: 'sB', route_id: 'B', shape_id: undefined, start_of_trip: '2019-03-31', arrival: 1553993940, // 2019-03-31T01:59:00+01:00 departure: 1553994060,// 2019-03-31T03:01:00+02:00 }]) }) test('compute-stopovers: handles timezones properly', async (t) => { const readFile = readFilesFromFixture('timezones') const stopovers = computeStopovers(readFile, 'Europe/Berlin') // todo: remove fallback timezone const res = [] for await (const s of stopovers) res.push(s) t.equal(res.length, 8, 'res must have 8 items') t.deepEqual(res[0], { // todo: don't slice service_id: 's1', stop_id: 's1', trip_id: 't1', route_id: 'r1', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T04:00+01:00') / 1000, departure: Date.parse('2021-02-02T04:01+01:00') / 1000, }) t.deepEqual(res[1], { stop_id: 's2', trip_id: 't1', service_id: 's1', route_id: 'r1', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T04:20+01:00') / 1000, departure: Date.parse('2021-02-02T04:21+01:00') / 1000, }) t.deepEqual(res[2], { stop_id: 's1', trip_id: 't2', service_id: 's1', route_id: 'r2', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T08:00+01:00') / 1000, departure: Date.parse('2021-02-02T08:01+01:00') / 1000, }) t.deepEqual(res[3], { stop_id: 's3', trip_id: 't2', service_id: 's1', route_id: 'r2', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T07:20+00:00') / 1000, departure: Date.parse('2021-02-02T07:21+00:00') / 1000, }) t.deepEqual(res[4], { stop_id: 's1', trip_id: 't3', service_id: 's1', route_id: 'r3', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T12:00+01:00') / 1000, departure: Date.parse('2021-02-02T12:01+01:00') / 1000, }) t.deepEqual(res[5], { stop_id: 's3a', trip_id: 't3', service_id: 's1', route_id: 'r3', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T11:20+00:00') / 1000, departure: Date.parse('2021-02-02T11:21+00:00') / 1000, }) t.deepEqual(res[6], { stop_id: 's1', trip_id: 't4', service_id: 's1', route_id: 'r4', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T16:00+01:00') / 1000, departure: Date.parse('2021-02-02T16:01+01:00') / 1000, }) t.deepEqual(res[7], { stop_id: 's3b', trip_id: 't4', service_id: 's1', route_id: 'r4', shape_id: undefined, start_of_trip: '2021-02-02', arrival: Date.parse('2021-02-02T15:20+00:00') / 1000, departure: Date.parse('2021-02-02T15:21+00:00') / 1000, }) }) test('compute-sorted-connections', async (t) => { const sortedCons = await computeSortedConnections(readFile, 'Europe/Berlin') const from = 1552324800 // 2019-03-11T18:20:00+01:00 const to = 1552377500 // 2019-03-12T08:58:20+01:00 const fromI = sortedCons.findIndex(c => c.departure >= from) const toI = sortedCons.findIndex(c => c.departure > to) const connections = sortedCons.slice(fromI, toI) t.deepEqual(connections, [{ tripId: 'b-outbound-on-working-days', serviceId: 'on-working-days', routeId: 'B', fromStop: 'lake', departure: 1552324920, toStop: 'airport', arrival: 1552325400, headwayBased: false }, { tripId: 'b-downtown-on-working-days', serviceId: 'on-working-days', routeId: 'B', fromStop: 'airport', departure: 1552377360, toStop: 'lake', arrival: 1552377720, headwayBased: false }]) }) test('compute-sorted-connections: handles timezones properly', async (t) => { const readFile = readFilesFromFixture('timezones') const cons = await computeSortedConnections(readFile, 'Europe/Berlin') t.deepEqual(cons, [{ tripId: 't1', serviceId: 's1', routeId: 'r1', fromStop: 's1', departure: Date.parse('2021-02-02T04:01+01:00') / 1000, toStop: 's2', arrival: Date.parse('2021-02-02T04:20+01:00') / 1000, headwayBased: false, }, { tripId: 't2', serviceId: 's1', routeId: 'r2', fromStop: 's1', departure: Date.parse('2021-02-02T08:01+01:00') / 1000, toStop: 's3', arrival: Date.parse('2021-02-02T07:20+00:00') / 1000, headwayBased: false, }, { tripId: 't3', serviceId: 's1', routeId: 'r3', fromStop: 's1', departure: Date.parse('2021-02-02T12:01+01:00') / 1000, toStop: 's3a', arrival: Date.parse('2021-02-02T11:20+00:00') / 1000, headwayBased: false, }, { tripId: 't4', serviceId: 's1', routeId: 'r4', fromStop: 's1', departure: Date.parse('2021-02-02T16:01+01:00') / 1000, toStop: 's3b', arrival: Date.parse('2021-02-02T15:20+00:00') / 1000, headwayBased: false, }]) }) test('compute-service-breaks', async (t) => { const connections = await computeSortedConnections(readFile, 'Europe/Berlin') const allBreaks = computeServiceBreaks(connections, { minLength: 30 * 60, // 30m }) const breaks = [] const from = 1557309600 // 2019-05-08T12:00:00+02:00 const to = 1557493200 // 2019-05-10T15:00:00+02:00 for await (const br of allBreaks) { if (br.start < from || br.start > to) continue if (br.fromStop !== 'airport' || br.toStop !== 'lake') continue breaks.push(br) } t.deepEqual(breaks, [{ fromStop: 'airport', toStop: 'lake', start: Date.parse('2019-05-08T13:14:00+02:00') / 1000, end: Date.parse('2019-05-09T08:56:00+02:00') / 1000, duration: 70920, routeId: 'B', serviceId: 'on-working-days', }, { fromStop: 'airport', toStop: 'lake', start: Date.parse('2019-05-09T08:56:00+02:00') / 1000, end: Date.parse('2019-05-09T13:14:00+02:00') / 1000, duration: 15480, routeId: 'B', serviceId: 'on-working-days', }, { fromStop: 'airport', toStop: 'lake', start: Date.parse('2019-05-09T13:14:00+02:00') / 1000, end: Date.parse('2019-05-10T08:56:00+02:00') / 1000, duration: 70920, routeId: 'B', serviceId: 'on-working-days', }, { fromStop: 'airport', toStop: 'lake', start: Date.parse('2019-05-10T08:56:00+02:00') / 1000, end: Date.parse('2019-05-10T13:14:00+02:00') / 1000, duration: 15480, routeId: 'B', serviceId: 'on-working-days', }, { fromStop: 'airport', toStop: 'lake', start: Date.parse('2019-05-10T13:14:00+02:00') / 1000, end: Date.parse('2019-05-11T08:56:00+02:00') / 1000, duration: 70920, routeId: 'B', serviceId: 'on-working-days', }]) }) test('extendedToBasic', (t) => { t.plan(2) t.equal(extendedToBasic(110), 0) t.equal(extendedToBasic(706), 3) }) require('./read-pathways') require('./read-shapes') require('./build-trajectory') require('./compute-trajectories') test('optimise-services-and-exceptions: works', async (t) => { const readFile = readFilesFromFixture('optimise-services-and-exceptions') const optimisedServices = optimiseServicesAndExceptions(readFile, 'Europe/Berlin') const res = {} const baseSvc = { monday: '0', tuesday: '0', wednesday: '0', thursday: '0', friday: '0', saturday: '0', sunday: '0', start_date: '20220301', end_date: '20220410', } const expected = { 'more-exceptions-than-regular': { changed: true, svc: { ...baseSvc, service_id: 'more-exceptions-than-regular', }, exceptions: [{ service_id: 'more-exceptions-than-regular', date: '20220302', exception_type: '1', }, { service_id: 'more-exceptions-than-regular', date: '20220324', exception_type: '1', }, { service_id: 'more-exceptions-than-regular', date: '20220330', exception_type: '1', }, { service_id: 'more-exceptions-than-regular', date: '20220331', exception_type: '1', }], }, 'more-regular-than-exceptions': { changed: true, svc: { ...baseSvc, service_id: 'more-regular-than-exceptions', monday: '1', friday: '1', }, exceptions: [], }, 'should-stay-unchanged': { changed: false, svc: { ...baseSvc, service_id: 'should-stay-unchanged', tuesday: '1', saturday: '1', }, exceptions: [{ service_id: 'should-stay-unchanged', date: '20220314', exception_type: '1', }], }, } for await (const [id, changed, svc, exceptions] of optimisedServices) { t.deepEqual(expected[id].changed, changed, id + ': changed') t.deepEqual(expected[id].svc, svc, id + ': svc') t.deepEqual(expected[id].exceptions, exceptions, id + ': exceptions') } })