mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(outlines): restore voice outlines without public Overpass
use bundled place boundaries and shared OpenFreeMap tiles before a guarded Nominatim fallback preserve multipart shapes, holes and cancellation, and keep pending annotations visible while ground sampling finishes across frames keep non-Latin name distinctions when selecting provider results, and retain an outline anchor only when it lies in filled geometry
This commit is contained in:
commit
d4434aa6d7
1489 files changed
+196442
-12353
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { findNaturalRegion, pointInRing } from '../data/naturalEarthRegions.js';
|
||||
|
||||
/**
|
||||
* The unqualified Alps ask names the European range, not a same-named peak
|
||||
* or local POI. Keep this correction exact and Alps-only: other ranges and
|
||||
* geographic qualifiers retain the caller's configured geocoder semantics.
|
||||
*/
|
||||
export function createAlpsGeocoder({ lookupRegion = findNaturalRegion } = {}) {
|
||||
return {
|
||||
async geocode(query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const name = String(query ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!/^(?:the )?alps(?: mountains)?$/i.test(name))
|
||||
return { place: null, answered: true };
|
||||
|
||||
const region = await lookupRegion('Alps');
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
region?.name !== 'Alps' ||
|
||||
region.kind !== 'natural' ||
|
||||
!Array.isArray(region.polygons) ||
|
||||
region.polygons.length === 0 ||
|
||||
!region.polygons.every(
|
||||
(ring) =>
|
||||
Array.isArray(ring) &&
|
||||
ring.length >= 3 &&
|
||||
ring.every(
|
||||
(point) =>
|
||||
Array.isArray(point) &&
|
||||
point.length === 2 &&
|
||||
point.every(Number.isFinite) &&
|
||||
Math.abs(point[0]) <= 180 &&
|
||||
Math.abs(point[1]) <= 90,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error('Bundled Alps geometry unavailable');
|
||||
const vertices = region.polygons.flat();
|
||||
const west = Math.min(...vertices.map(([lon]) => lon));
|
||||
const east = Math.max(...vertices.map(([lon]) => lon));
|
||||
const south = Math.min(...vertices.map(([, lat]) => lat));
|
||||
const north = Math.max(...vertices.map(([, lat]) => lat));
|
||||
const lat = (south + north) / 2;
|
||||
const lng = (west + east) / 2;
|
||||
if (
|
||||
![west, east, south, north, lat, lng].every(Number.isFinite) ||
|
||||
!region.polygons.some((ring) => pointInRing(ring, lat, lng))
|
||||
) {
|
||||
// A broken pack is not permission to cache an unrelated network peak
|
||||
// as the canonical range. Existing search error/retry handling owns it.
|
||||
throw new Error('Bundled Alps geometry unavailable');
|
||||
}
|
||||
return {
|
||||
answered: true,
|
||||
place: {
|
||||
lat,
|
||||
lng,
|
||||
name: region.name,
|
||||
label: region.name,
|
||||
types: ['natural_feature'],
|
||||
exact: true,
|
||||
viewport: {
|
||||
southwest: { lat: south, lng: west },
|
||||
northeast: { lat: north, lng: east },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseCoordinateQuery } from './coordinateParser.js';
|
||||
test('search refuses omitted components and repeated separators', () => {
|
||||
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
|
||||
assert.equal(parseCoordinateQuery(q), null, q);
|
||||
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
|
||||
assert.deepEqual(
|
||||
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
|
||||
[30, -97],
|
||||
);
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a decimal-degree query without asking anyone.
|
||||
* Answer a decimal-degree or geo-URI query without asking anyone.
|
||||
*
|
||||
* It sits ahead of the network geocoders, so a coordinate costs no request and
|
||||
* works with no key. Anything that is not exactly a coordinate is passed on
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
|
||||
* successful search. So every rule here is exact — a component is a decimal
|
||||
* number, optionally signed, optionally marked with one hemisphere letter, and
|
||||
* nothing else. Degrees/minutes/seconds and grid references are not handled;
|
||||
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
|
||||
* Degrees/minutes/seconds and grid references are not handled;
|
||||
* they fall through to the geocoders, which is what happens today.
|
||||
*/
|
||||
|
||||
@@ -13,6 +14,10 @@
|
||||
const COMPONENT =
|
||||
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
|
||||
|
||||
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
|
||||
const GEO_URI =
|
||||
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
|
||||
|
||||
/** Hemisphere letters, by the axis each one can name. */
|
||||
const LATITUDE_LETTERS = new Set(['N', 'S']);
|
||||
|
||||
@@ -42,15 +47,37 @@ function readComponent(text) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Split on a comma, a semicolon, or whitespace — never on anything else. */
|
||||
/**
|
||||
* Split on a comma, a semicolon, or whitespace — never on anything else.
|
||||
* A hemisphere letter stays with the number it names, so `40.7 N 74.0 W`
|
||||
* is two components. Splitting on the space before that letter used to
|
||||
* leave three pieces and the search declined the coordinate.
|
||||
*/
|
||||
function splitComponents(query) {
|
||||
const text = String(query).trim();
|
||||
if (!text) return null;
|
||||
const parts = /[,;]/.test(text)
|
||||
? text.split(/[,;]/)
|
||||
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
|
||||
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
|
||||
return trimmed.length === 2 ? trimmed : null;
|
||||
if (/[,;]/.test(text)) {
|
||||
const trimmed = text.split(/[,;]/).map((part) => part.trim());
|
||||
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
|
||||
}
|
||||
const words = text.split(/\s+/);
|
||||
// Each component has at most a number, a degree sign and one hemisphere.
|
||||
if (words.length > 6) return null;
|
||||
// Keep every character: extracting tokens would turn `1e3` or `1.2.3`
|
||||
// into invented coordinates. A lone letter between numbers can name either
|
||||
// component; require an unambiguous split rather than guessing its owner.
|
||||
let result = null;
|
||||
for (let boundary = words.length - 1; boundary > 0; boundary -= 1) {
|
||||
const parts = [
|
||||
words.slice(0, boundary).join(' '),
|
||||
words.slice(boundary).join(' '),
|
||||
];
|
||||
if (parts.every((part) => readComponent(part))) {
|
||||
if (result) return null;
|
||||
result = parts;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Present a parsed pair the way a coordinate is normally written. */
|
||||
@@ -61,20 +88,25 @@ export function formatCoordinateLabel(lat, lon) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decimal-degree query, or return null.
|
||||
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
|
||||
*
|
||||
* Both orders are accepted when the hemisphere letters say which is which
|
||||
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
|
||||
* latitude-then-longitude order applies. Two letters naming the same axis, a
|
||||
* letter that fixes nothing, an out-of-range latitude or longitude, and any
|
||||
* trailing text all decline.
|
||||
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
|
||||
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
|
||||
*
|
||||
* @param {string} query
|
||||
* @returns {{ lat: number, lon: number, label: string } | null}
|
||||
*/
|
||||
export function parseCoordinateQuery(query) {
|
||||
if (typeof query !== 'string') return null;
|
||||
const parts = splitComponents(query);
|
||||
const text = query.trim();
|
||||
const parts = /^geo:/i.test(text)
|
||||
? GEO_URI.exec(text)?.slice(1, 3)
|
||||
: splitComponents(text);
|
||||
if (!parts) return null;
|
||||
|
||||
const first = readComponent(parts[0]);
|
||||
|
||||
@@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => {
|
||||
label: '40.0000° N, 74.0000° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
|
||||
// Written the way a coordinate is usually pasted, with no comma.
|
||||
assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), {
|
||||
lat: 40.7128,
|
||||
lon: -74.006,
|
||||
label: '40.7128° N, 74.0060° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006);
|
||||
assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), {
|
||||
lat: 40.7128,
|
||||
lon: -74.006,
|
||||
label: '40.7128° N, 74.0060° W',
|
||||
});
|
||||
assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128);
|
||||
// One letter is enough: it fixes its own axis, the other takes what is left.
|
||||
assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74);
|
||||
assert.deepEqual(parseCoordinateQuery('-74, 40 N'), {
|
||||
@@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => {
|
||||
// The reviewer's case: trailing text must not be discarded.
|
||||
'12junk, 34oops',
|
||||
'12junk,34',
|
||||
'12junk 34',
|
||||
'1e3 5',
|
||||
'1e3',
|
||||
'1E3',
|
||||
'1e-3',
|
||||
'1.2.3',
|
||||
'12-34',
|
||||
'40 NW 74',
|
||||
'40 N 74',
|
||||
'40 W 74',
|
||||
'40 E 74',
|
||||
'°40 74',
|
||||
'40°° 74',
|
||||
'40 74°°',
|
||||
'40 26 46 N 79 58 56 W',
|
||||
'43.1731, -79.0384x',
|
||||
'abc, def',
|
||||
'Paris',
|
||||
@@ -132,3 +160,89 @@ test('the poles and the dateline are inside the accepted range', () => {
|
||||
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
|
||||
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
|
||||
});
|
||||
|
||||
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
|
||||
for (const query of [
|
||||
'geo:39.7392,-104.9903',
|
||||
'geo:39.7392,-104.9903;crs=wgs84',
|
||||
' GEO:39.7392,-104.9903;CRS=WGS84 ',
|
||||
]) {
|
||||
assert.deepEqual(parseCoordinateQuery(query), {
|
||||
lat: 39.7392,
|
||||
lon: -104.9903,
|
||||
label: '39.7392° N, 104.9903° W',
|
||||
});
|
||||
}
|
||||
for (const [query, decimal] of [
|
||||
['geo:0,0', '0,0'],
|
||||
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
|
||||
['geo:90,180', '90,180'],
|
||||
['geo:-90,-180;crs=wgs84', '-90,-180'],
|
||||
['geo:09,007', '9,7'],
|
||||
['geo:-0,-0', '-0,-0'],
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
parseCoordinateQuery(query),
|
||||
parseCoordinateQuery(decimal),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
|
||||
for (const query of [
|
||||
'geo:',
|
||||
'geo:39.7392',
|
||||
'geo:,104.9903',
|
||||
'geo:39.7392,,104.9903',
|
||||
'geo:39.7392,-104.9903,',
|
||||
'geo:91,0',
|
||||
'geo:-90.1,0',
|
||||
'geo:0,181',
|
||||
'geo:0,-180.1',
|
||||
'geo:000,0',
|
||||
'geo:0,0000',
|
||||
'geo:+39.7392,-104.9903',
|
||||
'geo:.5,-.25',
|
||||
'geo:1e1,2',
|
||||
'geo:NaN,0',
|
||||
'geo:Infinity,0',
|
||||
'geo:39.7392, -104.9903',
|
||||
'geo:39.7392;-104.9903',
|
||||
'geo:39.7392N,104.9903W',
|
||||
'geo:39.7392,-104.9903,1600',
|
||||
'geo:39.7392,-104.9903;u=10',
|
||||
'geo:39.7392,-104.9903;crs=unknown',
|
||||
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
|
||||
'geo:39.7392,-104.9903;crs=wgs84;u=0',
|
||||
'geo:39.7392,-104.9903;custom=value',
|
||||
'geo:0,0?q=39.7392,-104.9903(Denver)',
|
||||
'geo:39.7392,-104.9903#Denver',
|
||||
'geo:39.7392,-104.9903 trailing text',
|
||||
'geo://39.7392,-104.9903',
|
||||
'https://example.com/geo:39.7392,-104.9903',
|
||||
]) {
|
||||
assert.equal(parseCoordinateQuery(query), null, query);
|
||||
}
|
||||
});
|
||||
|
||||
test('whitespace boundaries preserve degree signs and hemisphere ownership', () => {
|
||||
for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) {
|
||||
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
|
||||
assert.equal(parseCoordinateQuery(query)?.lon, 74, query);
|
||||
}
|
||||
for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) {
|
||||
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
|
||||
assert.equal(parseCoordinateQuery(query)?.lon, -74, query);
|
||||
}
|
||||
});
|
||||
|
||||
test('a signed plain component leaves a spaced letter with the other component', () => {
|
||||
assert.deepEqual(
|
||||
parseCoordinateQuery('-40 N 74'),
|
||||
parseCoordinateQuery('-40, N 74'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseCoordinateQuery('40 N -74'),
|
||||
parseCoordinateQuery('40 N, -74'),
|
||||
);
|
||||
});
|
||||
+17
-10
@@ -6,9 +6,10 @@ import { createGoogleGeocoder } from './google.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
import { createAlpsGeocoder } from './alpsGeocoder.js';
|
||||
|
||||
/**
|
||||
* Coordinates and bundled names first — both answer offline and with no key —
|
||||
* Coordinates, bundled names and the unqualified Alps first — all offline —
|
||||
* then Google when configured, then keyless Photon, then the local Nominatim
|
||||
* route as a last resort. Transport stays local to setup.
|
||||
*
|
||||
@@ -37,21 +38,27 @@ export function createDefaultPlaceSearch({
|
||||
providers: providers.geocode || [
|
||||
createCoordinateGeocoder(),
|
||||
...(presets ? [createPresetGeocoder({ presets })] : []),
|
||||
createAlpsGeocoder(),
|
||||
...(selected
|
||||
? [selected]
|
||||
: [
|
||||
// Google answers through the local proxy, which holds the server
|
||||
// key: its Geocoding web service refuses referrer-restricted keys,
|
||||
// so calling it from here would force the bundled browser key to be
|
||||
// left unrestricted (#363). That key still gates the provider,
|
||||
// because a keyless session must not spend a request to learn it
|
||||
// has no Google: the proxy's "not configured" reads as an
|
||||
// unanswered provider, which stops the chain caching the negative
|
||||
// Photon or Nominatim then produces.
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const key = resolveApiKey?.();
|
||||
if (!key) return null;
|
||||
const url = new URL(
|
||||
endpoints.geocode ||
|
||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
if (!resolveApiKey?.()) return null;
|
||||
const params = new URLSearchParams({ address: query });
|
||||
if (bias) params.set('bounds', bias);
|
||||
return fetchImpl(
|
||||
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
url.searchParams.set('address', query);
|
||||
url.searchParams.set('key', key);
|
||||
if (bias) url.searchParams.set('bounds', bias);
|
||||
return fetchImpl(url.toString(), { signal });
|
||||
},
|
||||
}),
|
||||
// `endpoints.photon === null` is the operator's "no Photon".
|
||||
|
||||
+7
-8
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
|
||||
endpoints = {},
|
||||
} = {}) {
|
||||
const urls = {
|
||||
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
// Same-origin like its siblings below: Google's Geocoding web service
|
||||
// refuses referrer-restricted keys, so the key stays on the server (#363).
|
||||
reverse: '/api/google/reverse-geocode',
|
||||
textSearch: '/api/google/text-search',
|
||||
nearby: '/api/google/nearby-places',
|
||||
route: '/api/route',
|
||||
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
|
||||
},
|
||||
routeProfiles: ['foot', 'car', 'bike'],
|
||||
async reverseGeocode(latitude, longitude, options) {
|
||||
const key = resolveApiKey?.();
|
||||
if (resolveApiKey && !key) return null;
|
||||
// The browser key gates the provider without travelling with the
|
||||
// request: the proxy supplies the key Google actually sees.
|
||||
if (resolveApiKey && !resolveApiKey()) return null;
|
||||
return normalizeGoogleReverse(
|
||||
await json(
|
||||
urls.reverse,
|
||||
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
|
||||
options,
|
||||
),
|
||||
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
|
||||
);
|
||||
},
|
||||
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
|
||||
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
|
||||
import { createPresetGeocoder } from './presetGeocoder.js';
|
||||
import { createPlaceSearch } from './placeSearch.js';
|
||||
import { createDefaultPlaceSearch } from './defaults.js';
|
||||
|
||||
const PRESETS = {
|
||||
austin: {
|
||||
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
|
||||
const requests = [];
|
||||
const search = createDefaultPlaceSearch({
|
||||
resolveApiKey: () => 'test-key',
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(url);
|
||||
throw new Error('No network request should be needed');
|
||||
},
|
||||
});
|
||||
const { place, fallbackUsed } = await search.geocode(
|
||||
'geo:39.7392,-104.9903;crs=wgs84',
|
||||
);
|
||||
assert.deepEqual(requests, []);
|
||||
assert.equal(fallbackUsed, false);
|
||||
assert.equal(place.lat, 39.7392);
|
||||
assert.equal(place.lng, -104.9903);
|
||||
assert.equal(place.exact, true);
|
||||
assert.deepEqual(place.types, ['coordinate']);
|
||||
assert.ok(place.viewport.southwest.lat < place.lat);
|
||||
assert.ok(place.viewport.northeast.lat > place.lat);
|
||||
assert.ok(place.viewport.southwest.lng < place.lng);
|
||||
assert.ok(place.viewport.northeast.lng > place.lng);
|
||||
});
|
||||
|
||||
test('unsupported geo URIs reach the next search provider unchanged', async () => {
|
||||
const queries = [
|
||||
'geo:39.7392,-104.9903,1600',
|
||||
'geo:39.7392,-104.9903;u=10',
|
||||
'geo:39.7392,-104.9903;crs=unknown',
|
||||
'geo:0,0?q=Denver',
|
||||
];
|
||||
const received = [];
|
||||
const search = createPlaceSearch({
|
||||
providers: [
|
||||
createCoordinateGeocoder(),
|
||||
{
|
||||
async geocode(query) {
|
||||
received.push(query);
|
||||
return { place: null, answered: true };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const query of queries) {
|
||||
assert.equal((await search.geocode(query)).place, null);
|
||||
}
|
||||
assert.deepEqual(received, queries);
|
||||
});
|
||||
@@ -1,28 +1,8 @@
|
||||
// PHOTON_URL: unset keeps the public default, empty disables, else a URL.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { photonEndpointSetting } from '../keylessGeocoder.js';
|
||||
import { createDefaultPlaceSearch } from './defaults.js';
|
||||
|
||||
test('the Photon setting reads unset, disabled and operator values', () => {
|
||||
assert.equal(photonEndpointSetting(undefined), undefined);
|
||||
for (const off of [
|
||||
'',
|
||||
' ',
|
||||
'off',
|
||||
'none',
|
||||
'disabled',
|
||||
'not a url',
|
||||
'ftp://x.test/',
|
||||
'https://u:[email protected]/api/',
|
||||
])
|
||||
assert.equal(photonEndpointSetting(off), null, off);
|
||||
assert.equal(
|
||||
photonEndpointSetting('https://photon.example.test/api/'),
|
||||
'https://photon.example.test/api/',
|
||||
);
|
||||
});
|
||||
|
||||
async function hosts(endpoints) {
|
||||
const asked = [];
|
||||
const search = createDefaultPlaceSearch({
|
||||
@@ -35,7 +15,7 @@ async function hosts(endpoints) {
|
||||
);
|
||||
},
|
||||
});
|
||||
await search.geocode(`nowhere-${Math.random()}`);
|
||||
await search.geocode('nowhere-photon-setting');
|
||||
return asked;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
|
||||
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
|
||||
const urls = [];
|
||||
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
|
||||
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
|
||||
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
|
||||
} });
|
||||
const result = await service.geocode('Hanoi');
|
||||
assert.equal(result.place.name, 'Hà Nội');
|
||||
|
||||
Reference in new issue
Block a user