Merge current main into HAL-522 review candidate

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 16:53:13 +02:00
commit 4240d04642
61 files changed
+10819 -1251

No files matched your search

+22 -2
View File
@@ -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
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
// Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
+199
View File
@@ -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);
});