Merge remote-tracking branch 'upstream/main' into feat/bikeshare-detroit-mogo

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
jeffblakely committed 2026-10-14 09:03:13 -04:00
commit 52f96708e6
7 files changed
+607 -3

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
- GBFS v3 station names use English translations when available, then the first
usable translation or short name. Empty or malformed names no longer appear as
`[object Object]` or interrupt station loading; legacy string names remain
supported in the globe and bike-share tools. Thanks to
[Kristóf Kékesi](https://github.com/KristofKekesi) (#61).
- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo
(HIBike) to the GBFS bikeshare registry, the layer's first stations in
Michigan.
+13
View File
@@ -1092,6 +1092,19 @@ normal fallback chain. The existing declared media size ceiling remains 64 MiB.
## GBFS upstream bounds
Station information is normalized in `src/sources/gbfsStations.js` for both the
globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized
String arrays regardless of the reported feed version. Nonempty English text
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
usable translation. An unusable `name` falls back to `short_name` with the same
rules; if neither supplies text, the station remains available with an empty name
(the globe uses its station-ID label and the tool returns `null`). Malformed
translation entries do not interrupt loading of the other stations. This covers
station names, not complete GBFS v3 support.
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
station selection, keyboard dismissal, mobile cards and bike-share tool against
either a dev or preview server (`--url` selects the target).
The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
streaming. The 12-second deadline includes reading the body, and rejected or
+3 -1
View File
@@ -979,5 +979,7 @@
"src/tools/views.test.mjs",
"src/app/embed.test.mjs",
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs"
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs"
]
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Keyless station-name regression against the real globe and bike-share tool.
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
* GBFS documents are synthetic; no operator feed or provider key is required.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const urlIndex = process.argv.indexOf('--url');
const appUrl =
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
const shots = path.resolve('qa-shots/gbfs-names');
fs.mkdirSync(shots, { recursive: true });
const information = {
version: '3.0',
data: {
stations: [
{
station_id: 'english',
name: [
{ language: 'hu', text: 'Magyar tér' },
{ language: 'en', text: 'QA English Square' },
],
},
{
station_id: 'short',
name: [null, { language: 'en', text: ' ' }],
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
},
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
{ station_id: 'legacy', name: ' QA Legacy Station ' },
{
station_id: 'fallback',
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
},
].map((station, index) => ({
...station,
lat: 30.2681 + index * 0.001,
lon: -97.7469 + index * 0.001,
capacity: 13,
})),
},
};
const status = {
data: {
stations: information.data.stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
num_docks_available: 9,
is_renting: true,
is_returning: true,
})),
},
};
const browser = await puppeteer.launch({
headless: 'new',
...(process.env.PUPPETEER_EXECUTABLE_PATH
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
: {}),
args:
process.platform === 'darwin'
? ['--use-angle=metal']
: ['--use-gl=angle', '--use-angle=swiftshader'],
});
let checks = 0;
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name);
checks++;
console.log(`[PASS] ${name}`);
};
async function open(mode = 'normal') {
const page = await browser.newPage();
await page.bringToFront();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.setViewport({ width: 1440, height: 900 });
await page.evaluateOnNewDocument(
({ information, status, mode }) => {
localStorage.clear();
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
window.__qaPainted = [];
const paint = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
if (
String(text).startsWith('QA ') ||
String(text) === 'Station unnamed'
) {
window.__qaPainted.push(String(text));
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
}
return paint.call(this, text, ...args);
};
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = String(
typeof input === 'string' || input instanceof URL ? input : input.url,
);
if (url.includes('/api/gbfs/')) {
if (mode === 'failure') return new Response('{}', { status: 503 });
const payload =
mode === 'empty'
? { data: { stations: [] } }
: decodeURIComponent(url).includes('station_information')
? information
: status;
return new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/flights')) return new Response('{"states":[]}');
if (url.includes('/api/military')) return new Response('{"ac":[]}');
if (url.includes('/api/vessels'))
return new Response('{"connected":false,"vessels":[]}');
return original(input, init);
};
},
{ information, status, mode },
);
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
{ timeout: 60000 },
);
await page.waitForFunction(
() =>
!document.getElementById('loading-screen') ||
(document.getElementById('loading-screen').classList.contains('hidden') &&
getComputedStyle(document.getElementById('loading-screen')).opacity ===
'0'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { dataManager, viewer } = window.__godsEyeView;
for (const [id, layer] of dataManager.layers) {
if (layer.enabled)
await dataManager.setEnabled(id, false, { origin: 'user' });
}
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
window.__godsEyeView.requestRender('gbfs-name-qa');
});
return { page, errors };
}
try {
const { page, errors } = await open();
await page.waitForFunction(
() =>
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
.count === 5,
{ timeout: 15000 },
);
const records = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords(),
);
check(
'the layer retains all five stations and normalized names',
records.map((r) => r.name),
[
'QA English Square',
'QA Short Name',
null,
'QA Legacy Station',
'QA Magyar Station',
],
);
const tool = await page.evaluate(() =>
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
}),
);
check('the real bike-share tool succeeds', tool.ok, true);
check(
'the tool preserves localized, short, unnamed, legacy and non-English names',
tool.data.rows.map((r) => r.name).sort(),
[
null,
'QA English Square',
'QA Short Name',
'QA Legacy Station',
'QA Magyar Station',
].sort(),
);
check(
'availability remains attached to every station',
tool.data.rows.every(
(r) => r.bikes_available === 4 && r.docks_available === 9,
),
true,
);
const clickPoint = await page.evaluate(() => {
window.__qaPainted = [];
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const point = viewer.scene.cartesianToCanvasCoordinates(
Cartesian3.fromDegrees(-97.7469, 30.2681),
);
const rect = viewer.scene.canvas.getBoundingClientRect();
return { x: rect.left + point.x, y: rect.top + point.y };
});
await page.mouse.click(clickPoint.x, clickPoint.y);
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'a real pointer click selects the station with its localized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.keyboard.press('Escape');
for (const [id, title] of [
['english', 'QA English Square'],
['short', 'QA Short Name'],
['unnamed', 'Station unnamed'],
]) {
await page.evaluate((id) => {
window.__qaPainted = [];
const app = window.__godsEyeView;
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
app.requestRender('gbfs-name-selection-qa');
}, id);
await page.waitForFunction(
(title) => window.__qaPainted.includes(title),
{ timeout: 10000 },
title,
);
check(
`the selected ${id} card paints the expected title`,
await page.evaluate((title) => window.__qaPainted.includes(title), title),
true,
);
await page.screenshot({ path: path.join(shots, `${id}.png`) });
await page.keyboard.press('Escape');
await page.waitForFunction(() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.every((record) => !record.skipLabel),
);
check(
`Escape clears the ${id} card`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
),
0,
);
}
await page.setViewport({ width: 390, height: 844 });
await page.evaluate(() => {
window.__qaPainted = [];
const app = window.__godsEyeView;
// Use the supported clean view to inspect the mobile card clear of panels.
app.styleManager.toggleCleanView(true);
const Cartesian3 = app.viewer.camera.position.constructor;
app.viewer.camera.cancelFlight();
app.viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
app.dataManager.layers
.get('bikeshare')
.module._selectBikeshareStationForTest('austin-capmetro:english');
app.requestRender('gbfs-name-mobile-qa');
});
await page.waitForFunction(
() => window.__qaPainted.includes('QA English Square'),
{ timeout: 10000 },
);
check(
'the mobile selected card paints its normalized name',
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
true,
);
await page.screenshot({ path: path.join(shots, 'mobile.png') });
await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
origin: 'user',
}),
);
check(
'disabling clears selected cards and analyst records',
await page.evaluate(() => ({
count: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getAnalystRecords().length,
selected: window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getDetectableObjects()
.filter((record) => record.skipLabel).length,
})),
{ count: 0, selected: 0 },
);
check('normal fixtures produce no page exceptions', errors, []);
await page.close();
for (const mode of ['empty', 'failure']) {
const { page, errors } = await open(mode);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().loading,
);
check(
`${mode} leaves no rendered stations`,
await page.evaluate(
() =>
window.__godsEyeView.dataManager.layers
.get('bikeshare')
.module.getStats().count,
),
0,
);
check(`${mode} produces no page exceptions`, errors, []);
await page.close();
}
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
} finally {
await browser.close();
}
+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);
});
+28
View File
@@ -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,