mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into pr/kartaview
This commit is contained in:
7 files changed
+607
-3
No files matched your search
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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).
|
||||||
|
|
||||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||||
|
|
||||||
|
|||||||
@@ -1092,6 +1092,19 @@ normal fallback chain. The existing declared media size ceiling remains 64 MiB.
|
|||||||
|
|
||||||
## GBFS upstream bounds
|
## 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
|
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
|
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
|
streaming. The 12-second deadline includes reading the body, and rejected or
|
||||||
|
|||||||
@@ -996,5 +996,7 @@
|
|||||||
"src/tools/views.test.mjs",
|
"src/tools/views.test.mjs",
|
||||||
"src/app/embed.test.mjs",
|
"src/app/embed.test.mjs",
|
||||||
"src/tools/globePanel.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"
|
||||||
]
|
]
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
|
|||||||
return [];
|
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.
|
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||||
* Handles field-name variations across different GBFS providers
|
* Handles field-name variations across different GBFS providers
|
||||||
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
|
|||||||
const stationMap = new Map();
|
const stationMap = new Map();
|
||||||
|
|
||||||
for (const raw of stations) {
|
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 stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||||
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
|
|||||||
|
|
||||||
stationMap.set(stationId, {
|
stationMap.set(stationId, {
|
||||||
stationId,
|
stationId,
|
||||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
name: stationName(raw?.name) || stationName(raw?.short_name),
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
capacity: toNonNegativeInteger(raw?.capacity),
|
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);
|
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 () => {
|
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||||
const none = composeCatalog({
|
const none = composeCatalog({
|
||||||
tools: coreTools,
|
tools: coreTools,
|
||||||
|
|||||||
Reference in new issue
Block a user