Files
gods-eye-view/src/data/railFeeds.test.mjs
T
Sameh Khamis ace2160002 fix(rail): Montpellier tram speeds are converted from km/h
TaM publishes km/h where GTFS-Realtime asks m/s, and the Transit layer's
entry for the same feed already sets speedUnit: 'km/h'. The Rail entry did
not, so the proxy passed the readings through and the card multiplied them
by 3.6 again: a tram at 30 km/h read about 108 km/h. A registry test now
requires a Rail feed that Transit also reads to declare the same unit.
2026-11-11 14:54:01 -08:00

492 lines
19 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
RAIL_CATEGORIES,
RAIL_ENABLED_FEEDS,
RAIL_FEED_ID_PATTERN,
RAIL_FEED_REGISTRY,
RAIL_MODES,
RAIL_POSITION_SOURCES,
classifyTrain,
getRailFeed,
getRegisteredRailFeed,
isRailFeedPollable,
publicRailCatalog,
railEnabledFeeds,
railFeedPositions,
railFeedsInRange,
} from './railFeeds.js';
const isHttps = (url) => new URL(url).protocol === 'https:';
test('every registered feed is complete, https and uniquely named', () => {
const ids = new Set();
for (const feed of RAIL_FEED_REGISTRY) {
assert.match(feed.id, RAIL_FEED_ID_PATTERN, feed.id);
assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`);
ids.add(feed.id);
const positions = railFeedPositions(feed);
assert.ok(RAIL_POSITION_SOURCES.includes(positions), feed.id);
assert.ok(isHttps(feed.url), feed.id);
for (const url of [feed.tripUpdatesUrl, feed.staticGtfsUrl])
if (url !== undefined) assert.ok(isHttps(url), `${feed.id} ${url}`);
assert.ok(
['gtfs-rt', 'irish-rail-xml', 'siri-vm'].includes(feed.format),
feed.id,
);
if (feed.format === 'irish-rail-xml')
assert.equal(positions, 'event', `${feed.id} positions are events`);
for (const key of ['name', 'operator', 'region', 'license', 'attribution'])
assert.ok(feed[key], `${feed.id} has ${key}`);
assert.ok(URL.canParse(feed.licenseUrl), `${feed.id} licence url`);
assert.equal(typeof feed.defaultEnabled, 'boolean', feed.id);
assert.equal(typeof feed.terms?.quote, 'string', feed.id);
assert.equal(typeof feed.classify, 'function', feed.id);
assert.ok(
Number.isFinite(feed.center.lat) && Number.isFinite(feed.center.lon),
);
assert.ok(feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000, feed.id);
for (const ms of [feed.pollIntervalMs, feed.tripUpdatesIntervalMs])
if (ms !== undefined) assert.ok(ms >= 5_000 && ms <= 300_000, feed.id);
}
});
// The fields the registry documents (railFeeds.js, RAIL_FEED_REGISTRY):
// an entry carries nothing else, so no feed asks for a key, a sign-in or a
// timetable to place its trains.
const REGISTRY_FIELDS = new Set([
'id',
'name',
'operator',
'region',
'center',
'loadRadiusKm',
'format',
'url',
'positions',
'headers',
'headerClock',
'speedUnit',
'pollIntervalMs',
'tripUpdatesUrl',
'tripUpdatesIntervalMs',
'maxBodyBytes',
'limits',
'routesFrom',
'positionsByTrainNumber',
'staticGtfsUrl',
'staticGtfsEntry',
'staticHeaders',
'staticRouteTypes',
'staticTripEnds',
'staticSimplifyM',
'staticRouteRename',
'staticTripRename',
'license',
'licenseUrl',
'attribution',
'defaultEnabled',
'terms',
'classify',
]);
test('every feed reports live positions: GPS or signalling events', () => {
for (const feed of RAIL_FEED_REGISTRY) {
assert.notEqual(feed.positions, 'schedule', feed.id);
assert.ok(
['gps', 'event'].includes(railFeedPositions(feed)),
`${feed.id} positions`,
);
assert.equal(typeof feed.url, 'string', `${feed.id} has a live feed`);
}
for (const entry of publicRailCatalog())
assert.ok(['gps', 'event'].includes(entry.positions), entry.id);
});
/** Query parameter names that carry a key, a token or a signature. */
const ACCESS_PARAM =
/^(api[-_]?key|appid|app[-_]?key|key|token|access[-_]?token|auth|sig|signature|subscription[-_]?key)$/i;
/** `name=value` of each access parameter in a URL (values decoded). */
function accessParams(url) {
if (!url) return [];
return [...new URL(url).searchParams]
.filter(([name]) => ACCESS_PARAM.test(name))
.map(([name, value]) => `${name}=${value}`);
}
// The only access parameter allowed, a fixed public value the publisher
// prints in the published link for everyone, the same for every user and not
// a credential of the deployer's: Bordeaux Métropole's TBM `apiKey` (on the
// dataset page).
const PUBLISHED_ACCESS_VALUES = new Set([
'tbm-bordeaux apiKey=opendata-bordeaux-metropole-flux-gtfs-rt',
]);
test('an access parameter in a feed URL is found, whatever its spelling', () => {
for (const url of [
'https://x.test/a?apiKey=1',
'https://x.test/a?api_key=1',
'https://x.test/a?APIKEY=1',
'https://x.test/a?f=pb&key=1',
'https://x.test/a?token=1',
'https://x.test/a?access_token=1',
'https://x.test/a?sr=b&sig=abc%3D',
'https://x.test/a?signature=1',
'https://x.test/a?subscription-key=1',
])
assert.equal(accessParams(url).length, 1, url);
assert.deepEqual(accessParams('https://x.test/a?format=pb&route=key'), []);
assert.deepEqual(accessParams(null), []);
// Each published value is still in use, so the allow-list cannot go stale.
const used = new Set();
for (const feed of RAIL_FEED_REGISTRY)
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
for (const param of accessParams(url)) used.add(`${feed.id} ${param}`);
assert.deepEqual([...used].sort(), [...PUBLISHED_ACCESS_VALUES].sort());
});
test('every feed is on, credited with its licence and terms, and fetched as published', () => {
const ids = new Set();
for (const feed of RAIL_FEED_REGISTRY) {
assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`);
ids.add(feed.id);
assert.equal(feed.defaultEnabled, true, `${feed.id} is on`);
for (const field of Object.keys(feed))
assert.ok(REGISTRY_FIELDS.has(field), `${feed.id}: ${field}`);
assert.ok(typeof feed.license === 'string' && feed.license, feed.id);
assert.ok(isHttps(feed.licenseUrl), `${feed.id} licence url`);
assert.ok(
typeof feed.attribution === 'string' && feed.attribution,
feed.id,
);
assert.equal(typeof feed.terms?.quote, 'string', `${feed.id} terms`);
assert.ok(feed.terms.quote || feed.terms.note, `${feed.id} terms basis`);
// No URL carries an access parameter, but for the fixed public values
// their publishers print for everyone (PUBLISHED_ACCESS_VALUES).
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
for (const param of accessParams(url))
assert.ok(
PUBLISHED_ACCESS_VALUES.has(`${feed.id} ${param}`),
`${feed.id} ${url}`,
);
}
assert.equal(ids.size, RAIL_FEED_REGISTRY.length);
assert.deepEqual(
RAIL_ENABLED_FEEDS.map((feed) => feed.id),
RAIL_FEED_REGISTRY.map((feed) => feed.id),
);
});
test('a feed is switched on only when its terms were read and quoted', () => {
for (const feed of RAIL_ENABLED_FEEDS) {
assert.ok(
feed.terms.quote || feed.terms.note,
`${feed.id} is enabled without a recorded basis`,
);
}
// Tri-Rail states no licence; it is used without one by project owner
// decision, and the terms say so.
const triRail = getRailFeed('tri-rail');
assert.ok(triRail, 'tri-rail is pollable');
assert.ok(
triRail.terms.note.startsWith(
'Used without a published licence (project owner decision, 2026-11-10)',
),
);
assert.match(triRail.license, /^No licence stated/);
});
test('only enabled, well-formed ids resolve to a pollable feed', () => {
assert.equal(getRailFeed('mta-metro-north')?.id, 'mta-metro-north');
for (const id of ['', 'Nope', '../x', 'a', null, 42, 'unknown-feed'])
assert.equal(getRailFeed(id), null, String(id));
const mbta = getRegisteredRailFeed('mbta-rail');
assert.equal(isRailFeedPollable(mbta), true);
assert.equal(isRailFeedPollable({ ...mbta, defaultEnabled: false }), false);
assert.deepEqual(
railEnabledFeeds().map((entry) => entry.id),
RAIL_ENABLED_FEEDS.map((entry) => entry.id),
);
});
test('classifiers keep trains and drop buses in mixed feeds', () => {
const kind = (id, route) => classifyTrain(getRegisteredRailFeed(id), route);
assert.deepEqual(kind('mbta-rail', 'CR-Worcester'), {
category: 'local',
mode: 'rail',
});
assert.equal(kind('mbta-rail', 'Red').mode, 'metro');
assert.equal(kind('mbta-rail', 'Green-E').mode, 'tram');
assert.equal(kind('mbta-rail', 'Mattapan').category, 'transit');
assert.equal(kind('mbta-rail', '1'), null);
assert.equal(kind('mbta-rail', 'Boat-F1'), null);
assert.equal(kind('mbta-rail', 'Shuttle-Generic'), null);
assert.equal(kind('mbta-rail', null), null);
assert.equal(kind('rtd-denver-rail', 'A').category, 'local');
assert.equal(kind('rtd-denver-rail', '113G').category, 'local');
assert.equal(kind('rtd-denver-rail', '117N').category, 'local');
assert.equal(kind('rtd-denver-rail', '101E').category, 'transit');
assert.equal(kind('rtd-denver-rail', '107R').category, 'transit');
assert.equal(kind('rtd-denver-rail', '15'), null);
assert.equal(kind('rtd-denver-rail', '15L'), null);
assert.equal(kind('capmetro-rail', '550').category, 'local');
assert.equal(kind('capmetro-rail', '801'), null);
// Northstar's old route id is a bus now and must not come back as a train.
assert.equal(kind('metrotransit-rail', '888'), null);
assert.equal(kind('metrotransit-rail', '902').mode, 'tram');
assert.equal(kind('septa-trolley', 'M1').mode, 'metro');
assert.equal(kind('septa-trolley', '17'), null);
assert.equal(kind('translink-seq-rail', 'BRCA-5').category, 'local');
assert.equal(kind('translink-seq-rail', '61-3'), null);
assert.equal(kind('adelaide-metro', 'SEAFRD').category, 'local');
assert.equal(kind('adelaide-metro', 'GLNELG').mode, 'tram');
assert.equal(kind('adelaide-metro', 'J1A'), null);
assert.equal(kind('mta-metro-north', null).category, 'local');
});
test('Irish Rail: DART and Commuter local, InterCity by province, Enterprise international', () => {
const kind = (code, route) =>
classifyTrain(getRegisteredRailFeed('irish-rail'), route, {
tripId: code,
})?.category ?? null;
assert.equal(kind('E244', 'Howth - Bray'), 'local');
assert.equal(kind('D822', 'Grand Canal Dock - Drogheda'), 'local');
assert.equal(kind('P571', 'Midleton - Cork'), 'local');
assert.equal(kind('A216', 'Dublin Heuston - Cork'), 'national');
assert.equal(kind('A720', 'Dublin Heuston - Galway'), 'national');
assert.equal(kind('B701', 'Galway - Dublin Heuston'), 'national');
assert.equal(kind('A308', 'Mallow - Tralee'), 'regional', 'both Munster');
assert.equal(kind('A606', 'Dublin Connolly - Rosslare Europort'), 'regional');
assert.equal(kind('A118', 'Dublin Connolly - Belfast'), 'international');
assert.equal(kind('A131', 'Belfast - Dublin Connolly'), 'international');
assert.equal(kind('A999', 'Nowhere - Elsewhere'), 'national');
assert.equal(kind('A999', null), 'national');
assert.equal(kind('not a code', 'Howth - Bray'), null);
assert.equal(kind(null, 'Howth - Bray'), null);
});
test('a classifier answer outside the known lists is treated as not a train', () => {
const feed = (answer) => ({ classify: () => answer });
assert.equal(
classifyTrain(feed({ category: 'express', mode: 'rail' })),
null,
);
assert.equal(classifyTrain(feed({ category: 'local', mode: 'bus' })), null);
assert.equal(classifyTrain(feed(null)), null);
assert.equal(classifyTrain({}), null);
assert.equal(classifyTrain(null), null);
for (const feed of RAIL_FEED_REGISTRY) {
for (const route of [
'CR-Lowell',
'A',
'550',
'901',
'T1',
'BRCA-5',
'GAW',
null,
]) {
const kind = feed.classify(route);
if (!kind) continue;
assert.ok(RAIL_CATEGORIES.includes(kind.category), `${feed.id} ${route}`);
assert.ok(RAIL_MODES.includes(kind.mode), `${feed.id} ${route}`);
}
}
});
test('coverage selection returns enabled feeds near the point, nearest first', () => {
const nyc = railFeedsInRange(40.75, -73.99).map((feed) => feed.id);
assert.ok(nyc.includes('mta-metro-north') && nyc.includes('mta-lirr'));
assert.deepEqual(railFeedsInRange(0, -150), []);
assert.deepEqual(railFeedsInRange(Number.NaN, 0), []);
const brisbane = railFeedsInRange(-27.47, 153.02);
assert.equal(brisbane[0].id, 'translink-seq-rail');
// The browser passes the server's catalog, which lists a feed that reads
// its routes from a schedule pack only once that pack is built.
const madrid = (built) =>
railFeedsInRange(
40.42,
-3.7,
0,
publicRailCatalog({ schedulePackBuilt: () => built }),
).map((feed) => feed.id);
assert.ok(
!madrid(false).includes('renfe-larga-distancia'),
'no pack: not offered',
);
assert.ok(madrid(false).includes('renfe-cercanias'), 'needs no pack');
assert.ok(madrid(true).includes('renfe-larga-distancia'));
assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, null), []);
assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, [{ id: 'x' }]), []);
});
test('the public catalog exposes coverage and credit but never upstream URLs', () => {
const catalog = publicRailCatalog();
assert.equal(catalog.length, RAIL_ENABLED_FEEDS.length);
for (const entry of catalog) {
assert.equal(entry.url, undefined);
assert.equal(entry.tripUpdatesUrl, undefined);
assert.equal(entry.headers, undefined);
assert.equal(entry.classify, undefined);
assert.ok(entry.attribution && entry.licenseUrl);
assert.ok(RAIL_POSITION_SOURCES.includes(entry.positions));
assert.equal(typeof entry.tripUpdates, 'boolean');
}
const byId = new Map(catalog.map((entry) => [entry.id, entry]));
assert.equal(byId.get('irish-rail').positions, 'event');
assert.equal(byId.get('irish-rail').tripUpdates, false);
assert.equal(byId.get('mbta-rail').positions, 'gps');
assert.equal(byId.get('mbta-rail').tripUpdates, true);
// No upstream URL reaches the browser, by any field but the licence link
// (an access point's page can be both the licence and the download).
const text = JSON.stringify(
catalog.map((entry) => ({ ...entry, licenseUrl: null })),
);
for (const feed of RAIL_FEED_REGISTRY)
for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl])
if (url) assert.ok(!text.includes(url), `${feed.id} ${url}`);
});
test('Finland: commuter local, H/HDM regional, long distance national, Haaparanta international', () => {
const kind = (route) =>
classifyTrain(getRegisteredRailFeed('fintraffic-vr'), route)?.category ??
null;
assert.equal(kind('EPO_HKI_E_HL_10'), 'local');
assert.equal(kind('SK_ÄHT_480_HDM_10'), 'regional');
assert.equal(kind('TPE_PRI_1_H_10'), 'regional');
for (const route of [
'HKI_ROI_23_IC_10',
'HKI_OL_45_S_10',
'HKI_KOK_101_P_10',
'HKI_KLI_265_PYO_10',
])
assert.equal(kind(route), 'national', route);
assert.equal(kind('OL_HPA_407_IC_10'), 'international');
assert.equal(kind('HPA_OL_408_IC_10'), 'international');
for (const route of ['HKI_TKU_1_T_10', 'IC 23', null])
assert.equal(kind(route), null, String(route));
});
test('Renfe: products and the autonomous-community rule', () => {
const feed = getRegisteredRailFeed('renfe-larga-distancia');
const kind = (route) => classifyTrain(feed, route)?.category ?? null;
for (const product of ['AVE', 'AVLO', 'ALVIA', 'EUROMED', 'Intercity'])
for (const scope of ['S', 'X'])
assert.equal(kind(`${product}~${scope}~1`), 'national', product);
for (const product of ['MD', 'REGIONAL', 'REG.EXP.', 'PROXIMDAD', 'AVANT']) {
assert.equal(kind(`${product}~S~1`), 'regional', product);
assert.equal(kind(`${product}~X~1`), 'national', product);
}
assert.equal(kind('AVE INT~X~1'), 'international');
assert.equal(kind('TRENCELTA~X~1'), 'international');
assert.equal(kind('MD~B~1'), 'international', 'an end abroad');
assert.equal(kind('BUS~S~1'), null);
assert.equal(kind('1700037606GL023'), null, 'an unscoped operator id');
assert.equal(kind(null), null);
// The rename the path build applies to routes.txt rows.
const places = {
17000: { lat: 40.47, lon: -3.68 },
37606: { lat: 38.89, lon: -6.98 },
18000: { lat: 40.41, lon: -3.7 },
87089: { lat: 43.3, lon: 5.38 },
};
const helpers = {
stop: (id) => places[id] ?? null,
stateAt: (lat) =>
lat > 43
? { country: 'FR', state: 'FR-13' }
: lat < 39.5
? { country: 'ES', state: 'ES-EX' }
: { country: 'ES', state: 'ES-MD' },
};
const rename = (id, product) =>
feed.staticRouteRename(
{ route_id: id, route_short_name: product },
helpers,
);
assert.equal(rename('1700037606GL023', 'MD'), 'MD~X~1700037606GL023');
assert.equal(
rename('1700018000GL001', 'REGIONAL'),
'REGIONAL~S~1700018000GL001',
);
assert.equal(rename('1700087089AV016', 'AVE'), 'AVE~B~1700087089AV016');
assert.equal(
rename('1700099999GL001', 'MD'),
'MD~X~1700099999GL001',
'unplaced',
);
assert.equal(rename('short', 'MD'), null);
assert.equal(
classifyTrain(getRegisteredRailFeed('renfe-cercanias'), null)?.category,
'local',
);
});
test('Norway: by line and county, replacement buses dropped, Flåmsbana a tourist line', () => {
const kind = (id, route, tripId = 'VYG:ServiceJourney:536_906310-R') =>
classifyTrain(getRegisteredRailFeed(id), route, { tripId });
assert.equal(kind('entur-vy', 'VYG:Line:F4')?.category, 'national');
assert.equal(kind('entur-vy', 'VYG:Line:RE11')?.category, 'national');
assert.equal(kind('entur-vy', 'VYG:Line:L1')?.category, 'local');
assert.equal(kind('entur-vy', 'VYG:Line:RE20')?.category, 'international');
assert.equal(kind('entur-vy', 'VYG:Line:F1')?.category, 'international');
assert.equal(kind('entur-vy', 'VYG:Line:R55')?.category, 'regional');
const flam = kind('entur-vy', 'VYG:Line:R45');
assert.equal(flam.category, 'regional');
assert.equal(flam.tourist, true);
assert.equal(kind('entur-go-ahead', 'GOA:Line:50')?.category, 'national');
assert.equal(kind('entur-go-ahead', 'GOA:Line:59')?.category, 'local');
assert.equal(kind('entur-flytoget', 'FLT:Line:FLY1')?.category, 'local');
assert.equal(kind('entur-sj-nord', 'SJN:Line:21')?.category, 'national');
assert.equal(kind('entur-sj-nord', 'SJN:Line:72')?.category, 'international');
assert.equal(kind('entur-sj-nord', 'SJN:Line:26')?.category, 'regional');
assert.equal(
kind('entur-vy', 'VYG:Line:RE11', 'VYG:ServiceJourney:BUS-836_761763-R'),
null,
'a replacement bus on a train line',
);
assert.equal(kind('entur-vy', 'VYG:Line:X99'), null);
assert.match(getRegisteredRailFeed('entur-sj-nord').terms.note, /missing/);
// Its positions come from SIRI VM, the timetable still gives its stops.
assert.equal(
railFeedPositions(getRegisteredRailFeed('entur-sj-nord')),
'gps',
);
assert.equal(getRegisteredRailFeed('entur-sj-nord').format, 'siri-vm');
});
test('feeds that read routes from their schedule pack can build one', () => {
for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.routesFrom)) {
assert.equal(feed.routesFrom, 'schedule', feed.id);
assert.ok(isHttps(feed.staticGtfsUrl), feed.id);
}
for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.headerClock))
assert.equal(feed.headerClock, 'untrusted', feed.id);
// Identify-yourself headers never carry a person.
for (const feed of RAIL_FEED_REGISTRY)
for (const value of Object.values({
...(feed.headers || {}),
...(feed.staticHeaders || {}),
}))
assert.ok(!/@/.test(value), feed.id);
});
test('a feed Transit also reads declares the same speed unit', async () => {
const { TRANSIT_FEED_REGISTRY } = await import('./transitFeeds.js');
const transitByUrl = new Map(
TRANSIT_FEED_REGISTRY.map((feed) => [feed.url, feed]),
);
let shared = 0;
for (const feed of RAIL_FEED_REGISTRY) {
const transit = transitByUrl.get(feed.url);
if (!transit) continue;
shared += 1;
assert.equal(feed.speedUnit, transit.speedUnit, feed.id);
}
assert.ok(shared > 0);
assert.equal(getRegisteredRailFeed('tam-montpellier').speedUnit, 'km/h');
});