mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Harden shared GBFS station-name parsing and add regression coverage
This commit is contained in:
1 parent
16e0f35c74
commit
ddd932f0a7
7 files changed
+605
-14
No files matched your search
+21
-13
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
|
||||
function stationName(value) {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (!Array.isArray(value)) return '';
|
||||
|
||||
let fallback = '';
|
||||
for (const translation of value) {
|
||||
if (typeof translation?.text !== 'string') continue;
|
||||
const text = translation.text.trim();
|
||||
if (!text) continue;
|
||||
if (!fallback) fallback = text;
|
||||
if (
|
||||
typeof translation.language === 'string' &&
|
||||
/^en(?:-|$)/i.test(translation.language.trim())
|
||||
)
|
||||
return text;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
@@ -69,21 +89,9 @@ export function parseStationInformation(payload) {
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
let name;
|
||||
if (payload?.version >= 3) {
|
||||
const handleLocalizedString = (array) =>
|
||||
array.find((localizedString) =>
|
||||
localizedString.language.toLowerCase() === 'en') || array[0];
|
||||
name = String(
|
||||
handleLocalizedString(raw?.name).text ||
|
||||
handleLocalizedString(raw?.short_name).text || '').trim();
|
||||
} else {
|
||||
name = String(raw?.name || raw?.short_name || '').trim();
|
||||
}
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name,
|
||||
name: stationName(raw?.name) || stationName(raw?.short_name),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseStationInformation } from './gbfsStations.js';
|
||||
|
||||
const station = (fields = {}) => ({
|
||||
station_id: 's1',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
...fields,
|
||||
});
|
||||
const parseName = (fields, version) =>
|
||||
parseStationInformation({
|
||||
version,
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1').name;
|
||||
|
||||
test('GBFS v2 station names remain trimmed strings', () => {
|
||||
assert.equal(
|
||||
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('GBFS v3 station names prefer English over the first translation', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
{
|
||||
name: [
|
||||
{ language: 'hu', text: 'Köztársaság tér' },
|
||||
{ language: 'en', text: ' Republic Square ' },
|
||||
],
|
||||
},
|
||||
'3.0',
|
||||
),
|
||||
'Republic Square',
|
||||
);
|
||||
});
|
||||
|
||||
test('English preference accepts case differences and regional language tags', () => {
|
||||
for (const language of ['EN', 'en-GB', 'En-US']) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Hungarian' },
|
||||
{ language, text: 'English' },
|
||||
],
|
||||
}),
|
||||
'English',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('without usable English text the first usable translation wins', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [
|
||||
null,
|
||||
{ language: 'en', text: ' ' },
|
||||
{ language: 'en', text: 42 },
|
||||
{ language: 'hu', text: ' Első ' },
|
||||
{ language: 'fr', text: 'Deuxième' },
|
||||
],
|
||||
}),
|
||||
'Első',
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed language fields cannot abort translation selection', () => {
|
||||
const name = [
|
||||
null,
|
||||
false,
|
||||
'bad',
|
||||
{},
|
||||
{ language: 42, text: 'Fallback' },
|
||||
{ language: { unexpected: true }, text: 'Other' },
|
||||
{ language: 'en', text: 'English' },
|
||||
];
|
||||
assert.equal(parseName({ name }), 'English');
|
||||
assert.equal(
|
||||
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
|
||||
'Usable text without a locale',
|
||||
);
|
||||
});
|
||||
|
||||
test('a usable name takes precedence over an English short name', () => {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name: [{ language: 'hu', text: 'Full name' }],
|
||||
short_name: [{ language: 'en', text: 'Short name' }],
|
||||
}),
|
||||
'Full name',
|
||||
);
|
||||
});
|
||||
|
||||
test('missing, empty and malformed names fall back to localized short_name', () => {
|
||||
for (const name of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
' ',
|
||||
[],
|
||||
{},
|
||||
0,
|
||||
42,
|
||||
false,
|
||||
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(
|
||||
parseName({
|
||||
name,
|
||||
short_name: [
|
||||
{ language: 'hu', text: 'Rövid' },
|
||||
{ language: 'en', text: ' Short ' },
|
||||
],
|
||||
}),
|
||||
'Short',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('localized names can fall back to legacy short_name strings', () => {
|
||||
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
|
||||
});
|
||||
|
||||
test('no usable name leaves the station available with an empty name', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
[],
|
||||
{},
|
||||
42,
|
||||
true,
|
||||
[null, { text: false }, { language: 'en', text: ' ' }],
|
||||
]) {
|
||||
assert.equal(parseName({ name: value, short_name: value }), '');
|
||||
}
|
||||
});
|
||||
|
||||
test('name shape is normalized even when a feed omits or misstates its version', () => {
|
||||
const name = [{ language: 'en', text: 'Republic Square' }];
|
||||
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
|
||||
assert.equal(parseName({ name }, version), 'Republic Square');
|
||||
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
|
||||
}
|
||||
});
|
||||
|
||||
test('bad names do not lose valid stations or their metadata', () => {
|
||||
const information = parseStationInformation({
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({ station_id: 'bad', name: [null, {}] }),
|
||||
station({
|
||||
station_id: 'short',
|
||||
name: [],
|
||||
short_name: [{ language: 'en', text: 'Short' }],
|
||||
}),
|
||||
station({
|
||||
station_id: 'good',
|
||||
name: [{ language: 'en', text: 'Republic Square' }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(information.size, 3);
|
||||
assert.equal(information.get('bad').name, '');
|
||||
assert.equal(information.get('short').name, 'Short');
|
||||
assert.deepEqual(information.get('good'), {
|
||||
stationId: 'good',
|
||||
name: 'Republic Square',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
isInstalled: true,
|
||||
isRenting: true,
|
||||
isReturning: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizing a localized name leaves the feed document unchanged', () => {
|
||||
const payload = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: [
|
||||
station({
|
||||
name: [
|
||||
{ language: 'hu', text: 'Magyar' },
|
||||
{ language: 'en', text: ' English ' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
const original = structuredClone(payload);
|
||||
parseStationInformation(payload);
|
||||
assert.deepEqual(payload, original);
|
||||
});
|
||||
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
|
||||
assert.equal(result.data.rows[1].capacity, 10);
|
||||
});
|
||||
|
||||
test('bike share returns localized names without losing stations with malformed names', async () => {
|
||||
const informationUrl = 'https://gbfs.example/austin/station_information.json';
|
||||
const information = structuredClone(documents[informationUrl]);
|
||||
information.version = '3.0';
|
||||
information.data.stations[0].name = [
|
||||
{ language: 'hu', text: 'Magyar név' },
|
||||
{ language: 'en', text: 'Congress & 2nd' },
|
||||
];
|
||||
information.data.stations[2].name = [null, { language: 42, text: [] }];
|
||||
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
...source,
|
||||
getStations: (url) =>
|
||||
url === informationUrl ? information : source.getStations(url),
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', { area: downtown });
|
||||
assert.equal(result.data.total, 2);
|
||||
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
|
||||
assert.equal(result.data.rows[0].bikes_available, 4);
|
||||
assert.equal(result.data.rows[1].name, null);
|
||||
assert.doesNotMatch(result.summary, /unavailable/);
|
||||
});
|
||||
|
||||
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
Reference in new issue
Block a user