diff --git a/.gitignore b/.gitignore index 44af817e..21bd24e5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ Thumbs.db node_modules npm-debug.log +package-lock.json + /id.json diff --git a/docs/arrivals.md b/docs/arrivals.md new file mode 100644 index 00000000..683c47ed --- /dev/null +++ b/docs/arrivals.md @@ -0,0 +1,3 @@ +# `arrivals(station, [opt])` + +Just like [`departures(station, [opt])`](departures.md), except that it gives arrival times instead of departure times. diff --git a/docs/changelog.md b/docs/changelog.md index 13b38e79..a227e6a1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,48 @@ # Changelog +## `3.0.0` + +This version is not fully backwords-compatible. Check out [the migration guide](migrating-to-3.md). + +### new features ✨ + +- 2d3796a BVG profile +- 0db84ce #61 parse remarks for stopovers and journey legs +- ac9819b `arrivals()` method – [docs](arrivals.md) +- 5b754aa `refreshJourney()` method – [docs](refresh-journey.md) +- 21c273c `journeys()`/`trip()`: leg stopovers: parse & expose delays +- 021ae45 `journeys()`/`trip()`: leg stopovers: parse & expose platforms +- 84bce0c `arrivals()`/`departures()`: parse & expose platforms +- 85e0bdf `journeys()`: `startWithWalking` option with default `true` +- f6ae29c journey legs with `type: 'walking'` now have a `distance` in meters +- 0d5a8fa departures, arrivals, stopovers: former scheduled platform(s) +- 0199749 `language` option with default `en` +- 1551943 `arrivals()`/`departures()`: `includeRelatedStations` option with default `true` + +### breaking changes 💥 + +- c4935bc new mandatory `User-Agent` parameter +- b7c1ee3 profiles: new products markup ([guide](https://github.com/public-transport/hafas-client/blob/ebe4fa64d871f711ced99d528c0171b180edc135/docs/writing-a-profile.md#3-products)) +- 40b559f change `radar(n, w, s, e)` signature to `radar({north, west, south, east})` +- 005f3f8 remove `journey.departure`, `journey.arrival`, … +- 0ef0301 validate `opt.when` +- 431574b parse polylines using `profile.parsePolyLine` – [docs for the output format](https://github.com/public-transport/hafas-client/blob/ebe4fa64d871f711ced99d528c0171b180edc135/docs/journey-leg.md#polyline-option) +- a356a26 throw if 0 products enabled +- c82ad23 `journeys()`: `opt.when` → `opt.departure`/`opt.arrival` +- 665bed9 rename `location(id)` to `station(id)` +- 6611f26 `journeys()`/`trip()`: `leg.passed` → `leg.stopovers` +- ebe4fa6 `journeys()`/`trip()`: `opt.passedStations` → `opt.stopovers` +- 3e672ee `journeys()`/`trip()`: `stopover.station` → `stopover.stop` +- 2e6aefe journey leg, departure, movement: `journeyId` -> `tripId` +- 8881d8a & b6fbaa5: change parsers signature to `parse…(profile, opt, data)` +- cabe5fa: option to parse & expose `station.lines`, default off +- c8ff217 rename `journeyLeg()` to `trip()` +- 8de4447 rename `profile.journeyLeg` to `profile.trip` + +### bugfixes + +- dd0a9b2 `parseStopover`: fix first/last canceled stopovers 🐛 + ## `2.10.3` - 50bd440 better `User-Agent` randomization diff --git a/docs/departures.md b/docs/departures.md index 255478c3..a7680a73 100644 --- a/docs/departures.md +++ b/docs/departures.md @@ -23,17 +23,24 @@ With `opt`, you can override the default options, which look like this: ```js { + // todo: products when: new Date(), direction: null, // only show departures heading to this station - duration: 10 // show departures for the next n minutes + duration: 10, // show departures for the next n minutes + stationLines: false, // parse & expose lines of the station? + remarks: true, // parse & expose hints & warnings? + // departures at related stations + // e.g. those that belong together on the metro map. + includeRelatedStations: true, + language: 'en' // language to get results in } ``` ## Response -*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the `when` field includes the current delay. The `delay` field, if present, expresses how much the former differs from the schedule. +*Note:* As stated in the [*Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.1.1), the `when` field includes the current delay. The `delay` field, if present, expresses how much the former differs from the schedule. -You may pass the `journeyId` field into [`journeyLeg(ref, lineName, [opt])`](journey-leg.md) to get details on the vehicle's journey. +You may pass the `tripId` field into [`trip(id, lineName, [opt])`](trip.md) to get details on the vehicle's trip. As an example, we're going to use the [VBB profile](../p/vbb): @@ -41,7 +48,7 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') // S Charlottenburg client.departures('900000024101', {duration: 3}) @@ -53,9 +60,8 @@ The response may look like this: ```js [ { - journeyId: '1|31431|28|86|17122017', - trip: 31431, - station: { + tripId: '1|31431|28|86|17122017', + stop: { type: 'station', id: '900000024101', name: 'S Charlottenburg', @@ -80,10 +86,10 @@ The response may look like this: line: { type: 'line', id: '18299', - name: 'S9', - public: true, mode: 'train', product: 'suburban', + public: true, + name: 'S9', symbol: 'S', nr: 9, metro: false, @@ -96,21 +102,21 @@ The response may look like this: name: 'S-Bahn Berlin GmbH' } }, - direction: 'S Spandau' + direction: 'S Spandau', + trip: 31431 }, { - journeyId: '1|30977|8|86|17122017', - trip: 30977, - station: { /* … */ }, + tripId: '1|30977|8|86|17122017', + stop: { /* … */ }, when: null, delay: null, cancelled: true, line: { type: 'line', id: '16441', - name: 'S5', - public: true, mode: 'train', product: 'suburban', + public: true, + name: 'S5', symbol: 'S', nr: 5, metro: false, @@ -119,39 +125,22 @@ The response may look like this: productCode: 0, operator: { /* … */ } }, - direction: 'S Westkreuz' + direction: 'S Westkreuz', + trip: 30977 }, { - journeyId: '1|28671|4|86|17122017', + tripId: '1|28671|4|86|17122017', trip: 28671, - station: { - type: 'station', - id: '900000024202', - name: 'U Wilmersdorfer Str.', - location: { - type: 'location', - latitude: 52.506415, - longitude: 13.306777 - }, - products: { - suburban: false, - subway: true, - tram: false, - bus: false, - ferry: false, - express: false, - regional: false - } - }, + stop: { /* … */ }, when: '2017-12-17T19:35:00.000+01:00', delay: 0, platform: null, line: { type: 'line', id: '19494', - name: 'U7', - public: true, mode: 'train', product: 'subway', + public: true, + name: 'U7', symbol: 'U', nr: 7, metro: false, diff --git a/docs/journey-leg.md b/docs/journey-leg.md deleted file mode 100644 index f54b0e6e..00000000 --- a/docs/journey-leg.md +++ /dev/null @@ -1,121 +0,0 @@ -# `journeyLeg(ref, lineName, [opt])` - -This method can be used to refetch information about a leg of a journey. Note that it is not supported by every profile/endpoint. - -Let's say you used [`journeys`](journeys.md) and now want to get more up-to-date data about the arrival/departure of a leg. You'd pass in a journey leg `id` like `'1|24983|22|86|18062017'`. `lineName` must be the name of the journey leg's `line.name`. You can get them like this: - -```js -const createClient = require('hafas-client') -const vbbProfile = require('hafas-client/p/vbb') - -const client = createClient(vbbProfile) - -// Hauptbahnhof to Heinrich-Heine-Str. -client.journeys('900000003201', '900000100008', {results: 1}) -.then(([journey]) => { - const leg = journey.legs[0] - return client.journeyLeg(leg.id, leg.line.name) -}) -.then(console.log) -.catch(console.error) -``` - -With `opt`, you can override the default options, which look like this: - -```js -{ - when: new Date(), - passedStations: true, // return stations on the way? - polyline: false // return a shape for the leg? -} -``` - -## Response - -*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. - -As an example, we're going to use the [VBB profile](../p/vbb): - -```js -const createClient = require('hafas-client') -const vbbProfile = require('hafas-client/p/vbb') - -const client = createClient(vbbProfile) - -client.journeyLeg('1|31431|28|86|17122017', 'S9', {when: 1513534689273}) -.then(console.log) -.catch(console.error) -``` - -The response looked like this: - -```js -{ - id: '1|31431|28|86|17122017', - origin: { - type: 'station', - id: '900000260005', - name: 'S Flughafen Berlin-Schönefeld', - location: { - type: 'location', - latitude: 52.390796, - longitude: 13.51352 - }, - products: { - suburban: true, - subway: false, - tram: false, - bus: true, - ferry: false, - express: false, - regional: true - } - }, - departure: '2017-12-17T18:37:00.000+01:00', - departurePlatform: '13', - destination: { - type: 'station', - id: '900000029101', - name: 'S Spandau', - location: { - type: 'location', - latitude: 52.534794, - longitude: 13.197477 - }, - products: { - suburban: true, - subway: false, - tram: false, - bus: true, - ferry: false, - express: true, - regional: true - } - }, - arrival: '2017-12-17T19:49:00.000+01:00', - arrivalPlatform: '2', - line: { - type: 'line', - id: '18299', - name: 'S9', - public: true, - mode: 'train', - product: 'suburban', - symbol: 'S', - nr: 9, - metro: false, - express: false, - night: false, - productCode: 0, - operator: { - type: 'operator', - id: 's-bahn-berlin-gmbh', - name: 'S-Bahn Berlin GmbH' - } - }, - direction: 'S Spandau', - passed: [ /* … */ ] -} -``` - -If you pass `polyline: true`, the leg will have a `polyline` field, containing an encoded shape. You can use e.g. [`@mapbox/polyline`](https://www.npmjs.com/package/@mapbox/polyline) to decode it. diff --git a/docs/journeys.md b/docs/journeys.md index d928d361..3242eb23 100644 --- a/docs/journeys.md +++ b/docs/journeys.md @@ -40,13 +40,15 @@ With `opt`, you can override the default options, which look like this: ```js { - when: new Date(), - whenRepresents: 'departure', // use 'arrival' for journeys arriving before `when` + // Use either `departure` or `arrival` to specify a date/time. + departure: new Date(), + arrival: null, + earlierThan: null, // ref to get journeys earlier than the last query laterThan: null, // ref to get journeys later than the last query results: 5, // how many journeys? via: null, // let journeys pass this station - passedStations: false, // return stations on the way? + stopovers: false, // return stations on the way? transfers: 5, // maximum of 5 transfers transferTime: 0, // minimum time for a single transfer in minutes accessibility: 'none', // 'none', 'partial' or 'complete' @@ -63,14 +65,16 @@ With `opt`, you can override the default options, which look like this: }, tickets: false, // return tickets? only available with some profiles polylines: false, // return a shape for each leg? + remarks: true, // parse & expose hints & warnings? // Consider walking to nearby stations at the beginning of a journey? - startWithWalking: false + startWithWalking: true, + language: 'en' // language to get results in } ``` ## Response -*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. +*Note:* As stated in the [*Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.1.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. As an example, we're going to use the [VBB profile](../p/vbb): @@ -78,12 +82,12 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') // Hauptbahnhof to Heinrich-Heine-Str. client.journeys('900000003201', '900000100008', { results: 1, - passedStations: true + stopovers: true }) .then(console.log) .catch(console.error) @@ -95,7 +99,7 @@ The response may look like this: [ { legs: [ { - id: '1|31041|35|86|17122017', + id: '1|32615|6|86|10072018', origin: { type: 'station', id: '900000003201', @@ -115,30 +119,23 @@ The response may look like this: regional: true } }, - departure: '2017-12-17T19:07:00.000+01:00', - departurePlatform: '16', destination: { type: 'station', - id: '900000024101', - name: 'S Charlottenburg', + id: '900000100004', + name: 'S+U Jannowitzbrücke', location: { type: 'location', latitude: 52.504806, longitude: 13.303846 }, - products: { - suburban: true, - subway: false, - tram: false, - bus: true, - ferry: false, - express: false, - regional: true - } + products: { /* … */ } }, - arrival: '2017-12-17T19:47:00.000+01:00', - arrivalPlatform: '8', - arrivalDelay: 30, + departure: '2018-07-10T23:54:00.000+02:00', + departureDelay: 60, + departurePlatform: '15', + arrival: '2018-07-11T00:02:00.000+02:00', + arrivalDelay: 60, + arrivalPlatform: '3', line: { type: 'line', id: '16845', @@ -146,21 +143,21 @@ The response may look like this: public: true, mode: 'train', product: 'suburban', + operator: { + type: 'operator', + id: 's-bahn-berlin-gmbh', + name: 'S-Bahn Berlin GmbH' + }, symbol: 'S', nr: 7, metro: false, express: false, night: false, - productCode: 0, - operator: { - type: 'operator', - id: 's-bahn-berlin-gmbh', - name: 'S-Bahn Berlin GmbH' - } + productCode: 0 }, - direction: 'S Potsdam Hauptbahnhof', - passed: [ { - station: { + direction: 'S Ahrensfelde', + stopovers: [ { + stop: { type: 'station', id: '900000003201', name: 'S+U Berlin Hauptbahnhof', @@ -169,46 +166,64 @@ The response may look like this: }, arrival: null, departure: null, - cancelled: true + cancelled: true, + remarks: [ + {type: 'hint', code: 'bf', text: 'barrier-free'}, + {type: 'hint', code: 'FB', text: 'Bicycle conveyance'} + ] }, { - station: { + stop: { type: 'station', - id: '900000003102', - name: 'S Bellevue', + id: '900000100001', + name: 'S+U Friedrichstr.', location: { /* … */ }, products: { /* … */ } }, - arrival: '2017-12-17T19:09:00.000+01:00', - departure: '2017-12-17T19:09:00.000+01:00' - }, /* … */ { - station: { + arrival: '2018-07-10T23:56:00.000+02:00', + arrivalDelay: 60, + arrivalPlatform: null, + departure: '2018-07-10T23:57:00.000+02:00', + departureDelay: 60, + departurePlatform: null, + remarks: [ /* … */ ] + }, + /* … */ + { type: 'station', - id: '900000024101', - name: 'S Charlottenburg', + id: '900000100004', + name: 'S+U Jannowitzbrücke', location: { /* … */ }, products: { /* … */ } }, - arrival: '2017-12-17T19:17:00.000+01:00', - departure: '2017-12-17T19:17:00.000+01:00' + arrival: '2018-07-11T00:02:00.000+02:00', + arrivalDelay: 60, + arrivalPlatform: null, + departure: '2018-07-11T00:02:00.000+02:00', + departureDelay: null, + departurePlatform: null, + remarks: [ /* … */ ] } ] - } ], - origin: { - type: 'station', - id: '900000003201', - name: 'S+U Berlin Hauptbahnhof', - location: { /* … */ }, - products: { /* … */ } - }, - departure: '2017-12-17T19:07:00.000+01:00', - destination: { - type: 'station', - id: '900000024101', - name: 'S Charlottenburg', - location: { /* … */ }, - products: { /* … */ } - }, - arrival: '2017-12-17T19:47:00.000+01:00', - arrivalDelay: 30 + }, { + origin: { + type: 'station', + id: '900000100004', + name: 'S+U Jannowitzbrücke', + location: { /* … */ }, + products: { /* … */ } + }, + destination: { + type: 'station', + id: '900000100008', + name: 'U Heinrich-Heine-Str.', + location: { /* … */ }, + products: { /* … */ } + }, + departure: '2018-07-11T00:01:00.000+02:00', + arrival: '2018-07-11T00:10:00.000+02:00', + mode: 'walking', + public: true, + distance: 558 + } ] }, earlierRef: '…', // use with the `earlierThan` option laterRef: '…' // use with the `laterThan` option @@ -263,16 +278,16 @@ const heinrichHeineStr = '900000100008' client.journeys(hbf, heinrichHeineStr) .then((journeys) => { const lastJourney = journeys[journeys.length - 1] - console.log('departure of last journey', lastJourney.departure) + console.log('departure of last journey', lastJourney.legs[0].departure) // get later journeys return client.journeys(hbf, heinrichHeineStr, { laterThan: journeys.laterRef }) }) -.then((laterourneys) => { - const firstJourney = laterourneys[laterourneys.length - 1] - console.log('departure of first (later) journey', firstJourney.departure) +.then((laterJourneys) => { + const firstJourney = laterJourneys[laterJourneys.length - 1] + console.log('departure of first (later) journey', firstJourney.legs[0].departure) }) .catch(console.error) ``` @@ -282,4 +297,4 @@ departure of last journey 2017-12-17T19:07:00.000+01:00 departure of first (later) journey 2017-12-17T19:19:00.000+01:00 ``` -If you pass `polylines: true`, each journey leg will have a `polyline` field, containing an encoded shape. You can use e.g. [`@mapbox/polyline`](https://www.npmjs.com/package/@mapbox/polyline) to decode it. +If you pass `polylines: true`, each journey leg will have a `polyline` field. Refer to [the section in the `trip()` docs](trip.md#polyline-option) for details. diff --git a/docs/locations.md b/docs/locations.md index 8b9d17ab..169dd342 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -11,6 +11,8 @@ With `opt`, you can override the default options, which look like this: , stations: true , addresses: true , poi: true // points of interest + , stationLines: false // parse & expose lines of the station? + , language: 'en' // language to get results in } ``` @@ -22,7 +24,7 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') client.locations('Alexanderplatz', {results: 3}) .then(console.log) @@ -33,7 +35,7 @@ The response may look like this: ```js [ { - type: 'station', + type: 'stop', id: '900000100003', name: 'S+U Alexanderplatz', location: { @@ -52,14 +54,14 @@ The response may look like this: } }, { // point of interest type: 'location', - name: 'Berlin, Holiday Inn Centre Alexanderplatz****', id: '900980709', + name: 'Berlin, Holiday Inn Centre Alexanderplatz****', latitude: 52.523549, longitude: 13.418441 }, { // point of interest type: 'location', - name: 'Berlin, Hotel Agon am Alexanderplatz', id: '900980176', + name: 'Berlin, Hotel Agon am Alexanderplatz', latitude: 52.524556, longitude: 13.420266 } ] diff --git a/docs/migrating-to-3.md b/docs/migrating-to-3.md new file mode 100644 index 00000000..0855dd02 --- /dev/null +++ b/docs/migrating-to-3.md @@ -0,0 +1,71 @@ +# Migrating to `hafas-client@3` + +## New `User-Agent` parameter + +Pass an additional `User-Agent` string into `createClient`: + +```js +const createClient = require('hafas-client') +const dbProfile = require('hafas-client/p/db') + +const client = createClient(dbProfile, 'my-awesome-program') +``` + +Pick a name that describes your program and – if possible – the website/repo of it. + +## If you use the `journeyLeg()` method… + +…change the `journeyLeg(id, lineName)` call to `trip(id, lineName)`. c8ff217 + +## If you use the `journeys()` or `trip()` methods… + +- …instead of `journey.departure`, use `journey.legs[0].departure`. 005f3f8 +- …instead of `journey.arrival`, use `journey.legs[last].arrival`. 005f3f8 +- …rename `opt.passedStations` to `opt.stopovers`. ebe4fa6 +- …rename `leg.journeyId` to `leg.tripId`. 2e6aefe +- …rename `leg.passed` to `leg.stopovers`. 6611f26 +- …rename `leg.stopovers[].station` to `leg.stopovers[].stop`. 3e672ee + +## If you use the `journeys()` method and `opt.when`… + +…use `opt.departure` instead. Use `opt.arrival` to get journeys arriving before the specified date+time. This replaces the `opt.when` & `opt.whenRepresents` options from `hafas-client@2`. c82ad23 + +## If you use the `journeys()` and `opt.polylines` or `trip()` and `opt.polyline`… + +…`leg.polyline` will be [parsed for you now](https://github.com/public-transport/hafas-client/blob/f6c824eecb459181ea90ddf41bf1a1e8b64539ec/docs/journey-leg.md#polyline-option). + +## If you use the `departures()` method… + +…rename `departure.journeyId` to `departure.tripId`. 2e6aefe + +## If you use the `location()` method… + +…change the `location(id)` call to `station(id)`. 665bed9 + +## If you use the `radar()` method… + +- …change the `radar(north, west, south, east)` call to `radar({north, west, south, east})`. 40b559f +- …rename `movement.journeyId` to `movement.tripId`. 2e6aefe + +## If you use `hafas-client` with a custom profile… + +- …write your profile in [the new format](writing-a-profile.md). Then, you can pass it into `hafas-client` just like before. #32/b7c1ee3 +- …rename the `profile.journeyLeg` flag to `profile.trip`. 8de4447 + +## If you use `hafas-client` with custom parse functions… + +…change the following parsers to the `parse…(profile, opt, data)` signature. 8881d8a/b6fbaa5 + +- `parseDeparture` +- `parseJourney` +- `parseJourneyLeg` +- `parseLine` +- `parseMovement` +- `parseLocation` +- `parseNearby` +- `parsePolyline` +- `parseStopover` + +## If you use `station.lines` array anywhere… + +…add the `stationLines: true` option to the method call, e.g. `hafas.departures('123', {stationLines: true}). cabe5fa diff --git a/docs/nearby.md b/docs/nearby.md index 397162f0..21123b8f 100644 --- a/docs/nearby.md +++ b/docs/nearby.md @@ -2,7 +2,7 @@ This method can be used to find stations close to a location. Note that it is not supported by every profile/endpoint. -`location` must be an [*FPTF* `location` object](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#location-objects). +`location` must be an [*FPTF* `location` object](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md#location-objects). With `opt`, you can override the default options, which look like this: @@ -11,6 +11,8 @@ With `opt`, you can override the default options, which look like this: distance: null, // maximum walking distance in meters poi: false, // return points of interest? stations: true, // return stations? + stationLines: false, // parse & expose lines of the station? + language: 'en' // language to get results in } ``` @@ -22,7 +24,7 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') client.nearby({ type: 'location', @@ -37,7 +39,7 @@ The response may look like this: ```js [ { - type: 'station', + type: 'stop', id: '900000120001', name: 'S+U Frankfurter Allee', location: { @@ -56,7 +58,7 @@ The response may look like this: }, distance: 56 }, { - type: 'station', + type: 'stop', id: '900000120540', name: 'Scharnweberstr./Weichselstr.', location: { @@ -67,7 +69,7 @@ The response may look like this: products: { /* … */ }, distance: 330 }, { - type: 'station', + type: 'stop', id: '900000160544', name: 'Rathaus Lichtenberg', location: { diff --git a/docs/profile-boilerplate.js b/docs/profile-boilerplate.js new file mode 100644 index 00000000..97f7291c --- /dev/null +++ b/docs/profile-boilerplate.js @@ -0,0 +1,44 @@ +'use strict' + +// see the ./writing-a-profile.md guide +const products = [ + { + id: 'nationalExp', + mode: 'train', + bitmasks: [1], + name: 'InterCityExpress', + short: 'ICE', + default: true + }, + { + id: 'national', + mode: 'train', + bitmasks: [2], + name: 'InterCity & EuroCity', + short: 'IC/EC', + default: true + } +] + +const transformReqBody = (body) => { + // get these from the recorded app requests + // body.client = { … } + // body.ver = … + // body.auth = { … } + // body.lang = … + return body +} + +const insaProfile = { + // locale: …, + // timezone: …, + // endpoint: …, + transformReqBody, + + products: products, + + trip: false, + radar: false +} + +module.exports = insaProfile diff --git a/docs/radar.md b/docs/radar.md index 8c4fa6a8..308f6dce 100644 --- a/docs/radar.md +++ b/docs/radar.md @@ -1,4 +1,4 @@ -# `radar(north, west, south, east, [opt])` +# `radar({north, west, south, east}, [opt])` Use this method to find all vehicles currently in an area. Note that it is not supported by every profile/endpoint. @@ -11,13 +11,14 @@ With `opt`, you can override the default options, which look like this: results: 256, // maximum number of vehicles duration: 30, // compute frames for the next n seconds frames: 3, // nr of frames to compute - polylines: false // return a track shape for each vehicle? + polylines: false, // return a track shape for each vehicle? + language: 'en' // language to get results in } ``` ## Response -*Note:* As stated in the [*Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.0.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. +*Note:* As stated in the [*Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.1.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. As an example, we're going to use the [VBB profile](../p/vbb): @@ -25,9 +26,14 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') -client.radar(52.52411, 13.41002, 52.51942, 13.41709, {results: 5}) +client.radar({ + north: 52.52411, + west: 13.41002, + south: 52.51942, + east: 13.41709 +}, {results: 5}) .then(console.log) .catch(console.error) ``` @@ -62,8 +68,8 @@ The response may look like this: direction: 'S Flughafen Berlin-Schönefeld', trip: 31463, nextStops: [ { - station: { - type: 'station', + stop: { + type: 'stop', id: '900000029101', name: 'S Spandau', location: { @@ -88,14 +94,14 @@ The response may look like this: } /* … */ ], frames: [ { origin: { - type: 'station', + type: 'stop', id: '900000100003', name: 'S+U Alexanderplatz', location: { /* … */ }, products: { /* … */ } }, destination: { - type: 'station', + type: 'stop', id: '900000100004', name: 'S+U Jannowitzbrücke', location: { /* … */ }, @@ -134,13 +140,13 @@ The response may look like this: direction: 'Heinersdorf', trip: 26321, nextStops: [ { - station: { /* S+U Alexanderplatz/Dircksenstr. */ }, + stop: { /* S+U Alexanderplatz/Dircksenstr. */ }, arrival: null, arrivalDelay: null, departure: '2017-12-17T19:52:00.000+01:00', departureDelay: null }, { - station: { /* Memhardstr. */ }, + stop: { /* Memhardstr. */ }, arrival: '2017-12-17T19:54:00.000+01:00', arrivalDelay: null, departure: '2017-12-17T19:54:00.000+01:00', @@ -158,4 +164,4 @@ The response may look like this: }, /* … */ ] ``` -If you pass `polylines: true`, each result will have a `polyline` field, containing an encoded shape. You can use e.g. [`@mapbox/polyline`](https://www.npmjs.com/package/@mapbox/polyline) to decode it. +If you pass `polylines: true`, each movement will have a `polyline` field, as documented in [the corresponding section in the `trip()` docs](trip.md#polyline-option), with the exception that station info is missing. diff --git a/docs/readme.md b/docs/readme.md index ea55aa82..9c85bce2 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,10 +1,12 @@ # API documentation - [`journeys(from, to, [opt])`](journeys.md) – get journeys between locations -- [`journeyLeg(ref, lineName, [opt])`](journey-leg.md) – get details for a leg of a journey +- [`refreshJourney(refreshToken, [opt])`](refresh-journey.md) – fetch up-to-date/more details of a `journey` +- [`trip(id, lineName, [opt])`](trip.md) – get details for a trip - [`departures(station, [opt])`](departures.md) – query the next departures at a station +- [`arrivals(station, [opt])`](arrivals.md) – query the next arrivals at a station - [`locations(query, [opt])`](locations.md) – find stations, POIs and addresses -- [`location(id)`](location.md) – get details about a location +- [`station(id, [opt])`](station.md) – get details about a station - [`nearby(location, [opt])`](nearby.md) – show stations & POIs around - [`radar(north, west, south, east, [opt])`](radar.md) – find all vehicles currently in a certain area diff --git a/docs/refresh-journey.md b/docs/refresh-journey.md new file mode 100644 index 00000000..1d2da5e6 --- /dev/null +++ b/docs/refresh-journey.md @@ -0,0 +1,38 @@ +# `refreshJourney(refreshToken, [opt])` + +`refreshToken` must be a string, taken from `journey.refreshToken`. + +With `opt`, you can override the default options, which look like this: + +```js +{ + stopovers: false, // return stations on the way? + polylines: false, // return a shape for each leg? + tickets: false, // return tickets? only available with some profiles + remarks: true, // parse & expose hints & warnings? + language: 'en' // language to get results in +} +``` + +## Response + +As an example, we're going to use the [VBB profile](../p/vbb): + +```js +const createClient = require('hafas-client') +const vbbProfile = require('hafas-client/p/vbb') + +const client = createClient(vbbProfile) + +// Hauptbahnhof to Heinrich-Heine-Str. +client.journeys('900000003201', '900000100008', {results: 1}) +.then(([journey]) => { + // later, fetch up-to-date info on the journey + client.refreshJourney(journey.refreshToken, {stopovers: true, remarks: true}) + .then(console.log) + .catch(console.error) +}) +.catch(console.error) +``` + +`refreshJourney()` will return a *single* [*Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.1.1) `journey`, in the same format as with `journeys()`. diff --git a/docs/location.md b/docs/station.md similarity index 68% rename from docs/location.md rename to docs/station.md index 72cf9a40..831d6ff9 100644 --- a/docs/location.md +++ b/docs/station.md @@ -1,9 +1,9 @@ -# `location(station)` +# `station(id, [opt])` -`station` must be in one of these formats: +`id` must be in one of these formats: ```js -// a station ID, in a format compatible to the profile you use +// a station ID, in a format compatible with the profile you use '900000123456' // an FPTF `station` object @@ -19,6 +19,15 @@ } ``` +With `opt`, you can override the default options, which look like this: + +```js +{ + stationLines: false, // parse & expose lines of the station? + language: 'en' // language to get results in +} +``` + ## Response As an example, we're going to use the [VBB profile](../p/vbb): @@ -27,9 +36,9 @@ As an example, we're going to use the [VBB profile](../p/vbb): const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') -client.location('900000042101') // U Spichernstr. +client.station('900000042101') // U Spichernstr. .then(console.log) .catch(console.error) ``` @@ -38,7 +47,7 @@ The response may look like this: ```js { - type: 'station', + type: 'stop', id: '900000042101', name: 'U Spichernstr.', location: { @@ -58,24 +67,26 @@ The response may look like this: lines: [ { type: 'line', id: 'u1', - name: 'U1', - public: true, - class: 2, - product: 'subway', mode: 'train', + product: 'subway', + public: true, + name: 'U1', + class: 2, symbol: 'U', nr: 1, metro: false, express: false, - night: false }, - // … - { type: 'line', + night: false + }, + // … + { + type: 'line', id: 'n9', - name: 'N9', - public: true, - class: 8, - product: 'bus', mode: 'bus', + product: 'bus', + public: true, + name: 'N9', + class: 8, symbol: 'N', nr: 9, metro: false, diff --git a/docs/trip.md b/docs/trip.md new file mode 100644 index 00000000..b4f0976e --- /dev/null +++ b/docs/trip.md @@ -0,0 +1,187 @@ +# `trip(id, lineName, [opt])` + +This method can be used to refetch information about a trip – a vehicle stopping at a set of stops at specific times. + +*Note*: This method is not supported by every profile/endpoint. + +Let's say you used [`journeys`](journeys.md) and now want to get more up-to-date data about the arrival/departure of a leg. You'd pass in the trip ID from `leg.id`, e.g. `'1|24983|22|86|18062017'`, and the name of the line from `leg.line.name` like this: + +```js +const createClient = require('hafas-client') +const vbbProfile = require('hafas-client/p/vbb') + +const client = createClient(vbbProfile, 'my-awesome-program') + +// Hauptbahnhof to Heinrich-Heine-Str. +client.journeys('900000003201', '900000100008', {results: 1}) +.then(([journey]) => { + const leg = journey.legs[0] + return client.trip(leg.id, leg.line.name) +}) +.then(console.log) +.catch(console.error) +``` + +With `opt`, you can override the default options, which look like this: + +```js +{ + when: new Date(), + stopovers: true, // return stations on the way? + polyline: false, // return a shape for the trip? + remarks: true, // parse & expose hints & warnings? + language: 'en' // language to get results in +} +``` + +## Response + +*Note:* As stated in the [*Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/tree/1.1.1), the returned `departure` and `arrival` times include the current delay. The `departureDelay`/`arrivalDelay` fields express how much they differ from the schedule. + +As an example, we're going to use the [VBB profile](../p/vbb): + +```js +const createClient = require('hafas-client') +const vbbProfile = require('hafas-client/p/vbb') + +const client = createClient(vbbProfile) + +client.trip('1|31431|28|86|17122017', 'S9', {when: 1513534689273}) +.then(console.log) +.catch(console.error) +``` + +The response looked like this: + +```js +{ + id: '1|31431|28|86|17122017', + origin: { + type: 'station', + id: '900000260005', + name: 'S Flughafen Berlin-Schönefeld', + location: { + type: 'location', + latitude: 52.390796, + longitude: 13.51352 + }, + products: { + suburban: true, + subway: false, + tram: false, + bus: true, + ferry: false, + express: false, + regional: true + } + }, + departure: '2017-12-17T18:37:00.000+01:00', + departurePlatform: '13', + destination: { + type: 'station', + id: '900000029101', + name: 'S Spandau', + location: { + type: 'location', + latitude: 52.534794, + longitude: 13.197477 + }, + products: { + suburban: true, + subway: false, + tram: false, + bus: true, + ferry: false, + express: true, + regional: true + } + }, + arrival: '2017-12-17T19:49:00.000+01:00', + arrivalPlatform: '2', + line: { + type: 'line', + id: '18299', + name: 'S9', + public: true, + mode: 'train', + product: 'suburban', + symbol: 'S', + nr: 9, + metro: false, + express: false, + night: false, + productCode: 0, + operator: { + type: 'operator', + id: 's-bahn-berlin-gmbh', + name: 'S-Bahn Berlin GmbH' + } + }, + direction: 'S Spandau', + stopovers: [ /* … */ ] +} +``` + +### `polyline` option + +If you pass `polyline: true`, the trip will have a `polyline` field, containing a [GeoJSON](http://geojson.org) [`FeatureCollection`](https://tools.ietf.org/html/rfc7946#section-3.3) of [`Point`s](https://tools.ietf.org/html/rfc7946#appendix-A.1). Every `Point` next to a station will have `properties` containing the station's metadata. + +We'll look at an example for *U6* from *Alt-Mariendorf* to *Alt-Tegel*, taken from the [VBB profile](../p/vbb): + +```js +{ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + type: 'station', + id: '900000070301', + name: 'U Alt-Mariendorf', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.3875, 52.43993] // longitude, latitude + } + }, + /* … */ + { + type: 'Feature', + properties: { + type: 'station', + id: '900000017101', + name: 'U Mehringdamm', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.38892, 52.49448] // longitude, latitude + } + }, + /* … */ + { + // intermediate point, without associated station + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [13.28599, 52.58742] // longitude, latitude + } + }, + { + type: 'Feature', + properties: { + type: 'station', + id: '900000089301', + name: 'U Alt-Tegel', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.28406, 52.58915] // longitude, latitude + } + } + ] +} +``` diff --git a/docs/writing-a-profile.md b/docs/writing-a-profile.md index e44a8d0f..426dfae2 100644 --- a/docs/writing-a-profile.md +++ b/docs/writing-a-profile.md @@ -12,7 +12,7 @@ This guide is about writing such a profile. If you just want to use an already s ## 0. How do the profiles work? -A profile contains of three things: +A profile may consist of three things: - **mandatory details about the HAFAS endpoint** - `endpoint`: The protocol, host and path of the endpoint. @@ -37,8 +37,8 @@ Assuming the endpoint returns all lines names prefixed with `foo `, We can strip // get the default line parser const createParseLine = require('hafas-client/parse/line') -const createParseLineWithoutFoo = (profile, operators) => { - const parseLine = createParseLine(profile, operators) +const createParseLineWithoutFoo = (profile, opt, data) => { + const parseLine = createParseLine(profile, opt, data) // wrapper function with additional logic const parseLineWithoutFoo = (l) => { @@ -78,47 +78,50 @@ If you pass this profile into `hafas-client`, the `parseLine` method will overri - Add a function `transformReqBody(body)` to your profile, which assigns them to `body`. - Some profiles have a `checksum` parameter (like [here](https://gist.github.com/derhuerst/2a735268bd82a0a6779633f15dceba33#file-journey-details-1-http-L1)) or two `mic` & `mac` parameters (like [here](https://gist.github.com/derhuerst/5fa86ed5aec63645e5ae37e23e555886#file-1-http-L1)). If you see one of them in your requests, jump to [*Appendix A: checksum, mic, mac*](#appendix-a-checksum-mic-mac). Unfortunately, this is necessary to get the profile working. +You may want to use the [profile boilerplate code](profile-boilerplate.js). + ## 3. Products In `hafas-client`, there's a difference between the `mode` and the `product` field: -- The `mode` field describes the mode of transport in general. [Standardised by the *Friendly Public Transport Format* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#modes), it is on purpose limited to a very small number of possible values, e.g. `train` or `bus`. +- The `mode` field describes the mode of transport in general. [Standardised by the *Friendly Public Transport Format* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md#modes), it is on purpose limited to a very small number of possible values, e.g. `train` or `bus`. - The value for `product` relates to how a means of transport "works" *in local context*. Example: Even though [*S-Bahn*](https://en.wikipedia.org/wiki/Berlin_S-Bahn) and [*U-Bahn*](https://en.wikipedia.org/wiki/Berlin_U-Bahn) in Berlin are both `train`s, they have different operators, service patterns, stations and look different. Therefore, they are two distinct `product`s `subway` and `suburban`. **Specify `product`s that appear in the app** you recorded requests of. For a fictional transit network, this may look like this: ```js -const products = { - commuterTrain: { - product: 'commuterTrain', +const products = [ + { + id: 'commuterTrain', mode: 'train', - bitmask: 1, + bitmasks: [16], name: 'ACME Commuter Rail', - short: 'CR' + short: 'CR', + default: true }, - metro: { - product: 'metro', + { + id: 'metro', mode: 'train', - bitmask: 2, + bitmasks: [8], name: 'Foo Bar Metro', - short: 'M' + short: 'M', + default: true } -} +] ``` Let's break this down: -- `product`: A sensible, [camelCased](https://en.wikipedia.org/wiki/Camel_case#Variations_and_synonyms), alphanumeric identifier. Use it for the key in the `products` object as well. -- `mode`: A [valid *Friendly Public Transport Format* `1.0.1` mode](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#modes). -- `bitmask`: HAFAS endpoints work with a [bitmask](https://en.wikipedia.org/wiki/Mask_(computing)#Arguments_to_functions) that toggles the individual products. the value should toggle the appropriate bit(s) in the bitmask (see below). +- `id`: A sensible, [camelCased](https://en.wikipedia.org/wiki/Camel_case#Variations_and_synonyms), alphanumeric identifier. Use it for the key in the `products` array as well. +- `mode`: A [valid *Friendly Public Transport Format* `1.1.1` mode](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md#modes). +- `bitmasks`: HAFAS endpoints work with a [bitmask](https://en.wikipedia.org/wiki/Mask_(computing)#Arguments_to_functions) that toggles the individual products. It should be an array of values that toggle the appropriate bit(s) in the bitmask (see below). - `name`: A short, but distinct name for the means of transport, *just precise enough in local context*, and in the local language. In Berlin, `S-Bahn-Schnellzug` would be too much, because everyone knows what `S-Bahn` means. - `short`: The shortest possible symbol that identifies the product. +- `default`: Should the product be used for queries (e.g. journeys) by default? -todo: `defaultProducts`, `allProducts`, `bitmasks`, add to profile +If you want, you can now **verify that the profile works**; We've prepared [a script](https://runkit.com/derhuerst/hafas-client-profile-example/0.2.1) for that. Alternatively, [submit a Pull Request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) and we will help you out with testing and improvements. -If you want, you can now **verify that the profile works**; We've prepared [a script](https://runkit.com/public-transport/hafas-client-profile-example/0.1.0) for that. Alternatively, [submit a Pull Request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) and we will help you out with testing and improvements. - -### Finding the right values for the `bitmask` field +### Finding the right values for the `bitmasks` field As shown in [the video](https://stuff.jannisr.de/how-to-record-hafas-requests.mp4), search for a journey and toggle off one product at a time, recording the requests. After extracting the products bitmask ([example](https://gist.github.com/derhuerst/193ef489f8aa50c2343f8bf1f2a22069#file-via-http-L34)) you will end up with values looking like these: @@ -127,17 +130,17 @@ toggles value binary subtraction bit(s) all products 31 11111 31 - 0 all but ACME Commuter Rail 15 01111 31 - 2^4 2^4 all but Foo Bar Metro 23 10111 31 - 2^3 2^3 -all but product E 30 11001 31 - 2^2 - 2^1 2^2, 2^1 -all but product F 253 11110 31 - 2^1 2^0 +all but product E 25 11001 31 - 2^2 - 2^1 2^2, 2^1 +all but product F 30 11110 31 - 2^0 2^0 ``` ## 4. Additional info We consider these improvements to be *optional*: -- **Check if the endpoint supports the journey legs call.** +- **Check if the endpoint supports the trips call.** - In the app, check if you can query details for the status of a single journey leg. It should load realtime delays and the current progress. - - If this feature is supported, add `journeyLeg: true` to the profile. + - If this feature is supported, add `trip: true` to the profile. - **Check if the endpoint supports the live map call.** Does the app have a "live map" showing all vehicles within an area? If so, add `radar: true` to the profile. - **Consider transforming station & line names** into the formats that's most suitable for *local users*. Some examples: - `M13 (Tram)` -> `M13`. With Berlin context, it is obvious that `M13` is a tram. diff --git a/format/location.js b/format/location.js index 121cd13f..39f0a990 100644 --- a/format/location.js +++ b/format/location.js @@ -1,14 +1,15 @@ 'use strict' -const formatLocation = (profile, l) => { +const formatLocation = (profile, l, name = 'location') => { if ('string' === typeof l) return profile.formatStation(l) if ('object' === typeof l && !Array.isArray(l)) { if (l.type === 'station') return profile.formatStation(l.id) if ('string' === typeof l.id) return profile.formatPoi(l) if ('string' === typeof l.address) return profile.formatAddress(l) - throw new Error('invalid location type: ' + l.type) + if (!l.type) throw new Error(`missing ${name}.type`) + throw new Error(`invalid ${name}.type: ${l.type}`) } - throw new Error('valid station, address or poi required.') + throw new Error(name + ': valid station, address or poi required.') } module.exports = formatLocation diff --git a/format/products-bitmask.js b/format/products-bitmask.js deleted file mode 100644 index b3baab87..00000000 --- a/format/products-bitmask.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict' - -const createFormatBitmask = (allProducts) => { - const formatBitmask = (products) => { - if(Object.keys(products).length === 0) throw new Error('products filter must not be empty') - let bitmask = 0 - for (let product in products) { - if (!allProducts[product]) throw new Error('unknown product ' + product) - if (products[product] === true) bitmask += allProducts[product].bitmask - } - return bitmask - } - return formatBitmask -} - -module.exports = createFormatBitmask diff --git a/format/products-filter.js b/format/products-filter.js new file mode 100644 index 00000000..e4252f8f --- /dev/null +++ b/format/products-filter.js @@ -0,0 +1,37 @@ +'use strict' + +const isObj = require('lodash/isObject') + +const hasProp = (o, k) => Object.prototype.hasOwnProperty.call(o, k) + +const createFormatProductsFilter = (profile) => { + const byProduct = {} + const defaultProducts = {} + for (let product of profile.products) { + byProduct[product.id] = product + defaultProducts[product.id] = product.default + } + + const formatProductsFilter = (filter) => { + if (!isObj(filter)) throw new Error('products filter must be an object') + filter = Object.assign({}, defaultProducts, filter) + + let res = 0, products = 0 + for (let product in filter) { + if (!hasProp(filter, product) || filter[product] !== true) continue + if (!byProduct[product]) throw new Error('unknown product ' + product) + products++ + for (let bitmask of byProduct[product].bitmasks) res += bitmask + } + if (products === 0) throw new Error('no products used') + + return { + type: 'PROD', + mode: 'INC', + value: res + '' + } + } + return formatProductsFilter +} + +module.exports = createFormatProductsFilter diff --git a/index.js b/index.js index f47d691c..bb4c82e9 100644 --- a/index.js +++ b/index.js @@ -2,66 +2,104 @@ const minBy = require('lodash/minBy') const maxBy = require('lodash/maxBy') +const isObj = require('lodash/isObject') -const validateProfile = require('./lib/validate-profile') const defaultProfile = require('./lib/default-profile') +const createParseBitmask = require('./parse/products-bitmask') +const createFormatProductsFilter = require('./format/products-filter') +const validateProfile = require('./lib/validate-profile') const _request = require('./lib/request') -const isObj = o => o !== null && 'object' === typeof o && !Array.isArray(o) const isNonEmptyString = str => 'string' === typeof str && str.length > 0 -const createClient = (profile, request = _request) => { +const createClient = (profile, userAgent, request = _request) => { profile = Object.assign({}, defaultProfile, profile) + if (!profile.parseProducts) { + profile.parseProducts = createParseBitmask(profile) + } + if (!profile.formatProductsFilter) { + profile.formatProductsFilter = createFormatProductsFilter(profile) + } validateProfile(profile) - const departures = (station, opt = {}) => { + if ('string' !== typeof userAgent) { + throw new Error('userAgent must be a string'); + } + + const _stationBoard = (station, type, parser, opt = {}) => { if (isObj(station)) station = profile.formatStation(station.id) else if ('string' === typeof station) station = profile.formatStation(station) else throw new Error('station must be an object or a string.') + if ('string' !== typeof type || !type) { + throw new Error('type must be a non-empty string.') + } + opt = Object.assign({ direction: null, // only show departures heading to this station - duration: 10 // show departures for the next n minutes + duration: 10, // show departures for the next n minutes + stationLines: false, // parse & expose lines of the station? + remarks: true, // parse & expose hints & warnings? + // departures at related stations + // e.g. those that belong together on the metro map. + includeRelatedStations: true }, opt) - opt.when = opt.when || new Date() - const products = profile.formatProducts(opt.products || {}) + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') + const products = profile.formatProductsFilter(opt.products || {}) const dir = opt.direction ? profile.formatStation(opt.direction) : null - return request(profile, { + return request(profile, userAgent, opt, { meth: 'StationBoard', req: { - type: 'DEP', + type, date: profile.formatDate(profile, opt.when), time: profile.formatTime(profile, opt.when), stbLoc: station, dirLoc: dir, jnyFltrL: [products], dur: opt.duration, - getPasslist: false + getPasslist: false, // todo + stbFltrEquiv: !opt.includeRelatedStations } }) .then((d) => { - if (!Array.isArray(d.jnyL)) return [] // todo: throw err? - const parse = profile.parseDeparture(profile, d.locations, d.lines, d.remarks) + if (!Array.isArray(d.jnyL)) return [] + const parse = parser(profile, opt, { + locations: d.locations, + lines: d.lines, + hints: d.hints, + warnings: d.warnings + }) return d.jnyL.map(parse) .sort((a, b) => new Date(a.when) - new Date(b.when)) }) } + const departures = (station, opt = {}) => { + return _stationBoard(station, 'DEP', profile.parseDeparture, opt) + } + const arrivals = (station, opt = {}) => { + return _stationBoard(station, 'ARR', profile.parseArrival, opt) + } + const journeys = (from, to, opt = {}) => { - from = profile.formatLocation(profile, from) - to = profile.formatLocation(profile, to) + from = profile.formatLocation(profile, from, 'from') + to = profile.formatLocation(profile, to, 'to') if (('earlierThan' in opt) && ('laterThan' in opt)) { - throw new Error('opt.laterThan and opt.laterThan are mutually exclusive.') + throw new Error('opt.earlierThan and opt.laterThan are mutually exclusive.') + } + if (('departure' in opt) && ('arrival' in opt)) { + throw new Error('opt.departure and opt.arrival are mutually exclusive.') } let journeysRef = null if ('earlierThan' in opt) { if (!isNonEmptyString(opt.earlierThan)) { throw new Error('opt.earlierThan must be a non-empty string.') } - if ('when' in opt) { - throw new Error('opt.earlierThan and opt.when are mutually exclusive.') + if (('departure' in opt) || ('arrival' in opt)) { + throw new Error('opt.earlierThan and opt.departure/opt.arrival are mutually exclusive.') } journeysRef = opt.earlierThan } @@ -69,8 +107,8 @@ const createClient = (profile, request = _request) => { if (!isNonEmptyString(opt.laterThan)) { throw new Error('opt.laterThan must be a non-empty string.') } - if ('when' in opt) { - throw new Error('opt.laterThan and opt.when are mutually exclusive.') + if (('departure' in opt) || ('arrival' in opt)) { + throw new Error('opt.laterThan and opt.departure/opt.arrival are mutually exclusive.') } journeysRef = opt.laterThan } @@ -78,8 +116,7 @@ const createClient = (profile, request = _request) => { opt = Object.assign({ results: 5, // how many journeys? via: null, // let journeys pass this station? - passedStations: false, // return stations on the way? - whenRepresents: 'departure', // use 'arrival' for journeys arriving before `when` + stopovers: false, // return stations on the way? transfers: 5, // maximum of 5 transfers transferTime: 0, // minimum time for a single transfer in minutes // todo: does this work with every endpoint? @@ -87,18 +124,31 @@ const createClient = (profile, request = _request) => { bike: false, // only bike-friendly journeys tickets: false, // return tickets? polylines: false, // return leg shapes? + remarks: true, // parse & expose hints & warnings? // Consider walking to nearby stations at the beginning of a journey? - startWithWalking: false + startWithWalking: true }, opt) - if (opt.via) opt.via = profile.formatLocation(profile, opt.via) - opt.when = opt.when || new Date() + if (opt.via) opt.via = profile.formatLocation(profile, opt.via, 'opt.via') + + if (opt.when !== undefined) { + throw new Error('opt.when is not supported anymore. Use opt.departure/opt.arrival.') + } + let when = new Date(), outFrwd = true + if (opt.departure !== undefined && opt.departure !== null) { + when = new Date(opt.departure) + if (Number.isNaN(+when)) throw new Error('opt.departure is invalid') + } else if (opt.arrival !== undefined && opt.arrival !== null) { + when = new Date(opt.arrival) + if (Number.isNaN(+when)) throw new Error('opt.arrival is invalid') + outFrwd = false + } if (opt.whenRepresents !== 'departure' && opt.whenRepresents !== 'arrival') { throw new Error('opt.whenRepresents must be `departure` or `arrival`.') } const filters = [ - profile.formatProducts(opt.products || {}) + profile.formatProductsFilter(opt.products || {}) ] if ( opt.accessibility && @@ -121,7 +171,7 @@ const createClient = (profile, request = _request) => { outDate: profile.formatDate(profile, when), outTime: profile.formatTime(profile, when), ctxScr: journeysRef, - getPasslist: !!opt.passedStations, + getPasslist: !!opt.stopovers, maxChg: opt.transfers, minChgTime: opt.transferTime, depLocL: [from], @@ -129,7 +179,7 @@ const createClient = (profile, request = _request) => { arrLocL: [to], jnyFltrL: filters, getTariff: !!opt.tickets, - outFrwd: opt.whenRepresents !== 'arrival', + outFrwd, ushrp: !!opt.startWithWalking, // todo: what is req.gisFltrL? @@ -139,7 +189,7 @@ const createClient = (profile, request = _request) => { } if (profile.journeysNumF) query.numF = opt.results - return request(profile, { + return request(profile, userAgent, opt, { cfg: {polyEnc: 'GPA'}, meth: 'TripSearch', req: profile.transformJourneysQuery(query, opt) @@ -147,11 +197,13 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!Array.isArray(d.outConL)) return [] - let polylines = [] - if (opt.polylines && Array.isArray(d.common.polyL)) { - polylines = d.common.polyL - } - const parse = profile.parseJourney(profile, d.locations, d.lines, d.remarks, polylines) + const parse = profile.parseJourney(profile, opt, { + locations: d.locations, + lines: d.lines, + hints: d.hints, + warnings: d.warnings, + polylines: opt.polylines && d.common.polyL || [] + }) if (!journeys.earlierRef) journeys.earlierRef = d.outCtxScrB @@ -160,11 +212,11 @@ const createClient = (profile, request = _request) => { j = parse(j) journeys.push(j) - if (journeys.length === opt.results) { // collected enough + if (journeys.length >= opt.results) { // collected enough journeys.laterRef = d.outCtxScrF return journeys } - const dep = +new Date(j.departure) + const dep = +new Date(j.legs[0].departure) if (dep > latestDep) latestDep = dep } @@ -173,7 +225,45 @@ const createClient = (profile, request = _request) => { }) } - return more(opt.when, journeysRef) + return more(when, journeysRef) + } + + const refreshJourney = (refreshToken, opt = {}) => { + if ('string' !== typeof refreshToken || !refreshToken) { + new Error('refreshToken must be a non-empty string.') + } + + opt = Object.assign({ + stopovers: false, // return stations on the way? + tickets: false, // return tickets? + polylines: false, // return leg shapes? + remarks: true // parse & expose hints & warnings? + }, opt) + + return request(profile, userAgent, opt, { + meth: 'Reconstruction', + req: { + ctxRecon: refreshToken, + getIST: true, // todo: make an option + getPasslist: !!opt.stopovers, + getPolyline: !!opt.polylines, + getTariff: !!opt.tickets + } + }) + .then((d) => { + if (!Array.isArray(d.outConL) || !d.outConL[0]) { + throw new Error('invalid response') + } + + const parse = profile.parseJourney(profile, opt, { + locations: d.locations, + lines: d.lines, + hints: d.hints, + warnings: d.warnings, + polylines: opt.polylines && d.common.polyL || [] + }) + return parse(d.outConL[0]) + }) } const locations = (query, opt = {}) => { @@ -185,11 +275,12 @@ const createClient = (profile, request = _request) => { results: 10, // how many search results? stations: true, addresses: true, - poi: true // points of interest + poi: true, // points of interest + stationLines: false // parse & expose lines of the station? }, opt) const f = profile.formatLocationFilter(opt.stations, opt.addresses, opt.poi) - return request(profile, { + return request(profile, userAgent, opt, { cfg: {polyEnc: 'GPA'}, meth: 'LocMatch', req: {input: { @@ -204,16 +295,19 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!d.match || !Array.isArray(d.match.locL)) return [] const parse = profile.parseLocation - return d.match.locL.map(loc => parse(profile, loc, d.lines)) + return d.match.locL.map(loc => parse(profile, opt, {lines: d.lines}, loc)) }) } - const location = (station) => { + const station = (station, opt = {}) => { if ('object' === typeof station) station = profile.formatStation(station.id) else if ('string' === typeof station) station = profile.formatStation(station) else throw new Error('station must be an object or a string.') - return request(profile, { + opt = Object.assign({ + stationLines: false // parse & expose lines of the station? + }, opt) + return request(profile, userAgent, opt, { meth: 'LocDetails', req: { locL: [station] @@ -224,7 +318,7 @@ const createClient = (profile, request = _request) => { // todo: proper stack trace? throw new Error('invalid response') } - return profile.parseLocation(profile, d.locL[0], d.lines) + return profile.parseLocation(profile, opt, {lines: d.lines}, d.locL[0]) }) } @@ -244,9 +338,10 @@ const createClient = (profile, request = _request) => { distance: null, // maximum walking distance in meters poi: false, // return points of interest? stations: true, // return stations? + stationLines: false // parse & expose lines of the station? }, opt) - return request(profile, { + return request(profile, userAgent, opt, { cfg: {polyEnc: 'GPA'}, meth: 'LocGeoPos', req: { @@ -266,40 +361,44 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!Array.isArray(d.locL)) return [] const parse = profile.parseNearby - return d.locL.map(loc => parse(profile, loc)) + return d.locL.map(loc => parse(profile, opt, d, loc)) }) } - const journeyLeg = (ref, lineName, opt = {}) => { - if (!isNonEmptyString(ref)) { - throw new Error('ref must be a non-empty string.') + const trip = (id, lineName, opt = {}) => { + if (!isNonEmptyString(id)) { + throw new Error('id must be a non-empty string.') } if (!isNonEmptyString(lineName)) { throw new Error('lineName must be a non-empty string.') } opt = Object.assign({ - passedStations: true, // return stations on the way? - polyline: false + stopovers: true, // return stations on the way? + polyline: false, // return a track shape? + remarks: true // parse & expose hints & warnings? }, opt) - opt.when = opt.when || new Date() + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') - return request(profile, { + return request(profile, userAgent, opt, { cfg: {polyEnc: 'GPA'}, meth: 'JourneyDetails', req: { // todo: getTrainComposition - jid: ref, + jid: id, name: lineName, date: profile.formatDate(profile, opt.when), getPolyline: !!opt.polyline } }) .then((d) => { - let polylines = [] - if (opt.polyline && Array.isArray(d.common.polyL)) { - polylines = d.common.polyL - } - const parse = profile.parseJourneyLeg(profile, d.locations, d.lines, d.remarks, polylines) + const parse = profile.parseJourneyLeg(profile, opt, { + locations: d.locations, + lines: d.lines, + hints: d.hints, + warnings: d.warnings, + polylines: opt.polyline && d.common.polyL || [] + }) const leg = { // pretend the leg is contained in a journey type: 'JNY', @@ -307,27 +406,31 @@ const createClient = (profile, request = _request) => { arr: maxBy(d.journey.stopL, 'idx'), jny: d.journey } - return parse(d.journey, leg, !!opt.passedStations) + return parse(d.journey, leg, !!opt.stopovers) }) } - const radar = (north, west, south, east, opt) => { + const radar = ({north, west, south, east}, opt) => { if ('number' !== typeof north) throw new Error('north must be a number.') if ('number' !== typeof west) throw new Error('west must be a number.') if ('number' !== typeof south) throw new Error('south must be a number.') if ('number' !== typeof east) throw new Error('east must be a number.') + if (north <= south) throw new Error('north must be larger than south.') + if (east <= west) throw new Error('east must be larger than west.') opt = Object.assign({ results: 256, // maximum number of vehicles duration: 30, // compute frames for the next n seconds + // todo: what happens with `frames: 0`? frames: 3, // nr of frames to compute products: null, // optionally an object of booleans polylines: false // return a track shape for each vehicle? }, opt || {}) - opt.when = opt.when || new Date() + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') const durationPerStep = opt.duration / Math.max(opt.frames, 1) * 1000 - return request(profile, { + return request(profile, userAgent, opt, { meth: 'JourneyGeoPos', req: { maxJny: opt.results, @@ -340,7 +443,7 @@ const createClient = (profile, request = _request) => { perStep: Math.round(durationPerStep), ageOfReport: true, // todo: what is this? jnyFltrL: [ - profile.formatProducts(opt.products || {}) + profile.formatProductsFilter(opt.products || {}) ], trainPosMode: 'CALC' // todo: what is this? what about realtime? } @@ -348,18 +451,21 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!Array.isArray(d.jnyL)) return [] - let polylines = [] - if (opt.polylines && d.common && Array.isArray(d.common.polyL)) { - polylines = d.common.polyL - } - const parse = profile.parseMovement(profile, d.locations, d.lines, d.remarks, polylines) + const parse = profile.parseMovement(profile, opt, { + locations: d.locations, + lines: d.lines, + hints: d.hints, + warnings: d.warnings, + polylines: opt.polyline && d.common.polyL || [] + }) return d.jnyL.map(parse) }) } - const client = {departures, journeys, locations, location, nearby} - if (profile.journeyLeg) client.journeyLeg = journeyLeg + const client = {departures, arrivals, journeys, locations, station, nearby} + if (profile.trip) client.trip = trip if (profile.radar) client.radar = radar + if (profile.refreshJourney) client.refreshJourney = refreshJourney Object.defineProperty(client, 'profile', {value: profile}) return client } diff --git a/lib/default-profile.js b/lib/default-profile.js index 340de25f..1ed38de5 100644 --- a/lib/default-profile.js +++ b/lib/default-profile.js @@ -2,14 +2,17 @@ const parseDateTime = require('../parse/date-time') const parseDeparture = require('../parse/departure') +const parseArrival = require('../parse/arrival') const parseJourneyLeg = require('../parse/journey-leg') const parseJourney = require('../parse/journey') const parseLine = require('../parse/line') const parseLocation = require('../parse/location') +const parsePolyline = require('../parse/polyline') const parseMovement = require('../parse/movement') const parseNearby = require('../parse/nearby') const parseOperator = require('../parse/operator') -const parseRemark = require('../parse/remark') +const parseHint = require('../parse/hint') +const parseWarning = require('../parse/warning') const parseStopover = require('../parse/stopover') const formatAddress = require('../format/address') @@ -37,15 +40,18 @@ const defaultProfile = { parseDateTime, parseDeparture, + parseArrival, parseJourneyLeg, parseJourney, parseLine, parseStationName: id, parseLocation, + parsePolyline, parseMovement, parseNearby, parseOperator, - parseRemark, + parseHint, + parseWarning, parseStopover, formatAddress, @@ -60,8 +66,9 @@ const defaultProfile = { filters, journeysNumF: true, // `journeys()` method: support for `numF` field? - journeyLeg: false, - radar: false + trip: false, + radar: false, + refreshJourney: true } module.exports = defaultProfile diff --git a/lib/generate-install-id.js b/lib/generate-install-id.js new file mode 100755 index 00000000..14235608 --- /dev/null +++ b/lib/generate-install-id.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +const {randomBytes} = require('crypto') + +const id = randomBytes(6).toString('hex') +process.stdout.write(JSON.stringify(id) + '\n') diff --git a/lib/request.js b/lib/request.js index 681626c6..50247888 100644 --- a/lib/request.js +++ b/lib/request.js @@ -1,26 +1,36 @@ 'use strict' +const {join} = require('path') const createHash = require('create-hash') let captureStackTrace = () => {} -if (process.env.NODE_ENV === 'dev') { +if (process.env.NODE_DEBUG === 'hafas-client') { captureStackTrace = require('capture-stack-trace') } const {stringify} = require('query-string') const Promise = require('pinkie-promise') const {fetch} = require('fetch-ponyfill')({Promise}) -const userAgent = 'https://github.com/public-transport/hafas-client' -const clientId = Math.random().toString(16).substr(2, 10) +let id +try { + id = require('../id.json') +} catch (err) { + const p = join(__dirname, '..', 'id.json') + console.error(`Failed to load the install-unique ID from ${p}.`) + process.exit(1) +} + +const randomizeUserAgent = (userAgent) => { + const i = Math.round(Math.random() * userAgent.length) + return userAgent.slice(0, i) + id + userAgent.slice(i) +} const md5 = input => createHash('md5').update(input).digest() -const randomizeUserAgent = () => { - const i = Math.round(Math.random() * userAgent.length) - return userAgent.slice(0, i) + clientId + userAgent.slice(i) -} - -const request = (profile, data) => { - const body = profile.transformReqBody({lang: 'en', svcReqL: [data]}) +const request = (profile, userAgent, opt, data) => { + const body = profile.transformReqBody({ + lang: opt.language || 'en', + svcReqL: [data] + }) const req = profile.transformReq({ method: 'post', // todo: CORS? referrer policy? @@ -29,7 +39,7 @@ const request = (profile, data) => { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip, deflate', 'Accept': 'application/json', - 'user-agent': randomizeUserAgent() + 'user-agent': randomizeUserAgent(userAgent) }, query: {} }) @@ -89,19 +99,38 @@ const request = (profile, data) => { const d = b.svcResL[0].res const c = d.common || {} - if (Array.isArray(c.remL)) { - d.remarks = c.remL.map(rem => profile.parseRemark(profile, rem)) + d.hints = [] + if (opt.remarks && Array.isArray(c.remL)) { + const icons = opt.remarks && c.icoL || [] + d.hints = c.remL.map(hint => profile.parseHint(profile, hint, icons)) + } + d.warnings = [] + if (opt.remarks && Array.isArray(c.himL)) { + const icons = opt.remarks && c.icoL || [] + d.warnings = c.himL.map(w => profile.parseWarning(profile, w, icons)) } if (Array.isArray(c.opL)) { d.operators = c.opL.map(op => profile.parseOperator(profile, op)) } if (Array.isArray(c.prodL)) { - const parse = profile.parseLine(profile, d.operators) + const parse = profile.parseLine(profile, opt, { + operators: d.operators + }) d.lines = c.prodL.map(parse) } if (Array.isArray(c.locL)) { - const parse = loc => profile.parseLocation(profile, loc, d.lines) + const data = {lines: d.lines} + const parse = loc => profile.parseLocation(profile, opt, data, loc) + d.locations = c.locL.map(parse) + for (let i = 0; i < d.locations.length; i++) { + const raw = c.locL[i] + const loc = d.locations[i] + if ('number' === typeof raw.mMastLocX) { + loc.station = Object.assign({}, d.locations[raw.mMastLocX]) + loc.station.type = 'station' + } else if (raw.isMainMast) loc.type = 'station' + } } return d }) diff --git a/lib/validate-profile.js b/lib/validate-profile.js index 19465c3f..4c7f6374 100644 --- a/lib/validate-profile.js +++ b/lib/validate-profile.js @@ -11,15 +11,18 @@ const types = { parseDateTime: 'function', parseDeparture: 'function', + parseArrival: 'function', parseJourneyLeg: 'function', parseJourney: 'function', parseLine: 'function', parseStationName: 'function', parseLocation: 'function', + parsePolyline: 'function', parseMovement: 'function', parseNearby: 'function', parseOperator: 'function', - parseRemark: 'function', + parseHint: 'function', + parseWarning: 'function', parseStopover: 'function', formatAddress: 'function', @@ -47,6 +50,34 @@ const validateProfile = (profile) => { throw new Error(`profile.${key} must not be null.`) } } + + if (!Array.isArray(profile.products)) { + throw new Error('profile.products must be an array.') + } + if (profile.products.length === 0) throw new Error('profile.products is empty.') + for (let product of profile.products) { + if ('string' !== typeof product.id) { + throw new Error('profile.products[].id must be a string.') + } + if ('boolean' !== typeof product.default) { + throw new Error('profile.products[].default must be a boolean.') + } + if (!Array.isArray(product.bitmasks)) { + throw new Error(product.id + '.bitmasks must be an array.') + } + for (let bitmask of product.bitmasks) { + if ('number' !== typeof bitmask) { + throw new Error(product.id + '.bitmasks[] must be a number.') + } + } + } + + if ('trip' in profile && 'boolean' !== typeof profile.trip) { + throw new Error('profile.trip must be a boolean.') + } + if ('journeyLeg' in profile) { + throw new Error('profile.journeyLeg has been removed. Use profile.trip.') + } } module.exports = validateProfile diff --git a/p/bvg/example.js b/p/bvg/example.js new file mode 100644 index 00000000..5a79930f --- /dev/null +++ b/p/bvg/example.js @@ -0,0 +1,37 @@ +'use strict' + +const createClient = require('../..') +const vbbProfile = require('.') + +const client = createClient(vbbProfile, 'hafas-client-example') + +// Hauptbahnhof to Charlottenburg +client.journeys('900000003201', '900000024101', {results: 1, polylines: true}) +// client.departures('900000013102', {duration: 1}) +// client.arrivals('900000013102', {duration: 10, stationLines: true}) +// client.locations('Alexanderplatz', {results: 2}) +// client.station('900000042101', {stationLines: true}) // Spichernstr +// client.nearby({ +// type: 'location', +// latitude: 52.5137344, +// longitude: 13.4744798 +// }, {distance: 60}) +// client.radar({ +// north: 52.52411, +// west: 13.41002, +// south: 52.51942, +// east: 13.41709 +// }, {results: 10}) + +// .then(([journey]) => { +// const leg = journey.legs[0] +// return client.trip(leg.id, leg.line.name, {polyline: true}) +// }) + +// .then(([journey]) => { +// return client.refreshJourney(journey.refreshToken, {stopovers: true, remarks: true}) +// }) +.then((data) => { + console.log(require('util').inspect(data, {depth: null})) +}) +.catch(console.error) diff --git a/p/bvg/index.js b/p/bvg/index.js new file mode 100644 index 00000000..b3057a44 --- /dev/null +++ b/p/bvg/index.js @@ -0,0 +1,112 @@ +'use strict' + +const shorten = require('vbb-short-station-name') +const {to12Digit, to9Digit} = require('vbb-translate-ids') +const parseLineName = require('vbb-parse-line') +const getStations = require('vbb-stations') + +const _createParseLine = require('../../parse/line') +const _parseLocation = require('../../parse/location') +const _createParseDeparture = require('../../parse/departure') +const _formatStation = require('../../format/station') + +const products = require('./products') + +const transformReqBody = (body) => { + body.client = {type: 'IPA', id: 'BVG', name: 'FahrInfo', v: '4070700'} + body.ext = 'BVG.1' + body.ver = '1.15' // todo: 1.16 with `mic` and `mac` query params + body.auth = {type: 'AID', aid: '1Rxs112shyHLatUX4fofnmdxK'} + + return body +} + +const createParseLine = (profile, opt, data) => { + const parseLine = _createParseLine(profile, opt, data) + + const parseLineWithMoreDetails = (l) => { + const res = parseLine(l) + + res.name = l.name.replace(/^(bus|tram)\s+/i, '') + const details = parseLineName(res.name) + res.symbol = details.symbol + res.nr = details.nr + res.metro = details.metro + res.express = details.express + res.night = details.night + + return res + } + return parseLineWithMoreDetails +} + +const parseLocation = (profile, opt, data, l) => { + const res = _parseLocation(profile, opt, data, l) + + if (res.type === 'stop' || res.type === 'station') { + res.name = shorten(res.name) + res.id = to12Digit(res.id) + if (!res.location.latitude || !res.location.longitude) { + const [s] = getStations(res.id) + if (s) Object.assign(res.location, s.location) + } + } + return res +} + +const createParseDeparture = (profile, opt, data) => { + const parseDeparture = _createParseDeparture(profile, opt, data) + + const ringbahnClockwise = /^ringbahn s\s?41$/i + const ringbahnAnticlockwise = /^ringbahn s\s?42$/i + const parseDepartureRenameRingbahn = (j) => { + const res = parseDeparture(j) + + if (res.line && res.line.product === 'suburban') { + const d = res.direction && res.direction.trim() + if (ringbahnClockwise.test(d)) res.direction = 'Ringbahn S41 ⟳' + else if (ringbahnAnticlockwise.test(d)) res.direction = 'Ringbahn S42 ⟲' + } + + return res + } + + return parseDepartureRenameRingbahn +} + +const validIBNR = /^\d+$/ +const formatStation = (id) => { + if ('string' !== typeof id) throw new Error('station ID must be a string.') + const l = id.length + if ((l !== 7 && l !== 9 && l !== 12) || !validIBNR.test(id)) { + throw new Error('station ID must be a valid IBNR.') + } + // BVG has some 7-digit stations. We don't convert them to 12 digits, + // because it only recognizes in the 7-digit format. see derhuerst/vbb-hafas#22 + if (l !== 7) id = to9Digit(id) + return _formatStation(id) +} + +// todo: adapt/extend `vbb-parse-ticket` to support the BVG markup + +const bvgProfile = { + locale: 'de-DE', + timezone: 'Europe/Berlin', + endpoint: 'https://bvg-apps.hafas.de/bin/mgate.exe', + + transformReqBody, + + products, + + parseStationName: shorten, + parseLocation, + parseLine: createParseLine, + parseDeparture: createParseDeparture, + + formatStation, + + trip: true, + radar: true +} + +module.exports = bvgProfile diff --git a/p/bvg/products.js b/p/bvg/products.js new file mode 100644 index 00000000..6d091247 --- /dev/null +++ b/p/bvg/products.js @@ -0,0 +1,60 @@ +'use strict' + +module.exports = [ + { + id: 'suburban', + mode: 'train', + bitmasks: [1], + name: 'S-Bahn', + short: 'S', + default: true + }, + { + id: 'subway', + mode: 'train', + bitmasks: [2], + name: 'U-Bahn', + short: 'U', + default: true + }, + { + id: 'tram', + mode: 'train', + bitmasks: [4], + name: 'Tram', + short: 'T', + default: true + }, + { + id: 'bus', + mode: 'bus', + bitmasks: [8], + name: 'Bus', + short: 'B', + default: true + }, + { + id: 'ferry', + mode: 'watercraft', + bitmasks: [16], + name: 'Fähre', + short: 'F', + default: true + }, + { + id: 'express', + mode: 'train', + bitmasks: [32], + name: 'IC/ICE', + short: 'E', + default: true + }, + { + id: 'regional', + mode: 'train', + bitmasks: [64], + name: 'RB/RE', + short: 'R', + default: true + } +] diff --git a/p/bvg/readme.md b/p/bvg/readme.md new file mode 100644 index 00000000..0304acb2 --- /dev/null +++ b/p/bvg/readme.md @@ -0,0 +1,21 @@ +# BVG profile for `hafas-client` + +[*Verkehrsverbund Berlin-Brandenburg (BVG)*](https://en.wikipedia.org/wiki/Verkehrsverbund_Berlin-Brandenburg) is the major local transport provider in [Berlin](https://en.wikipedia.org/wiki/Berlin). This profile adds *BVG*-specific customizations to `hafas-client`. + +## Usage + +```js +const createClient = require('hafas-client') +const bvgProfile = require('hafas-client/p/bvg') + +// create a client with BVG profile +const client = createClient(bvgProfile, 'my-awesome-program') +``` + + +## Customisations + +- parses *BVG*-specific products (such as *X-Bus*) +- strips parts from station names that are unnecessary in the Berlin context +- parses line names to give more information (e.g. "Is it an express bus?") +- renames *Ringbahn* line names to contain `⟳` and `⟲` diff --git a/p/db/example.js b/p/db/example.js index f740bea9..1ca575b9 100644 --- a/p/db/example.js +++ b/p/db/example.js @@ -3,15 +3,20 @@ const createClient = require('../../') const dbProfile = require('.') -const client = createClient(dbProfile) +const client = createClient(dbProfile, 'hafas-client-example') // Berlin Jungfernheide to München Hbf client.journeys('8011167', '8000261', {results: 1, tickets: true}) // client.departures('8011167', {duration: 1}) +// client.arrivals('8011167', {duration: 10, stationLines: true}) // client.locations('Berlin Jungfernheide') // client.locations('Atze Musiktheater', {poi: true, addressses: false, fuzzy: false}) -// client.location('8000309') // Regensburg Hbf -// client.nearby(52.4751309, 13.3656537, {results: 1}) +// client.station('8000309') // Regensburg Hbf +// client.nearby({ +// type: 'location', +// latitude: 52.4751309, +// longitude: 13.3656537 +// }, {results: 1}) .then((data) => { console.log(require('util').inspect(data, {depth: null})) diff --git a/p/db/index.js b/p/db/index.js index 0b3a65bf..2e49a675 100644 --- a/p/db/index.js +++ b/p/db/index.js @@ -1,17 +1,15 @@ 'use strict' -const _createParseLine = require('../../parse/line') +const trim = require('lodash/trim') + const _createParseJourney = require('../../parse/journey') +const _parseHint = require('../../parse/hint') const _formatStation = require('../../format/station') -const createParseBitmask = require('../../parse/products-bitmask') -const createFormatBitmask = require('../../format/products-bitmask') const {bike} = require('../../format/filters') -const modes = require('./modes') +const products = require('./products') const formatLoyaltyCard = require('./loyalty-cards').format -const formatBitmask = createFormatBitmask(modes) - const transformReqBody = (body) => { body.client = {id: 'DB', v: '16040000', type: 'IPH', name: 'DB Navigator'} body.ext = 'DB.R15.12.a' @@ -39,28 +37,8 @@ const transformJourneysQuery = (query, opt) => { return query } -const createParseLine = (profile, operators) => { - const parseLine = _createParseLine(profile, operators) - - const parseLineWithMode = (l) => { - const res = parseLine(l) - - res.mode = res.product = null - if ('class' in res) { - const data = modes.bitmasks[parseInt(res.class)] - if (data) { - res.mode = data.mode - res.product = data.product - } - } - - return res - } - return parseLineWithMode -} - -const createParseJourney = (profile, stations, lines, remarks, polylines) => { - const parseJourney = _createParseJourney(profile, stations, lines, remarks, polylines) +const createParseJourney = (profile, opt, data) => { + const parseJourney = _createParseJourney(profile, opt, data) // todo: j.sotRating, j.conSubscr, j.isSotCon, j.showARSLink, k.sotCtxt // todo: j.conSubscr, j.showARSLink, j.useableTime @@ -87,7 +65,11 @@ const createParseJourney = (profile, stations, lines, remarks, polylines) => { ) { const tariff = j.trfRes.fareSetL[0].fareL[0] if (tariff.prc >= 0) { // wat - res.price = {amount: tariff.prc / 100, hint: null} + res.price = { + amount: tariff.prc / 100, + currency: 'EUR', + hint: null + } } } @@ -97,33 +79,225 @@ const createParseJourney = (profile, stations, lines, remarks, polylines) => { return parseJourneyWithPrice } +const hintsByCode = Object.assign(Object.create(null), { + fb: { + type: 'hint', + code: 'bicycle-conveyance', + summary: 'bicycles conveyed' + }, + fr: { + type: 'hint', + code: 'bicycle-conveyance-reservation', + summary: 'bicycles conveyed, subject to reservation' + }, + nf: { + type: 'hint', + code: 'no-bicycle-conveyance', + summary: 'bicycles not conveyed' + }, + k2: { + type: 'hint', + code: '2nd-class-only', + summary: '2. class only' + }, + eh: { + type: 'hint', + code: 'boarding-ramp', + summary: 'vehicle-mounted boarding ramp available' + }, + ro: { + type: 'hint', + code: 'wheelchairs-space', + summary: 'space for wheelchairs' + }, + oa: { + type: 'hint', + code: 'wheelchairs-space-reservation', + summary: 'space for wheelchairs, subject to reservation' + }, + wv: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available' + }, + wi: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available' + }, + sn: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase' + }, + mb: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase' + }, + mp: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase at the seat' + }, + bf: { + type: 'hint', + code: 'barrier-free', + summary: 'barrier-free' + }, + rg: { + type: 'hint', + code: 'barrier-free-vehicle', + summary: 'barrier-free vehicle' + }, + bt: { + type: 'hint', + code: 'on-board-bistro', + summary: 'Bordbistro available' + }, + br: { + type: 'hint', + code: 'on-board-restaurant', + summary: 'Bordrestaurant available' + }, + ki: { + type: 'hint', + code: 'childrens-area', + summary: `children's area available` + }, + kk: { + type: 'hint', + code: 'parents-childrens-compartment', + summary: `parent-and-children compartment available` + }, + kr: { + type: 'hint', + code: 'kids-service', + summary: 'DB Kids Service available' + }, + ls: { + type: 'hint', + code: 'power-sockets', + summary: 'power sockets available' + }, + ev: { + type: 'hint', + code: 'replacement-service', + summary: 'replacement service' + }, + kl: { + type: 'hint', + code: 'air-conditioned', + summary: 'air-conditioned vehicle' + }, + r0: { + type: 'hint', + code: 'upward-escalator', + summary: 'upward escalator' + }, + au: { + type: 'hint', + code: 'elevator', + summary: 'elevator available' + }, + ck: { + type: 'hint', + code: 'komfort-checkin', + summary: 'Komfort-Checkin available' + }, + it: { + type: 'hint', + code: 'ice-sprinter', + summary: 'ICE Sprinter service' + }, + rp: { + type: 'hint', + code: 'compulsory-reservation', + summary: 'compulsory seat reservation' + }, + rm: { + type: 'hint', + code: 'optional-reservation', + summary: 'optional seat reservation' + }, + scl: { + type: 'hint', + code: 'all-2nd-class-seats-reserved', + summary: 'all 2nd class seats reserved' + }, + acl: { + type: 'hint', + code: 'all-seats-reserved', + summary: 'all seats reserved' + }, + sk: { + type: 'hint', + code: 'oversize-luggage-forbidden', + summary: 'oversize luggage not allowed' + }, + hu: { + type: 'hint', + code: 'animals-forbidden', + summary: 'animals not allowed, except guide dogs' + }, + ik: { + type: 'hint', + code: 'baby-cot-required', + summary: 'baby cot/child seat required' + }, + ee: { + type: 'hint', + code: 'on-board-entertainment', + summary: 'on-board entertainment available' + }, + toilet: { + type: 'hint', + code: 'toilet', + summary: 'toilet available' + }, + oc: { + type: 'hint', + code: 'wheelchair-accessible-toilet', + summary: 'wheelchair-accessible toilet available' + }, + iz: { + type: 'hint', + code: 'intercity-2', + summary: 'Intercity 2' + } +}) + +const codesByText = Object.assign(Object.create(null), { + 'journey cancelled': 'journey-cancelled', // todo: German variant + 'stop cancelled': 'stop-cancelled', // todo: change to `stopover-cancelled`, German variant + 'signal failure': 'signal-failure', + 'signalstörung': 'signal-failure', + 'additional stop': 'additional-stopover', // todo: German variant + 'platform change': 'changed platform', // todo: use dash, German variant +}) + +const parseHint = (profile, h, icons) => { + if (h.type === 'A') { + const hint = hintsByCode[h.code && h.code.trim().toLowerCase()] + if (hint) { + return Object.assign({text: h.txtN}, hint) + } + } + + const res = _parseHint(profile, h, icons) + if (res && h.txtN) { + const text = trim(h.txtN.toLowerCase(), ' ()') + if (codesByText[text]) res.code = codesByText[text] + } + return res +} + const isIBNR = /^\d{6,}$/ const formatStation = (id) => { if (!isIBNR.test(id)) throw new Error('station ID must be an IBNR.') return _formatStation(id) } -const defaultProducts = { - suburban: true, - subway: true, - tram: true, - bus: true, - ferry: true, - national: true, - nationalExp: true, - regional: true, - regionalExp: true, - taxi: false -} -const formatProducts = (products) => { - products = Object.assign(Object.create(null), defaultProducts, products) - return { - type: 'PROD', - mode: 'INC', - value: formatBitmask(products) + '' - } -} - // todo: find option for absolute number of results const dbProfile = { @@ -137,17 +311,15 @@ const dbProfile = { transformReqBody, transformJourneysQuery, - products: modes.allProducts, + products: products, // todo: parseLocation - parseLine: createParseLine, - parseProducts: createParseBitmask(modes.allProducts, defaultProducts), parseJourney: createParseJourney, + parseHint, formatStation, - formatProducts, - journeyLeg: true // todo: #49 + trip: true // todo: #49 } module.exports = dbProfile diff --git a/p/db/modes.js b/p/db/modes.js deleted file mode 100644 index d8e872b7..00000000 --- a/p/db/modes.js +++ /dev/null @@ -1,108 +0,0 @@ -'use strict' - -// todo: https://gist.github.com/anonymous/d3323a5d2d6e159ed42b12afd0380434#file-haf_products-properties-L1-L95 -const m = { - nationalExp: { - bitmask: 1, - name: 'InterCityExpress', - short: 'ICE', - mode: 'train', - product: 'nationalExp' - }, - national: { - bitmask: 2, - name: 'InterCity & EuroCity', - short: 'IC/EC', - mode: 'train', - product: 'national' - }, - regionalExp: { - bitmask: 4, - name: 'RegionalExpress & InterRegio', - short: 'RE/IR', - mode: 'train', - product: 'regionalExp' - }, - regional: { - bitmask: 8, - name: 'Regio', - short: 'RB', - mode: 'train', - product: 'regional' - }, - suburban: { - bitmask: 16, - name: 'S-Bahn', - short: 'S', - mode: 'train', - product: 'suburban' - }, - bus: { - bitmask: 32, - name: 'Bus', - short: 'B', - mode: 'bus', - product: 'bus' - }, - ferry: { - bitmask: 64, - name: 'Ferry', - short: 'F', - mode: 'watercraft', - product: 'ferry' - }, - subway: { - bitmask: 128, - name: 'U-Bahn', - short: 'U', - mode: 'train', - product: 'subway' - }, - tram: { - bitmask: 256, - name: 'Tram', - short: 'T', - mode: 'train', - product: 'tram' - }, - taxi: { - bitmask: 512, - name: 'Group Taxi', - short: 'Taxi', - mode: 'taxi', - product: 'taxi' - }, - unknown: { - bitmask: 0, - name: 'unknown', - short: '?', - product: 'unknown' - } -} - -m.bitmasks = [] -m.bitmasks[1] = m.nationalExp -m.bitmasks[2] = m.national -m.bitmasks[4] = m.regionalExp -m.bitmasks[8] = m.regional -m.bitmasks[16] = m.suburban -m.bitmasks[32] = m.bus -m.bitmasks[64] = m.ferry -m.bitmasks[128] = m.subway -m.bitmasks[256] = m.tram -m.bitmasks[512] = m.taxi - -m.allProducts = [ - m.nationalExp, - m.national, - m.regionalExp, - m.regional, - m.suburban, - m.bus, - m.ferry, - m.subway, - m.tram, - m.taxi -] - -module.exports = m diff --git a/p/db/products.js b/p/db/products.js new file mode 100644 index 00000000..3381f401 --- /dev/null +++ b/p/db/products.js @@ -0,0 +1,85 @@ +'use strict' + +// todo: https://gist.github.com/anonymous/d3323a5d2d6e159ed42b12afd0380434#file-haf_products-properties-L1-L95 +module.exports = [ + { + id: 'nationalExp', + mode: 'train', + bitmasks: [1], + name: 'InterCityExpress', + short: 'ICE', + default: true + }, + { + id: 'national', + mode: 'train', + bitmasks: [2], + name: 'InterCity & EuroCity', + short: 'IC/EC', + default: true + }, + { + id: 'regionalExp', + mode: 'train', + bitmasks: [4], + name: 'RegionalExpress & InterRegio', + short: 'RE/IR', + default: true + }, + { + id: 'regional', + mode: 'train', + bitmasks: [8], + name: 'Regio', + short: 'RB', + default: true + }, + { + id: 'suburban', + mode: 'train', + bitmasks: [16], + name: 'S-Bahn', + short: 'S', + default: true + }, + { + id: 'bus', + mode: 'bus', + bitmasks: [32], + name: 'Bus', + short: 'B', + default: true + }, + { + id: 'ferry', + mode: 'watercraft', + bitmasks: [64], + name: 'Ferry', + short: 'F', + default: true + }, + { + id: 'subway', + mode: 'train', + bitmasks: [128], + name: 'U-Bahn', + short: 'U', + default: true + }, + { + id: 'tram', + mode: 'train', + bitmasks: [256], + name: 'Tram', + short: 'T', + default: true + }, + { + id: 'taxi', + mode: 'taxi', + bitmasks: [512], + name: 'Group Taxi', + short: 'Taxi', + default: true + } +] diff --git a/p/db/readme.md b/p/db/readme.md index 0d566c74..d55e0870 100644 --- a/p/db/readme.md +++ b/p/db/readme.md @@ -9,7 +9,7 @@ const createClient = require('hafas-client') const dbProfile = require('hafas-client/p/db') // create a client with DB profile -const client = createClient(dbProfile) +const client = createClient(dbProfile, 'my-awesome-program') ``` diff --git a/p/insa/example.js b/p/insa/example.js index 054ae1f3..8d92c4fe 100644 --- a/p/insa/example.js +++ b/p/insa/example.js @@ -3,24 +3,34 @@ const createClient = require('../..') const insaProfile = require('.') -const client = createClient(insaProfile) +const client = createClient(insaProfile, 'hafas-client-example') // from Magdeburg-Neustadt to Magdeburg-Buckau client.journeys('008010226', '008013456', {results: 1}) // client.departures('008010226', { duration: 5 }) +// client.arrivals('8010226', {duration: 10, stationLines: true}) // client.locations('Magdeburg Hbf', {results: 2}) // client.locations('Kunstmuseum Kloster Unser Lieben Frauen Magdeburg', {results: 2}) -// client.location('008010226') // Magdeburg-Neustadt +// client.station('008010226') // Magdeburg-Neustadt // client.nearby({ // type: 'location', // latitude: 52.148842, // longitude: 11.641705 // }, {distance: 200}) -// client.radar(52.148364, 11.600826, 52.108486, 11.651451, {results: 10}) +// client.radar({ +// north: 52.148364, +// west: 11.600826, +// south: 52.108486, +// east: 11.651451 +// }, {results: 10}) // .then(([journey]) => { // const leg = journey.legs[0] -// return client.journeyLeg(leg.id, leg.line.name) +// return client.trip(leg.id, leg.line.name) +// }) + +// .then(([journey]) => { +// return client.refreshJourney(journey.refreshToken, {stopovers: true, remarks: true}) // }) .then(data => { diff --git a/p/insa/index.js b/p/insa/index.js index def4d087..a6f65b43 100644 --- a/p/insa/index.js +++ b/p/insa/index.js @@ -1,20 +1,6 @@ 'use strict' -const _createParseLine = require('../../parse/line') const products = require('./products') -const createParseBitmask = require('../../parse/products-bitmask') -const createFormatBitmask = require('../../format/products-bitmask') - -const defaultProducts = { - nationalExp: true, - national: true, - regional: true, - suburban: true, - bus: true, - tram: true, - tourismTrain: true, -} - const transformReqBody = (body) => { body.client = { @@ -31,52 +17,17 @@ const transformReqBody = (body) => { return body } -const createParseLine = (profile, operators) => { - const parseLine = _createParseLine(profile, operators) - - const parseLineWithMode = (l) => { - const res = parseLine(l) - - res.mode = res.product = null - if ('class' in res) { - const data = products.bitmasks[parseInt(res.class)] - if (data) { - res.mode = data.mode - res.product = data.product - } - } - - return res - } - return parseLineWithMode -} - -const formatProducts = (products) => { - products = Object.assign(Object.create(null), defaultProducts, products) - return { - type: 'PROD', - mode: 'INC', - value: formatBitmask(products) + '' - } -} - -const formatBitmask = createFormatBitmask(products) - - const insaProfile = { locale: 'de-DE', timezone: 'Europe/Berlin', endpoint: 'https://reiseauskunft.insa.de/bin/mgate.exe', transformReqBody, - products: products.allProducts, - parseProducts: createParseBitmask(products.allProducts, defaultProducts), - formatProducts, + products: products, - parseLine: createParseLine, - - journeyLeg: true, - radar: true + trip: true, + radar: true, + refreshJourney: false } module.exports = insaProfile; diff --git a/p/insa/products.js b/p/insa/products.js index d6b343e5..485c3f23 100644 --- a/p/insa/products.js +++ b/p/insa/products.js @@ -1,82 +1,60 @@ 'use strict' -// TODO Jannis R.: DRY -const p = { - nationalExp: { - bitmask: 1, +module.exports = [ + { + id: 'nationalExp', + mode: 'train', + bitmasks: [1], name: 'InterCityExpress', short: 'ICE', - mode: 'train', - product: 'nationalExp' + default: true }, - national: { - bitmask: 2, + { + id: 'national', + mode: 'train', + bitmasks: [2], name: 'InterCity & EuroCity', short: 'IC/EC', - mode: 'train', - product: 'national' + default: true }, - regional: { - bitmask: 8, + { + id: 'regional', + mode: 'train', + bitmasks: [8], name: 'RegionalExpress & RegionalBahn', short: 'RE/RB', - mode: 'train', - product: 'regional' + default: true }, - suburban: { - bitmask: 16, + { + id: 'suburban', + mode: 'train', + bitmasks: [16], name: 'S-Bahn', short: 'S', - mode: 'train', - product: 'suburban' + default: true }, - tram: { - bitmask: 32, + { + id: 'tram', + mode: 'train', + bitmasks: [32], name: 'Tram', short: 'T', - mode: 'train', - product: 'tram' + default: true }, - bus: { - bitmask: 64+128, + { + id: 'bus', + mode: 'bus', + bitmasks: [64, 128], name: 'Bus', short: 'B', - mode: 'bus', - product: 'bus' + default: true }, - tourismTrain: { - bitmask: 256, + { + id: 'tourismTrain', + mode: 'train', + bitmasks: [256], name: 'Tourism Train', short: 'TT', - mode: 'train', - product: 'tourismTrain' - }, - unknown: { - bitmask: 0, - name: 'unknown', - short: '?', - product: 'unknown' + default: true } -} - -p.bitmasks = [] -p.bitmasks[1] = p.nationalExp -p.bitmasks[2] = p.national -p.bitmasks[8] = p.regional -p.bitmasks[16] = p.suburban -p.bitmasks[32] = p.tram -p.bitmasks[64] = p.bus -p.bitmasks[128] = p.bus -p.bitmasks[256] = p.tourismTrain - -p.allProducts = [ - p.nationalExp, - p.national, - p.regional, - p.suburban, - p.tram, - p.bus, - p.tourismTrain ] - -module.exports = p diff --git a/p/insa/readme.md b/p/insa/readme.md index 4918b2b4..4c0675e7 100644 --- a/p/insa/readme.md +++ b/p/insa/readme.md @@ -9,7 +9,7 @@ const createClient = require('hafas-client') const insaProfile = require('hafas-client/p/insa') // create a client with INSA profile -const client = createClient(insaProfile) +const client = createClient(insaProfile, 'my-awesome-program') ``` diff --git a/p/nahsh/example.js b/p/nahsh/example.js index ada76608..57a9aec5 100644 --- a/p/nahsh/example.js +++ b/p/nahsh/example.js @@ -3,15 +3,20 @@ const createClient = require('../..') const nahshProfile = require('.') -const client = createClient(nahshProfile) +const client = createClient(nahshProfile, 'hafas-client-example') // Flensburg Hbf to Kiel Hbf client.journeys('8000103', '8000199', {results: 10, tickets: true}) // client.departures('8000199', {duration: 10}) -// client.journeyLeg('1|30161|5|100|14032018', 'Bus 52') +// client.arrivals('8000199', {duration: 5, stationLines: true}) +// client.trip('1|30161|5|100|14032018', 'Bus 52') // client.locations('Schleswig', {results: 1}) -// client.location('706990') // Kiel Holunderbusch -// client.nearby({type: 'location', latitude: 54.295691, longitude: 10.116424}, {distance: 60}) +// client.station('706990') // Kiel Holunderbusch +// client.nearby({ +// type: 'location', +// latitude: 54.295691, +// longitude: 10.116424 +// }, {distance: 60}) // client.radar(54.4, 10.0, 54.2, 10.2, {results: 10}) .then((data) => { diff --git a/p/nahsh/index.js b/p/nahsh/index.js index ca47ae2e..51a967dc 100644 --- a/p/nahsh/index.js +++ b/p/nahsh/index.js @@ -1,10 +1,8 @@ 'use strict' -const createParseBitmask = require('../../parse/products-bitmask') -const createFormatBitmask = require('../../format/products-bitmask') -const _createParseLine = require('../../parse/line') const _parseLocation = require('../../parse/location') const _createParseJourney = require('../../parse/journey') +const _createParseMovement = require('../../parse/movement') const products = require('./products') @@ -23,8 +21,8 @@ const transformReqBody = (body) => { return body } -const parseLocation = (profile, l, lines) => { - const res = _parseLocation(profile, l, lines) +const parseLocation = (profile, opt, data, l) => { + const res = _parseLocation(profile, opt, data, l) // weird fix for empty lines, e.g. IC/EC at Flensburg Hbf if (res.lines) { res.lines = res.lines.filter(x => x.id && x.name) @@ -38,28 +36,8 @@ const parseLocation = (profile, l, lines) => { return res } -const createParseLine = (profile, operators) => { - const parseLine = _createParseLine(profile, operators) - - const parseLineWithMode = (l) => { - const res = parseLine(l) - - res.mode = res.product = null - if ('class' in res) { - const data = products.bitmasks[parseInt(res.class)] - if (data) { - res.mode = data.mode - res.product = data.product - } - } - - return res - } - return parseLineWithMode -} - -const createParseJourney = (profile, stations, lines, remarks) => { - const parseJourney = _createParseJourney(profile, stations, lines, remarks) +const createParseJourney = (profile, opt, data) => { + const parseJourney = _createParseJourney(profile, opt, data) const parseJourneyWithTickets = (j) => { const res = parseJourney(j) @@ -100,26 +78,17 @@ const createParseJourney = (profile, stations, lines, remarks) => { return parseJourneyWithTickets } -const defaultProducts = { - nationalExp: true, - national: true, - interregional: true, - regional: true, - suburban: true, - bus: true, - ferry: true, - subway: true, - tram: true, - onCall: true -} -const formatBitmask = createFormatBitmask(products) -const formatProducts = (products) => { - products = Object.assign(Object.create(null), defaultProducts, products) - return { - type: 'PROD', - mode: 'INC', - value: formatBitmask(products) + '' +const createParseMovement = (profile, opt, data) => { + const _parseMovement = _createParseMovement(profile, opt, data) + const parseMovement = (m) => { + const res = _parseMovement(m) + // filter out empty nextStops entries + res.nextStops = res.nextStops.filter((f) => { + return f.stop !== null || f.arrival !== null || f.departure !== null + }) + return res } + return parseMovement } const nahshProfile = { @@ -128,17 +97,14 @@ const nahshProfile = { endpoint: 'https://nah.sh.hafas.de/bin/mgate.exe', transformReqBody, - products: products.allProducts, + products, - parseProducts: createParseBitmask(products.allProducts, defaultProducts), - parseLine: createParseLine, parseLocation, parseJourney: createParseJourney, + parseMovement: createParseMovement, - formatProducts, - - journeyLeg: true, - radar: false // todo: see #34 + trip: true, + radar: true // todo: see #34 } module.exports = nahshProfile diff --git a/p/nahsh/products.js b/p/nahsh/products.js index 6d970f64..8a1029c4 100644 --- a/p/nahsh/products.js +++ b/p/nahsh/products.js @@ -1,101 +1,86 @@ 'use strict' -const p = { - nationalExp: { - bitmask: 1, +const p = [ + { + id: 'nationalExp', + mode: 'train', + bitmasks: [1], name: 'High-speed rail', short: 'ICE/HSR', - mode: 'train', - product: 'nationalExp' + default: true }, - national: { - bitmask: 2, + { + id: 'national', + mode: 'train', + bitmasks: [2], name: 'InterCity & EuroCity', short: 'IC/EC', - mode: 'train', - product: 'national' + default: true }, - interregional: { // todo: also includes EN? - bitmask: 4, + { // todo: also includes EN? + id: 'interregional', + mode: 'train', + bitmasks: [4], name: 'Interregional', short: 'IR', - mode: 'train', - product: 'interregional' + default: true }, - regional: { - bitmask: 8, + { + id: 'regional', + mode: 'train', + bitmasks: [8], name: 'Regional & RegionalExpress', short: 'RB/RE', - mode: 'train', - product: 'regional' + default: true }, - suburban: { - bitmask: 16, + { + id: 'suburban', + mode: 'train', + bitmasks: [16], name: 'S-Bahn', short: 'S', - mode: 'train', - product: 'suburban' + default: true }, - bus: { - bitmask: 32, + { + id: 'bus', + mode: 'bus', + bitmasks: [32], name: 'Bus', short: 'B', - mode: 'bus', - product: 'bus' + default: true }, - ferry: { - bitmask: 64, + { + id: 'ferry', + mode: 'watercraft', + bitmasks: [64], name: 'Ferry', short: 'F', - mode: 'watercraft', - product: 'ferry' + default: true }, - subway: { - bitmask: 128, + { + id: 'subway', + mode: 'train', + bitmasks: [128], name: 'U-Bahn', short: 'U', - mode: 'train', - product: 'subway' + default: true }, - tram: { - bitmask: 256, + { + id: 'tram', + mode: 'train', + bitmasks: [256], name: 'Tram', short: 'T', - mode: 'train', - product: 'tram' + default: true }, - onCall: { - bitmask: 512, + { + id: 'onCall', + mode: 'bus', // todo: is this correct? + bitmasks: [512], name: 'On-call transit', short: 'on-call', - mode: null, // todo - product: 'onCall' + default: true } -} - -p.bitmasks = [] -p.bitmasks[1] = p.nationalExp -p.bitmasks[2] = p.national -p.bitmasks[4] = p.interregional -p.bitmasks[8] = p.regional -p.bitmasks[16] = p.suburban -p.bitmasks[32] = p.bus -p.bitmasks[64] = p.ferry -p.bitmasks[128] = p.subway -p.bitmasks[256] = p.tram -p.bitmasks[512] = p.onCall - -p.allProducts = [ - p.nationalExp, - p.national, - p.interregional, - p.regional, - p.suburban, - p.bus, - p.ferry, - p.subway, - p.tram, - p.onCall ] module.exports = p diff --git a/p/nahsh/readme.md b/p/nahsh/readme.md index 9d9bdad7..c67a4136 100644 --- a/p/nahsh/readme.md +++ b/p/nahsh/readme.md @@ -9,7 +9,7 @@ const createClient = require('hafas-client') const nahshProfile = require('hafas-client/p/nahsh') // create a client with NAH.SH profile -const client = createClient(nahshProfile) +const client = createClient(nahshProfile, 'my-awesome-program') ``` diff --git a/p/oebb/example.js b/p/oebb/example.js index 6a04412c..f2c3ccdb 100644 --- a/p/oebb/example.js +++ b/p/oebb/example.js @@ -3,15 +3,25 @@ const createClient = require('../..') const oebbProfile = require('.') -const client = createClient(oebbProfile) +const client = createClient(oebbProfile, 'hafas-client-example') // Wien Westbahnhof to Salzburg Hbf client.journeys('1291501', '8100002', {results: 1}) // client.departures('8100002', {duration: 1}) +// client.arrivals('8100002', {duration: 10, stationLines: true}) // client.locations('Salzburg', {results: 2}) -// client.location('8100173') // Graz Hbf -// client.nearby(47.812851, 13.045604, {distance: 60}) -// client.radar(47.827203, 13.001261, 47.773278, 13.07562, {results: 10}) +// client.station('8100173') // Graz Hbf +// client.nearby({ +// type: 'location', +// latitude: 47.812851, +// longitude: 13.045604 +// }, {distance: 60}) +// client.radar({ +// north: 47.827203, +// west: 13.001261, +// south: 47.773278, +// east: 13.07562 +// }, {results: 10}) .then((data) => { console.log(require('util').inspect(data, {depth: null})) diff --git a/p/oebb/index.js b/p/oebb/index.js index 5196ab49..2dd24b49 100644 --- a/p/oebb/index.js +++ b/p/oebb/index.js @@ -3,9 +3,6 @@ // todo: https://gist.github.com/anonymous/a5fc856bc80ae7364721943243f934f4#file-haf_config_base-properties-L5 // todo: https://gist.github.com/anonymous/a5fc856bc80ae7364721943243f934f4#file-haf_config_base-properties-L47-L234 -const createParseBitmask = require('../../parse/products-bitmask') -const createFormatBitmask = require('../../format/products-bitmask') -const _createParseLine = require('../../parse/line') const _parseLocation = require('../../parse/location') const _createParseMovement = require('../../parse/movement') @@ -28,32 +25,12 @@ const transformReqBody = (body) => { return body } -const createParseLine = (profile, operators) => { - const parseLine = _createParseLine(profile, operators) - - const parseLineWithMode = (l) => { - const res = parseLine(l) - - res.mode = res.product = null - if ('class' in res) { - const data = products.bitmasks[parseInt(res.class)] - if (data) { - res.mode = data.mode - res.product = data.product - } - } - - return res - } - return parseLineWithMode -} - -const parseLocation = (profile, l, lines) => { +const parseLocation = (profile, opt, data, l) => { // ÖBB has some 'stations' **in austria** with no departures/products, // like station entrances, that are actually POIs. - const res = _parseLocation(profile, l, lines) + const res = _parseLocation(profile, opt, data, l) if ( - res.type === 'station' && + (res.type === 'station' || res.type === 'stop') && !res.products && res.name && res.id && res.id.length !== 7 @@ -67,8 +44,8 @@ const parseLocation = (profile, l, lines) => { return res } -const createParseMovement = (profile, locations, lines, remarks) => { - const _parseMovement = _createParseMovement(profile, locations, lines, remarks) +const createParseMovement = (profile, opt, data) => { + const _parseMovement = _createParseMovement(profile, opt, data) const parseMovement = (m) => { const res = _parseMovement(m) // filter out POIs @@ -82,28 +59,6 @@ const createParseMovement = (profile, locations, lines, remarks) => { return parseMovement } -const defaultProducts = { - nationalExp: true, - national: true, - interregional: true, - regional: true, - suburban: true, - bus: true, - ferry: true, - subway: true, - tram: true, - onCall: true -} -const formatBitmask = createFormatBitmask(products) -const formatProducts = (products) => { - products = Object.assign(Object.create(null), defaultProducts, products) - return { - type: 'PROD', - mode: 'INC', - value: formatBitmask(products) + '' - } -} - const oebbProfile = { locale: 'de-AT', timezone: 'Europe/Vienna', @@ -111,16 +66,12 @@ const oebbProfile = { endpoint: 'http://fahrplan.oebb.at/bin/mgate.exe', transformReqBody, - products: products.allProducts, + products: products, - parseProducts: createParseBitmask(products.allProducts, defaultProducts), - parseLine: createParseLine, parseLocation, parseMovement: createParseMovement, - formatProducts, - - journeyLeg: true, + trip: true, radar: true } diff --git a/p/oebb/products.js b/p/oebb/products.js index 646d4588..91bcf65e 100644 --- a/p/oebb/products.js +++ b/p/oebb/products.js @@ -1,112 +1,84 @@ 'use strict' -const p = { - nationalExp: { - bitmask: 1, +module.exports = [ + { + id: 'nationalExp', + mode: 'train', + bitmasks: [1], name: 'InterCityExpress & RailJet', short: 'ICE/RJ', - mode: 'train', - product: 'nationalExp' + default: true }, - national: { - bitmask: 2 + 4, + { + id: 'national', + mode: 'train', + bitmasks: [2, 4], name: 'InterCity & EuroCity', short: 'IC/EC', - mode: 'train', - product: 'national' + default: true }, - interregional: { - bitmask: 8 + 4096, + { + id: 'interregional', + mode: 'train', + bitmasks: [8, 4096], name: 'Durchgangszug & EuroNight', short: 'D/EN', - mode: 'train', - product: 'interregional' + default: true }, - regional: { - bitmask: 16, + { + id: 'regional', + mode: 'train', + bitmasks: [16], name: 'Regional & RegionalExpress', short: 'R/REX', - mode: 'train', - product: 'regional' + default: true }, - suburban: { - bitmask: 32, + { + id: 'suburban', + mode: 'train', + bitmasks: [32], name: 'S-Bahn', short: 'S', - mode: 'train', - product: 'suburban' + default: true }, - bus: { - bitmask: 64, + { + id: 'bus', + mode: 'bus', + bitmasks: [64], name: 'Bus', short: 'B', - mode: 'bus', - product: 'bus' + default: true }, - ferry: { - bitmask: 128, + { + id: 'ferry', + mode: 'watercraft', + bitmasks: [128], name: 'Ferry', short: 'F', - mode: 'watercraft', - product: 'ferry' + default: true }, - subway: { - bitmask: 256, + { + id: 'subway', + mode: 'train', + bitmasks: [256], name: 'U-Bahn', short: 'U', - mode: 'train', - product: 'subway' + default: true }, - tram: { - bitmask: 512, + { + id: 'tram', + mode: 'train', + bitmasks: [512], name: 'Tram', short: 'T', - mode: 'train', - product: 'tram' + default: true }, - onCall: { - bitmask: 2048, + { + id: 'onCall', + mode: null, // todo + bitmasks: [2048], name: 'On-call transit', short: 'on-call', - mode: null, // todo - product: 'onCall' - }, - unknown: { - bitmask: 0, - name: 'unknown', - short: '?', - product: 'unknown' + default: true } -} - -p.bitmasks = [] -p.bitmasks[1] = p.nationalExp -p.bitmasks[2] = p.national -p.bitmasks[4] = p.national -p.bitmasks[2+4] = p.national -p.bitmasks[8] = p.interregional -p.bitmasks[16] = p.regional -p.bitmasks[32] = p.suburban -p.bitmasks[64] = p.bus -p.bitmasks[128] = p.ferry -p.bitmasks[256] = p.subway -p.bitmasks[512] = p.tram -p.bitmasks[1024] = p.unknown -p.bitmasks[2048] = p.onCall -p.bitmasks[4096] = p.interregional -p.bitmasks[8+4096] = p.interregional - -p.allProducts = [ - p.nationalExp, - p.national, - p.interregional, - p.regional, - p.suburban, - p.bus, - p.ferry, - p.subway, - p.tram, - p.onCall ] - -module.exports = p diff --git a/p/oebb/readme.md b/p/oebb/readme.md index f45b9c28..1771c420 100644 --- a/p/oebb/readme.md +++ b/p/oebb/readme.md @@ -9,11 +9,11 @@ const createClient = require('hafas-client') const oebbProfile = require('hafas-client/p/oebb') // create a client with ÖBB profile -const client = createClient(oebbProfile) +const client = createClient(oebbProfile, 'my-awesome-program') ``` ## Customisations - parses *ÖBB*-specific products (such as *RailJet*) -- parses invalid empty stations from the API as [`location`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#location-objects)s +- parses invalid empty stations from the API as [`location`](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md#location-objects)s diff --git a/p/readme.md b/p/readme.md index dea7cc16..7cb66714 100644 --- a/p/readme.md +++ b/p/readme.md @@ -1,6 +1,6 @@ # Profiles -This directory contains specific customisations for each endpoint, called *profiles*. They **parse data from the API differently, add additional information, or enable non-default methods** (such as [`journeyLeg`](../docs/journey-leg.md)) if they are supported. +This directory contains specific customisations for each endpoint, called *profiles*. They **parse data from the API differently, add additional information, or enable non-default methods** (such as [`trip`](../docs/trip.md)) if they are supported. Each profile has it's own directory. It will be passed into `hafas-client` and is expected to be in a certain structure: diff --git a/p/vbb/example.js b/p/vbb/example.js index 85d6ea28..5a79930f 100644 --- a/p/vbb/example.js +++ b/p/vbb/example.js @@ -3,19 +3,33 @@ const createClient = require('../..') const vbbProfile = require('.') -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'hafas-client-example') // Hauptbahnhof to Charlottenburg client.journeys('900000003201', '900000024101', {results: 1, polylines: true}) // client.departures('900000013102', {duration: 1}) +// client.arrivals('900000013102', {duration: 10, stationLines: true}) // client.locations('Alexanderplatz', {results: 2}) -// client.location('900000042101') // Spichernstr -// client.nearby(52.5137344, 13.4744798, {distance: 60}) -// client.radar(52.52411, 13.41002, 52.51942, 13.41709, {results: 10}) +// client.station('900000042101', {stationLines: true}) // Spichernstr +// client.nearby({ +// type: 'location', +// latitude: 52.5137344, +// longitude: 13.4744798 +// }, {distance: 60}) +// client.radar({ +// north: 52.52411, +// west: 13.41002, +// south: 52.51942, +// east: 13.41709 +// }, {results: 10}) // .then(([journey]) => { // const leg = journey.legs[0] -// return client.journeyLeg(leg.id, leg.line.name, {polyline: true}) +// return client.trip(leg.id, leg.line.name, {polyline: true}) +// }) + +// .then(([journey]) => { +// return client.refreshJourney(journey.refreshToken, {stopovers: true, remarks: true}) // }) .then((data) => { console.log(require('util').inspect(data, {depth: null})) diff --git a/p/vbb/index.js b/p/vbb/index.js index 68b6496b..18909ded 100644 --- a/p/vbb/index.js +++ b/p/vbb/index.js @@ -11,12 +11,8 @@ const _parseLocation = require('../../parse/location') const _createParseJourney = require('../../parse/journey') const _createParseDeparture = require('../../parse/departure') const _formatStation = require('../../format/station') -const createParseBitmask = require('../../parse/products-bitmask') -const createFormatBitmask = require('../../format/products-bitmask') -const modes = require('./modes') - -const formatBitmask = createFormatBitmask(modes) +const products = require('./products') const transformReqBody = (body) => { body.client = {type: 'IPA', id: 'VBB', name: 'vbbPROD', v: '4010300'} @@ -27,21 +23,12 @@ const transformReqBody = (body) => { return body } -const createParseLine = (profile, operators) => { - const parseLine = _createParseLine(profile, operators) +const createParseLine = (profile, opt, data) => { + const parseLine = _createParseLine(profile, opt, data) - const parseLineWithMode = (l) => { + const parseLineWithMoreDetails = (l) => { const res = parseLine(l) - res.mode = res.product = null - if ('class' in res) { - const data = modes.bitmasks[parseInt(res.class)] - if (data) { - res.mode = data.mode - res.product = data.product - } - } - res.name = l.name.replace(/^(bus|tram)\s+/i, '') const details = parseLineName(res.name) res.symbol = details.symbol @@ -52,13 +39,13 @@ const createParseLine = (profile, operators) => { return res } - return parseLineWithMode + return parseLineWithMoreDetails } -const parseLocation = (profile, l, lines) => { - const res = _parseLocation(profile, l, lines) +const parseLocation = (profile, opt, data, l) => { + const res = _parseLocation(profile, opt, data, l) - if (res.type === 'station') { + if (res.type === 'stop' || res.type === 'station') { res.name = shorten(res.name) res.id = to12Digit(res.id) if (!res.location.latitude || !res.location.longitude) { @@ -69,8 +56,8 @@ const parseLocation = (profile, l, lines) => { return res } -const createParseJourney = (profile, stations, lines, remarks, polylines) => { - const parseJourney = _createParseJourney(profile, stations, lines, remarks, polylines) +const createParseJourney = (profile, opt, data) => { + const parseJourney = _createParseJourney(profile, opt, data) const parseJourneyWithTickets = (j) => { const res = parseJourney(j) @@ -99,8 +86,8 @@ const createParseJourney = (profile, stations, lines, remarks, polylines) => { return parseJourneyWithTickets } -const createParseDeparture = (profile, stations, lines, remarks) => { - const parseDeparture = _createParseDeparture(profile, stations, lines, remarks) +const createParseDeparture = (profile, opt, data) => { + const parseDeparture = _createParseDeparture(profile, opt, data) const ringbahnClockwise = /^ringbahn s\s?41$/i const ringbahnAnticlockwise = /^ringbahn s\s?42$/i @@ -132,24 +119,6 @@ const formatStation = (id) => { return _formatStation(id) } -const defaultProducts = { - suburban: true, - subway: true, - tram: true, - bus: true, - ferry: true, - express: true, - regional: true -} -const formatProducts = (products) => { - products = Object.assign(Object.create(null), defaultProducts, products) - return { - type: 'PROD', - mode: 'INC', - value: formatBitmask(products) + '' - } -} - const vbbProfile = { locale: 'de-DE', timezone: 'Europe/Berlin', @@ -162,20 +131,18 @@ const vbbProfile = { transformReqBody, - products: modes.allProducts, + products: products, parseStationName: shorten, parseLocation, parseLine: createParseLine, - parseProducts: createParseBitmask(modes.allProducts, defaultProducts), parseJourney: createParseJourney, parseDeparture: createParseDeparture, formatStation, - formatProducts, journeysNumF: false, - journeyLeg: true, + trip: true, radar: true } diff --git a/p/vbb/modes.js b/p/vbb/modes.js deleted file mode 100644 index 3e771793..00000000 --- a/p/vbb/modes.js +++ /dev/null @@ -1,112 +0,0 @@ -'use strict' - -// todo: remove useless keys -const m = { - suburban: { - category: 0, - bitmask: 1, - name: 'S-Bahn', - mode: 'train', - short: 'S', - product: 'suburban' - }, - - subway: { - category: 1, - bitmask: 2, - name: 'U-Bahn', - mode: 'train', - short: 'U', - product: 'subway' - }, - - tram: { - category: 2, - bitmask: 4, - name: 'Tram', - mode: 'train', - short: 'T', - product: 'tram' - }, - - bus: { - category: 3, - bitmask: 8, - name: 'Bus', - mode: 'bus', - short: 'B', - product: 'bus' - }, - - ferry: { - category: 4, - bitmask: 16, - name: 'Fähre', - mode: 'watercraft', - short: 'F', - product: 'ferry' - }, - - express: { - category: 5, - bitmask: 32, - name: 'IC/ICE', - mode: 'train', - short: 'E', - product: 'express' - }, - - regional: { - category: 6, - bitmask: 64, - name: 'RB/RE', - mode: 'train', - short: 'R', - product: 'regional' - }, - - unknown: { - category: null, - bitmask: 0, - name: 'unknown', - mode: null, - short: '?', - product: 'unknown' - } -} - -m.bitmasks = [] -m.bitmasks[1] = m.suburban -m.bitmasks[2] = m.subway -m.bitmasks[4] = m.tram -m.bitmasks[8] = m.bus -m.bitmasks[16] = m.ferry -m.bitmasks[32] = m.express -m.bitmasks[64] = m.regional - -m.categories = [ - m.suburban, - m.subway, - m.tram, - m.bus, - m.ferry, - m.express, - m.regional, - m.unknown -] - -m.allProducts = [ - m.suburban, - m.subway, - m.tram, - m.bus, - m.ferry, - m.express, - m.regional -] - -// m.parseCategory = (category) => { -// return m.categories[parseInt(category)] || m.unknown -// } - -module.exports = m diff --git a/p/vbb/products.js b/p/vbb/products.js new file mode 100644 index 00000000..6d091247 --- /dev/null +++ b/p/vbb/products.js @@ -0,0 +1,60 @@ +'use strict' + +module.exports = [ + { + id: 'suburban', + mode: 'train', + bitmasks: [1], + name: 'S-Bahn', + short: 'S', + default: true + }, + { + id: 'subway', + mode: 'train', + bitmasks: [2], + name: 'U-Bahn', + short: 'U', + default: true + }, + { + id: 'tram', + mode: 'train', + bitmasks: [4], + name: 'Tram', + short: 'T', + default: true + }, + { + id: 'bus', + mode: 'bus', + bitmasks: [8], + name: 'Bus', + short: 'B', + default: true + }, + { + id: 'ferry', + mode: 'watercraft', + bitmasks: [16], + name: 'Fähre', + short: 'F', + default: true + }, + { + id: 'express', + mode: 'train', + bitmasks: [32], + name: 'IC/ICE', + short: 'E', + default: true + }, + { + id: 'regional', + mode: 'train', + bitmasks: [64], + name: 'RB/RE', + short: 'R', + default: true + } +] diff --git a/p/vbb/readme.md b/p/vbb/readme.md index 35b4b89a..51d4c0eb 100644 --- a/p/vbb/readme.md +++ b/p/vbb/readme.md @@ -9,7 +9,7 @@ const createClient = require('hafas-client') const vbbProfile = require('hafas-client/p/vbb') // create a client with VBB profile -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'my-awesome-program') ``` diff --git a/package.json b/package.json index ec355756..3f0a1da8 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "node": ">=6" }, "dependencies": { + "@mapbox/polyline": "^1.0.0", + "br2nl": "^1.0.0", "capture-stack-trace": "^1.0.0", "create-hash": "^1.2.0", "fetch-ponyfill": "^6.0.0", + "gps-distance": "0.0.4", "lodash": "^4.17.5", - "luxon": "^1.2.1", + "luxon": "^1.3.0", "p-throttle": "^1.1.0", "pinkie-promise": "^2.0.1", "query-string": "^6.0.0", @@ -51,14 +54,15 @@ "db-stations": "^2.3.0", "is-coordinates": "^2.0.2", "is-roughly-equal": "^0.1.0", - "tap-spec": "^4.1.1", + "tap-spec": "^5.0.0", "tape": "^4.8.0", "tape-promise": "^3.0.0", - "validate-fptf": "^1.2.1", + "validate-fptf": "^2.0.1", "vbb-stations-autocomplete": "^3.1.0" }, "scripts": { - "test": "env NODE_ENV=dev node test/index.js", - "prepublishOnly": "npm test | tap-spec" + "test": "env NODE_ENV=dev NODE_DEBUG=hafas-client node test/index.js", + "prepublishOnly": "npm test | tap-spec", + "install": "lib/generate-install-id.js >id.json" } } diff --git a/parse/arrival-or-departure.js b/parse/arrival-or-departure.js new file mode 100644 index 00000000..cfcfce28 --- /dev/null +++ b/parse/arrival-or-departure.js @@ -0,0 +1,68 @@ +'use strict' + +const findRemark = require('./find-remark') + +// todo: what is d.jny.dirFlg? +// todo: d.stbStop.dProgType/d.stbStop.aProgType + +const createParseArrOrDep = (profile, opt, data, prefix) => { + const {locations, lines, hints, warnings} = data + if (prefix !== 'a' && prefix !== 'd') throw new Error('invalid prefix') + + const parseArrOrDep = (d) => { + const t = d.stbStop[prefix + 'TimeR'] || d.stbStop[prefix + 'TimeS'] + const when = profile.parseDateTime(profile, d.date, t) + + const res = { + tripId: d.jid, + stop: locations[parseInt(d.stbStop.locX)] || null, + when: when.toISO(), + direction: profile.parseStationName(d.dirTxt), + line: lines[parseInt(d.prodX)] || null, + remarks: [], + // todo: res.trip from rawLine.prodCtx.num? + trip: +d.jid.split('|')[1] // todo: this seems brittle + } + + // todo: DRY with parseStopover + // todo: DRY with parseJourneyLeg + const tR = d.stbStop[prefix + 'TimeR'] + const tP = d.stbStop[prefix + 'TimeS'] + if (tR && tP) { + const realtime = profile.parseDateTime(profile, d.date, tR) + const planned = profile.parseDateTime(profile, d.date, tP) + res.delay = Math.round((realtime - planned) / 1000) + } else res.delay = null + + // todo: DRY with parseStopover + // todo: DRY with parseJourneyLeg + const pR = d.stbStop[prefix + 'PlatfR'] + const pP = d.stbStop[prefix + 'PlatfS'] + res.platform = pR || pP || null + if (pR && pP && pR !== pP) res.formerScheduledPlatform = pP + + // todo: DRY with parseStopover + // todo: DRY with parseJourneyLeg + if (d.stbStop[prefix + 'Cncl']) { + res.cancelled = true + Object.defineProperty(res, 'canceled', {value: true}) + res.when = res.delay = null + + const when = profile.parseDateTime(profile, d.date, tP) + res.formerScheduledWhen = when.toISO() + } + + if (opt.remarks) { + res.remarks = [] + .concat(d.remL || [], d.msgL || []) + .map(ref => findRemark(hints, warnings, ref)) + .filter(rem => !!rem) // filter unparsable + } + + return res + } + + return parseArrOrDep +} + +module.exports = createParseArrOrDep diff --git a/parse/arrival.js b/parse/arrival.js new file mode 100644 index 00000000..e2c2b554 --- /dev/null +++ b/parse/arrival.js @@ -0,0 +1,10 @@ +'use strict' + +const createParseArrOrDep = require('./arrival-or-departure') + +const ARRIVAL = 'a' +const createParseArrival = (profile, opt, data) => { + return createParseArrOrDep(profile, opt, data, ARRIVAL) +} + +module.exports = createParseArrival diff --git a/parse/date-time.js b/parse/date-time.js index 78635354..63ea681a 100644 --- a/parse/date-time.js +++ b/parse/date-time.js @@ -2,8 +2,6 @@ const {DateTime} = require('luxon') -const validDate = /^(\d{4})-(\d{2})-(\d{2})$/ - const parseDateTime = (profile, date, time) => { const pDate = [date.substr(-8, 4), date.substr(-4, 2), date.substr(-2, 2)] if (!pDate[0] || !pDate[1] || !pDate[2]) { diff --git a/parse/departure.js b/parse/departure.js index 89224e12..dc86f7a5 100644 --- a/parse/departure.js +++ b/parse/departure.js @@ -1,53 +1,10 @@ 'use strict' -// todo: what is d.jny.dirFlg? -// todo: d.stbStop.dProgType -// todo: d.freq, d.freq.jnyL, see https://github.com/public-transport/hafas-client/blob/9203ed1481f08baacca41ac5e3c19bf022f01b0b/parse.js#L115 +const createParseArrOrDep = require('./arrival-or-departure') -const createParseDeparture = (profile, stations, lines, remarks) => { - const findRemark = rm => remarks[parseInt(rm.remX)] || null - - const parseDeparture = (d) => { - const when = profile.parseDateTime(profile, d.date, d.stbStop.dTimeR || d.stbStop.dTimeS) - const res = { - journeyId: d.jid, - station: stations[parseInt(d.stbStop.locX)] || null, - when: when.toISO(), - direction: profile.parseStationName(d.dirTxt), - line: lines[parseInt(d.prodX)] || null, - remarks: d.remL ? d.remL.map(findRemark) : [], - trip: +d.jid.split('|')[1] // todo: this seems brittle - } - // todo: res.trip from rawLine.prodCtx.num? - - // todo: DRY with parseStopover - // todo: DRY with parseJourneyLeg - if (d.stbStop.dTimeR && d.stbStop.dTimeS) { - const realtime = profile.parseDateTime(profile, d.date, d.stbStop.dTimeR) - const planned = profile.parseDateTime(profile, d.date, d.stbStop.dTimeS) - res.delay = Math.round((realtime - planned) / 1000) - } else res.delay = null - - // todo: DRY with parseStopover - // todo: DRY with parseJourneyLeg - res.platform = d.stbStop.dPlatfR || d.stbStop.dPlatfS || null - // todo: `formerScheduledPlatform` - - // todo: DRY with parseStopover - // todo: DRY with parseJourneyLeg - if (d.stbStop.aCncl || d.stbStop.dCncl) { - res.cancelled = true - Object.defineProperty(res, 'canceled', {value: true}) - res.when = res.delay = null - - const when = profile.parseDateTime(profile, d.date, d.stbStop.dTimeS) - res.formerScheduledWhen = when.toISO() - } - - return res - } - - return parseDeparture +const DEPARTURE = 'd' +const createParseDeparture = (profile, opt, data) => { + return createParseArrOrDep(profile, opt, data, DEPARTURE) } module.exports = createParseDeparture diff --git a/parse/find-remark.js b/parse/find-remark.js new file mode 100644 index 00000000..9bf89884 --- /dev/null +++ b/parse/find-remark.js @@ -0,0 +1,17 @@ +'use strict' + +// There are two kinds of notes: "remarks" (in `remL`) and HAFAS +// Information Manager (HIM) notes (in `himL`). The former describe +// the regular operating situation, e.g. "bicycles allows", whereas +// the latter describe cancellations, construction work, etc. + +// hafas-client's naming scheme: +// - hints: notes from `remL` for regular operation +// - warnings: notes from `himL` for cancellations, construction, etc +// - remarks: both "notes" and "warnings" + +const findRemark = (hints, warnings, ref) => { + return warnings[ref.himX] || hints[ref.remX] || null +} + +module.exports = findRemark diff --git a/parse/hint.js b/parse/hint.js new file mode 100644 index 00000000..7dd6f045 --- /dev/null +++ b/parse/hint.js @@ -0,0 +1,248 @@ +'use strict' + +const hints = Object.assign(Object.create(null), { + fb: { + type: 'hint', + code: 'bicycle-conveyance', + summary: 'bicycles conveyed' + }, + fr: { + type: 'hint', + code: 'bicycle-conveyance-reservation', + summary: 'bicycles conveyed, subject to reservation' + }, + nf: { + type: 'hint', + code: 'no-bicycle-conveyance', + summary: 'bicycles not conveyed' + }, + k2: { + type: 'hint', + code: '2nd-class-only', + summary: '2. class only' + }, + eh: { + type: 'hint', + code: 'boarding-ramp', + summary: 'vehicle-mounted boarding ramp available' + }, + ro: { + type: 'hint', + code: 'wheelchairs-space', + summary: 'space for wheelchairs' + }, + oa: { + type: 'hint', + code: 'wheelchairs-space-reservation', + summary: 'space for wheelchairs, subject to reservation' + }, + wv: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available' + }, + wi: { + type: 'hint', + code: 'wifi', + summary: 'WiFi available' + }, + sn: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase' + }, + mb: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase' + }, + mp: { + type: 'hint', + code: 'snacks', + summary: 'snacks available for purchase at the seat' + }, + bf: { + type: 'hint', + code: 'barrier-free', + summary: 'barrier-free' + }, + rg: { + type: 'hint', + code: 'barrier-free-vehicle', + summary: 'barrier-free vehicle' + }, + bt: { + type: 'hint', + code: 'on-board-bistro', + summary: 'Bordbistro available' + }, + br: { + type: 'hint', + code: 'on-board-restaurant', + summary: 'Bordrestaurant available' + }, + ki: { + type: 'hint', + code: 'childrens-area', + summary: `children's area available` + }, + kk: { + type: 'hint', + code: 'parents-childrens-compartment', + summary: `parent-and-children compartment available` + }, + kr: { + type: 'hint', + code: 'kids-service', + summary: 'DB Kids Service available' + }, + ls: { + type: 'hint', + code: 'power-sockets', + summary: 'power sockets available' + }, + ev: { + type: 'hint', + code: 'replacement-service', + summary: 'replacement service' + }, + kl: { + type: 'hint', + code: 'air-conditioned', + summary: 'air-conditioned vehicle' + }, + r0: { + type: 'hint', + code: 'upward-escalator', + summary: 'upward escalator' + }, + au: { + type: 'hint', + code: 'elevator', + summary: 'elevator available' + }, + ck: { + type: 'hint', + code: 'komfort-checkin', + summary: 'Komfort-Checkin available' + }, + it: { + type: 'hint', + code: 'ice-sprinter', + summary: 'ICE Sprinter service' + }, + rp: { + type: 'hint', + code: 'compulsory-reservation', + summary: 'compulsory seat reservation' + }, + rm: { + type: 'hint', + code: 'optional-reservation', + summary: 'optional seat reservation' + }, + scl: { + type: 'hint', + code: 'all-2nd-class-seats-reserved', + summary: 'all 2nd class seats reserved' + }, + acl: { + type: 'hint', + code: 'all-seats-reserved', + summary: 'all seats reserved' + }, + sk: { + type: 'hint', + code: 'oversize-luggage-forbidden', + summary: 'oversize luggage not allowed' + }, + hu: { + type: 'hint', + code: 'animals-forbidden', + summary: 'animals not allowed, except guide dogs' + }, + ik: { + type: 'hint', + code: 'baby-cot-required', + summary: 'baby cot/child seat required' + }, + ee: { + type: 'hint', + code: 'on-board-entertainment', + summary: 'on-board entertainment available' + }, + toilet: { + type: 'hint', + code: 'toilet', + summary: 'toilet available' + }, + oc: { + type: 'hint', + code: 'wheelchair-accessible-toilet', + summary: 'wheelchair-accessible toilet available' + }, + iz: { + type: 'hint', + code: 'intercity-2', + summary: 'Intercity 2' + } +}) + +const codesByIcon = Object.assign(Object.create(null), { + cancel: 'cancelled' +}) + +// todo: is passing in profile necessary? +const parseHint = (profile, h, icons) => { + // todo: C + // todo: + // { type: 'Q', + // code: '', + // icoX: 11, + // txtN: + // 'RE 3132: Berlin Zoologischer Garten - Brandenburg Hbf: Information. A railway carriage is missing', + // sIdx: 4 } + + const text = h.txtN && h.txtN.trim() || '' + const icon = 'number' === typeof h.icoX && icons[h.icoX] || null + const code = h.code || (icon && icon.res && codesByIcon[icon.res]) || null + + if (h.type === 'M') { + return { + type: 'status', + summary: h.txtS && h.txtS.trim() || '', + code, + text + } + } + + if (h.type === 'L') { + return { + type: 'status', + code: 'alternative-trip', + text, + tripId: h.jid + } + } + if (h.type === 'A') { + return { + type: 'hint', + code: h.code || null, + text: h.txtN || null + } + } + + if (h.type === 'D' || h.type === 'U' || h.type === 'R' || h.type === 'N') { + // todo: how can we identify the individual types? + // todo: does `D` mean "disturbance"? + return { + type: 'status', + code, + text + } + } + + return null +} + +module.exports = parseHint diff --git a/parse/index.js b/parse/index.js index 11a5bd0f..e73bfb79 100644 --- a/parse/index.js +++ b/parse/index.js @@ -4,7 +4,7 @@ module.exports = { dateTime: require('./date-time'), location: require('./location'), line: require('./line'), - remark: require('./remark'), + hint: require('./hint'), operator: require('./operator'), stopover: require('./stopover'), journeyLeg: require('./journey-leg'), diff --git a/parse/journey-leg.js b/parse/journey-leg.js index 06ba7f86..0107a85f 100644 --- a/parse/journey-leg.js +++ b/parse/journey-leg.js @@ -1,24 +1,61 @@ 'use strict' const parseDateTime = require('./date-time') +const findRemark = require('./find-remark') const clone = obj => Object.assign({}, obj) -const createParseJourneyLeg = (profile, stations, lines, remarks, polylines) => { - // todo: finish parse/remark.js first - const applyRemark = (j, rm) => {} +const locX = Symbol('locX') +const applyRemarks = (leg, hints, warnings, refs) => { + for (let ref of refs) { + const remark = findRemark(hints, warnings, ref) + if (!remark) continue + + if ('number' === typeof ref.fLocX && 'number' === typeof ref.tLocX) { + const fromI = leg.stopovers.findIndex(s => s[locX] === ref.fLocX) + const toI = leg.stopovers.findIndex(s => s[locX] === ref.tLocX) + if (fromI < 0 || toI < 0) continue + + const wholeLeg = fromI === 0 && toI === (leg.stopovers.length - 1) + if (!wholeLeg) { + for (let i = fromI; i <= toI; i++) { + const stopover = leg.stopovers[i] + if (!stopover) continue + if (Array.isArray(stopover.remarks)) { + stopover.remarks.push(remark) + } else { + stopover.remarks = [remark] + } + } + + continue + } + } + + if (Array.isArray(leg.remarks)) leg.remarks.push(remark) + else leg.remarks = [remark] + // todo: `ref.tagL` + } +} + +const createParseJourneyLeg = (profile, opt, data) => { + const {locations, lines, hints, warnings, polylines} = data + // todo: pt.status + // todo: pt.status, pt.isPartCncl + // todo: pt.isRchbl, pt.chRatingRT, pt.chgDurR, pt.minChg // todo: pt.sDays // todo: pt.dep.dProgType, pt.arr.dProgType // todo: what is pt.jny.dirFlg? - // todo: how does pt.freq work? - // todo: what is pt.himL? - const parseJourneyLeg = (j, pt, passed = true) => { // j = journey, pt = part + + // j = journey, pt = part + // todo: pt.planrtTS + const parseJourneyLeg = (j, pt, parseStopovers = true) => { const dep = profile.parseDateTime(profile, j.date, pt.dep.dTimeR || pt.dep.dTimeS) const arr = profile.parseDateTime(profile, j.date, pt.arr.aTimeR || pt.arr.aTimeS) const res = { - origin: clone(stations[parseInt(pt.dep.locX)]) || null, - destination: clone(stations[parseInt(pt.arr.locX)]), + origin: clone(locations[parseInt(pt.dep.locX)]) || null, + destination: clone(locations[parseInt(pt.arr.locX)]), departure: dep.toISO(), arrival: arr.toISO() } @@ -38,32 +75,48 @@ const createParseJourneyLeg = (profile, stations, lines, remarks, polylines) => if (pt.jny && pt.jny.polyG) { let p = pt.jny.polyG.polyXL - p = p && polylines[p[0]] + p = Array.isArray(p) && polylines[p[0]] // todo: there can be >1 polyline - res.polyline = p && p.crdEncYX || null + const parse = profile.parsePolyline(profile, opt, data) + res.polyline = p && parse(p) || null } if (pt.type === 'WALK' || pt.type === 'TRSF') { res.mode = 'walking' res.public = true res.distance = pt.gis && pt.gis.dist || null + if (pt.type === 'TRSF') res.transfer = true + + if (opt.remarks && Array.isArray(pt.gis.msgL)) { + applyRemarks(res, hints, warnings, pt.gis.msgL) + } } else if (pt.type === 'JNY') { // todo: pull `public` value from `profile.products` res.id = pt.jny.jid res.line = lines[parseInt(pt.jny.prodX)] || null - res.direction = profile.parseStationName(pt.jny.dirTxt) + res.direction = profile.parseStationName(pt.jny.dirTxt) || null if (pt.dep.dPlatfS) res.departurePlatform = pt.dep.dPlatfS if (pt.arr.aPlatfS) res.arrivalPlatform = pt.arr.aPlatfS - if (passed && pt.jny.stopL) { - const parse = profile.parseStopover(profile, stations, lines, remarks, j.date) - const passedStations = pt.jny.stopL.map(parse) + if (parseStopovers && pt.jny.stopL) { + const parse = profile.parseStopover(profile, opt, data, j.date) + const stopL = pt.jny.stopL + res.stopovers = stopL.map(parse) + + // todo: is there a `pt.jny.remL`? + if (opt.remarks && Array.isArray(pt.jny.msgL)) { + for (let i = 0; i < stopL.length; i++) { + Object.defineProperty(res.stopovers[i], locX, { + value: stopL[i].locX + }) + } + // todo: apply leg-wide remarks if `parseStopovers` is false + applyRemarks(res, hints, warnings, pt.jny.msgL) + } + // filter stations the train passes without stopping, as this doesn't comply with fptf (yet) - res.passed = passedStations.filter((x) => !x.passBy) - } - if (Array.isArray(pt.jny.remL)) { - for (let remark of pt.jny.remL) applyRemark(j, remark) + res.stopovers = res.stopovers.filter((x) => !x.passBy) } const freq = pt.jny.freq || {} diff --git a/parse/journey.js b/parse/journey.js index 20a8c0ec..0ebe1001 100644 --- a/parse/journey.js +++ b/parse/journey.js @@ -1,36 +1,35 @@ 'use strict' -const clone = obj => Object.assign({}, obj) +const findRemark = require('./find-remark') -const createParseJourney = (profile, stations, lines, remarks, polylines) => { - const parseLeg = profile.parseJourneyLeg(profile, stations, lines, remarks, polylines) +const createParseJourney = (profile, opt, data) => { + const parseLeg = profile.parseJourneyLeg(profile, opt, data) + const {hints, warnings} = data // todo: c.sDays - // todo: c.dep.dProgType, c.arr.dProgType // todo: c.conSubscr // todo: c.trfRes x vbb-parse-ticket + // todo: c.sotRating, c.isSotCon, c.sotCtxt + // todo: c.showARSLink + // todo: c.useableTime + // todo: c.cksum + // todo: c.isNotRdbl + // todo: c.badSecRefX + // todo: c.bfATS, c.bfIOSTS const parseJourney = (j) => { const legs = j.secL.map(leg => parseLeg(j, leg)) const res = { type: 'journey', legs, - origin: legs[0].origin, - destination: legs[legs.length - 1].destination, - departure: legs[0].departure, - arrival: legs[legs.length - 1].arrival + refreshToken: j.ctxRecon || null } - if (legs.some(p => p.cancelled)) { - res.cancelled = true - Object.defineProperty(res, 'canceled', {value: true}) - res.departure = res.arrival = null - const firstLeg = j.secL[0] - const dep = profile.parseDateTime(profile, j.date, firstLeg.dep.dTimeS) - res.formerScheduledDeparture = dep.toISO() - - const lastLeg = j.secL[j.secL.length - 1] - const arr = profile.parseDateTime(profile, j.date, lastLeg.arr.aTimeS) - res.formerScheduledArrival = arr.toISO() + if (opt.remarks && Array.isArray(j.msgL)) { + res.remarks = [] + for (let ref of j.msgL) { + const remark = findRemark(hints, warnings, ref) + if (remark) res.remarks.push(remark) + } } return res diff --git a/parse/line.js b/parse/line.js index 79300c08..716bb69a 100644 --- a/parse/line.js +++ b/parse/line.js @@ -2,8 +2,14 @@ const slugg = require('slugg') -// todo: are p.number and p.line ever different? -const createParseLine = (profile, operators) => { +const createParseLine = (profile, opt, {operators}) => { + const byBitmask = [] + for (let product of profile.products) { + for (let bitmask of product.bitmasks) { + byBitmask[bitmask] = product + } + } + const parseLine = (p) => { if (!p) return null // todo: handle this upstream const res = { @@ -13,8 +19,9 @@ const createParseLine = (profile, operators) => { public: true } // todo: what is p.prodCtx && p.prodCtx.num? + // todo: what is p.number? - // This is terrible, but FPTF demands an ID. Let's pray for VBB to expose an ID. + // This is terrible, but FPTF demands an ID. Let's pray for HaCon to expose an ID. // todo: find a better way if (p.line) res.id = slugg(p.line.trim()) else if (p.name) res.id = slugg(p.name.trim()) @@ -24,7 +31,12 @@ const createParseLine = (profile, operators) => { res.productCode = +p.prodCtx.catCode } - // todo: parse mode, remove from profiles + if ('class' in res) { + // todo: what if `res.class` is the sum of two bitmasks? + const product = byBitmask[parseInt(res.class)] + res.mode = product && product.mode || null + res.product = product && product.id || null + } if ('number' === typeof p.oprX) { res.operator = operators[p.oprX] || null diff --git a/parse/location.js b/parse/location.js index 1659ea92..2283ef2b 100644 --- a/parse/location.js +++ b/parse/location.js @@ -5,10 +5,7 @@ const STATION = 'S' const ADDRESS = 'A' // todo: what is s.rRefL? -// todo: is passing in profile necessary? - -// todo: [breaking] change to createParseLocation(profile, lines) => (l) => loc -const parseLocation = (profile, l, lines) => { +const parseLocation = (profile, opt, {lines}, l) => { const res = {type: 'location'} if (l.crd) { res.latitude = l.crd.y / 1000000 @@ -16,24 +13,28 @@ const parseLocation = (profile, l, lines) => { } if (l.type === STATION) { - const station = { - type: 'station', + const stop = { + type: l.isMainMast ? 'station' : 'stop', id: l.extId, - name: profile.parseStationName(l.name), + name: l.name ? profile.parseStationName(l.name) : null, location: 'number' === typeof res.latitude ? res : null } - if ('pCls' in l) station.products = profile.parseProducts(l.pCls) + if ('pCls' in l) stop.products = profile.parseProducts(l.pCls) - if (Array.isArray(l.pRefL) && Array.isArray(lines)) { - station.lines = [] + if ( + opt.stationLines && + Array.isArray(l.pRefL) && + Array.isArray(lines) + ) { + stop.lines = [] for (let pRef of l.pRefL) { const line = lines[pRef] - if (line) station.lines.push(line) + if (line) stop.lines.push(line) } } - return station + return stop } if (l.type === ADDRESS) res.address = l.name diff --git a/parse/movement.js b/parse/movement.js index 7999eb52..c51513a3 100644 --- a/parse/movement.js +++ b/parse/movement.js @@ -1,6 +1,8 @@ 'use strict' -const createParseMovement = (profile, locations, lines, remarks, polylines = []) => { +const createParseMovement = (profile, opt, data) => { + const {locations, lines, polylines} = data + // todo: what is m.dirGeo? maybe the speed? // todo: what is m.stopL? // todo: what is m.proc? wut? @@ -8,11 +10,11 @@ const createParseMovement = (profile, locations, lines, remarks, polylines = []) // todo: what is m.ani.dirGeo[n]? maybe the speed? // todo: what is m.ani.proc[n]? wut? const parseMovement = (m) => { - const pStopover = profile.parseStopover(profile, locations, lines, remarks, m.date) + const pStopover = profile.parseStopover(profile, opt, data, m.date) const res = { direction: profile.parseStationName(m.dirTxt), - journeyId: m.jid || null, + tripId: m.jid || null, trip: m.jid && +m.jid.split('|')[1] || null, // todo: this seems brittle line: lines[m.prodX] || null, location: m.pos ? { @@ -35,12 +37,15 @@ const createParseMovement = (profile, locations, lines, remarks, polylines = []) } } - if (m.ani.poly && m.ani.poly.crdEncYX) { - res.polyline = m.ani.poly.crdEncYX - } else if (m.ani.polyG && Array.isArray(m.ani.polyG.polyXL)) { - let p = m.ani.polyG.polyXL[0] + if (m.ani.poly) { + const parse = profile.parsePolyline(profile, opt, data) + res.polyline = parse(m.ani.poly) + } else if (m.ani.polyG) { + let p = m.ani.polyG.polyXL + p = Array.isArray(p) && polylines[p[0]] // todo: there can be >1 polyline - res.polyline = polylines[p] && polylines[p].crdEncYX || null + const parse = profile.parsePolyline(profile, opt, data) + res.polyline = p && parse(p) || null } } diff --git a/parse/nearby.js b/parse/nearby.js index 7c5b3b9a..1bc31897 100644 --- a/parse/nearby.js +++ b/parse/nearby.js @@ -6,9 +6,9 @@ // todo: what is s.wt? // todo: what is s.dur? -// todo: [breaking] change to createParseNearby(profile, lines) => (n) => nearby -const parseNearby = (profile, n, lines) => { - const res = profile.parseLocation(profile, n, lines) +// todo: [breaking] change to createParseNearby(profile, data) => (n) => nearby +const parseNearby = (profile, opt, data, n) => { + const res = profile.parseLocation(profile, opt, data, n) res.distance = n.dist return res } diff --git a/parse/operator.js b/parse/operator.js index b3682199..372b525e 100644 --- a/parse/operator.js +++ b/parse/operator.js @@ -2,7 +2,6 @@ const slugg = require('slugg') -// todo: is passing in profile necessary? const parseOperator = (profile, a) => { return { type: 'operator', diff --git a/parse/polyline.js b/parse/polyline.js new file mode 100644 index 00000000..c2d5e15e --- /dev/null +++ b/parse/polyline.js @@ -0,0 +1,53 @@ +'use strict' + +const {toGeoJSON} = require('@mapbox/polyline') +const distance = require('gps-distance') + +const createParsePolyline = (profile, opt, {locations}) => { + // todo: what is p.delta? + // todo: what is p.type? + // todo: what is p.crdEncS? + // todo: what is p.crdEncF? + const parsePolyline = (p) => { + const shape = toGeoJSON(p.crdEncYX) + if (shape.coordinates.length === 0) return null + + const res = shape.coordinates.map(crd => ({ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: crd + } + })) + + if (Array.isArray(p.ppLocRefL)) { + for (let ref of p.ppLocRefL) { + const p = res[ref.ppIdx] + const loc = locations[ref.locX] + if (p && loc) p.properties = loc + } + + // Often there is one more point right next to each point at a station. + // We filter them here if they are < 5m from each other. + for (let i = 1; i < res.length; i++) { + const p1 = res[i - 1].geometry.coordinates + const p2 = res[i].geometry.coordinates + const d = distance(p1[1], p1[0], p2[1], p2[0]) + if (d >= .005) continue + const l1 = Object.keys(res[i - 1].properties).length + const l2 = Object.keys(res[i].properties).length + if (l1 === 0 && l2 > 0) res.splice(i - 1, 1) + else if (l2 === 0 && l1 > 0) res.splice(i, 1) + } + } + + return { + type: 'FeatureCollection', + features: res + } + } + return parsePolyline +} + +module.exports = createParsePolyline diff --git a/parse/products-bitmask.js b/parse/products-bitmask.js index 790ce42e..c1fb9b69 100644 --- a/parse/products-bitmask.js +++ b/parse/products-bitmask.js @@ -1,28 +1,26 @@ 'use strict' -const createParseBitmask = (allProducts, defaultProducts) => { - allProducts = allProducts.sort((p1, p2) => p2.bitmask - p1.bitmask) // desc - if (allProducts.length === 0) throw new Error('allProducts is empty.') - for (let product of allProducts) { - if ('string' !== typeof product.product) { - throw new Error('allProducts[].product must be a string.') - } - if ('number' !== typeof product.bitmask) { - throw new Error(product.product + '.bitmask must be a number.') +const createParseBitmask = (profile) => { + const defaultProducts = {} + let withBitmask = [] + for (let product of profile.products) { + defaultProducts[product.id] = false + for (let bitmask of product.bitmasks) { + withBitmask.push([bitmask, product]) } } + withBitmask.sort((a, b) => b[0] - a[0]) // descending const parseBitmask = (bitmask) => { const res = Object.assign({}, defaultProducts) - for (let product of allProducts) { - if (bitmask === 0) break - if ((product.bitmask & bitmask) > 0) { - res[product.product] = true - bitmask -= product.bitmask + for (let [pBitmask, product] of withBitmask) { + if ((pBitmask & bitmask) > 0) { + res[product.id] = true + bitmask -= pBitmask } else{ - res[product.product] = false + res[product.id] = false } } diff --git a/parse/remark.js b/parse/remark.js deleted file mode 100644 index 0b8c7a52..00000000 --- a/parse/remark.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict' - -// todo: is passing in profile necessary? -const parseRemark = (profile, r) => { - return null // todo -} - -module.exports = parseRemark diff --git a/parse/stopover.js b/parse/stopover.js index e9dfdbb9..8c97acdd 100644 --- a/parse/stopover.js +++ b/parse/stopover.js @@ -1,15 +1,19 @@ 'use strict' -// todo: arrivalDelay, departureDelay or only delay ? -// todo: arrivalPlatform, departurePlatform -const createParseStopover = (profile, stations, lines, remarks, date) => { +const findRemark = require('./find-remark') + +const createParseStopover = (profile, opt, data, date) => { + const {locations, lines, hints, warnings} = data + const parseStopover = (st) => { const res = { - station: stations[parseInt(st.locX)] || null, + stop: locations[parseInt(st.locX)] || null, arrival: null, arrivalDelay: null, + arrivalPlatform: st.aPlatfR || st.aPlatfS || null, departure: null, - departureDelay: null + departureDelay: null, + departurePlatform: st.dPlatfR || st.dPlatfS || null } // todo: DRY with parseDeparture @@ -34,6 +38,13 @@ const createParseStopover = (profile, stations, lines, remarks, date) => { res.departureDelay = Math.round((realtime - planned) / 1000) } + if (st.aPlatfR && st.aPlatfS && st.aPlatfR !== st.aPlatfS) { + res.formerScheduledArrivalPlatform = st.aPlatfS + } + if (st.dPlatfR && st.dPlatfS && st.dPlatfR !== st.dPlatfS) { + res.formerScheduledDeparturePlatform = st.dPlatfS + } + // mark stations the train passes without stopping if(st.dInS === false && st.aOutS === false) res.passBy = true @@ -58,6 +69,14 @@ const createParseStopover = (profile, stations, lines, remarks, date) => { } } + if (opt.remarks && Array.isArray(st.msgL)) { + res.remarks = [] + for (let ref of st.msgL) { + const remark = findRemark(hints, warnings, ref) + if (remark) res.remarks.push(remark) + } + } + return res } diff --git a/parse/warning.js b/parse/warning.js new file mode 100644 index 00000000..aae5074b --- /dev/null +++ b/parse/warning.js @@ -0,0 +1,42 @@ +'use strict' + +const brToNewline = require('br2nl') + +const parseDateTime = require('./date-time') + +const typesByIcon = Object.assign(Object.create(null), { + HimWarn: 'status' +}) + +// todo: is passing in profile necessary? +const parseWarning = (profile, w, icons) => { + // todo: hid, act, pub, lead, tckr, icoX, fLocX, tLocX, prod, comp, + // todo: cat (1, 2), pubChL + // pubChL: + // [ { name: 'timetable', + // fDate: '20180606', + // fTime: '073000', + // tDate: '20180713', + // tTime: '030000' }, + // { name: 'export', + // fDate: '20180606', + // fTime: '073000', + // tDate: '20180713', + // tTime: '030000' } ] + + const icon = 'number' === typeof w.icoX && icons[w.icoX] || null + const type = icon && icon.res && typesByIcon[icon.res] || 'warning' + + return { + type, + summary: brToNewline(w.head), + text: brToNewline(w.text), + priority: w.prio, + category: w.cat, // todo: parse to sth meaningful + validFrom: parseDateTime(profile, w.sDate, w.sTime).toISO(), + validUntil: parseDateTime(profile, w.eDate, w.eTime).toISO(), + modified: parseDateTime(profile, w.lModDate, w.lModTime).toISO() + } +} + +module.exports = parseWarning diff --git a/readme.md b/readme.md index ab51b0ed..45e8a331 100644 --- a/readme.md +++ b/readme.md @@ -6,12 +6,13 @@ HAFAS endpoint | wrapper library | docs | example code | source code ---------------|------------------|------|---------|------------ [Deutsche Bahn (DB)](https://en.wikipedia.org/wiki/Deutsche_Bahn) | [`db-hafas`](https://github.com/derhuerst/db-hafas) | [docs](p/db/readme.md) | [example code](p/db/example.js) | [src](p/db/index.js) [Berlin & Brandenburg public transport (VBB)](https://en.wikipedia.org/wiki/Verkehrsverbund_Berlin-Brandenburg) | [`vbb-hafas`](https://github.com/derhuerst/vbb-hafas) | [docs](p/vbb/readme.md) | [example code](p/vbb/example.js) | [src](p/vbb/index.js) +[Berlin public transport (BVG)](https://en.wikipedia.org/wiki/Berliner_Verkehrsbetriebe) | – | [docs](p/bvg/readme.md) | [example code](p/bvg/example.js) | [src](p/bvg/index.js) [Österreichische Bundesbahnen (ÖBB)](https://en.wikipedia.org/wiki/Austrian_Federal_Railways) | [`oebb-hafas`](https://github.com/juliuste/oebb-hafas) | [docs](p/oebb/readme.md) | [example code](p/oebb/example.js) | [src](p/oebb/index.js) [Nahverkehr Sachsen-Anhalt (NASA)](https://de.wikipedia.org/wiki/Nahverkehrsservice_Sachsen-Anhalt)/[INSA](https://insa.de) | [`insa-hafas`](https://github.com/derhuerst/insa-hafas) | [docs](p/insa/readme.md) | [example code](p/insa/example.js) | [src](p/insa/index.js) [Nahverkehrsverbund Schleswig-Holstein (NAH.SH)](https://de.wikipedia.org/wiki/Nahverkehrsverbund_Schleswig-Holstein) | [`nahsh-hafas`](https://github.com/juliuste/nahsh-hafas) | [docs](p/nahsh/readme.md) | [example code](p/nahsh/example.js) | [src](p/nahsh/index.js) [![npm version](https://img.shields.io/npm/v/hafas-client.svg)](https://www.npmjs.com/package/hafas-client) -[![build status](https://img.shields.io/travis/public-transport/hafas-client.svg)](https://travis-ci.org/public-transport/hafas-client) +[![build status](https://img.shields.io/travis/public-transport/hafas-client.svg?branch=master)](https://travis-ci.org/public-transport/hafas-client) ![ISC-licensed](https://img.shields.io/github/license/public-transport/hafas-client.svg) [![chat on gitter](https://badges.gitter.im/public-transport/Lobby.svg)](https://gitter.im/public-transport/Lobby) [![support me on Patreon](https://img.shields.io/badge/support%20me-on%20patreon-fa7664.svg)](https://patreon.com/derhuerst) @@ -21,7 +22,7 @@ HAFAS endpoint | wrapper library | docs | example code | source code There's [a company called HaCon](http://hacon.de) that sells [a public transport management system called HAFAS](https://de.wikipedia.org/wiki/HAFAS). It is [used by companies all over Europe](https://gist.github.com/derhuerst/2b7ed83bfa5f115125a5) to serve routing and departure information for apps. All those endpoints are similar, with the same terms and API routes, but have slightly different options, filters and sets of enabled features. -`hafas-client` contains all logic for communicating with these, as well as serialising from and parsing to [*Friendly Public Transport Format (FPTF)* `1.0.1`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md). Endpoint-specific customisations (called *profiles* here) increase the quality of the returned data. +`hafas-client` contains all logic for communicating with these, as well as serialising from and parsing to [*Friendly Public Transport Format (FPTF)* `1.1.1`](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md). Endpoint-specific customisations (called *profiles* here) increase the quality of the returned data. ## Installing @@ -47,7 +48,7 @@ const createClient = require('hafas-client') const dbProfile = require('hafas-client/p/db') // create a client with Deutsche Bahn profile -const client = createClient(dbProfile) +const client = createClient(dbProfile, 'my-awesome-program') // Berlin Jungfernheide to München Hbf client.journeys('8011167', '8000261', {results: 1}) @@ -55,7 +56,7 @@ client.journeys('8011167', '8000261', {results: 1}) .catch(console.error) ``` -The returned [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/promise) will resolve with an array of one [*FPTF* `journey`](https://github.com/public-transport/friendly-public-transport-format/blob/1.0.1/spec/readme.md#journey). +The returned [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/promise) will resolve with an array of one [*FPTF* `journey`](https://github.com/public-transport/friendly-public-transport-format/blob/1.1.1/spec/readme.md#journey). ```js [ { @@ -177,7 +178,11 @@ The returned [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript - [`vbb-hafas`](https://github.com/derhuerst/vbb-hafas#vbb-hafas) – JavaScript client for Berlin & Brandenburg public transport HAFAS API. - [`hafas-departures-in-direction`](https://github.com/derhuerst/hafas-departures-in-direction#hafas-departures-in-direction) – Pass in a HAFAS client, get departures in a certain direction. - [`hafas-collect-departures-at`](https://github.com/derhuerst/hafas-collect-departures-at#hafas-collect-departures-at) – Utility to collect departures, using any HAFAS client. +- [`hafas-monitor-departures`](https://github.com/derhuerst/hafas-monitor-departures#hafas-monitor-departures) – Pass in a HAFAS client, fetch all departures at any set of stations. - [`hafas-discover-stations`](https://github.com/derhuerst/hafas-discover-stations#hafas-discover-stations) – Pass in a HAFAS client, discover stations by querying departures. +- [`hafas-record-delays`](https://github.com/derhuerst/hafas-record-delays#hafas-record-delays) – Record delays from hafas-monitor-departures into a LevelDB. +- [`hafas-estimate-station-weight`](https://github.com/derhuerst/hafas-estimate-station-weight#hafas-estimate-station-weight) – Pass in a HAFAS client, estimate the importance of a station. +- [`hafas-client-rpc`](https://github.com/derhuerst/hafas-client-rpc) – Make JSON-RPC calls to `hafas-client` via WebSockets. - [`hafas-rest-api`](https://github.com/derhuerst/hafas-rest-api#hafas-rest-api) – Expose a HAFAS client via an HTTP REST API. - [List of european long-distance transport operators, available API endpoints, GTFS feeds and client modules.](https://github.com/public-transport/european-transport-operators) - [Collection of european transport JavaScript modules.](https://github.com/public-transport/european-transport-modules) diff --git a/test/bvg.js b/test/bvg.js new file mode 100644 index 00000000..f721d3a2 --- /dev/null +++ b/test/bvg.js @@ -0,0 +1,369 @@ +'use strict' + +// todo: DRY with vbb tests + +const stations = require('vbb-stations-autocomplete') +const a = require('assert') +const shorten = require('vbb-short-station-name') +const tapePromise = require('tape-promise').default +const tape = require('tape') +const isRoughlyEqual = require('is-roughly-equal') + +const co = require('./lib/co') +const createClient = require('..') +const bvgProfile = require('../p/bvg') +const products = require('../p/bvg/products') +const createValidate = require('./lib/validate-fptf-with') +const { + cfg, + validateStation, + validateLine, + validateJourneyLeg, + validateDeparture, + validateMovement +} = require('./lib/vbb-bvg-validators') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testDeparturesInDirection = require('./lib/departures-in-direction') +const testDeparturesWithoutRelatedStations = require('./lib/departures-without-related-stations') +const testArrivals = require('./lib/arrivals') +const testJourneysWithDetour = require('./lib/journeys-with-detour') + +const when = cfg.when + +const validateDirection = (dir, name) => { + if (!stations(dir, true, false)[0]) { + console.error(name + `: station "${dir}" is unknown`) + } +} + +const validate = createValidate(cfg, { + station: validateStation, + line: validateLine, + journeyLeg: validateJourneyLeg, + departure: validateDeparture, + movement: validateMovement +}) + +const test = tapePromise(tape) +const client = createClient(bvgProfile, 'public-transport/hafas-client:test') + +const amrumerStr = '900000009101' +const spichernstr = '900000042101' +const bismarckstr = '900000024201' +const westhafen = '900000001201' +const wedding = '900000009104' +const württembergallee = '900000026153' + +test('journeys – Spichernstr. to Bismarckstr.', co(function* (t) { + const journeys = yield client.journeys(spichernstr, bismarckstr, { + results: 3, + departure: when, + stopovers: true + }) + + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: spichernstr, + toId: bismarckstr + }) + // todo: find a journey where there ticket info is always available + + t.end() +})) + +test('journeys – only subway', co(function* (t) { + const journeys = yield client.journeys(spichernstr, bismarckstr, { + results: 20, + departure: when, + products: { + suburban: false, + subway: true, + tram: false, + bus: false, + ferry: false, + express: false, + regional: false + } + }) + + validate(t, journeys, 'journeys', 'journeys') + t.ok(journeys.length > 1) + for (let i = 0; i < journeys.length; i++) { + const journey = journeys[i] + for (let j = 0; j < journey.legs.length; j++) { + const leg = journey.legs[j] + + const name = `journeys[${i}].legs[${i}].line` + if (leg.line) { + t.equal(leg.line.mode, 'train', name + '.mode is invalid') + t.equal(leg.line.product, 'subway', name + '.product is invalid') + } + t.ok(journey.legs.some(l => l.line), name + '.legs has no subway leg') + } + } + + t.end() +})) + +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: spichernstr, + toId: bismarckstr, + when, + products + }) + t.end() +}) + +test('earlier/later journeys', co(function* (t) { + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: spichernstr, + toId: bismarckstr + }) + + t.end() +})) + +test('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: spichernstr, + toId: bismarckstr, + when + }) + t.end() +})) + +test('trip details', co(function* (t) { + const journeys = yield client.journeys(spichernstr, amrumerStr, { + results: 1, departure: when + }) + + const p = journeys[0].legs[0] + t.ok(p.id, 'precondition failed') + t.ok(p.line.name, 'precondition failed') + const trip = yield client.trip(p.id, p.line.name, {when}) + + validate(t, trip, 'journeyLeg', 'trip') + t.end() +})) + +test('journeys – station to address', co(function* (t) { + const torfstr = { + type: 'location', + address: '13353 Berlin-Wedding, Torfstr. 17', + latitude: 52.541797, + longitude: 13.350042 + } + const journeys = yield client.journeys(spichernstr, torfstr, { + results: 3, + departure: when + }) + + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: spichernstr, + to: torfstr + }) + t.end() +})) + +test('journeys – station to POI', co(function* (t) { + const atze = { + type: 'location', + id: '900980720', + name: 'Berlin, Atze Musiktheater für Kinder', + latitude: 52.543333, + longitude: 13.351686 + } + const journeys = yield client.journeys(spichernstr, atze, { + results: 3, + departure: when + }) + + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: spichernstr, + to: atze + }) + t.end() +})) + +test('journeys: via works – with detour', co(function* (t) { + // Going from Westhafen to Wedding via Württembergalle without detour + // is currently impossible. We check if the routing engine computes a detour. + const journeys = yield client.journeys(westhafen, wedding, { + via: württembergallee, + results: 1, + departure: when, + stopovers: true + }) + + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: [württembergallee] + }) + t.end() +})) + +// todo: without detour test + +test('departures', co(function* (t) { + const departures = yield client.departures(spichernstr, { + duration: 5, when + }) + + yield testDepartures({ + test: t, + departures, + validate, + id: spichernstr + }) + t.end() +})) + +test('departures with station object', co(function* (t) { + const deps = yield client.departures({ + type: 'station', + id: spichernstr, + name: 'U Spichernstr', + location: { + type: 'location', + latitude: 1.23, + longitude: 2.34 + } + }, {when}) + + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test('departures at Spichernstr. in direction of Westhafen', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: spichernstr, + directionIds: [westhafen], + when, + validate + }) + t.end() +})) + +test('departures at 7-digit station', co(function* (t) { + const eisenach = '8010097' // see derhuerst/vbb-hafas#22 + yield client.departures(eisenach, {when}) + t.pass('did not fail') + t.end() +})) + +test('departures without related stations', co(function* (t) { + yield testDeparturesWithoutRelatedStations({ + test: t, + fetchDepartures: client.departures, + id: '900000024101', // Charlottenburg + when, + products: {bus: false, suburban: false, regional: false}, + linesOfRelatedStations: ['U7'] + }) + t.end() +})) + +test('arrivals', co(function* (t) { + const arrivals = yield client.arrivals(spichernstr, { + duration: 5, when + }) + + yield testArrivals({ + test: t, + arrivals, + validate, + id: spichernstr + }) + t.end() +})) + +test('nearby', co(function* (t) { + const berlinerStr = '900000044201' + const landhausstr = '900000043252' + + // Berliner Str./Bundesallee + const nearby = yield client.nearby({ + type: 'location', + latitude: 52.4873452, + longitude: 13.3310411 + }, {distance: 200}) + + validate(t, nearby, 'locations', 'nearby') + + t.equal(nearby[0].id, berlinerStr) + t.equal(nearby[0].name, 'U Berliner Str.') + t.ok(nearby[0].distance > 0) + t.ok(nearby[0].distance < 100) + + t.equal(nearby[1].id, landhausstr) + t.equal(nearby[1].name, 'Landhausstr.') + t.ok(nearby[1].distance > 100) + t.ok(nearby[1].distance < 200) + + t.end() +})) + +test('locations', co(function* (t) { + const locations = yield client.locations('Alexanderplatz', {results: 20}) + + validate(t, locations, 'locations', 'locations') + t.ok(locations.length <= 20) + + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) + t.ok(locations.find(s => s.id && s.name)) // POIs + t.ok(locations.find(s => !s.name && s.address)) // addresses + + t.end() +})) + +test('station', co(function* (t) { + const s = yield client.station(spichernstr) + + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, spichernstr) + + t.end() +})) + +test('radar', co(function* (t) { + const vehicles = yield client.radar({ + north: 52.52411, + west: 13.41002, + south: 52.51942, + east: 13.41709 + }, { + duration: 5 * 60, when + }) + + validate(t, vehicles, 'movements', 'vehicles') + t.end() +})) diff --git a/test/db.js b/test/db.js index 62f39074..e4d0d013 100644 --- a/test/db.js +++ b/test/db.js @@ -1,83 +1,59 @@ 'use strict' -const getStations = require('db-stations').full +const stations = require('db-stations/full.json') +const a = require('assert') const tapePromise = require('tape-promise').default const tape = require('tape') const isRoughlyEqual = require('is-roughly-equal') -const co = require('./co') +const {createWhen} = require('./lib/util') +const co = require('./lib/co') const createClient = require('..') const dbProfile = require('../p/db') -const {allProducts} = require('../p/db/modes') +const products = require('../p/db/products') const { - assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidLine, - assertValidStopover, - createWhen, assertValidWhen -} = require('./util.js') + station: createValidateStation, + journeyLeg: createValidateJourneyLeg +} = require('./lib/validators') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testDeparturesInDirection = require('./lib/departures-in-direction') +const testDeparturesWithoutRelatedStations = require('./lib/departures-without-related-stations') +const testArrivals = require('./lib/arrivals') +const testJourneysWithDetour = require('./lib/journeys-with-detour') + +const isObj = o => o !== null && 'object' === typeof o && !Array.isArray(o) const when = createWhen('Europe/Berlin', 'de-DE') -const assertValidStationProducts = (t, p) => { - t.ok(p) - t.equal(typeof p.nationalExp, 'boolean') - t.equal(typeof p.national, 'boolean') - t.equal(typeof p.regionalExp, 'boolean') - t.equal(typeof p.regional, 'boolean') - t.equal(typeof p.suburban, 'boolean') - t.equal(typeof p.bus, 'boolean') - t.equal(typeof p.ferry, 'boolean') - t.equal(typeof p.subway, 'boolean') - t.equal(typeof p.tram, 'boolean') - t.equal(typeof p.taxi, 'boolean') +const cfg = { + when, + stationCoordsOptional: false, + products } -const findStation = (id) => new Promise((yay, nay) => { - const stations = getStations() - stations - .once('error', nay) - .on('data', (s) => { - if ( - s.id === id || - (s.additionalIds && s.additionalIds.includes(id)) - ) { - yay(s) - stations.destroy() - } - }) - .once('end', yay) -}) - -const isJungfernheide = (s) => { - return s.type === 'station' && - (s.id === '008011167' || s.id === jungfernh) && - s.name === 'Berlin Jungfernheide' && - s.location && - isRoughlyEqual(s.location.latitude, 52.530408, .0005) && - isRoughlyEqual(s.location.longitude, 13.299424, .0005) -} - -const assertIsJungfernheide = (t, s) => { - t.equal(s.type, 'station') - t.ok(s.id === '008011167' || s.id === jungfernh, 'id should be 8011167') - t.equal(s.name, 'Berlin Jungfernheide') - t.ok(s.location) - t.ok(isRoughlyEqual(s.location.latitude, 52.530408, .0005)) - t.ok(isRoughlyEqual(s.location.longitude, 13.299424, .0005)) -} - -// todo: DRY with assertValidStationProducts -// todo: DRY with other tests -const assertValidProducts = (t, p) => { - for (let product of allProducts) { - product = product.product // wat - t.equal(typeof p[product], 'boolean', 'product ' + p + ' must be a boolean') +const _validateStation = createValidateStation(cfg) +const validateStation = (validate, s, name) => { + _validateStation(validate, s, name) + const match = stations.some(station => ( + station.id === s.id || + (station.additionalIds && station.additionalIds.includes(s.id)) + )) + if (!match) { + console.error(name + `.id: unknown ID "${s.id}"`) } } +const validate = createValidate(cfg, { + station: validateStation +}) + const assertValidPrice = (t, p) => { t.ok(p) if (p.amount !== null) { @@ -91,248 +67,185 @@ const assertValidPrice = (t, p) => { } const test = tapePromise(tape) -const client = createClient(dbProfile) +const client = createClient(dbProfile, 'public-transport/hafas-client:test') -const jungfernh = '8011167' const berlinHbf = '8011160' const münchenHbf = '8000261' -const hannoverHbf = '8000152' +const jungfernheide = '8011167' +const blnSchwedterStr = '732652' +const westhafen = '008089116' +const wedding = '008089131' +const württembergallee = '731084' const regensburgHbf = '8000309' +const blnOstbahnhof = '8010255' -test('Berlin Jungfernheide to München Hbf', co(function* (t) { - const journeys = yield client.journeys(jungfernh, münchenHbf, { - when, passedStations: true +test('journeys – Berlin Schwedter Str. to München Hbf', co(function* (t) { + const journeys = yield client.journeys(blnSchwedterStr, münchenHbf, { + results: 3, + departure: when, + stopovers: true }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length > 0, 'no journeys') + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: blnSchwedterStr, + toId: münchenHbf + }) + // todo: find a journey where there pricing info is always available for (let journey of journeys) { - t.equal(journey.type, 'journey') - - assertValidStation(t, journey.origin) - assertValidStationProducts(t, journey.origin.products) - if (!(yield findStation(journey.origin.id))) { - console.error('unknown station', journey.origin.id, journey.origin.name) - } - if (journey.origin.products) { - assertValidProducts(t, journey.origin.products) - } - assertValidWhen(t, journey.departure, when) - - assertValidStation(t, journey.destination) - assertValidStationProducts(t, journey.origin.products) - if (!(yield findStation(journey.origin.id))) { - console.error('unknown station', journey.destination.id, journey.destination.name) - } - if (journey.destination.products) { - assertValidProducts(t, journey.destination.products) - } - assertValidWhen(t, journey.arrival, when) - - t.ok(Array.isArray(journey.legs)) - t.ok(journey.legs.length > 0, 'no legs') - const leg = journey.legs[0] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - if (!(yield findStation(leg.origin.id))) { - console.error('unknown station', leg.origin.id, leg.origin.name) - } - assertValidWhen(t, leg.departure, when) - t.equal(typeof leg.departurePlatform, 'string') - - assertValidStation(t, leg.destination) - assertValidStationProducts(t, leg.origin.products) - if (!(yield findStation(leg.destination.id))) { - console.error('unknown station', leg.destination.id, leg.destination.name) - } - assertValidWhen(t, leg.arrival, when) - t.equal(typeof leg.arrivalPlatform, 'string') - - assertValidLine(t, leg.line) - - t.ok(Array.isArray(leg.passed)) - for (let stopover of leg.passed) assertValidStopover(t, stopover) - if (journey.price) assertValidPrice(t, journey.price) } t.end() })) -test('Berlin Jungfernheide to Torfstraße 17', co(function* (t) { - const journeys = yield client.journeys(jungfernh, { - type: 'location', address: 'Torfstraße 17', - latitude: 52.5416823, longitude: 13.3491223 - }, {when}) +// todo: journeys, only one product - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const leg = journey.legs[journey.legs.length - 1] +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: blnSchwedterStr, + toId: münchenHbf, + when, + products + }) + t.end() +}) - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - if (!(yield findStation(leg.origin.id))) { - console.error('unknown station', leg.origin.id, leg.origin.name) +test('Berlin Schwedter Str. to Torfstraße 17', co(function* (t) { + const torfstr = { + type: 'location', + address: 'Torfstraße 17', + latitude: 52.5416823, + longitude: 13.3491223 } - if (leg.origin.products) assertValidProducts(t, leg.origin.products) - assertValidWhen(t, leg.departure, when) - assertValidWhen(t, leg.arrival, when) - - const d = leg.destination - assertValidAddress(t, d) - t.equal(d.address, 'Torfstraße 17') - t.ok(isRoughlyEqual(.0001, d.latitude, 52.5416823)) - t.ok(isRoughlyEqual(.0001, d.longitude, 13.3491223)) + const journeys = yield client.journeys(blnSchwedterStr, torfstr, { + results: 3, + departure: when + }) + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: blnSchwedterStr, + to: torfstr + }) t.end() })) -test('Berlin Jungfernheide to ATZE Musiktheater', co(function* (t) { - const journeys = yield client.journeys(jungfernh, { - type: 'location', id: '991598902', name: 'ATZE Musiktheater', - latitude: 52.542417, longitude: 13.350437 - }, {when}) - - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const leg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - if (!(yield findStation(leg.origin.id))) { - console.error('unknown station', leg.origin.id, leg.origin.name) +test('Berlin Schwedter Str. to ATZE Musiktheater', co(function* (t) { + const atze = { + type: 'location', + id: '991598902', + name: 'ATZE Musiktheater', + latitude: 52.542417, + longitude: 13.350437 } - if (leg.origin.products) assertValidProducts(t, leg.origin.products) - assertValidWhen(t, leg.departure, when) - assertValidWhen(t, leg.arrival, when) - - const d = leg.destination - assertValidPoi(t, d) - t.equal(d.name, 'ATZE Musiktheater') - t.ok(isRoughlyEqual(.0001, d.latitude, 52.542399)) - t.ok(isRoughlyEqual(.0001, d.longitude, 13.350402)) + const journeys = yield client.journeys(blnSchwedterStr, atze, { + results: 3, + departure: when + }) + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: blnSchwedterStr, + to: atze + }) t.end() })) test('journeys: via works – with detour', co(function* (t) { // Going from Westhafen to Wedding via Württembergalle without detour // is currently impossible. We check if the routing engine computes a detour. - const westhafen = '008089116' - const wedding = '008089131' - const württembergallee = '731084' - const [journey] = yield client.journeys(westhafen, wedding, { + const journeys = yield client.journeys(westhafen, wedding, { via: württembergallee, results: 1, - when, - passedStations: true + departure: when, + stopovers: true }) - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === württembergallee)) - t.ok(l, 'Württembergalle is not being passed') - + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: [württembergallee] + }) t.end() })) -test('journeys: via works – without detour', co(function* (t) { - // When going from Ruhleben to Zoo via Kastanienallee, there is *no need* - // to change trains / no need for a "detour". - const ruhleben = '000731058' - const zoo = '008010406' - const kastanienallee = '730983' - const [journey] = yield client.journeys(ruhleben, zoo, { - via: kastanienallee, - results: 1, - when, - passedStations: true - }) - - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === kastanienallee)) - t.ok(l, 'Kastanienallee is not being passed') - - t.end() -})) +// todo: without detour test('earlier/later journeys, Jungfernheide -> München Hbf', co(function* (t) { - const model = yield client.journeys(jungfernh, münchenHbf, { - results: 3, when + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: jungfernheide, + toId: münchenHbf }) - t.equal(typeof model.earlierRef, 'string') - t.ok(model.earlierRef) - t.equal(typeof model.laterRef, 'string') - t.ok(model.laterRef) - - // when and earlierThan/laterThan should be mutually exclusive - t.throws(() => { - client.journeys(jungfernh, münchenHbf, { - when, earlierThan: model.earlierRef - }) - }) - t.throws(() => { - client.journeys(jungfernh, münchenHbf, { - when, laterThan: model.laterRef - }) - }) - - let earliestDep = Infinity, latestDep = -Infinity - for (let j of model) { - const dep = +new Date(j.departure) - if (dep < earliestDep) earliestDep = dep - else if (dep > latestDep) latestDep = dep - } - - const earlier = yield client.journeys(jungfernh, münchenHbf, { - results: 3, - // todo: single journey ref? - earlierThan: model.earlierRef - }) - for (let j of earlier) { - t.ok(new Date(j.departure) < earliestDep) - } - - const later = yield client.journeys(jungfernh, münchenHbf, { - results: 3, - // todo: single journey ref? - laterThan: model.laterRef - }) - for (let j of later) { - t.ok(new Date(j.departure) > latestDep) - } - t.end() })) -test('departures at Berlin Jungfernheide', co(function* (t) { - const deps = yield client.departures(jungfernh, { +test('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: jungfernheide, + toId: münchenHbf, + when + }) + t.end() +})) + +test('trip details', co(function* (t) { + const journeys = yield client.journeys(berlinHbf, münchenHbf, { + results: 1, departure: when + }) + + const p = journeys[0].legs[0] + t.ok(p.id, 'precondition failed') + t.ok(p.line.name, 'precondition failed') + const trip = yield client.trip(p.id, p.line.name, {when}) + + const validateJourneyLeg = createValidateJourneyLeg(cfg) + const validate = createValidate(cfg, { + journeyLeg: (validate, leg, name) => { + if (!leg.direction) leg.direction = 'foo' // todo, see #49 + validateJourneyLeg(validate, leg, name) + } + }) + validate(t, trip, 'journeyLeg', 'trip') + + t.end() +})) + +test('departures at Berlin Schwedter Str.', co(function* (t) { + const departures = yield client.departures(blnSchwedterStr, { duration: 5, when }) - t.ok(Array.isArray(deps)) - for (let dep of deps) { - assertValidStation(t, dep.station) - assertValidStationProducts(t, dep.station.products) - if (!(yield findStation(dep.station.id))) { - console.error('unknown station', dep.station.id, dep.station.name) - } - if (dep.station.products) assertValidProducts(t, dep.station.products) - assertValidWhen(t, dep.when, when) - } - + yield testDepartures({ + test: t, + departures, + validate, + id: blnSchwedterStr + }) t.end() })) test('departures with station object', co(function* (t) { - yield client.departures({ + const deps = yield client.departures({ type: 'station', - id: jungfernh, + id: jungfernheide, name: 'Berlin Jungfernheide', location: { type: 'location', @@ -341,7 +254,46 @@ test('departures with station object', co(function* (t) { } }, {when}) - t.ok('did not fail') + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test('departures at Berlin Hbf in direction of Berlin Ostbahnhof', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: berlinHbf, + directionIds: [blnOstbahnhof, '8089185', '732676'], + when, + validate + }) + t.end() +})) + +test('departures without related stations', co(function* (t) { + yield testDeparturesWithoutRelatedStations({ + test: t, + fetchDepartures: client.departures, + id: '8089051', // Berlin Yorckstr. (S1) + when, + products: {bus: false}, + linesOfRelatedStations: ['S 2', 'S 25', 'S 26', 'U 7'] + }) + t.end() +})) + +test('arrivals at Berlin Schwedter Str.', co(function* (t) { + const arrivals = yield client.arrivals(blnSchwedterStr, { + duration: 5, when + }) + + yield testArrivals({ + test: t, + arrivals, + validate, + id: blnSchwedterStr + }) t.end() })) @@ -354,17 +306,18 @@ test('nearby Berlin Jungfernheide', co(function* (t) { results: 2, distance: 400 }) - t.ok(Array.isArray(nearby)) + validate(t, nearby, 'locations', 'nearby') + t.equal(nearby.length, 2) - assertIsJungfernheide(t, nearby[0]) - t.ok(nearby[0].distance >= 0) - t.ok(nearby[0].distance <= 100) - - for (let n of nearby) { - if (n.type === 'station') assertValidStation(t, n) - else assertValidLocation(t, n) - } + const s0 = nearby[0] + // todo: trim IDs + t.ok(s0.id === '008011167' || s0.id === jungfernheide) + t.equal(s0.name, 'Berlin Jungfernheide') + t.ok(isRoughlyEqual(.0005, s0.location.latitude, 52.530408)) + t.ok(isRoughlyEqual(.0005, s0.location.longitude, 13.299424)) + t.ok(s0.distance >= 0) + t.ok(s0.distance <= 100) t.end() })) @@ -374,29 +327,24 @@ test('locations named Jungfernheide', co(function* (t) { results: 10 }) - t.ok(Array.isArray(locations)) - t.ok(locations.length > 0) + validate(t, locations, 'locations', 'locations') t.ok(locations.length <= 10) - - for (let l of locations) { - if (l.type === 'station') assertValidStation(t, l) - else assertValidLocation(t, l) - } - t.ok(locations.some(isJungfernheide)) + t.ok(locations.some((l) => { + // todo: trim IDs + if (l.station) { + if (l.station.id === '008011167' || l.station.id === jungfernheide) return true + } + return l.id === '008011167' || l.id === jungfernheide + }), 'Jungfernheide not found') t.end() })) -test('location', co(function* (t) { - const loc = yield client.location(regensburgHbf) +test('station', co(function* (t) { + const s = yield client.station(regensburgHbf) - assertValidStation(t, loc) - t.equal(loc.id, regensburgHbf) - - t.ok(Array.isArray(loc.lines)) - if (Array.isArray(loc.lines)) { - for (let line of loc.lines) assertValidLine(t, line) - } + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, regensburgHbf) t.end() })) diff --git a/test/index.js b/test/index.js index 4762f78a..2b893d16 100644 --- a/test/index.js +++ b/test/index.js @@ -2,6 +2,7 @@ require('./db') require('./vbb') +require('./bvg') require('./oebb') require('./insa') require('./nahsh') diff --git a/test/insa.js b/test/insa.js index 28679120..90aee588 100644 --- a/test/insa.js +++ b/test/insa.js @@ -3,388 +3,275 @@ const tapePromise = require('tape-promise').default const tape = require('tape') const isRoughlyEqual = require('is-roughly-equal') -const validateFptf = require('validate-fptf') -const co = require('./co') +const {createWhen} = require('./lib/util') +const co = require('./lib/co') const createClient = require('..') const insaProfile = require('../p/insa') -const {allProducts} = require('../p/insa/products') -const { - assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidLine, - assertValidStopover, - hour, - createWhen, - assertValidWhen -} = require('./util.js') +const products = require('../p/insa/products') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testDeparturesInDirection = require('./lib/departures-in-direction') +const testArrivals = require('./lib/arrivals') +const testJourneysWithDetour = require('./lib/journeys-with-detour') + +const isObj = o => o !== null && 'object' === typeof o && !Array.isArray(o) const when = createWhen('Europe/Berlin', 'de-DE') -const assertValidStationProducts = (t, p) => { - t.ok(p) - t.equal(typeof p.nationalExp, 'boolean') - t.equal(typeof p.national, 'boolean') - t.equal(typeof p.regional, 'boolean') - t.equal(typeof p.suburban, 'boolean') - t.equal(typeof p.tram, 'boolean') - t.equal(typeof p.bus, 'boolean') - t.equal(typeof p.tourismTrain, 'boolean') +const cfg = { + when, + stationCoordsOptional: false, + products } -const isMagdeburgHbf = s => { - return ( - s.type === 'station' && - (s.id === '8010224' || s.id === '008010224') && - s.name === 'Magdeburg Hbf' && - s.location && - isRoughlyEqual(s.location.latitude, 52.130352, 0.001) && - isRoughlyEqual(s.location.longitude, 11.626891, 0.001) - ) -} - -const assertIsMagdeburgHbf = (t, s) => { - t.equal(s.type, 'station') - t.ok(s.id === '8010224' || s.id === '008010224', 'id should be 8010224') - t.equal(s.name, 'Magdeburg Hbf') - t.ok(s.location) - t.ok(isRoughlyEqual(s.location.latitude, 52.130352, 0.001)) - t.ok(isRoughlyEqual(s.location.longitude, 11.626891, 0.001)) -} - -// todo: DRY with assertValidStationProducts -// todo: DRY with other tests -const assertValidProducts = (t, p) => { - for (let product of allProducts) { - product = product.product // wat - t.equal(typeof p[product], 'boolean', 'product ' + p + ' must be a boolean') - } -} +const validate = createValidate(cfg, {}) const test = tapePromise(tape) -const client = createClient(insaProfile) +const client = createClient(insaProfile, 'public-transport/hafas-client:test') -test('Magdeburg Hbf to Magdeburg-Buckau', co(function*(t) { - const magdeburgHbf = '8010224' - const magdeburgBuckau = '8013456' +const magdeburgHbf = '8010224' +const magdeburgBuckau = '8013456' +const leiterstr = '7464' +const hasselbachplatzSternstrasse = '000006545' +const stendal = '008010334' +const dessau = '008010077' +const universitaet = '19686' + +test('journeys – Magdeburg Hbf to Magdeburg-Buckau', co(function* (t) { const journeys = yield client.journeys(magdeburgHbf, magdeburgBuckau, { - when, - passedStations: true + results: 3, + departure: when, + stopovers: true }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length > 0, 'no journeys') - for (let journey of journeys) { - assertValidStation(t, journey.origin) - assertValidStationProducts(t, journey.origin.products) - if (journey.origin.products) { - assertValidProducts(t, journey.origin.products) - } - assertValidWhen(t, journey.departure, when) - - assertValidStation(t, journey.destination) - assertValidStationProducts(t, journey.origin.products) - if (journey.destination.products) { - assertValidProducts(t, journey.destination.products) - } - assertValidWhen(t, journey.arrival, when) - - t.ok(Array.isArray(journey.legs)) - t.ok(journey.legs.length > 0, 'no legs') - const leg = journey.legs[0] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - assertValidWhen(t, leg.departure, when) - t.equal(typeof leg.departurePlatform, 'string') - - assertValidStation(t, leg.destination) - assertValidStationProducts(t, leg.origin.products) - assertValidWhen(t, leg.arrival, when) - t.equal(typeof leg.arrivalPlatform, 'string') - - assertValidLine(t, leg.line) - - t.ok(Array.isArray(leg.passed)) - for (let stopover of leg.passed) assertValidStopover(t, stopover) - } - + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: magdeburgHbf, + toId: magdeburgBuckau + }) t.end() })) +// todo: journeys, only one product + +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: magdeburgHbf, + toId: magdeburgBuckau, + when, + products + }) + t.end() +}) + test('Magdeburg Hbf to 39104 Magdeburg, Sternstr. 10', co(function*(t) { - const magdeburgHbf = '8010224' const sternStr = { type: 'location', + address: 'Magdeburg - Altenstadt, Sternstraße 10', latitude: 52.118414, - longitude: 11.422332, - address: 'Magdeburg - Altenstadt, Sternstraße 10' + longitude: 11.422332 } const journeys = yield client.journeys(magdeburgHbf, sternStr, { - when + results: 3, + departure: when }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, firstLeg.origin) - assertValidStationProducts(t, firstLeg.origin.products) - if (firstLeg.origin.products) - assertValidProducts(t, firstLeg.origin.products) - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - const d = lastLeg.destination - assertValidAddress(t, d) - t.equal(d.address, 'Magdeburg - Altenstadt, Sternstraße 10') - t.ok(isRoughlyEqual(0.0001, d.latitude, 52.118414)) - t.ok(isRoughlyEqual(0.0001, d.longitude, 11.422332)) - + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: magdeburgHbf, + to: sternStr + }) t.end() })) -test('Kloster Unser Lieben Frauen to Magdeburg Hbf', co(function*(t) { +test('Magdeburg Hbf to Kloster Unser Lieben Frauen', co(function*(t) { const kloster = { type: 'location', - latitude: 52.127601, - longitude: 11.636437, + id: '970012223', name: 'Magdeburg, Kloster Unser Lieben Frauen (Denkmal)', - id: '970012223' + latitude: 52.127601, + longitude: 11.636437 } - const magdeburgHbf = '8010224' - const journeys = yield client.journeys(kloster, magdeburgHbf, { - when + const journeys = yield client.journeys(magdeburgHbf, kloster, { + results: 3, + departure: when }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - const o = firstLeg.origin - assertValidPoi(t, o) - t.equal(o.name, 'Magdeburg, Kloster Unser Lieben Frauen (Denkmal)') - t.ok(isRoughlyEqual(0.0001, o.latitude, 52.127601)) - t.ok(isRoughlyEqual(0.0001, o.longitude, 11.636437)) - - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - assertValidStation(t, lastLeg.destination) - assertValidStationProducts(t, lastLeg.destination.products) - if (lastLeg.destination.products) - assertValidProducts(t, lastLeg.destination.products) - + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: magdeburgHbf, + to: kloster + }) t.end() })) test('journeys: via works – with detour', co(function* (t) { - // Going from Magdeburg, Hasselbachplatz (Sternstr.) (Tram/Bus) to Stendal via Dessau without detour - // is currently impossible. We check if the routing engine computes a detour. - const hasselbachplatzSternstrasse = '000006545' - const stendal = '008010334' - const dessau = '008010077' - const dessauPassed = '8010077' - const [journey] = yield client.journeys(hasselbachplatzSternstrasse, stendal, { + // Going from Magdeburg, Hasselbachplatz (Sternstr.) (Tram/Bus) to Stendal + // via Dessau without detour is currently impossible. We check if the routing + // engine computes a detour. + const journeys = yield client.journeys(hasselbachplatzSternstrasse, stendal, { via: dessau, results: 1, - when, - passedStations: true + departure: when, + stopovers: true }) - t.ok(journey) + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: ['8010077', dessau] // todo: trim IDs + }) + t.end() +})) - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === dessauPassed)) - t.ok(l, 'Dessau is not being passed') +// todo: without detour + +test('earlier/later journeys', co(function* (t) { + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: magdeburgHbf, + toId: magdeburgBuckau + }) t.end() })) -test('journeys: via works – without detour', co(function* (t) { - // When going from Magdeburg, Hasselbachplatz (Sternstr.) (Tram/Bus) to Magdeburg, Universität via Magdeburg, Breiter Weg, there is *no need* - // to change trains / no need for a "detour". - const hasselbachplatzSternstrasse = '000006545' - const universitaet = '000019686' - const breiterWeg = '000013519' - const breiterWegPassed = '13519' - - const [journey] = yield client.journeys(hasselbachplatzSternstrasse, universitaet, { - via: breiterWeg, - results: 1, - when, - passedStations: true +test('trip details', co(function* (t) { + const journeys = yield client.journeys(magdeburgHbf, magdeburgBuckau, { + results: 1, departure: when }) - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === breiterWegPassed)) - t.ok(l, 'Magdeburg, Breiter Weg is not being passed') + const p = journeys[0].legs[0] + t.ok(p.id, 'precondition failed') + t.ok(p.line.name, 'precondition failed') + const trip = yield client.trip(p.id, p.line.name, {when}) + validate(t, trip, 'journeyLeg', 'trip') t.end() })) -test('departures at Magdeburg Hbf', co(function*(t) { - const magdeburgHbf = '8010224' - const deps = yield client.departures(magdeburgHbf, { - duration: 5, - when +test('departures at Magdeburg Leiterstr.', co(function*(t) { + const departures = yield client.departures(leiterstr, { + duration: 5, when }) - t.ok(Array.isArray(deps)) - for (let dep of deps) { - assertValidStation(t, dep.station) - assertValidStationProducts(t, dep.station.products) - if (dep.station.products) { - assertValidProducts(t, dep.station.products) + yield testDepartures({ + test: t, + departures, + validate, + id: leiterstr + }) + t.end() +})) + +test('departures with station object', co(function* (t) { + const deps = yield client.departures({ + type: 'station', + id: magdeburgHbf, + name: 'Magdeburg Hbf', + location: { + type: 'location', + latitude: 1.23, + longitude: 2.34 } - assertValidWhen(t, dep.when, when) - } + }, {when}) + validate(t, deps, 'departures', 'departures') t.end() })) -test('nearby Magdeburg Hbf', co(function*(t) { - const magdeburgHbfPosition = { - type: 'location', - latitude: 52.130352, - longitude: 11.626891 - } - const nearby = yield client.nearby(magdeburgHbfPosition, { - results: 2, - distance: 400 +test('departures at Leiterstr in direction of Universität', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: leiterstr, + directionIds: [universitaet], + when, + validate + }) + t.end() +})) + +test('arrivals at Magdeburg Leiterstr.', co(function*(t) { + const arrivals = yield client.arrivals(leiterstr, { + duration: 5, when }) - t.ok(Array.isArray(nearby)) - t.equal(nearby.length, 2) - - assertIsMagdeburgHbf(t, nearby[0]) - t.ok(nearby[0].distance >= 0) - t.ok(nearby[0].distance <= 100) - - for (let n of nearby) { - if (n.type === 'station') assertValidStation(t, n) - else assertValidLocation(t, n) - } - - t.end() -})) - -test('journey leg details', co(function* (t) { - const magdeburgHbf = '8010224' - const magdeburgBuckau = '8013456' - const [journey] = yield client.journeys(magdeburgHbf, magdeburgBuckau, { - results: 1, when + yield testArrivals({ + test: t, + arrivals, + validate, + id: leiterstr }) - - const p = journey.legs[0] - t.ok(p, 'missing legs[0]') - t.ok(p.id, 'missing legs[0].id') - t.ok(p.line, 'missing legs[0].line') - t.ok(p.line.name, 'missing legs[0].line.name') - const leg = yield client.journeyLeg(p.id, p.line.name, {when}) - - t.equal(typeof leg.id, 'string') - t.ok(leg.id) - - assertValidLine(t, leg.line) - - t.equal(typeof leg.direction, 'string') - t.ok(leg.direction) - - t.ok(Array.isArray(leg.passed)) - for (let passed of leg.passed) assertValidStopover(t, passed) - t.end() })) +// todo: nearby + test('locations named Magdeburg', co(function*(t) { const locations = yield client.locations('Magdeburg', { - results: 10 + results: 20 }) - t.ok(Array.isArray(locations)) - t.ok(locations.length > 0) - t.ok(locations.length <= 10) + validate(t, locations, 'locations', 'locations') + t.ok(locations.length <= 20) - for (let l of locations) { - if (l.type === 'station') assertValidStation(t, l) - else assertValidLocation(t, l) - } - t.ok(locations.some(isMagdeburgHbf)) + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) + t.ok(locations.find(s => s.id && s.name)) // POIs + t.ok(locations.some((l) => { + // todo: trim IDs + if (l.station) { + if (l.station.id === '008010224' || l.station.id === magdeburgHbf) return true + } + return l.id === '008010224' || l.id === magdeburgHbf + })) t.end() })) -test('location', co(function*(t) { - const magdeburgBuckau = '8013456' - const loc = yield client.location(magdeburgBuckau) +test('station Magdeburg-Buckau', co(function* (t) { + const s = yield client.station(magdeburgBuckau) - assertValidStation(t, loc) - t.equal(loc.id, magdeburgBuckau) + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, magdeburgBuckau) t.end() })) test('radar', co(function* (t) { - const north = 52.148364 - const west = 11.600826 - const south = 52.108486 - const east = 11.651451 - const vehicles = yield client.radar(north, west, south, east, { + const vehicles = yield client.radar({ + north: 52.148364, + west: 11.600826, + south: 52.108486, + east: 11.651451 + }, { duration: 5 * 60, when, results: 10 }) - t.ok(Array.isArray(vehicles)) - t.ok(vehicles.length > 0) - for (let v of vehicles) { - assertValidLine(t, v.line) + const customCfg = Object.assign({}, cfg, { + stationCoordsOptional: true, // see #28 + }) + const validate = createValidate(customCfg, {}) + validate(t, vehicles, 'movements', 'vehicles') - t.equal(typeof v.location.latitude, 'number') - t.ok(v.location.latitude <= 57, 'vehicle is too far away') - t.ok(v.location.latitude >= 47, 'vehicle is too far away') - t.equal(typeof v.location.longitude, 'number') - t.ok(v.location.longitude >= 8, 'vehicle is too far away') - t.ok(v.location.longitude <= 14, 'vehicle is too far away') - - t.ok(Array.isArray(v.nextStops)) - for (let st of v.nextStops) { - assertValidStopover(t, st, true) - - if (st.arrival) { - t.equal(typeof st.arrival, 'string') - const arr = +new Date(st.arrival) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, arr)) - } - if (st.departure) { - t.equal(typeof st.departure, 'string') - const dep = +new Date(st.departure) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, dep)) - } - } - - t.ok(Array.isArray(v.frames)) - for (let f of v.frames) { - // see #28 - // todo: check if this works by now - assertValidStation(t, f.origin, true) - assertValidStationProducts(t, f.origin.products) - assertValidStation(t, f.destination, true) - assertValidStationProducts(t, f.destination.products) - t.equal(typeof f.t, 'number') - } - } t.end() })) diff --git a/test/lib/arrivals.js b/test/lib/arrivals.js new file mode 100644 index 00000000..4c486f49 --- /dev/null +++ b/test/lib/arrivals.js @@ -0,0 +1,25 @@ +'use strict' + +const co = require('./co') + +const testArrivals = co(function* (cfg) { + const {test: t, arrivals: arrs, validate, id} = cfg + + validate(t, arrs, 'arrivals', 'arrivals') + t.ok(arrs.length > 0, 'must be >0 arrivals') + for (let i = 0; i < arrs.length; i++) { + let stop = arrs[i].stop + let name = `arrs[${i}].stop` + if (stop.station) { + stop = stop.station + name += '.station' + } + + t.equal(stop.id, id, name + '.id is invalid') + } + + // todo: move into arrivals validator + t.deepEqual(arrs, arrs.sort((a, b) => t.when > b.when)) +}) + +module.exports = testArrivals diff --git a/test/co.js b/test/lib/co.js similarity index 98% rename from test/co.js rename to test/lib/co.js index 595deb14..c01ad0b5 100644 --- a/test/co.js +++ b/test/lib/co.js @@ -1,3 +1,5 @@ +'use strict' + // https://github.com/babel/babel/blob/3c8d831fe41f502cbe2459a271d19c7329ffe369/packages/babel-helpers/src/helpers.js#L242-L270 const co = (fn) => { return function run () { diff --git a/test/lib/departures-in-direction.js b/test/lib/departures-in-direction.js new file mode 100644 index 00000000..bc82a790 --- /dev/null +++ b/test/lib/departures-in-direction.js @@ -0,0 +1,38 @@ +'use strict' + +const co = require('./co') + +const testDeparturesInDirection = co(function* (cfg) { + const { + test: t, + fetchDepartures, + fetchTrip, + id, + directionIds, + when, + validate + } = cfg + + const deps = yield fetchDepartures(id, { + direction: directionIds[0], + when + }) + validate(t, deps, 'departures', 'departures') + t.ok(deps.length > 0, 'must be >0 departures') + + for (let i = 0; i < deps.length; i++) { + const dep = deps[i] + const name = `deps[${i}]` + + const line = dep.line && dep.line.name + const trip = yield fetchTrip(dep.tripId, line, { + when, stopovers: true + }) + t.ok(trip.stopovers.some(st => ( + st.stop.station && directionIds.includes(st.stop.station.id) || + directionIds.includes(st.stop.id) + )), `trip ${dep.tripId} of ${name} has no stopover at ${directionIds}`) + } +}) + +module.exports = testDeparturesInDirection diff --git a/test/lib/departures-without-related-stations.js b/test/lib/departures-without-related-stations.js new file mode 100644 index 00000000..02c60f57 --- /dev/null +++ b/test/lib/departures-without-related-stations.js @@ -0,0 +1,41 @@ +'use strict' + +const co = require('./co') + +const testDeparturesWithoutUnrelatedStations = co(function* (cfg) { + const { + test: t, + fetchDepartures, + id, + when + // duration, products + } = cfg + + const relatedLines = cfg.linesOfRelatedStations + .map(lName => lName.toLowerCase().trim()) + + const isUnrelatedLine = (dep) => { + if (!dep.line || !dep.line.name) return false + return relatedLines.includes(dep.line.name.toLowerCase().trim()) + } + + const depsWith = yield fetchDepartures(id, { + when, + duration: cfg.duration || 20, + products: cfg.products || {} + }) + t.ok(depsWith.some(isUnrelatedLine), 'precondition failed: no line at related station found') + + const depsWithout = yield fetchDepartures(id, { + includeRelatedStations: false, + when, + duration: cfg.duration || 20, + products: cfg.products || {} + }) + + const unrelatedDep = depsWithout.find(isUnrelatedLine) + if (unrelatedDep) t.fail('line at related station: ' + unrelatedDep.line.name) + else t.pass('no lines from related stations') +}) + +module.exports = testDeparturesWithoutUnrelatedStations diff --git a/test/lib/departures.js b/test/lib/departures.js new file mode 100644 index 00000000..e0853fe1 --- /dev/null +++ b/test/lib/departures.js @@ -0,0 +1,25 @@ +'use strict' + +const co = require('./co') + +const testDepartures = co(function* (cfg) { + const {test: t, departures: deps, validate, id} = cfg + + validate(t, deps, 'departures', 'departures') + t.ok(deps.length > 0, 'must be >0 departures') + for (let i = 0; i < deps.length; i++) { + let stop = deps[i].stop + let name = `deps[${i}].stop` + if (stop.station) { + stop = stop.station + name += '.station' + } + + t.equal(stop.id, id, name + '.id is invalid') + } + + // todo: move into deps validator + t.deepEqual(deps, deps.sort((a, b) => t.when > b.when)) +}) + +module.exports = testDepartures diff --git a/test/lib/earlier-later-journeys.js b/test/lib/earlier-later-journeys.js new file mode 100644 index 00000000..aa6da9d8 --- /dev/null +++ b/test/lib/earlier-later-journeys.js @@ -0,0 +1,82 @@ +'use strict' + +const co = require('./co') + +const testEarlierLaterJourneys = co(function* (cfg) { + const { + test: t, + fetchJourneys, + fromId, + toId, + when, + // todo: validate + } = cfg + + const model = yield fetchJourneys(fromId, toId, { + results: 3, departure: when + }) + + // todo: move to journeys validator? + t.equal(typeof model.earlierRef, 'string') + t.ok(model.earlierRef) + t.equal(typeof model.laterRef, 'string') + t.ok(model.laterRef) + + // departure/arrival and earlierThan/laterThan should be mutually exclusive + t.throws(() => { + fetchJourneys(fromId, toId, { + departure: when, earlierThan: model.earlierRef + }) + // silence rejections, we're only interested in exceptions + .catch(() => {}) + }) + t.throws(() => { + fetchJourneys(fromId, toId, { + departure: when, laterThan: model.laterRef + }) + // silence rejections, we're only interested in exceptions + .catch(() => {}) + }) + t.throws(() => { + fetchJourneys(fromId, toId, { + arrival: when, earlierThan: model.earlierRef + }) + // silence rejections, we're only interested in exceptions + .catch(() => {}) + }) + t.throws(() => { + fetchJourneys(fromId, toId, { + arrival: when, laterThan: model.laterRef + }) + // silence rejections, we're only interested in exceptions + .catch(() => {}) + }) + + let earliestDep = Infinity, latestDep = -Infinity + for (let j of model) { + if (j.legs[0].departure === null) continue + const dep = +new Date(j.legs[0].departure) + if (dep < earliestDep) earliestDep = dep + else if (dep > latestDep) latestDep = dep + } + + const earlier = yield fetchJourneys(fromId, toId, { + results: 3, + // todo: single journey ref? + earlierThan: model.earlierRef + }) + for (let j of earlier) { + t.ok(new Date(j.legs[0].departure) < earliestDep) + } + + const later = yield fetchJourneys(fromId, toId, { + results: 3, + // todo: single journey ref? + laterThan: model.laterRef + }) + for (let j of later) { + t.ok(new Date(j.legs[0].departure) > latestDep) + } +}) + +module.exports = testEarlierLaterJourneys diff --git a/test/lib/journeys-fails-with-no-product.js b/test/lib/journeys-fails-with-no-product.js new file mode 100644 index 00000000..f3ee4de8 --- /dev/null +++ b/test/lib/journeys-fails-with-no-product.js @@ -0,0 +1,23 @@ +'use strict' + +const journeysFailsWithNoProduct = (cfg) => { + const { + test: t, + fetchJourneys, + fromId, + toId, + when, + products + } = cfg + + const productsObj = Object.create(null) + for (let p of products) productsObj[p.id] = false + + t.throws(() => { + client.journeys(fromId, toId, {departure: when, products}) + // silence rejections, we're only interested in exceptions + .catch(() => {}) + }) +} + +module.exports = journeysFailsWithNoProduct diff --git a/test/lib/journeys-station-to-address.js b/test/lib/journeys-station-to-address.js new file mode 100644 index 00000000..1184aeda --- /dev/null +++ b/test/lib/journeys-station-to-address.js @@ -0,0 +1,30 @@ +'use strict' + +const isRoughlyEqual = require('is-roughly-equal') + +const co = require('./co') + +const testJourneysStationToAddress = co(function* (cfg) { + const {test: t, journeys, validate, fromId} = cfg + const {address, latitude, longitude} = cfg.to + + validate(t, journeys, 'journeys', 'journeys') + t.strictEqual(journeys.length, 3) + for (let i = 0; i < journeys.length; i++) { + const j = journeys[i] + + const firstLeg = j.legs[0] + const orig = firstLeg.origin.station || firstLeg.origin + t.ok(orig.id, fromId) + + const d = j.legs[j.legs.length - 1].destination + const n = `journeys[0].legs[${i}].destination` + + t.strictEqual(d.type, 'location', n + '.type is invalid') + t.strictEqual(d.address, address, n + '.address is invalid') + t.ok(isRoughlyEqual(.0001, d.latitude, latitude), n + '.latitude is invalid') + t.ok(isRoughlyEqual(.0001, d.longitude, longitude), n + '.longitude is invalid') + } +}) + +module.exports = testJourneysStationToAddress diff --git a/test/lib/journeys-station-to-poi.js b/test/lib/journeys-station-to-poi.js new file mode 100644 index 00000000..ca7fe1da --- /dev/null +++ b/test/lib/journeys-station-to-poi.js @@ -0,0 +1,39 @@ +'use strict' + +const isRoughlyEqual = require('is-roughly-equal') + +const co = require('./co') + +const testJourneysStationToPoi = co(function* (cfg) { + const {test: t, journeys, validate, fromId} = cfg + const {id, name, latitude, longitude} = cfg.to + + validate(t, journeys, 'journeys', 'journeys') + t.strictEqual(journeys.length, 3) + for (let i = 0; i < journeys.length; i++) { + const j = journeys[i] + + let o = j.legs[0].origin + let oN = `journeys[0].legs[0].destination` + if (o.station) { + o = o.station + oN += '.station' + } + t.strictEqual(o.id, fromId) + + let d = j.legs[j.legs.length - 1].destination + let dN = `journeys[${i}].legs[${j.legs.length - 1}].destination` + if (d.station) { + d = d.station + dN += '.station' + } + + t.strictEqual(d.type, 'location', dN + '.type is invalid') + t.strictEqual(d.id, id, dN + '.id is invalid') + t.strictEqual(d.name, name, dN + '.name is invalid') + t.ok(isRoughlyEqual(.0001, d.latitude, latitude), dN + '.latitude is invalid') + t.ok(isRoughlyEqual(.0001, d.longitude, longitude), dN + '.longitude is invalid') + } +}) + +module.exports = testJourneysStationToPoi diff --git a/test/lib/journeys-station-to-station.js b/test/lib/journeys-station-to-station.js new file mode 100644 index 00000000..5cc2ef58 --- /dev/null +++ b/test/lib/journeys-station-to-station.js @@ -0,0 +1,22 @@ +'use strict' + +const co = require('./co') + +const testJourneysStationToStation = co(function* (cfg) { + const {test: t, journeys, validate, fromId, toId} = cfg + + validate(t, journeys, 'journeys', 'journeys') + t.strictEqual(journeys.length, 3) + for (let i = 0; i < journeys.length; i++) { + const j = journeys[i] + + let origin = j.legs[0].origin + if (origin.station) origin = origin.station + let dest = j.legs[j.legs.length - 1].destination + if (dest.station) dest = dest.station + t.strictEqual(origin.id, fromId) + t.strictEqual(dest.id, toId) + } +}) + +module.exports = testJourneysStationToStation diff --git a/test/lib/journeys-with-detour.js b/test/lib/journeys-with-detour.js new file mode 100644 index 00000000..049f103c --- /dev/null +++ b/test/lib/journeys-with-detour.js @@ -0,0 +1,22 @@ +'use strict' + +const co = require('./co') + +const testJourneysWithDetour = co(function* (cfg) { + const {test: t, journeys, validate, detourIds} = cfg + + // We assume that going from A to B via C *without* detour is currently + // impossible. We check if the routing engine computes a detour. + + validate(t, journeys, 'journeys', 'journeys') + + const leg = journeys[0].legs.some((leg) => { + return leg.stopovers && leg.stopovers.some((st) => ( + st.stop.station && detourIds.includes(st.stop.station.id) || + detourIds.includes(st.stop.id) + )) + }) + t.ok(leg, detourIds.join('/') + ' is not being passed') +}) + +module.exports = testJourneysWithDetour diff --git a/test/lib/refresh-journey.js b/test/lib/refresh-journey.js new file mode 100644 index 00000000..8311e689 --- /dev/null +++ b/test/lib/refresh-journey.js @@ -0,0 +1,51 @@ +'use strict' + +const co = require('./co') + +const simplify = j => j.legs.map(l => { + let departure = null + if (l.departure) { + departure = +new Date(l.departure) + if ('number' === typeof l.departureDelay) departure -= l.departureDelay * 1000 + } + let arrival = null + if (l.arrival) { + arrival = +new Date(l.arrival) + if ('number' === typeof l.arrivalDelay) arrival -= l.arrivalDelay * 1000 + } + return { + origin: l.origin, + destination: l.destination, + scheduledDeparture: departure, + scheduledArrival: arrival, + line: l.line + } +}) + +const testRefreshJourney = co(function* (cfg) { + const { + test: t, + fetchJourneys, + refreshJourney, + fromId, + toId, + when, + // todo: validate + } = cfg + + const [model] = yield fetchJourneys(fromId, toId, { + results: 1, departure: when, + stopovers: false + }) + + // todo: move to journeys validator? + t.equal(typeof model.refreshToken, 'string') + t.ok(model.refreshToken) + + const refreshed = yield refreshJourney(model.refreshToken, { + stopovers: false + }) + t.deepEqual(simplify(refreshed), simplify(model)) +}) + +module.exports = testRefreshJourney diff --git a/test/lib/util.js b/test/lib/util.js new file mode 100644 index 00000000..89381beb --- /dev/null +++ b/test/lib/util.js @@ -0,0 +1,28 @@ +'use strict' + +const isRoughlyEqual = require('is-roughly-equal') +const {DateTime} = require('luxon') +const a = require('assert') + +const hour = 60 * 60 * 1000 +const day = 24 * hour +const week = 7 * day + +// next Monday 10 am +const createWhen = (timezone, locale) => { + return DateTime.fromMillis(Date.now(), { + zone: timezone, + locale, + }).startOf('week').plus({weeks: 1, hours: 10}).toJSDate() +} + +const assertValidWhen = (actual, expected, name) => { + const ts = +new Date(actual) + a.ok(!Number.isNaN(ts), name + ' is not parsable by Date') + // the timestamps might be from long-distance trains + a.ok(isRoughlyEqual(day, +expected, ts), name + ' is out of range') +} + +module.exports = { + hour, createWhen, assertValidWhen +} diff --git a/test/lib/validate-fptf-with.js b/test/lib/validate-fptf-with.js new file mode 100644 index 00000000..9db61db5 --- /dev/null +++ b/test/lib/validate-fptf-with.js @@ -0,0 +1,30 @@ +'use strict' + +const {defaultValidators} = require('validate-fptf') +const anyOf = require('validate-fptf/lib/any-of') + +const validators = require('./validators') + +const create = (cfg, customValidators = {}) => { + const val = Object.assign({}, defaultValidators) + for (let key of Object.keys(validators)) { + val[key] = validators[key](cfg) + } + Object.assign(val, customValidators) + + const validateFptfWith = (t, item, allowedTypes, name) => { + try { + if ('string' === typeof allowedTypes) { + val[allowedTypes](val, item, name) + } else { + anyOf(allowedTypes, val, item, name) + } + t.pass(name + ' is valid') + } catch (err) { + t.ifError(err) // todo: improve error logging + } + } + return validateFptfWith +} + +module.exports = create diff --git a/test/lib/validators.js b/test/lib/validators.js new file mode 100644 index 00000000..985598fd --- /dev/null +++ b/test/lib/validators.js @@ -0,0 +1,364 @@ +'use strict' + +const a = require('assert') +const {defaultValidators} = require('validate-fptf') +const anyOf = require('validate-fptf/lib/any-of') + +const {assertValidWhen} = require('./util') + +const isObj = o => o !== null && 'object' === typeof o && !Array.isArray(o) +const is = val => val !== null && val !== undefined + +const createValidateStation = (cfg) => { + const validateStation = (val, s, name = 'station') => { + defaultValidators.station(val, s, name) + + if (!cfg.stationCoordsOptional) { + a.ok(is(s.location), `missing ${name}.location`) + } + a.ok(isObj(s.products), name + '.products must be an object') + for (let product of cfg.products) { + const msg = name + `.products[${product.id}] must be a boolean` + a.strictEqual(typeof s.products[product.id], 'boolean', msg) + } + + if ('lines' in s) { + a.ok(Array.isArray(s.lines), name + `.lines must be an array`) + for (let i = 0; i < s.lines.length; i++) { + val.line(val, s.lines[i], name + `.lines[${i}]`) + } + } + } + return validateStation +} + + +const validateStop = (val, s, name = 'stop') => { + // HAFAS doesn't always return the station of a stop. We mock it here + // to silence `validate-fptf`. + const station = Object.assign({}, s) + station.type = 'station' + s = Object.assign({station}, s) + defaultValidators.stop(val, s, name) +} + +const validatePoi = (val, poi, name = 'location') => { + defaultValidators.location(val, poi, name) + val.ref(val, poi.id, name + '.id') + a.ok(poi.name, name + '.name must not be empty') +} + +const validateAddress = (val, addr, name = 'location') => { + defaultValidators.location(val, addr, name) + a.strictEqual(typeof addr.address, 'string', name + '.address must be a string') + a.ok(addr.address, name + '.address must not be empty') +} + +const validateLocation = (val, loc, name = 'location') => { + a.ok(isObj(loc), name + ' must be an object') + if (loc.type === 'stop') val.stop(val, loc, name) + else if (loc.type === 'station') val.station(val, loc, name) + else if ('id' in loc) validatePoi(val, loc, name) + else if (!('name' in loc) && ('address' in loc)) { + validateAddress(val, loc, name) + } else defaultValidators.location(val, loc, name) +} + +const validateLocations = (val, locs, name = 'locations') => { + a.ok(Array.isArray(locs), name + ' must be an array') + a.ok(locs.length > 0, name + ' must not be empty') + for (let i = 0; i < locs.length; i++) { + val.location(val, locs[i], name + `[${i}]`) + } +} + +const createValidateLine = (cfg) => { + const validLineModes = [] + for (let product of cfg.products) { + if (!validLineModes.includes(product.mode)) { + validLineModes.push(product.mode) + } + } + + const validateLine = (val, line, name = 'line') => { + defaultValidators.line(val, line, name) + a.ok(validLineModes.includes(line.mode), name + '.mode is invalid') + } + return validateLine +} + +const createValidateStopover = (cfg) => { + const validateStopover = (val, s, name = 'stopover') => { + if (is(s.arrival)) { + val.date(val, s.arrival, name + '.arrival') + assertValidWhen(s.arrival, cfg.when, name) + } + if (is(s.departure)) { + val.date(val, s.departure, name + '.departure') + assertValidWhen(s.departure, cfg.when, name) + } + if (!is(s.arrival) && !is(s.departure)) { + a.fail(name + ' contains neither arrival nor departure') + } + + if (is(s.arrivalDelay)) { + const msg = name + '.arrivalDelay must be a number' + a.strictEqual(typeof s.arrivalDelay, 'number', msg) + } + if (is(s.departureDelay)) { + const msg = name + '.departureDelay must be a number' + a.strictEqual(typeof s.departureDelay, 'number', msg) + } + + if (is(s.arrivalPlatform)) { + const msg = name + '.arrivalPlatform must ' + a.strictEqual(typeof s.arrivalPlatform, 'string', msg + 'be a string') + a.ok(s.arrivalPlatform, msg + 'not be empty') + } + if (is(s.formerScheduledArrivalPlatform)) { + const msg = name + '.formerScheduledArrivalPlatform must ' + a.strictEqual(typeof s.formerScheduledArrivalPlatform, 'string', msg + 'be a string') + a.ok(s.formerScheduledArrivalPlatform, msg + 'not be empty') + } + if (is(s.departurePlatform)) { + const msg = name + '.departurePlatform must ' + a.strictEqual(typeof s.departurePlatform, 'string', msg + 'be a string') + a.ok(s.departurePlatform, msg + 'not be empty') + } + if (is(s.formerScheduledDeparturePlatform)) { + const msg = name + '.formerScheduledDeparturePlatform must ' + a.strictEqual(typeof s.formerScheduledDeparturePlatform, 'string', msg + 'be a string') + a.ok(s.formerScheduledDeparturePlatform, msg + 'not be empty') + } + + anyOf(['stop', 'station'], val, s.stop, name + '.stop') + } + return validateStopover +} + +const validateTicket = (val, ti, name = 'ticket') => { + a.strictEqual(typeof ti.name, 'string', name + '.name must be a string') + a.ok(ti.name, name + '.name must not be empty') + + if (is(ti.price)) { + a.strictEqual(typeof ti.price, 'number', name + '.price must be a number') + a.ok(ti.price > 0, name + '.price must be >0') + } + if (is(ti.amount)) { + a.strictEqual(typeof ti.amount, 'number', name + '.amount must be a number') + a.ok(ti.amount > 0, name + '.amount must be >0') + } + + // todo: move to VBB tests + if ('bike' in ti) { + a.strictEqual(typeof ti.bike, 'boolean', name + '.bike must be a boolean') + } + if ('shortTrip' in ti) { + a.strictEqual(typeof ti.shortTrip, 'boolean', name + '.shortTrip must be a boolean') + } + if ('group' in ti) { + a.strictEqual(typeof ti.group, 'boolean', name + '.group must be a boolean') + } + if ('fullDay' in ti) { + a.strictEqual(typeof ti.fullDay, 'boolean', name + '.fullDay must be a boolean') + } + if ('tariff' in ti) { + a.strictEqual(typeof ti.tariff, 'string', name + '.tariff must be a string') + a.ok(ti.tariff, name + '.tariff must not be empty') + } + if ('coverage' in ti) { + a.strictEqual(typeof ti.coverage, 'string', name + '.coverage must be a string') + a.ok(ti.coverage, name + '.coverage must not be empty') + } + if ('variant' in ti) { + a.strictEqual(typeof ti.variant, 'string', name + '.variant must be a string') + a.ok(ti.variant, name + '.variant must not be empty') + } +} + +const createValidateJourneyLeg = (cfg) => { + const validateJourneyLeg = (val, leg, name = 'journeyLeg') => { + const withFakeScheduleAndOperator = Object.assign({ + schedule: 'foo', // todo: let hafas-client parse a schedule ID + operator: 'bar' // todo: let hafas-client parse the operator + }, leg) + defaultValidators.journeyLeg(val, withFakeScheduleAndOperator, name) + + if (leg.arrival !== null) { + assertValidWhen(leg.arrival, cfg.when, name + '.arrival') + } + if (leg.departure !== null) { + assertValidWhen(leg.departure, cfg.when, name + '.departure') + } + // todo: leg.arrivalPlatform !== null + if (is(leg.arrivalPlatform)) { + const msg = name + '.arrivalPlatform must be a string' + a.strictEqual(typeof leg.arrivalPlatform, 'string', msg) + a.ok(leg.arrivalPlatform, name + '.arrivalPlatform must not be empty') + } + // todo: leg.departurePlatform !== null + if (is(leg.departurePlatform)) { + const msg = name + '.departurePlatform must be a string' + a.strictEqual(typeof leg.departurePlatform, 'string', msg) + a.ok(leg.departurePlatform, name + '.departurePlatform must not be empty') + } + + if ('stopovers' in leg) { + a.ok(Array.isArray(leg.stopovers), name + '.stopovers must be an array') + a.ok(leg.stopovers.length > 0, name + '.stopovers must not be empty') + + for (let i = 0; i < leg.stopovers.length; i++) { + val.stopover(val, leg.stopovers[i], name + `.stopovers[${i}]`) + } + } + + if (leg.mode === 'walking') { + if (leg.distance !== null) { + const msg = name + '.distance must be ' + a.strictEqual(typeof leg.distance, 'number', msg + 'a number') + a.ok(leg.distance > 0, msg + '> 0') + } + } else { + const msg = name + '.direction must be a string' + a.strictEqual(typeof leg.direction, 'string', msg) + a.ok(leg.direction, name + '.direction must not be empty') + } + + // todo: validate polyline + } + return validateJourneyLeg +} + +const validateJourney = (val, j, name = 'journey') => { + const withFakeId = Object.assign({ + id: 'foo' // todo: let hafas-client parse a journey ID + }, j) + defaultValidators.journey(val, withFakeId, name) + + if ('tickets' in j) { + a.ok(Array.isArray(j.tickets), name + '.tickets must be an array') + a.ok(j.tickets.length > 0, name + '.tickets must not be empty') + + for (let i = 0; i < j.tickets.length; i++) { + val.ticket(val, j.tickets[i], name + `.tickets[${i}]`) + } + } +} + +const validateJourneys = (val, js, name = 'journeys') => { + a.ok(Array.isArray(js), name + ' must be an array') + a.ok(js.length > 0, name + ' must not be empty') + for (let i = 0; i < js.length; i++) { + val.journey(val, js[i], name + `[${i}]`) + } +} + +const createValidateArrivalOrDeparture = (cfg) => { + const validateArrivalOrDeparture = (val, dep, name = 'arrOrDep') => { + a.ok(isObj(dep), name + ' must be an object') + // todo: let hafas-client add a .type field + + a.strictEqual(typeof dep.tripId, 'string', name + '.tripId must be a string') + a.ok(dep.tripId, name + '.tripId must not be empty') + a.strictEqual(typeof dep.trip, 'number', name + '.trip must be a number') + + anyOf(['stop', 'station'], val, dep.stop, name + '.stop') + + assertValidWhen(dep.when, cfg.when, name) + if (dep.delay !== null) { + const msg = name + '.delay must be a number' + a.strictEqual(typeof dep.delay, 'number', msg) + } + + if (dep.platform !== null) { + const msg = name + '.platform must ' + a.strictEqual(typeof dep.platform, 'string', msg + 'be a string') + a.ok(dep.platform, name + 'not be empty') + } + + val.line(val, dep.line, name + '.line') + a.strictEqual(typeof dep.direction, 'string', name + '.direction must be a string') + a.ok(dep.direction, name + '.direction must not be empty') + } + return validateArrivalOrDeparture +} + +const validateArrivals = (val, deps, name = 'arrivals') => { + a.ok(Array.isArray(deps), name + ' must be an array') + a.ok(deps.length > 0, name + ' must not be empty') + for (let i = 0; i < deps.length; i++) { + val.arrival(val, deps[i], name + `[${i}]`) + } +} +const validateDepartures = (val, deps, name = 'departures') => { + a.ok(Array.isArray(deps), name + ' must be an array') + a.ok(deps.length > 0, name + ' must not be empty') + for (let i = 0; i < deps.length; i++) { + val.departure(val, deps[i], name + `[${i}]`) + } +} + +const validateMovement = (val, m, name = 'movement') => { + a.ok(isObj(m), name + ' must be an object') + // todo: let hafas-client add a .type field + + val.line(val, m.line, name + '.line') + a.strictEqual(typeof m.direction, 'string', name + '.direction must be a string') + a.ok(m.direction, name + '.direction must not be empty') + + const lName = name + '.location' + val.location(val, m.location, lName) + a.ok(m.location.latitude <= 55, lName + '.latitude is too small') + a.ok(m.location.latitude >= 45, lName + '.latitude is too large') + a.ok(m.location.longitude >= 9, lName + '.longitude is too small') + a.ok(m.location.longitude <= 15, lName + '.longitude is too small') + + a.ok(Array.isArray(m.nextStops), name + '.nextStops must be an array') + for (let i = 0; i < m.nextStops.length; i++) { + const st = m.nextStops[i] + val.stopover(val, m.nextStops[i], name + `.nextStops[${i}]`) + } + + a.ok(Array.isArray(m.frames), name + '.frames must be an array') + a.ok(m.frames.length > 0, name + '.frames must not be empty') + for (let i = 0; i < m.frames.length; i++) { + const f = m.frames[i] + const fName = name + `.frames[${i}]` + + a.ok(isObj(f), fName + ' must be an object') + anyOf(['location', 'stop', 'station'], val, f.origin, fName + '.origin') + anyOf(['location', 'stop', 'station'], val, f.destination, fName + '.destination') + a.strictEqual(typeof f.t, 'number', fName + '.frames must be a number') + } + + // todo: validate polyline +} + +const validateMovements = (val, ms, name = 'movements') => { + a.ok(Array.isArray(ms), name + ' must be an array') + a.ok(ms.length > 0, name + ' must not be empty') + for (let i = 0; i < ms.length; i++) { + val.movement(val, ms[i], name + `[${i}]`) + } +} + +module.exports = { + station: createValidateStation, + stop: () => validateStop, + location: () => validateLocation, + locations: () => validateLocations, + poi: () => validatePoi, + address: () => validateAddress, + line: createValidateLine, + stopover: createValidateStopover, + ticket: () => validateTicket, + journeyLeg: createValidateJourneyLeg, + journey: () => validateJourney, + journeys: () => validateJourneys, + arrival: createValidateArrivalOrDeparture, + departure: createValidateArrivalOrDeparture, + departures: () => validateDepartures, + arrivals: () => validateArrivals, + movement: () => validateMovement, + movements: () => validateMovements +} diff --git a/test/lib/vbb-bvg-validators.js b/test/lib/vbb-bvg-validators.js new file mode 100644 index 00000000..4801e216 --- /dev/null +++ b/test/lib/vbb-bvg-validators.js @@ -0,0 +1,87 @@ +'use strict' + +const stations = require('vbb-stations-autocomplete') +const a = require('assert') +const shorten = require('vbb-short-station-name') +const products = require('../../p/bvg/products') + +const {createWhen} = require('./util') +const { + station: createValidateStation, + line: createValidateLine, + journeyLeg: createValidateJourneyLeg, + departure: createValidateDeparture, + movement: _validateMovement +} = require('./validators') + +const when = createWhen('Europe/Berlin', 'de-DE') + +const cfg = { + when, + stationCoordsOptional: false, + products +} + +const validateDirection = (dir, name) => { + if (!stations(dir, true, false)[0]) { + console.error(name + `: station "${dir}" is unknown`) + } +} + +// todo: coordsOptional = false +const _validateStation = createValidateStation(cfg) +const validateStation = (validate, s, name) => { + _validateStation(validate, s, name) + // todo: find station by ID + a.equal(s.name, shorten(s.name), name + '.name must be shortened') +} + +const _validateLine = createValidateLine(cfg) +const validateLine = (validate, l, name) => { + _validateLine(validate, l, name) + if (l.symbol !== null) { + a.strictEqual(typeof l.symbol, 'string', name + '.symbol must be a string') + a.ok(l.symbol, name + '.symbol must not be empty') + } + if (l.nr !== null) { + a.strictEqual(typeof l.nr, 'number', name + '.nr must be a string') + a.ok(l.nr, name + '.nr must not be empty') + } + if (l.metro !== null) { + a.strictEqual(typeof l.metro, 'boolean', name + '.metro must be a boolean') + } + if (l.express !== null) { + a.strictEqual(typeof l.express, 'boolean', name + '.express must be a boolean') + } + if (l.night !== null) { + a.strictEqual(typeof l.night, 'boolean', name + '.night must be a boolean') + } +} + +const _validateJourneyLeg = createValidateJourneyLeg(cfg) +const validateJourneyLeg = (validate, l, name) => { + _validateJourneyLeg(validate, l, name) + if (l.mode !== 'walking') { + validateDirection(l.direction, name + '.direction') + } +} + +const _validateDeparture = createValidateDeparture(cfg) +const validateDeparture = (validate, dep, name) => { + _validateDeparture(validate, dep, name) + validateDirection(dep.direction, name + '.direction') +} + +const validateMovement = (validate, m, name) => { + _validateMovement(validate, m, name) + validateDirection(m.direction, name + '.direction') +} + +module.exports = { + cfg, + validateStation, + validateLine, + validateJourneyLeg, + validateDeparture, + validateMovement +} diff --git a/test/nahsh.js b/test/nahsh.js index 0b8b2b04..bd5bd8d4 100644 --- a/test/nahsh.js +++ b/test/nahsh.js @@ -1,70 +1,52 @@ 'use strict' -// todo -// const getStations = require('db-stations').full const tapePromise = require('tape-promise').default const tape = require('tape') const isRoughlyEqual = require('is-roughly-equal') -const validateFptf = require('validate-fptf') -const validateLineWithoutMode = require('./validate-line-without-mode') - -const co = require('./co') +const {createWhen} = require('./lib/util') +const co = require('./lib/co') const createClient = require('..') const nahshProfile = require('../p/nahsh') -const {allProducts} = require('../p/nahsh/products') +const products = require('../p/nahsh/products') const { - assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidStopover, - hour, createWhen, assertValidWhen -} = require('./util.js') + line: createValidateLine, + station: createValidateStation +} = require('./lib/validators') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testDeparturesInDirection = require('./lib/departures-in-direction') +const testArrivals = require('./lib/arrivals') const when = createWhen('Europe/Berlin', 'de-DE') -const assertValidStationProducts = (t, p) => { - t.ok(p) - t.equal(typeof p.nationalExp, 'boolean') - t.equal(typeof p.national, 'boolean') - t.equal(typeof p.interregional, 'boolean') - t.equal(typeof p.regional, 'boolean') - t.equal(typeof p.suburban, 'boolean') - t.equal(typeof p.bus, 'boolean') - t.equal(typeof p.ferry, 'boolean') - t.equal(typeof p.subway, 'boolean') - t.equal(typeof p.tram, 'boolean') - t.equal(typeof p.onCall, 'boolean') +const cfg = { + when, + stationCoordsOptional: false, + products } -const isKielHbf = (s) => { - return s.type === 'station' && - (s.id === '8000199') && - s.name === 'Kiel Hbf' && - s.location && - isRoughlyEqual(s.location.latitude, 54.314982, .0005) && - isRoughlyEqual(s.location.longitude, 10.131976, .0005) -} - -const assertIsKielHbf = (t, s) => { - t.equal(s.type, 'station') - t.ok(s.id === '8000199', 'id should be 8000199') - t.equal(s.name, 'Kiel Hbf') - t.ok(s.location) - t.ok(isRoughlyEqual(s.location.latitude, 54.314982, .0005)) - t.ok(isRoughlyEqual(s.location.longitude, 10.131976, .0005)) -} - -// todo: DRY with assertValidStationProducts -// todo: DRY with other tests -const assertValidProducts = (t, p) => { - for (let product of allProducts) { - product = product.product // wat - t.equal(typeof p[product], 'boolean', 'product ' + p + ' must be a boolean') +const _validateLine = createValidateLine(cfg) +const validateLine = (validate, l, name) => { + if (l && l.product === 'onCall') { + // skip line validation + // https://github.com/derhuerst/hafas-client/issues/8#issuecomment-355839965 + l = Object.assign({}, l) + l.mode = 'taxi' } + _validateLine(validate, l, name) } +const validate = createValidate(cfg, { + line: validateLine +}) + const assertValidPrice = (t, p) => { t.ok(p) if (p.amount !== null) { @@ -77,275 +59,218 @@ const assertValidPrice = (t, p) => { } } -const assertValidLine = (t, l) => { // with optional mode - const validators = Object.assign({}, validateFptf.defaultValidators, { - line: validateLineWithoutMode - }) - const recurse = validateFptf.createRecurse(validators) - try { - recurse(['line'], l, 'line') - } catch (err) { - t.ifError(err) - } -} - const test = tapePromise(tape) -const client = createClient(nahshProfile) +const client = createClient(nahshProfile, 'public-transport/hafas-client:test') -const kielHbf = '8000199' -const flensburg = '8000103' -const luebeckHbf = '8000237' -const husum = '8000181' -const schleswig = '8005362' +const kielHbf = '9049079' +const flensburg = '9027253' +const luebeckHbf = '9057819' +const husum = '9044660' +const schleswig = '9081683' +const ellerbekerMarkt = '9049027' +const seefischmarkt = '9049245' +const kielRaeucherei = '9049217' -test('Kiel Hbf to Flensburg', co(function* (t) { +test('journeys – Kiel Hbf to Flensburg', co(function* (t) { const journeys = yield client.journeys(kielHbf, flensburg, { - when, passedStations: true + results: 3, + departure: when, + stopovers: true }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length > 0, 'no journeys') - for (let journey of journeys) { - t.equal(journey.type, 'journey') + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: kielHbf, + toId: flensburg + }) - assertValidStation(t, journey.origin) - assertValidStationProducts(t, journey.origin.products) - // todo - // if (!(yield findStation(journey.origin.id))) { - // console.error('unknown station', journey.origin.id, journey.origin.name) - // } - if (journey.origin.products) { - assertValidProducts(t, journey.origin.products) - } - assertValidWhen(t, journey.departure, when) - - assertValidStation(t, journey.destination) - assertValidStationProducts(t, journey.origin.products) - // todo - // if (!(yield findStation(journey.origin.id))) { - // console.error('unknown station', journey.destination.id, journey.destination.name) - // } - if (journey.destination.products) { - assertValidProducts(t, journey.destination.products) - } - assertValidWhen(t, journey.arrival, when) - - t.ok(Array.isArray(journey.legs)) - t.ok(journey.legs.length > 0, 'no legs') - const leg = journey.legs[0] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - // todo - // if (!(yield findStation(leg.origin.id))) { - // console.error('unknown station', leg.origin.id, leg.origin.name) - // } - assertValidWhen(t, leg.departure, when) - t.equal(typeof leg.departurePlatform, 'string') - - assertValidStation(t, leg.destination) - assertValidStationProducts(t, leg.origin.products) - // todo - // if (!(yield findStation(leg.destination.id))) { - // console.error('unknown station', leg.destination.id, leg.destination.name) - // } - assertValidWhen(t, leg.arrival, when) - t.equal(typeof leg.arrivalPlatform, 'string') - - assertValidLine(t, leg.line) - - t.ok(Array.isArray(leg.passed)) - for (let stopover of leg.passed) assertValidStopover(t, stopover) - - if (journey.price) assertValidPrice(t, journey.price) + for (let i = 0; i < journeys.length; i++) { + const j = journeys[i] + // todo: find a journey where there pricing info is always available + if (j.price) assertValidPrice(t, j.price, `journeys[${i}].price`) } t.end() })) -test('Kiel Hbf to Husum, Zingel 10', co(function* (t) { - const zingel = { +// todo: journeys, only one product + +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: kielHbf, + toId: flensburg, + when, + products + }) + t.end() +}) + +test('Kiel Hbf to Berliner Str. 80, Husum', co(function* (t) { + const berlinerStr = { type: 'location', - latitude: 54.475359, - longitude: 9.050798, - address: 'Husum, Zingel 10' + address: 'Husum, Berliner Straße 80', + latitude: 54.488995, + longitude: 9.056263 } + const journeys = yield client.journeys(kielHbf, berlinerStr, { + results: 3, + departure: when + }) - const journeys = yield client.journeys(kielHbf, zingel, {when}) - - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, firstLeg.origin) - assertValidStationProducts(t, firstLeg.origin.products) - // todo - // if (!(yield findStation(leg.origin.id))) { - // console.error('unknown station', leg.origin.id, leg.origin.name) - // } - if (firstLeg.origin.products) assertValidProducts(t, firstLeg.origin.products) - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - const d = lastLeg.destination - assertValidAddress(t, d) - t.equal(d.address, 'Husum, Zingel 10') - t.ok(isRoughlyEqual(.0001, d.latitude, 54.475359)) - t.ok(isRoughlyEqual(.0001, d.longitude, 9.050798)) - + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: kielHbf, + to: berlinerStr + }) t.end() })) -test('Holstentor to Kiel Hbf', co(function* (t) { +test('Kiel Hbf to Holstentor', co(function* (t) { const holstentor = { type: 'location', - latitude: 53.866321, - longitude: 10.679976, + id: '970004303', name: 'Hansestadt Lübeck, Holstentor (Denkmal)', - id: '970003547' + latitude: 53.866321, + longitude: 10.679976 } - const journeys = yield client.journeys(holstentor, kielHbf, {when}) - - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - const o = firstLeg.origin - assertValidPoi(t, o) - t.equal(o.name, 'Hansestadt Lübeck, Holstentor (Denkmal)') - t.ok(isRoughlyEqual(.0001, o.latitude, 53.866321)) - t.ok(isRoughlyEqual(.0001, o.longitude, 10.679976)) - - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - assertValidStation(t, lastLeg.destination) - assertValidStationProducts(t, lastLeg.destination.products) - if (lastLeg.destination.products) assertValidProducts(t, lastLeg.destination.products) - // todo - // if (!(yield findStation(leg.destination.id))) { - // console.error('unknown station', leg.destination.id, leg.destination.name) - // } + const journeys = yield client.journeys(kielHbf, holstentor, { + results: 3, + departure: when + }) + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: kielHbf, + to: holstentor + }) t.end() })) -test('Husum to Lübeck Hbf with stopover at Husum', co(function* (t) { - const [journey] = yield client.journeys(husum, luebeckHbf, { +test('Husum to Lübeck Hbf with stopover at Kiel Hbf', co(function* (t) { + const journeys = yield client.journeys(husum, luebeckHbf, { via: kielHbf, results: 1, - when + departure: when, + stopovers: true }) - const i1 = journey.legs.findIndex(leg => leg.destination.id === kielHbf) - t.ok(i1 >= 0, 'no leg with Kiel Hbf as destination') + validate(t, journeys, 'journeys', 'journeys') - const i2 = journey.legs.findIndex(leg => leg.origin.id === kielHbf) - t.ok(i2 >= 0, 'no leg with Kiel Hbf as origin') - t.ok(i2 > i1, 'leg with Kiel Hbf as origin must be after leg to it') + const leg = journeys[0].legs.some((leg) => { + return leg.stopovers && leg.stopovers.some((stopover) => { + const s = stopover.stop + return s.station && s.station.id === kielHbf || s.id === kielHbf + }) + }) + t.ok(leg, 'Kiel Hbf is not being passed') t.end() })) test('earlier/later journeys, Kiel Hbf -> Flensburg', co(function* (t) { - const model = yield client.journeys(kielHbf, flensburg, { - results: 3, when + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: kielHbf, + toId: flensburg }) - t.equal(typeof model.earlierRef, 'string') - t.ok(model.earlierRef) - t.equal(typeof model.laterRef, 'string') - t.ok(model.laterRef) - - // when and earlierThan/laterThan should be mutually exclusive - t.throws(() => { - client.journeys(kielHbf, flensburg, { - when, earlierThan: model.earlierRef - }) - }) - t.throws(() => { - client.journeys(kielHbf, flensburg, { - when, laterThan: model.laterRef - }) - }) - - let earliestDep = Infinity, latestDep = -Infinity - for (let j of model) { - const dep = +new Date(j.departure) - if (dep < earliestDep) earliestDep = dep - else if (dep > latestDep) latestDep = dep - } - - const earlier = yield client.journeys(kielHbf, flensburg, { - results: 3, - // todo: single journey ref? - earlierThan: model.earlierRef - }) - for (let j of earlier) { - t.ok(new Date(j.departure) < earliestDep) - } - - const later = yield client.journeys(kielHbf, flensburg, { - results: 3, - // todo: single journey ref? - laterThan: model.laterRef - }) - for (let j of later) { - t.ok(new Date(j.departure) > latestDep) - } - t.end() })) -test('leg details for Flensburg to Husum', co(function* (t) { +test('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: kielHbf, + toId: flensburg, + when + }) + t.end() +})) + +// todo: with detour test +// todo: without detour test + +test('trip details', co(function* (t) { const journeys = yield client.journeys(flensburg, husum, { - results: 1, when + results: 1, departure: when }) const p = journeys[0].legs[0] t.ok(p.id, 'precondition failed') t.ok(p.line.name, 'precondition failed') - const leg = yield client.journeyLeg(p.id, p.line.name, {when}) - - t.equal(typeof leg.id, 'string') - t.ok(leg.id) - - assertValidLine(t, leg.line) - - t.equal(typeof leg.direction, 'string') - t.ok(leg.direction) - - t.ok(Array.isArray(leg.passed)) - for (let passed of leg.passed) assertValidStopover(t, passed) + const trip = yield client.trip(p.id, p.line.name, {when}) + validate(t, trip, 'journeyLeg', 'trip') t.end() })) -test('departures at Kiel Hbf', co(function* (t) { - const deps = yield client.departures(kielHbf, { +test('departures at Kiel Räucherei', co(function* (t) { + const departures = yield client.departures(kielRaeucherei, { duration: 30, when }) - t.ok(Array.isArray(deps)) - for (let dep of deps) { - assertValidStation(t, dep.station) - assertValidStationProducts(t, dep.station.products) - // todo - // if (!(yield findStation(dep.station.id))) { - // console.error('unknown station', dep.station.id, dep.station.name) - // } - if (dep.station.products) assertValidProducts(t, dep.station.products) - assertValidWhen(t, dep.when, when) - } + yield testDepartures({ + test: t, + departures, + validate, + id: kielRaeucherei + }) + t.end() +})) +test('departures with station object', co(function* (t) { + const deps = yield client.departures({ + type: 'station', + id: kielHbf, + name: 'Kiel Hbf', + location: { + type: 'location', + latitude: 1.23, + longitude: 2.34 + } + }, {when}) + + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test('departures at Berlin Hbf in direction of Berlin Ostbahnhof', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: ellerbekerMarkt, + directionIds: [seefischmarkt, '710102'], + when, + validate + }) + t.end() +})) + +test('arrivals at Kiel Räucherei', co(function* (t) { + const arrivals = yield client.arrivals(kielRaeucherei, { + duration: 30, when + }) + + yield testArrivals({ + test: t, + arrivals, + validate, + id: kielRaeucherei + }) t.end() })) @@ -359,94 +284,66 @@ test('nearby Kiel Hbf', co(function* (t) { results: 2, distance: 400 }) + validate(t, nearby, 'locations', 'nearby') + t.ok(Array.isArray(nearby)) t.equal(nearby.length, 2) - assertIsKielHbf(t, nearby[0]) + t.ok(nearby[0].id === kielHbf || nearby[0].id === '8000199') + t.equal(nearby[0].name, 'Kiel Hbf') t.ok(nearby[0].distance >= 0) t.ok(nearby[0].distance <= 100) - for (let n of nearby) { - if (n.type === 'station') assertValidStation(t, n) - else assertValidLocation(t, n) - } - t.end() })) test('locations named Kiel', co(function* (t) { const locations = yield client.locations('Kiel', { - results: 10 + results: 20 }) - t.ok(Array.isArray(locations)) - t.ok(locations.length > 0) - t.ok(locations.length <= 10) + validate(t, locations, 'locations', 'locations') + t.ok(locations.length <= 20) - for (let l of locations) { - if (l.type === 'station') assertValidStation(t, l) - else assertValidLocation(t, l) - } - t.ok(locations.some(isKielHbf)) + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) + t.ok(locations.find(s => s.id && s.name)) // POIs + t.ok(locations.some(l => l.station && s.station.id === kielHbf || l.id === kielHbf)) t.end() })) -test('location', co(function* (t) { - const loc = yield client.location(schleswig) +test('station', co(function* (t) { + const s = yield client.station(kielHbf) - assertValidStation(t, loc) - t.equal(loc.id, schleswig) + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, kielHbf) t.end() })) -// todo: see #34 -test.skip('radar Kiel', co(function* (t) { - const vehicles = yield client.radar(54.4, 10.0, 54.2, 10.2, { +test('radar', co(function* (t) { + const vehicles = yield client.radar({ + north: 54.4, + west: 10.0, + south: 54.2, + east: 10.2 + }, { duration: 5 * 60, when }) - t.ok(Array.isArray(vehicles)) - t.ok(vehicles.length > 0) - for (let v of vehicles) { - - // todo - // t.ok(findStation(v.direction)) - assertValidLine(t, v.line) - - t.equal(typeof v.location.latitude, 'number') - t.ok(v.location.latitude <= 57, 'vehicle is too far away') - t.ok(v.location.latitude >= 51, 'vehicle is too far away') - t.equal(typeof v.location.longitude, 'number') - t.ok(v.location.longitude >= 7, 'vehicle is too far away') - t.ok(v.location.longitude <= 13, 'vehicle is too far away') - - t.ok(Array.isArray(v.nextStops)) - for (let st of v.nextStops) { - assertValidStopover(t, st, true) - - if (st.arrival) { - t.equal(typeof st.arrival, 'string') - const arr = +new Date(st.arrival) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, arr)) - } - if (st.departure) { - t.equal(typeof st.departure, 'string') - const dep = +new Date(st.departure) - t.ok(isRoughlyEqual(14 * hour, +when, dep)) - } + // todo: cfg.stationProductsOptional option + const allProducts = products.reduce((acc, p) => (acc[p.id] = true, acc), {}) + const validateStation = createValidateStation(cfg) + const validate = createValidate(cfg, { + station: (validate, s, name) => { + s = Object.assign({ + products: allProducts // todo: fix station.products + }, s) + if (!s.name) s.name = 'foo' // todo, see #34 + validateStation(validate, s, name) } + }) + validate(t, vehicles, 'movements', 'vehicles') - t.ok(Array.isArray(v.frames)) - for (let f of v.frames) { - assertValidStation(t, f.origin, true) - assertValidStationProducts(t, f.origin.products) - assertValidStation(t, f.destination, true) - assertValidStationProducts(t, f.destination.products) - t.equal(typeof f.t, 'number') - } - } t.end() })) diff --git a/test/oebb.js b/test/oebb.js index f9ee62d9..6ee84408 100644 --- a/test/oebb.js +++ b/test/oebb.js @@ -1,86 +1,42 @@ 'use strict' -// todo -// const getStations = require('db-stations').full const tapePromise = require('tape-promise').default const tape = require('tape') const isRoughlyEqual = require('is-roughly-equal') -const validateFptf = require('validate-fptf') +const validateLine = require('validate-fptf/line') -const validateLineWithoutMode = require('./validate-line-without-mode') - -const co = require('./co') +const {createWhen} = require('./lib/util') +const co = require('./lib/co') const createClient = require('..') const oebbProfile = require('../p/oebb') -const {allProducts} = require('../p/oebb/products') +const products = require('../p/oebb/products') const { - assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidStopover, - hour, createWhen, assertValidWhen -} = require('./util.js') + station: createValidateStation, + stop: validateStop +} = require('./lib/validators') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testJourneysWithDetour = require('./lib/journeys-with-detour') +const testDeparturesInDirection = require('./lib/departures-in-direction') const when = createWhen('Europe/Vienna', 'de-AT') -const assertValidStationProducts = (t, p) => { - t.ok(p) - t.equal(typeof p.nationalExp, 'boolean') - t.equal(typeof p.national, 'boolean') - t.equal(typeof p.interregional, 'boolean') - t.equal(typeof p.regional, 'boolean') - t.equal(typeof p.suburban, 'boolean') - t.equal(typeof p.bus, 'boolean') - t.equal(typeof p.ferry, 'boolean') - t.equal(typeof p.subway, 'boolean') - t.equal(typeof p.tram, 'boolean') - t.equal(typeof p.onCall, 'boolean') +const cfg = { + when, + stationCoordsOptional: false, + products } -// todo -// const findStation = (id) => new Promise((yay, nay) => { -// const stations = getStations() -// stations -// .once('error', nay) -// .on('data', (s) => { -// if ( -// s.id === id || -// (s.additionalIds && s.additionalIds.includes(id)) -// ) { -// yay(s) -// stations.destroy() -// } -// }) -// .once('end', yay) -// }) +// todo validateDirection: search list of stations for direction -const isSalzburgHbf = (s) => { - return s.type === 'station' && - (s.id === '008100002' || s.id === '8100002') && - s.name === 'Salzburg Hbf' && - s.location && - isRoughlyEqual(s.location.latitude, 47.812851, .0005) && - isRoughlyEqual(s.location.longitude, 13.045604, .0005) -} - -const assertIsSalzburgHbf = (t, s) => { - t.equal(s.type, 'station') - t.ok(s.id === '008100002' || s.id === '8100002', 'id should be 8100002') - t.equal(s.name, 'Salzburg Hbf') - t.ok(s.location) - t.ok(isRoughlyEqual(s.location.latitude, 47.812851, .0005)) - t.ok(isRoughlyEqual(s.location.longitude, 13.045604, .0005)) -} - -// todo: DRY with assertValidStationProducts -// todo: DRY with other tests -const assertValidProducts = (t, p) => { - for (let product of allProducts) { - product = product.product // wat - t.equal(typeof p[product], 'boolean', 'product ' + p + ' must be a boolean') - } -} +const validate = createValidate(cfg, { + line: validateLine // bypass line validator in lib/validators +}) const assertValidPrice = (t, p) => { t.ok(p) @@ -94,167 +50,97 @@ const assertValidPrice = (t, p) => { } } -// todo: fix this upstream -// see https://github.com/public-transport/hafas-client/blob/c6e558be217667f1bcdac4a605898eb75ea80374/p/oebb/products.js#L71 -const assertValidLine = (t, l) => { // with optional mode - const validators = Object.assign({}, validateFptf.defaultValidators, { - line: validateLineWithoutMode - }) - const recurse = validateFptf.createRecurse(validators) - try { - recurse(['line'], l, 'line') - } catch (err) { - t.ifError(err) - } -} - const test = tapePromise(tape) -const client = createClient(oebbProfile) +const client = createClient(oebbProfile, 'public-transport/hafas-client:test') const salzburgHbf = '8100002' -const wienWestbahnhof = '1291501' +const wienFickeystr = '911014' const wien = '1190100' +const wienWestbahnhof = '1291501' const klagenfurtHbf = '8100085' const muenchenHbf = '8000261' -const grazHbf = '8100173' +const wienRenngasse = '1390186' +const wienKarlsplatz = '1390461' +const wienPilgramgasse = '1390562' -test('Salzburg Hbf to Wien Westbahnhof', co(function* (t) { - const journeys = yield client.journeys(salzburgHbf, wienWestbahnhof, { - when, passedStations: true +test.skip('journeys – Salzburg Hbf to Wien Westbahnhof', co(function* (t) { + const journeys = yield client.journeys(salzburgHbf, wienFickeystr, { + results: 3, + departure: when, + stopovers: true }) - t.ok(Array.isArray(journeys)) - t.ok(journeys.length > 0, 'no journeys') - for (let journey of journeys) { - t.equal(journey.type, 'journey') + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: salzburgHbf, + toId: wienFickeystr + }) - assertValidStation(t, journey.origin) - assertValidStationProducts(t, journey.origin.products) - // todo - // if (!(yield findStation(journey.origin.id))) { - // console.error('unknown station', journey.origin.id, journey.origin.name) - // } - if (journey.origin.products) { - assertValidProducts(t, journey.origin.products) - } - assertValidWhen(t, journey.departure, when) - - assertValidStation(t, journey.destination) - assertValidStationProducts(t, journey.origin.products) - // todo - // if (!(yield findStation(journey.origin.id))) { - // console.error('unknown station', journey.destination.id, journey.destination.name) - // } - if (journey.destination.products) { - assertValidProducts(t, journey.destination.products) - } - assertValidWhen(t, journey.arrival, when) - - t.ok(Array.isArray(journey.legs)) - t.ok(journey.legs.length > 0, 'no legs') - const leg = journey.legs[0] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - // todo - // if (!(yield findStation(leg.origin.id))) { - // console.error('unknown station', leg.origin.id, leg.origin.name) - // } - assertValidWhen(t, leg.departure, when) - t.equal(typeof leg.departurePlatform, 'string') - - assertValidStation(t, leg.destination) - assertValidStationProducts(t, leg.origin.products) - // todo - // if (!(yield findStation(leg.destination.id))) { - // console.error('unknown station', leg.destination.id, leg.destination.name) - // } - assertValidWhen(t, leg.arrival, when) - t.equal(typeof leg.arrivalPlatform, 'string') - - assertValidLine(t, leg.line) - - t.ok(Array.isArray(leg.passed)) - for (let stopover of leg.passed) assertValidStopover(t, stopover) - - if (journey.price) assertValidPrice(t, journey.price) + for (let i = 0; i < journeys.length; i++) { + const j = journeys[i] + if (j.price) assertValidPrice(t, j.price, `journeys[${i}].price`) } t.end() })) +// todo: journeys, only one product + +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: salzburgHbf, + toId: wienFickeystr, + when, + products + }) + t.end() +}) + test('Salzburg Hbf to 1220 Wien, Wagramer Straße 5', co(function* (t) { const wagramerStr = { type: 'location', + address: '1220 Wien, Wagramer Straße 5', latitude: 48.236216, - longitude: 16.425863, - address: '1220 Wien, Wagramer Straße 5' + longitude: 16.425863 } + const journeys = yield client.journeys(salzburgHbf, wagramerStr, { + results: 3, + departure: when + }) - const journeys = yield client.journeys(salzburgHbf, wagramerStr, {when}) - - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, firstLeg.origin) - assertValidStationProducts(t, firstLeg.origin.products) - // todo - // if (!(yield findStation(leg.origin.id))) { - // console.error('unknown station', leg.origin.id, leg.origin.name) - // } - if (firstLeg.origin.products) assertValidProducts(t, firstLeg.origin.products) - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - const d = lastLeg.destination - assertValidAddress(t, d) - t.equal(d.address, '1220 Wien, Wagramer Straße 5') - t.ok(isRoughlyEqual(.0001, d.latitude, 48.236216)) - t.ok(isRoughlyEqual(.0001, d.longitude, 16.425863)) - + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: salzburgHbf, + to: wagramerStr + }) t.end() })) -test('Albertina to Salzburg Hbf', co(function* (t) { +test('Salzburg Hbf to Albertina', co(function* (t) { const albertina = { type: 'location', - latitude: 48.204699, - longitude: 16.368404, + id: '975900003', name: 'Albertina', - id: '975900003' + latitude: 48.204699, + longitude: 16.368404 } - const journeys = yield client.journeys(albertina, salzburgHbf, {when}) - - t.ok(Array.isArray(journeys)) - t.ok(journeys.length >= 1, 'no journeys') - const journey = journeys[0] - const firstLeg = journey.legs[0] - const lastLeg = journey.legs[journey.legs.length - 1] - - const o = firstLeg.origin - assertValidPoi(t, o) - t.equal(o.name, 'Albertina') - t.ok(isRoughlyEqual(.0001, o.latitude, 48.204699)) - t.ok(isRoughlyEqual(.0001, o.longitude, 16.368404)) - - assertValidWhen(t, firstLeg.departure, when) - assertValidWhen(t, firstLeg.arrival, when) - assertValidWhen(t, lastLeg.departure, when) - assertValidWhen(t, lastLeg.arrival, when) - - assertValidStation(t, lastLeg.destination) - assertValidStationProducts(t, lastLeg.destination.products) - if (lastLeg.destination.products) assertValidProducts(t, lastLeg.destination.products) - // todo - // if (!(yield findStation(leg.destination.id))) { - // console.error('unknown station', leg.destination.id, leg.destination.name) - // } + const journeys = yield client.journeys(salzburgHbf, albertina, { + results: 3, departure: when + }) + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: salzburgHbf, + to: albertina + }) t.end() })) @@ -265,237 +151,247 @@ test('journeys: via works – with detour', co(function* (t) { const schottenring = '001390163' const donauinsel = '001392277' const donauinselPassed = '922001' - const [journey] = yield client.journeys(stephansplatz, schottenring, { + const journeys = yield client.journeys(stephansplatz, schottenring, { via: donauinsel, results: 1, - when, - passedStations: true + departure: when, + stopovers: true }) - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === donauinselPassed)) - t.ok(l, 'Donauinsel is not being passed') - + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: [donauinsel, donauinselPassed] + }) t.end() })) test('journeys: via works – without detour', co(function* (t) { - // When going from Karlsplatz to Praterstern via Museumsquartier, there is *no need* - // to change trains / no need for a "detour". + // When going from Karlsplatz to Praterstern via Museumsquartier, there is + // *no need* to change trains / no need for a "detour". const karlsplatz = '001390461' const praterstern = '001290201' const museumsquartier = '001390171' const museumsquartierPassed = '901014' - const [journey] = yield client.journeys(karlsplatz, praterstern, { + const journeys = yield client.journeys(karlsplatz, praterstern, { via: museumsquartier, results: 1, - when, - passedStations: true + departure: when, + stopovers: true }) - t.ok(journey) + validate(t, journeys, 'journeys', 'journeys') - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === museumsquartierPassed)) - t.ok(l, 'Weihburggasse is not being passed') + const l1 = journeys[0].legs.some((leg) => { + return ( + leg.destination.id === museumsquartier || + leg.destination.id === museumsquartierPassed + ) + }) + t.notOk(l1, 'transfer at Museumsquartier') + + const l2 = journeys[0].legs.some((leg) => { + return leg.stopovers && leg.stopovers.some((stopover) => { + return stopover.stop.id === museumsquartierPassed + }) + }) + t.ok(l2, 'Museumsquartier is not being passed') t.end() })) test('earlier/later journeys, Salzburg Hbf -> Wien Westbahnhof', co(function* (t) { - const model = yield client.journeys(salzburgHbf, wienWestbahnhof, { - results: 3, when + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: salzburgHbf, + toId: wienWestbahnhof }) - t.equal(typeof model.earlierRef, 'string') - t.ok(model.earlierRef) - t.equal(typeof model.laterRef, 'string') - t.ok(model.laterRef) - - // when and earlierThan/laterThan should be mutually exclusive - t.throws(() => { - client.journeys(salzburgHbf, wienWestbahnhof, { - when, earlierThan: model.earlierRef - }) - }) - t.throws(() => { - client.journeys(salzburgHbf, wienWestbahnhof, { - when, laterThan: model.laterRef - }) - }) - - let earliestDep = Infinity, latestDep = -Infinity - for (let j of model) { - const dep = +new Date(j.departure) - if (dep < earliestDep) earliestDep = dep - else if (dep > latestDep) latestDep = dep - } - - const earlier = yield client.journeys(salzburgHbf, wienWestbahnhof, { - results: 3, - // todo: single journey ref? - earlierThan: model.earlierRef - }) - for (let j of earlier) { - t.ok(new Date(j.departure) < earliestDep) - } - - const later = yield client.journeys(salzburgHbf, wienWestbahnhof, { - results: 3, - // todo: single journey ref? - laterThan: model.laterRef - }) - for (let j of later) { - t.ok(new Date(j.departure) > latestDep) - } - t.end() })) -test('leg details for Wien Westbahnhof to München Hbf', co(function* (t) { +test('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: salzburgHbf, + toId: wienWestbahnhof, + when + }) + t.end() +})) + +test('trip details', co(function* (t) { const journeys = yield client.journeys(wienWestbahnhof, muenchenHbf, { - results: 1, when + results: 1, departure: when }) const p = journeys[0].legs[0] t.ok(p.id, 'precondition failed') t.ok(p.line.name, 'precondition failed') - const leg = yield client.journeyLeg(p.id, p.line.name, {when}) - - t.equal(typeof leg.id, 'string') - t.ok(leg.id) - - assertValidLine(t, leg.line) - - t.equal(typeof leg.direction, 'string') - t.ok(leg.direction) - - t.ok(Array.isArray(leg.passed)) - for (let passed of leg.passed) assertValidStopover(t, passed) + const trip = yield client.trip(p.id, p.line.name, {when}) + validate(t, trip, 'journeyLeg', 'trip') t.end() })) -test('departures at Salzburg Hbf', co(function* (t) { - const deps = yield client.departures(salzburgHbf, { - duration: 5, when +test('departures at Wien Leibenfrostgasse', co(function* (t) { + const wienLeibenfrostgasse = '1390469' + const ids = [ + wienLeibenfrostgasse, // station + '904029', // stop "Wien Leibenfrostgasse (Phorusgasse)s" + '904030' // stop "Wien Leibenfrostgasse (Ziegelofengasse)" + ] + + const deps = yield client.departures(wienLeibenfrostgasse, { + duration: 15, when }) - t.ok(Array.isArray(deps)) - for (let dep of deps) { - assertValidStation(t, dep.station) - assertValidStationProducts(t, dep.station.products) - // todo - // if (!(yield findStation(dep.station.id))) { - // console.error('unknown station', dep.station.id, dep.station.name) - // } - if (dep.station.products) assertValidProducts(t, dep.station.products) - assertValidWhen(t, dep.when, when) + validate(t, deps, 'departures', 'departures') + t.ok(deps.length > 0, 'must be >0 departures') + // todo: move into deps validator + t.deepEqual(deps, deps.sort((a, b) => t.when > b.when)) + + for (let i = 0; i < deps.length; i++) { + const dep = deps[i] + const msg = `deps[${i}].stop.id is invalid` + t.ok(ids.includes(dep.stop.id, msg)) } t.end() })) +test('departures with station object', co(function* (t) { + const deps = yield client.departures({ + type: 'station', + id: salzburgHbf, + name: 'Salzburg Hbf', + location: { + type: 'location', + latitude: 1.23, + longitude: 2.34 + } + }, {when}) + + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test('departures at Karlsplatz in direction of Pilgramgasse', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: wienKarlsplatz, + directionIds: [wienPilgramgasse, '905002'], + when, + validate + }) + t.end() +})) + +// todo: arrivals + test('nearby Salzburg Hbf', co(function* (t) { - const salzburgHbfPosition = { + const nearby = yield client.nearby({ type: 'location', longitude: 13.045604, latitude: 47.812851 - } - const nearby = yield client.nearby(salzburgHbfPosition, { - results: 2, distance: 400 + }, { + results: 5, distance: 400 }) - t.ok(Array.isArray(nearby)) - t.equal(nearby.length, 2) + validate(t, nearby, 'locations', 'nearby') + t.equal(nearby.length, 5) - assertIsSalzburgHbf(t, nearby[0]) - t.ok(nearby[0].distance >= 0) - t.ok(nearby[0].distance <= 100) - - for (let n of nearby) { - if (n.type === 'station') assertValidStation(t, n) - else assertValidLocation(t, n) - } + const s = nearby[0] + t.ok(s.id === '008100002' || s.id === '8100002', 'id should be 8100002') + t.equal(s.name, 'Salzburg Hbf') + t.ok(isRoughlyEqual(.0005, s.location.latitude, 47.812851)) + t.ok(isRoughlyEqual(.0005, s.location.longitude, 13.045604)) + t.ok(s.distance >= 0) + t.ok(s.distance <= 100) t.end() })) test('locations named Salzburg', co(function* (t) { const locations = yield client.locations('Salzburg', { - results: 10 + results: 20 }) - t.ok(Array.isArray(locations)) - t.ok(locations.length > 0) - t.ok(locations.length <= 10) + validate(t, locations, 'locations', 'locations') + t.ok(locations.length <= 20) - for (let l of locations) { - if (l.type === 'station') assertValidStation(t, l) - else assertValidLocation(t, l) - } - t.ok(locations.some(isSalzburgHbf)) + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) + t.ok(locations.find(s => s.id && s.name)) // POIs + t.ok(locations.some((s) => { + // todo: trim IDs + if (s.station) { + if (s.station.id === '008100002' || s.station.id === '8100002') return true + } + return s.id === '008100002' || s.id === '8100002' + })) t.end() })) -test('location', co(function* (t) { - const loc = yield client.location(grazHbf) +test('station', co(function* (t) { + const loc = yield client.station(wienRenngasse) - assertValidStation(t, loc) - t.equal(loc.id, grazHbf) + // todo: find a way to always get products from the API + // todo: cfg.stationProductsOptional option + const allProducts = products.reduce((acc, p) => (acc[p.id] = true, acc), {}) + const validateStation = createValidateStation(cfg) + const validate = createValidate(cfg, { + stop: (validate, s, name) => { + const withFakeProducts = Object.assign({products: allProducts}, s) + validateStop(validate, withFakeProducts, name) + }, + station: (validate, s, name) => { + const withFakeProducts = Object.assign({products: allProducts}, s) + validateStation(validate, withFakeProducts, name) + } + }) + validate(t, loc, ['stop', 'station'], 'station') + + t.equal(loc.id, wienRenngasse) t.end() })) test('radar Salzburg', co(function* (t) { - const vehicles = yield client.radar(47.827203, 13.001261, 47.773278, 13.07562, { - duration: 5 * 60, when + let vehicles = yield client.radar({ + north: 47.827203, + west: 13.001261, + south: 47.773278, + east: 13.07562 + }, { + duration: 5 * 60, + // when }) - t.ok(Array.isArray(vehicles)) - t.ok(vehicles.length > 0) - for (let v of vehicles) { + // todo: find a way to always get frames from the API + vehicles = vehicles.filter(m => m.frames && m.frames.length > 0) - // todo - // t.ok(findStation(v.direction)) - assertValidLine(t, v.line) + // todo: find a way to always get products from the API + // todo: cfg.stationProductsOptional option + const allProducts = products.reduce((acc, p) => (acc[p.id] = true, acc), {}) + const validateStation = createValidateStation(cfg) + const validate = createValidate(cfg, { + station: (validate, s, name) => { + const withFakeProducts = Object.assign({products: allProducts}, s) + validateStation(validate, withFakeProducts, name) + }, + line: validateLine + }) + validate(t, vehicles, 'movements', 'vehicles') - t.equal(typeof v.location.latitude, 'number') - t.ok(v.location.latitude <= 52, 'vehicle is too far away') - t.ok(v.location.latitude >= 42, 'vehicle is too far away') - t.equal(typeof v.location.longitude, 'number') - t.ok(v.location.longitude >= 10, 'vehicle is too far away') - t.ok(v.location.longitude <= 16, 'vehicle is too far away') - - t.ok(Array.isArray(v.nextStops)) - for (let st of v.nextStops) { - assertValidStopover(t, st, true) - - if (st.arrival) { - t.equal(typeof st.arrival, 'string') - const arr = +new Date(st.arrival) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, arr)) - } - if (st.departure) { - t.equal(typeof st.departure, 'string') - const dep = +new Date(st.departure) - t.ok(isRoughlyEqual(14 * hour, +when, dep)) - } - } - - t.ok(Array.isArray(v.frames)) - for (let f of v.frames) { - assertValidStation(t, f.origin, true) - // can contain stations in germany which don't have a products property, would break - // assertValidStationProducts(t, f.origin.products) - assertValidStation(t, f.destination, true) - // can contain stations in germany which don't have a products property, would break - // assertValidStationProducts(t, f.destination.products) - t.equal(typeof f.t, 'number') - } - } t.end() })) diff --git a/test/throttle.js b/test/throttle.js index cc849218..bbbdea87 100644 --- a/test/throttle.js +++ b/test/throttle.js @@ -5,6 +5,7 @@ const test = require('tape') const createThrottledClient = require('../throttle') const vbbProfile = require('../p/vbb') +const userAgent = 'public-transport/hafas-client:test' const spichernstr = '900000042101' test('throttle works', (t) => { @@ -15,7 +16,7 @@ test('throttle works', (t) => { } const mockProfile = Object.assign({}, vbbProfile, {transformReqBody}) - const client = createThrottledClient(mockProfile, 2, 1000) + const client = createThrottledClient(mockProfile, userAgent, 2, 1000) for (let i = 0; i < 10; i++) client.departures(spichernstr, {duration: 1}) t.plan(3) diff --git a/test/util.js b/test/util.js deleted file mode 100644 index fbd0d8c5..00000000 --- a/test/util.js +++ /dev/null @@ -1,158 +0,0 @@ -'use strict' - -const validateFptf = require('validate-fptf') -const isRoughlyEqual = require('is-roughly-equal') -const {DateTime} = require('luxon') -const isValidWGS84 = require('is-coordinates') - -const validateFptfWith = (t, item, allowedTypes, name) => { - try { - validateFptf.recurse(allowedTypes, item, name) - } catch (err) { - t.ifError(err) - } -} - -const assertValidStation = (t, s, coordsOptional = false) => { - validateFptfWith(t, s, ['station'], 'station') - - if (!coordsOptional || (s.location !== null && s.location !== undefined)) { - t.ok(s.location) - assertValidLocation(t, s.location, coordsOptional) - } -} - -const assertValidPoi = (t, p) => { - assertValidLocation(t, p, true) - - t.equal(typeof p.id, 'string') - t.equal(typeof p.name, 'string') - if (p.address !== null && p.address !== undefined) { - t.equal(typeof p.address, 'string') - t.ok(p.address) - } -} - -const assertValidAddress = (t, a) => { - assertValidLocation(t, a, true) - - t.equal(typeof a.address, 'string') -} - -const assertValidLocation = (t, l, coordsOptional = false) => { - t.equal(l.type, 'location') - if (l.name !== null && l.name !== undefined) { - t.equal(typeof l.name, 'string') - t.ok(l.name) - } - - if (l.address !== null && l.address !== undefined) { - t.equal(typeof l.address, 'string') - t.ok(l.address) - } - - const hasLatitude = l.latitude !== null && l.latitude !== undefined - const hasLongitude = l.longitude !== null && l.longitude !== undefined - if (!coordsOptional && hasLatitude) t.equal(typeof l.latitude, 'number') - if (!coordsOptional && hasLongitude) t.equal(typeof l.longitude, 'number') - if ((hasLongitude && !hasLatitude) || (hasLatitude && !hasLongitude)) { - t.fail('should have both .latitude and .longitude') - } - if (hasLatitude && hasLongitude) isValidWGS84([l.longitude, l.latitude]) - - if (!coordsOptional && l.altitude !== null && l.altitude !== undefined) { - t.equal(typeof l.altitude, 'number') - } -} - -const validLineModes = [ - 'train', 'bus', 'watercraft', 'taxi', 'gondola', 'aircraft', - 'car', 'bicycle', 'walking' -] - -const assertValidLine = (t, l) => { - validateFptfWith(t, l, ['line'], 'line') -} - -const isValidDateTime = (w) => { - return !Number.isNaN(+new Date(w)) -} - -const assertValidStopover = (t, s, coordsOptional = false) => { - if ('arrival' in s) t.ok(isValidDateTime(s.arrival)) - if ('departure' in s) t.ok(isValidDateTime(s.departure)) - if (s.arrivalDelay !== null && s.arrivalDelay !== undefined) { - t.equal(typeof s.arrivalDelay, 'number') - } - if (s.departureDelay !== null && s.departureDelay !== undefined) { - t.equal(typeof s.departureDelay, 'number') - } - if (!('arrival' in s) && !('departure' in s)) { - t.fail('stopover doesn\'t contain arrival or departure') - } - t.ok(s.station) - assertValidStation(t, s.station, coordsOptional) -} - -const hour = 60 * 60 * 1000 -const week = 7 * 24 * hour - -// next Monday 10 am -const createWhen = (timezone, locale) => { - return DateTime.fromMillis(Date.now(), { - zone: timezone, - locale, - }).startOf('week').plus({weeks: 1, hours: 10}).toJSDate() -} -const isValidWhen = (actual, expected) => { - const ts = +new Date(actual) - if (Number.isNaN(ts)) return false - return isRoughlyEqual(12 * hour, +expected, ts) -} - -const assertValidWhen = (t, actual, expected) => { - t.ok(isValidWhen(actual, expected), 'invalid when') -} - -const assertValidTicket = (t, ti) => { - t.strictEqual(typeof ti.name, 'string') - t.ok(ti.name.length > 0) - if (ti.price !== null) { - t.strictEqual(typeof ti.price, 'number') - t.ok(ti.price > 0) - } - if (ti.amount !== null) { - t.strictEqual(typeof ti.amount, 'number') - t.ok(ti.amount > 0) - } - - if ('bike' in ti) t.strictEqual(typeof ti.bike, 'boolean') - if ('shortTrip' in ti) t.strictEqual(typeof ti.shortTrip, 'boolean') - if ('group' in ti) t.strictEqual(typeof ti.group, 'boolean') - if ('fullDay' in ti) t.strictEqual(typeof ti.fullDay, 'boolean') - - if (ti.tariff !== null) { - t.strictEqual(typeof ti.tariff, 'string') - t.ok(ti.tariff.length > 0) - } - if (ti.coverage !== null) { - t.strictEqual(typeof ti.coverage, 'string') - t.ok(ti.coverage.length > 0) - } - if (ti.variant !== null) { - t.strictEqual(typeof ti.variant, 'string') - t.ok(ti.variant.length > 0) - } -} - -module.exports = { - assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidLine, - isValidDateTime, - assertValidStopover, - hour, createWhen, isValidWhen, assertValidWhen, - assertValidTicket -} diff --git a/test/validate-line-without-mode.js b/test/validate-line-without-mode.js deleted file mode 100644 index 8f916348..00000000 --- a/test/validate-line-without-mode.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict' - -const a = require('assert') -const is = require('@sindresorhus/is') - -const validateItem = require('validate-fptf/lib/item') -const validateReference = require('validate-fptf/lib/reference') - -// todo: this is copied code, DRY this up! -// see https://github.com/public-transport/validate-fptf/blob/373b4847ec9668c4a9ec9b0dbd50f8a70ffbe127/line.js -const validateLineWithoutMode = (validate, line, name) => { - validateItem(line, name) - - a.strictEqual(line.type, 'line', name + '.type must be `line`') - - validateReference(line.id, name + '.id') - - a.strictEqual(typeof line.name, 'string', name + '.name must be a string') - a.ok(line.name.length > 0, name + '.name can\'t be empty') - - // skipping line validation here - // see https://github.com/public-transport/hafas-client/issues/8#issuecomment-355839965 - if (is.undefined(line.mode) || is.null(line.mode)) { - console.error(`ÖBB: Missing \`mode\` for line ${line.name} (at ${name}).`) - } - - if (!is.undefined(line.subMode)) { - a.fail(name + '.subMode is reserved an should not be used for now') - } - - // todo: routes - - if (!is.null(line.operator) && !is.undefined(line.operator)) { - validate(['operator'], line.operator, name + '.operator') - } -} - -module.exports = validateLineWithoutMode diff --git a/test/vbb.js b/test/vbb.js index 5ac5a7f8..bd418508 100644 --- a/test/vbb.js +++ b/test/vbb.js @@ -1,125 +1,76 @@ 'use strict' -const a = require('assert') -const isRoughlyEqual = require('is-roughly-equal') -const stations = require('vbb-stations-autocomplete') const tapePromise = require('tape-promise').default const tape = require('tape') -const shorten = require('vbb-short-station-name') -const co = require('./co') +const co = require('./lib/co') const createClient = require('..') const vbbProfile = require('../p/vbb') +const products = require('../p/vbb/products') const { - assertValidStation: _assertValidStation, - assertValidPoi, - assertValidAddress, - assertValidLocation, - assertValidLine: _assertValidLine, - assertValidStopover, - hour, createWhen, - assertValidWhen, - assertValidTicket -} = require('./util') + cfg, + validateStation, + validateLine, + validateJourneyLeg, + validateDeparture, + validateMovement +} = require('./lib/vbb-bvg-validators') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testDeparturesInDirection = require('./lib/departures-in-direction') +const testDeparturesWithoutRelatedStations = require('./lib/departures-without-related-stations') +const testArrivals = require('./lib/arrivals') +const testJourneysWithDetour = require('./lib/journeys-with-detour') -const when = createWhen('Europe/Berlin', 'de-DE') +const when = cfg.when -const assertValidStation = (t, s, coordsOptional = false) => { - _assertValidStation(t, s, coordsOptional) - t.equal(s.name, shorten(s.name)) -} - -const assertValidStationProducts = (t, p) => { - t.ok(p) - t.equal(typeof p.suburban, 'boolean') - t.equal(typeof p.subway, 'boolean') - t.equal(typeof p.tram, 'boolean') - t.equal(typeof p.bus, 'boolean') - t.equal(typeof p.ferry, 'boolean') - t.equal(typeof p.express, 'boolean') - t.equal(typeof p.regional, 'boolean') -} - -const assertValidLine = (t, l) => { - _assertValidLine(t, l) - if (l.symbol !== null) t.equal(typeof l.symbol, 'string') - if (l.nr !== null) t.equal(typeof l.nr, 'number') - if (l.metro !== null) t.equal(typeof l.metro, 'boolean') - if (l.express !== null) t.equal(typeof l.express, 'boolean') - if (l.night !== null) t.equal(typeof l.night, 'boolean') -} - -const findStation = (query) => stations(query, true, false)[0] +const validate = createValidate(cfg, { + station: validateStation, + line: validateLine, + journeyLeg: validateJourneyLeg, + departure: validateDeparture, + movement: validateMovement +}) const test = tapePromise(tape) -const client = createClient(vbbProfile) +const client = createClient(vbbProfile, 'public-transport/hafas-client:test') const amrumerStr = '900000009101' const spichernstr = '900000042101' const bismarckstr = '900000024201' +const westhafen = '900000001201' +const wedding = '900000009104' +const württembergallee = '900000026153' -test('journeys – station to station', co(function* (t) { - const journeys = yield client.journeys(spichernstr, amrumerStr, { - results: 3, when, passedStations: true +test('journeys – Spichernstr. to Bismarckstr.', co(function* (t) { + const journeys = yield client.journeys(spichernstr, bismarckstr, { + results: 3, + departure: when, + stopovers: true }) - t.ok(Array.isArray(journeys)) - t.strictEqual(journeys.length, 3) + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: spichernstr, + toId: bismarckstr + }) + // todo: find a journey where there ticket info is always available - for (let journey of journeys) { - t.equal(journey.type, 'journey') - - assertValidStation(t, journey.origin) - assertValidStationProducts(t, journey.origin.products) - t.ok(journey.origin.name.indexOf('(Berlin)') === -1) - t.strictEqual(journey.origin.id, spichernstr) - assertValidWhen(t, journey.departure, when) - - assertValidStation(t, journey.destination) - assertValidStationProducts(t, journey.destination.products) - t.strictEqual(journey.destination.id, amrumerStr) - assertValidWhen(t, journey.arrival, when) - - t.ok(Array.isArray(journey.legs)) - t.strictEqual(journey.legs.length, 1) - const leg = journey.legs[0] - - t.equal(typeof leg.id, 'string') - t.ok(leg.id) - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - t.ok(leg.origin.name.indexOf('(Berlin)') === -1) - t.strictEqual(leg.origin.id, spichernstr) - assertValidWhen(t, leg.departure, when) - - assertValidStation(t, leg.destination) - assertValidStationProducts(t, leg.destination.products) - t.strictEqual(leg.destination.id, amrumerStr) - assertValidWhen(t, leg.arrival, when) - - assertValidLine(t, leg.line) - if (!findStation(leg.direction)) { - const err = new Error('unknown direction: ' + leg.direction) - err.stack = err.stack.split('\n').slice(0, 2).join('\n') - console.error(err) - } - t.ok(leg.direction.indexOf('(Berlin)') === -1) - - t.ok(Array.isArray(leg.passed)) - for (let passed of leg.passed) assertValidStopover(t, passed) - - // todo: find a journey where there ticket info is always available - if (journey.tickets) { - t.ok(Array.isArray(journey.tickets)) - for (let ticket of journey.tickets) assertValidTicket(t, ticket) - } - } t.end() })) test('journeys – only subway', co(function* (t) { const journeys = yield client.journeys(spichernstr, bismarckstr, { - results: 20, when, + results: 20, + departure: when, products: { suburban: false, subway: true, @@ -131,259 +82,160 @@ test('journeys – only subway', co(function* (t) { } }) - t.ok(Array.isArray(journeys)) + validate(t, journeys, 'journeys', 'journeys') t.ok(journeys.length > 1) + for (let i = 0; i < journeys.length; i++) { + const journey = journeys[i] + for (let j = 0; j < journey.legs.length; j++) { + const leg = journey.legs[j] - for (let journey of journeys) { - for (let leg of journey.legs) { + const name = `journeys[${i}].legs[${i}].line` if (leg.line) { - assertValidLine(t, leg.line) - t.equal(leg.line.mode, 'train') - t.equal(leg.line.product, 'subway') + t.equal(leg.line.mode, 'train', name + '.mode is invalid') + t.equal(leg.line.product, 'subway', name + '.product is invalid') } + t.ok(journey.legs.some(l => l.line), name + '.legs has no subway leg') } } + t.end() })) -test('journeys – fails with no product', co(function* (t) { - try { - client.journeys(spichernstr, bismarckstr, { - when, - products: { - suburban: false, - subway: false, - tram: false, - bus: false, - ferry: false, - express: false, - regional: false - } - }) - // silence rejections, we're only interested in exceptions - .catch(() => {}) - } catch (err) { - t.ok(err, 'error thrown') - t.end() - } -})) +// todo: journeys – with arrival time -test('journeys – with arrival time', co(function* (t) { - const journeys = yield client.journeys(spichernstr, bismarckstr, { - results: 3, +test('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: spichernstr, + toId: bismarckstr, when, - whenRepresents: 'arrival' + products }) - - for (let j of journeys) { - const arr = +new Date(j.arrival) - t.ok(arr <= when) - } - t.end() -})) +}) test('earlier/later journeys', co(function* (t) { - const model = yield client.journeys(spichernstr, bismarckstr, { - results: 3, when + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: spichernstr, + toId: bismarckstr }) - t.equal(typeof model.earlierRef, 'string') - t.ok(model.earlierRef) - t.equal(typeof model.laterRef, 'string') - t.ok(model.laterRef) - - // when and earlierThan/laterThan should be mutually exclusive - t.throws(() => { - client.journeys(spichernstr, bismarckstr, { - when, earlierThan: model.earlierRef - }) - }) - t.throws(() => { - client.journeys(spichernstr, bismarckstr, { - when, laterThan: model.laterRef - }) - }) - - let earliestDep = Infinity, latestDep = -Infinity - for (let j of model) { - const dep = +new Date(j.departure) - if (dep < earliestDep) earliestDep = dep - else if (dep > latestDep) latestDep = dep - } - - const earlier = yield client.journeys(spichernstr, bismarckstr, { - results: 3, - // todo: single journey ref? - earlierThan: model.earlierRef - }) - for (let j of earlier) { - t.ok(new Date(j.departure) < earliestDep) - } - - const later = yield client.journeys(spichernstr, bismarckstr, { - results: 3, - // todo: single journey ref? - laterThan: model.laterRef - }) - for (let j of later) { - t.ok(new Date(j.departure) > latestDep) - } - t.end() })) -test('journey leg details', co(function* (t) { +test('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: spichernstr, + toId: bismarckstr, + when + }) + t.end() +})) + +test('trip details', co(function* (t) { const journeys = yield client.journeys(spichernstr, amrumerStr, { - results: 1, when + results: 1, departure: when }) const p = journeys[0].legs[0] t.ok(p.id, 'precondition failed') t.ok(p.line.name, 'precondition failed') - const leg = yield client.journeyLeg(p.id, p.line.name, {when}) - - t.equal(typeof leg.id, 'string') - t.ok(leg.id) - - assertValidLine(t, leg.line) - - t.equal(typeof leg.direction, 'string') - t.ok(leg.direction) - - t.ok(Array.isArray(leg.passed)) - for (let passed of leg.passed) assertValidStopover(t, passed) + const trip = yield client.trip(p.id, p.line.name, {when}) + validate(t, trip, 'journeyLeg', 'trip') t.end() })) - - test('journeys – station to address', co(function* (t) { - const journeys = yield client.journeys(spichernstr, { + const torfstr = { type: 'location', - address: 'Torfstr. 17, Berlin', - latitude: 52.541797, longitude: 13.350042 - }, {results: 1, when}) - - t.ok(Array.isArray(journeys)) - t.strictEqual(journeys.length, 1) - const journey = journeys[0] - const leg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - assertValidWhen(t, leg.departure, when) - - const dest = leg.destination - assertValidAddress(t, dest) - t.strictEqual(dest.address, '13353 Berlin-Wedding, Torfstr. 17') - t.ok(isRoughlyEqual(.0001, dest.latitude, 52.541797)) - t.ok(isRoughlyEqual(.0001, dest.longitude, 13.350042)) - assertValidWhen(t, leg.arrival, when) + address: '13353 Berlin-Wedding, Torfstr. 17', + latitude: 52.541797, + longitude: 13.350042 + } + const journeys = yield client.journeys(spichernstr, torfstr, { + results: 3, + departure: when + }) + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: spichernstr, + to: torfstr + }) t.end() })) - - test('journeys – station to POI', co(function* (t) { - const journeys = yield client.journeys(spichernstr, { + const atze = { type: 'location', id: '900980720', name: 'Berlin, Atze Musiktheater für Kinder', - latitude: 52.543333, longitude: 13.351686 - }, {results: 1, when}) - - t.ok(Array.isArray(journeys)) - t.strictEqual(journeys.length, 1) - const journey = journeys[0] - const leg = journey.legs[journey.legs.length - 1] - - assertValidStation(t, leg.origin) - assertValidStationProducts(t, leg.origin.products) - assertValidWhen(t, leg.departure, when) - - const dest = leg.destination - assertValidPoi(t, dest) - t.strictEqual(dest.id, '900980720') - t.strictEqual(dest.name, 'Berlin, Atze Musiktheater für Kinder') - t.ok(isRoughlyEqual(.0001, dest.latitude, 52.543333)) - t.ok(isRoughlyEqual(.0001, dest.longitude, 13.351686)) - assertValidWhen(t, leg.arrival, when) + latitude: 52.543333, + longitude: 13.351686 + } + const journeys = yield client.journeys(spichernstr, atze, { + results: 3, + departure: when + }) + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: spichernstr, + to: atze + }) t.end() })) test('journeys: via works – with detour', co(function* (t) { // Going from Westhafen to Wedding via Württembergalle without detour // is currently impossible. We check if the routing engine computes a detour. - const westhafen = '900000001201' - const wedding = '900000009104' - const württembergallee = '900000026153' - const [journey] = yield client.journeys(westhafen, wedding, { + const journeys = yield client.journeys(westhafen, wedding, { via: württembergallee, results: 1, - when, - passedStations: true + departure: when, + stopovers: true }) - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === württembergallee)) - t.ok(l, 'Württembergalle is not being passed') - + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: [württembergallee] + }) t.end() })) -test('journeys: via works – without detour', co(function* (t) { - // When going from Ruhleben to Zoo via Kastanienallee, there is *no need* - // to change trains / no need for a "detour". - const ruhleben = '900000025202' - const zoo = '900000023201' - const kastanienallee = '900000020152' - const [journey] = yield client.journeys(ruhleben, zoo, { - via: kastanienallee, - results: 1, - when, - passedStations: true - }) - - t.ok(journey) - - const l = journey.legs.some(l => l.passed && l.passed.some(p => p.station.id === kastanienallee)) - t.ok(l, 'Kastanienallee is not being passed') - - t.end() -})) +// todo: without detour test test('departures', co(function* (t) { - const deps = yield client.departures(spichernstr, {duration: 5, when}) + const departures = yield client.departures(spichernstr, { + duration: 5, when + }) - t.ok(Array.isArray(deps)) - t.deepEqual(deps, deps.sort((a, b) => t.when > b.when)) - for (let dep of deps) { - t.equal(typeof dep.journeyId, 'string') - t.ok(dep.journeyId) - - t.equal(dep.station.name, 'U Spichernstr.') - assertValidStation(t, dep.station) - assertValidStationProducts(t, dep.station.products) - t.strictEqual(dep.station.id, spichernstr) - - assertValidWhen(t, dep.when, when) - if (!findStation(dep.direction)) { - const err = new Error('unknown direction: ' + dep.direction) - err.stack = err.stack.split('\n').slice(0, 2).join('\n') - console.error(err) - } - assertValidLine(t, dep.line) - } + yield testDepartures({ + test: t, + departures, + validate, + id: spichernstr + }) t.end() })) test('departures with station object', co(function* (t) { - yield client.departures({ + const deps = yield client.departures({ type: 'station', id: spichernstr, name: 'U Spichernstr', @@ -394,7 +246,20 @@ test('departures with station object', co(function* (t) { } }, {when}) - t.ok('did not fail') + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test('departures at Spichernstr. in direction of Westhafen', co(function* (t) { + yield testDeparturesInDirection({ + test: t, + fetchDepartures: client.departures, + fetchTrip: client.trip, + id: spichernstr, + directionIds: [westhafen], + when, + validate + }) t.end() })) @@ -402,13 +267,39 @@ test('departures at 7-digit station', co(function* (t) { const eisenach = '8010097' // see derhuerst/vbb-hafas#22 yield client.departures(eisenach, {when}) t.pass('did not fail') - t.end() })) +test('departures without related stations', co(function* (t) { + yield testDeparturesWithoutRelatedStations({ + test: t, + fetchDepartures: client.departures, + id: '900000024101', // Charlottenburg + when, + products: {bus: false, suburban: false, regional: false}, + linesOfRelatedStations: ['U7'] + }) + t.end() +})) +test('arrivals', co(function* (t) { + const arrivals = yield client.arrivals(spichernstr, { + duration: 5, when + }) + + yield testArrivals({ + test: t, + arrivals, + validate, + id: spichernstr + }) + t.end() +})) test('nearby', co(function* (t) { + const berlinerStr = '900000044201' + const landhausstr = '900000043252' + // Berliner Str./Bundesallee const nearby = yield client.nearby({ type: 'location', @@ -416,18 +307,14 @@ test('nearby', co(function* (t) { longitude: 13.3310411 }, {distance: 200}) - t.ok(Array.isArray(nearby)) - for (let n of nearby) { - if (n.type === 'station') assertValidStation(t, n) - else assertValidLocation(t, n, false) - } + validate(t, nearby, 'locations', 'nearby') - t.equal(nearby[0].id, '900000044201') + t.equal(nearby[0].id, berlinerStr) t.equal(nearby[0].name, 'U Berliner Str.') t.ok(nearby[0].distance > 0) t.ok(nearby[0].distance < 100) - t.equal(nearby[1].id, '900000043252') + t.equal(nearby[1].id, landhausstr) t.equal(nearby[1].name, 'Landhausstr.') t.ok(nearby[1].distance > 100) t.ok(nearby[1].distance < 200) @@ -435,93 +322,38 @@ test('nearby', co(function* (t) { t.end() })) - - test('locations', co(function* (t) { const locations = yield client.locations('Alexanderplatz', {results: 20}) - t.ok(Array.isArray(locations)) - t.ok(locations.length > 0) + validate(t, locations, 'locations', 'locations') t.ok(locations.length <= 20) - for (let l of locations) { - if (l.type === 'station') assertValidStation(t, l) - else assertValidLocation(t, l) - } - t.ok(locations.find(s => s.type === 'station')) + + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) t.ok(locations.find(s => s.id && s.name)) // POIs t.ok(locations.find(s => !s.name && s.address)) // addresses t.end() })) -test('location', co(function* (t) { - const loc = yield client.location(spichernstr) +test('station', co(function* (t) { + const s = yield client.station(spichernstr) - assertValidStation(t, loc) - t.equal(loc.id, spichernstr) - - t.ok(Array.isArray(loc.lines)) - if (Array.isArray(loc.lines)) { - for (let line of loc.lines) assertValidLine(t, line) - } + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, spichernstr) t.end() })) - - test('radar', co(function* (t) { - const vehicles = yield client.radar(52.52411, 13.41002, 52.51942, 13.41709, { + const vehicles = yield client.radar({ + north: 52.52411, + west: 13.41002, + south: 52.51942, + east: 13.41709 + }, { duration: 5 * 60, when }) - t.ok(Array.isArray(vehicles)) - t.ok(vehicles.length > 0) - for (let v of vehicles) { - - if (!findStation(v.direction)) { - const err = new Error('unknown direction: ' + v.direction) - err.stack = err.stack.split('\n').slice(0, 2).join('\n') - console.error(err) - } - assertValidLine(t, v.line) - - t.equal(typeof v.location.latitude, 'number') - t.ok(v.location.latitude <= 55, 'vehicle is too far away') - t.ok(v.location.latitude >= 45, 'vehicle is too far away') - t.equal(typeof v.location.longitude, 'number') - t.ok(v.location.longitude >= 9, 'vehicle is too far away') - t.ok(v.location.longitude <= 15, 'vehicle is too far away') - - t.ok(Array.isArray(v.nextStops)) - for (let st of v.nextStops) { - assertValidStopover(t, st, true) - t.strictEqual(st.station.name.indexOf('(Berlin)'), -1) - - if (st.arrival) { - t.equal(typeof st.arrival, 'string') - const arr = +new Date(st.arrival) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, arr)) - } - if (st.departure) { - t.equal(typeof st.departure, 'string') - const dep = +new Date(st.departure) - // note that this can be an ICE train - t.ok(isRoughlyEqual(14 * hour, +when, dep)) - } - } - - t.ok(Array.isArray(v.frames)) - for (let f of v.frames) { - assertValidStation(t, f.origin, true) - assertValidStationProducts(t, f.origin.products) - t.strictEqual(f.origin.name.indexOf('(Berlin)'), -1) - assertValidStation(t, f.destination, true) - assertValidStationProducts(t, f.destination.products) - t.strictEqual(f.destination.name.indexOf('(Berlin)'), -1) - t.equal(typeof f.t, 'number') - } - } + validate(t, vehicles, 'movements', 'vehicles') t.end() })) diff --git a/test/vbn.js b/test/vbn.js new file mode 100644 index 00000000..6b6bacea --- /dev/null +++ b/test/vbn.js @@ -0,0 +1,270 @@ +'use strict' + +const tapePromise = require('tape-promise').default +const tape = require('tape') +const isRoughlyEqual = require('is-roughly-equal') + +const {createWhen} = require('./lib/util') +const co = require('./lib/co') +const createClient = require('..') +const vbnProfile = require('../p/vbn') +const products = require('../p/vbn/products') +const createValidate = require('./lib/validate-fptf-with') +const testJourneysStationToStation = require('./lib/journeys-station-to-station') +const testJourneysStationToAddress = require('./lib/journeys-station-to-address') +const testJourneysStationToPoi = require('./lib/journeys-station-to-poi') +const testEarlierLaterJourneys = require('./lib/earlier-later-journeys') +const testRefreshJourney = require('./lib/refresh-journey') +const journeysFailsWithNoProduct = require('./lib/journeys-fails-with-no-product') +const testDepartures = require('./lib/departures') +const testArrivals = require('./lib/arrivals') +const testJourneysWithDetour = require('./lib/journeys-with-detour') + +const when = createWhen('Europe/Berlin', 'de-DE') + +const cfg = { + when, + stationCoordsOptional: false, + products +} + +const validate = createValidate(cfg, {}) + +const test = tapePromise(tape) +const client = createClient(vbnProfile, 'public-transport/hafas-client:test') + +const bremenHbf = '8000050' +const bremerhavenHbf = '8000051' + +test.only('journeys – Bremen Hbf to Bremerhaven Hbf', co(function* (t) { + const journeys = yield client.journeys(bremenHbf, bremerhavenHbf, { + results: 3, + departure: when, + stopovers: true + }) + + yield testJourneysStationToStation({ + test: t, + journeys, + validate, + fromId: bremenHbf, + toId: bremerhavenHbf + }) + t.end() +})) + +// todo: journeys, only one product + +test.skip('journeys – fails with no product', (t) => { + journeysFailsWithNoProduct({ + test: t, + fetchJourneys: client.journeys, + fromId: bremenHbf, + toId: bremerhavenHbf, + when, + products + }) + t.end() +}) + +test.skip('Magdeburg Hbf to 39104 Magdeburg, Sternstr. 10', co(function*(t) { + const sternStr = { + type: 'location', + address: 'Magdeburg - Altenstadt, Sternstraße 10', + latitude: 52.118414, + longitude: 11.422332 + } + + const journeys = yield client.journeys(bremenHbf, sternStr, { + results: 3, + departure: when + }) + + yield testJourneysStationToAddress({ + test: t, + journeys, + validate, + fromId: bremenHbf, + to: sternStr + }) + t.end() +})) + +test.skip('Magdeburg Hbf to Kloster Unser Lieben Frauen', co(function*(t) { + const kloster = { + type: 'location', + id: '970012223', + name: 'Magdeburg, Kloster Unser Lieben Frauen (Denkmal)', + latitude: 52.127601, + longitude: 11.636437 + } + const journeys = yield client.journeys(bremenHbf, kloster, { + results: 3, + departure: when + }) + + yield testJourneysStationToPoi({ + test: t, + journeys, + validate, + fromId: bremenHbf, + to: kloster + }) + t.end() +})) + +test.skip('journeys: via works – with detour', co(function* (t) { + // Going from Magdeburg, Hasselbachplatz (Sternstr.) (Tram/Bus) to Stendal + // via Dessau without detour is currently impossible. We check if the routing + // engine computes a detour. + const journeys = yield client.journeys(hasselbachplatzSternstrasse, stendal, { + via: dessau, + results: 1, + departure: when, + stopovers: true + }) + + yield testJourneysWithDetour({ + test: t, + journeys, + validate, + detourIds: ['8010077', dessau] // todo: trim IDs + }) + t.end() +})) + +// todo: without detour + +test.skip('earlier/later journeys', co(function* (t) { + yield testEarlierLaterJourneys({ + test: t, + fetchJourneys: client.journeys, + validate, + fromId: bremenHbf, + toId: bremerhavenHbf + }) + + t.end() +})) + +test.skip('refreshJourney', co(function* (t) { + yield testRefreshJourney({ + test: t, + fetchJourneys: client.journeys, + refreshJourney: client.refreshJourney, + validate, + fromId: bremenHbf, + toId: bremerhavenHbf, + when + }) + t.end() +})) + +test.skip('trip details', co(function* (t) { + const journeys = yield client.journeys(bremenHbf, bremerhavenHbf, { + results: 1, departure: when + }) + + const p = journeys[0].legs[0] + t.ok(p.tripId, 'precondition failed') + t.ok(p.line.name, 'precondition failed') + const trip = yield client.trip(p.tripId, p.line.name, {when}) + + validate(t, trip, 'journeyLeg', 'trip') + t.end() +})) + +test.skip('departures at Magdeburg Leiterstr.', co(function*(t) { + const departures = yield client.departures(leiterstr, { + duration: 5, when + }) + + yield testDepartures({ + test: t, + departures, + validate, + id: leiterstr + }) + t.end() +})) + +test.skip('departures with station object', co(function* (t) { + const deps = yield client.departures({ + type: 'station', + id: bremenHbf, + name: 'Magdeburg Hbf', + location: { + type: 'location', + latitude: 1.23, + longitude: 2.34 + } + }, {when}) + + validate(t, deps, 'departures', 'departures') + t.end() +})) + +test.skip('arrivals at Magdeburg Leiterstr.', co(function*(t) { + const arrivals = yield client.arrivals(leiterstr, { + duration: 5, when + }) + + yield testArrivals({ + test: t, + arrivals, + validate, + id: leiterstr + }) + t.end() +})) + +// todo: nearby + +test.skip('locations named Magdeburg', co(function*(t) { + const locations = yield client.locations('Magdeburg', { + results: 20 + }) + + validate(t, locations, 'locations', 'locations') + t.ok(locations.length <= 20) + + t.ok(locations.find(s => s.type === 'stop' || s.type === 'station')) + t.ok(locations.find(s => s.id && s.name)) // POIs + t.ok(locations.some((loc) => { + // todo: trim IDs + if (l.station) { + if (l.station.id === '008010224' || l.station.id === bremenHbf) return true + } + return l.id === '008010224' || l.id === bremenHbf + })) + + t.end() +})) + +test.skip('station Magdeburg-Buckau', co(function* (t) { + const s = yield client.station(bremerhavenHbf) + + validate(t, s, ['stop', 'station'], 'station') + t.equal(s.id, bremerhavenHbf) + + t.end() +})) + +test.skip('radar', co(function* (t) { + const vehicles = yield client.radar({ + north: 52.148364, + west: 11.600826, + south: 52.108486, + east: 11.651451 + }, { + duration: 5 * 60, when, results: 10 + }) + + const customCfg = Object.assign({}, cfg, { + stationCoordsOptional: true, // see #28 + }) + const validate = createValidate(customCfg, {}) + validate(t, vehicles, 'movements', 'vehicles') + + t.end() +})) diff --git a/throttle.js b/throttle.js index 99fb3cc0..995e855e 100644 --- a/throttle.js +++ b/throttle.js @@ -5,9 +5,9 @@ const throttle = require('p-throttle') const request = require('./lib/request') const createClient = require('.') -const createThrottledClient = (profile, limit = 5, interval = 1000) => { +const createThrottledClient = (profile, userAgent, limit = 5, interval = 1000) => { const throttledRequest = throttle(request, limit, interval) - return createClient(profile, throttledRequest) + return createClient(profile, userAgent, throttledRequest) } module.exports = createThrottledClient