mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
PBSC adds the bikes a station accepts in the geofenced area around its docks to `capacity`, and reports that part as `geofenced_capacity`, which is not a GBFS field. Bilbao Bizi publishes 39 docks as capacity 839 with geofenced_capacity 800, so its stations would read as nearly empty: red, drawn at maximum size, and carded "39 avail · 0 docks · 839 cap". BiciMAD (671 of 678 stations, about twice the docks) and Dbizi (11 of 70) do the same. The shared parser now reads `capacity − geofenced_capacity` when the extension is present and no larger than the total, so the layer, its colour ratio and get_bike_share all see the docks. No registered system changes: none of the 52 publishes a geofenced capacity above 0 (live feeds, 2026-10-17, all 13,397 capacities identical). Co-Authored-By: Claude Opus 5.5 <[email protected]>
255 lines
6.9 KiB
JavaScript
255 lines
6.9 KiB
JavaScript
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;
|
|
|
|
const parseCapacity = (fields) =>
|
|
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
|
.capacity;
|
|
|
|
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
|
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
|
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
|
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
|
|
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
|
|
});
|
|
|
|
test('capacity is read as given without a usable geofenced_capacity', () => {
|
|
assert.equal(parseCapacity({ capacity: 27 }), 27);
|
|
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
|
|
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
|
|
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
|
|
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
|
|
});
|
|
|
|
test('GBFS v2 station names remain trimmed strings', () => {
|
|
assert.equal(
|
|
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
|
'Republic Square',
|
|
);
|
|
});
|
|
|
|
test('underscores in station names read as word breaks', () => {
|
|
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
|
|
for (const [published, shown] of [
|
|
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
|
|
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
|
|
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
|
|
['Plaza__Mayor_ ', 'Plaza Mayor'],
|
|
]) {
|
|
assert.equal(parseName({ name: published }), shown);
|
|
}
|
|
});
|
|
|
|
test('spacing without underscores stays as the operator published it', () => {
|
|
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
|
|
});
|
|
|
|
test('a name of only underscores falls back to short_name, then to empty', () => {
|
|
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
|
|
assert.equal(parseName({ name: '__', short_name: '_' }), '');
|
|
});
|
|
|
|
test('underscores are read the same in GBFS v3 localized names', () => {
|
|
assert.equal(
|
|
parseName(
|
|
{
|
|
name: [
|
|
{ language: 'es', text: '_' },
|
|
{ language: 'en', text: '_COLON_I' },
|
|
],
|
|
},
|
|
'3.0',
|
|
),
|
|
'COLON I',
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|