mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
- Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only
625 lines
27 KiB
JavaScript
625 lines
27 KiB
JavaScript
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
|
import { createPlaceSearch } from '../search/placeSearch.js';
|
|
// Footprint-selection contract tests — pure fixtures, no network, no browser.
|
|
//
|
|
// Locks the field-test-7 monument fix (docs/field-test-rootcause-2026-06-30.md §1):
|
|
// a POINT-LIKE target ("Tejano Monument, Austin") must never adopt a nearby
|
|
// polygon that merely shares locality/context words ("Austin", "History").
|
|
// The fixtures replicate the REAL Overpass candidates captured over the Texas
|
|
// Capitol on 2026-07-01, where "Thompson Austin" (a hotel 680 m away) outscored
|
|
// everything because `nameOverlap * 1000` paid +1000 for the single word
|
|
// "Austin" — the monument itself is an OSM node and never even a candidate.
|
|
//
|
|
// Run with: npm test (node --test)
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
resolveAnnotationTarget as resolveTarget,
|
|
selectFootprint,
|
|
refineScope,
|
|
isGroundsLikeAsk,
|
|
} from './annotationResolver.js';
|
|
|
|
// Build a square way of ~`areaM2` centred `dLatM`/`dLonM` metres from an anchor,
|
|
// in Overpass `out geom` element shape ({ tags, geometry: [{lat,lon}...] }).
|
|
function squareWay(anchor, dLatM, dLonM, areaM2, tags) {
|
|
const mLat = 111320;
|
|
const mLon = mLat * Math.cos((anchor.lat * Math.PI) / 180);
|
|
const cLat = anchor.lat + dLatM / mLat;
|
|
const cLon = anchor.lon + dLonM / mLon;
|
|
const half = Math.sqrt(areaM2) / 2;
|
|
const dy = half / mLat;
|
|
const dx = half / mLon;
|
|
const ring = [
|
|
{ lat: cLat - dy, lon: cLon - dx },
|
|
{ lat: cLat - dy, lon: cLon + dx },
|
|
{ lat: cLat + dy, lon: cLon + dx },
|
|
{ lat: cLat + dy, lon: cLon - dx },
|
|
{ lat: cLat - dy, lon: cLon - dx },
|
|
];
|
|
return { type: 'way', tags, geometry: ring };
|
|
}
|
|
|
|
// The real Places anchor for "Tejano Monument, Austin" (on the Capitol grounds).
|
|
const ANCHOR = { lat: 30.27297, lon: -97.74029 };
|
|
|
|
// Real captured wrong-winners: named downtown features that share ONLY "Austin".
|
|
const wrongWinners = () => [
|
|
squareWay(ANCHOR, -660, -90, 3093, { building: 'yes', tourism: 'hotel', name: 'Thompson Austin' }),
|
|
squareWay(ANCHOR, -780, 120, 3772, { building: 'yes', name: 'Austin Police HQ' }),
|
|
squareWay(ANCHOR, -380, -60, 4499, { leisure: 'park', name: 'Black Austin Matters' }),
|
|
squareWay(ANCHOR, -240, -700, 1500, { building: 'yes', amenity: 'library', name: 'Austin Public Library - Austin History Center' }),
|
|
// The real enclosing grounds polygon — unnamed overlap with the monument query.
|
|
squareWay(ANCHOR, 0, 0, 100_000, { leisure: 'park', name: 'Capitol Square' }),
|
|
];
|
|
|
|
test('point mode: locality-word polygons never stand in for a monument (the Tejano bug)', () => {
|
|
const fp = selectFootprint(wrongWinners(), ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'point');
|
|
assert.equal(fp, null); // keep the honest point anchor
|
|
});
|
|
|
|
test('point mode: an (almost) exactly-named, monument-scale polygon still outlines', () => {
|
|
const els = wrongWinners();
|
|
els.push(squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' }));
|
|
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'point');
|
|
assert.ok(fp, 'expected the true monument way to be accepted');
|
|
assert.equal(fp.kind, 'area');
|
|
// Ring is centred on the true feature (a few metres from the anchor), not downtown.
|
|
const lat0 = fp.ring[0][1];
|
|
assert.ok(Math.abs(lat0 - ANCHOR.lat) < 0.001, `ring landed at ${lat0}, expected ~${ANCHOR.lat}`);
|
|
});
|
|
|
|
test('point mode: an exactly-named but park-sized polygon is rejected (size cap)', () => {
|
|
// "Pioneer Monument, Golden Gate Park" — the park matches 3/3 of its own name
|
|
// and contains the anchor, but a 4.1 km² park must not render as a monument.
|
|
const park = squareWay(ANCHOR, 0, 0, 4_100_000, { leisure: 'park', name: 'Golden Gate Park' });
|
|
const fp = selectFootprint([park], ANCHOR.lat, ANCHOR.lon, 'Pioneer Monument, Golden Gate Park', 'point');
|
|
assert.equal(fp, null);
|
|
});
|
|
|
|
test('loose mode: unchanged — word-overlap scoring still picks the named candidate', () => {
|
|
// Pins that the fix is SCOPED to point-like targets: generic loose lookups keep
|
|
// the existing scorer (changing it globally would need its own field evidence).
|
|
const fp = selectFootprint(wrongWinners(), ANCHOR.lat, ANCHOR.lon, 'Tejano Monument, Austin', 'loose');
|
|
assert.ok(fp, 'loose mode still resolves a footprint');
|
|
});
|
|
|
|
test('loose mode: a named compound still beats an unnamed containing building (Presidio case)', () => {
|
|
const els = [
|
|
squareWay(ANCHOR, 0, 0, 900, { building: 'yes' }), // unnamed building under the anchor
|
|
squareWay(ANCHOR, 400, 400, 6_000_000, { landuse: 'military', name: 'Presidio of San Francisco' }),
|
|
];
|
|
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Presidio of San Francisco', 'loose');
|
|
assert.ok(fp);
|
|
assert.equal(fp.kind, 'area');
|
|
});
|
|
|
|
test('strict mode: unchanged — named district-sized areas only', () => {
|
|
const els = [
|
|
squareWay(ANCHOR, 0, 0, 900, { building: 'yes', name: 'Mission Lofts' }), // building → rejected
|
|
squareWay(ANCHOR, 0, 0, 12_000, { landuse: 'retail', name: 'Mission Market' }), // tiny parcel → rejected
|
|
squareWay(ANCHOR, 200, 200, 500_000, { landuse: 'residential', name: 'Mission District' }),
|
|
];
|
|
const fp = selectFootprint(els, ANCHOR.lat, ANCHOR.lon, 'Mission District', 'strict');
|
|
assert.ok(fp);
|
|
assert.equal(fp.kind, 'area');
|
|
assert.ok(Math.abs(fp.ring[0][1] - (ANCHOR.lat + 200 / 111320)) < 0.01);
|
|
});
|
|
|
|
test('loose mode: a named water body beats a shore feature named after it (field test 9)', () => {
|
|
// "Lady Bird Lake" — the anchor sits ON the water. Before natural=water joined the
|
|
// sweep, the lake was never a candidate and a shoreline park NAMED AFTER it won on
|
|
// word overlap, drawing a squiggle on the bank instead of the lake.
|
|
const ANCHOR_ON_WATER = { lat: 30.2565, lon: -97.7365 };
|
|
const els = [
|
|
// The real lake: large named water polygon containing the anchor.
|
|
squareWay(ANCHOR_ON_WATER, 0, 0, 3_500_000, { natural: 'water', water: 'reservoir', name: 'Lady Bird Lake' }),
|
|
// Shore park named after the lake (partial name coverage, does not contain anchor).
|
|
squareWay(ANCHOR_ON_WATER, 450, -300, 90_000, { leisure: 'park', name: 'Auditorium Shores at Lady Bird Lake Metropolitan Park' }),
|
|
];
|
|
const fp = selectFootprint(els, ANCHOR_ON_WATER.lat, ANCHOR_ON_WATER.lon, 'Lady Bird Lake, Austin', 'loose');
|
|
assert.ok(fp);
|
|
assert.equal(fp.kind, 'area');
|
|
// Ring centred on the lake fixture, not offset onto the shore park.
|
|
const lat0 = fp.ring[0][1];
|
|
assert.ok(Math.abs(lat0 - ANCHOR_ON_WATER.lat) < 0.02, 'ring at ' + lat0);
|
|
assert.ok(Math.abs(lat0 - (ANCHOR_ON_WATER.lat + 450 / 111320)) > 0.001, 'must not be the shore park');
|
|
|
|
// Inverse: a LAND ask near the water is not stolen by the lake polygon.
|
|
const park = selectFootprint(els, ANCHOR_ON_WATER.lat + 0.004, ANCHOR_ON_WATER.lon - 0.003, 'Auditorium Shores', 'loose');
|
|
assert.ok(park);
|
|
assert.ok(Math.abs(park.ring[0][1] - (ANCHOR_ON_WATER.lat + 450 / 111320)) < 0.01, 'land ask keeps the park');
|
|
});
|
|
|
|
test('isGroundsLikeAsk: label wording and entityKind both count (field test 8)', () => {
|
|
// The model's real call shape: grounds word only in the LABEL, compound entityKind.
|
|
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Capitol grounds', 'compound'), true);
|
|
// Label alone is enough when no entityKind is given.
|
|
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Capitol grounds', null), true);
|
|
// Target wording still works as before.
|
|
assert.equal(isGroundsLikeAsk('Texas State Capitol grounds, Austin', null, null), true);
|
|
// An explicit non-compound entityKind vetoes grounds wording (trust the model's fact).
|
|
assert.equal(isGroundsLikeAsk('Capitol complex', 'the complex', 'building'), false);
|
|
// A plain building ask is not grounds-like.
|
|
assert.equal(isGroundsLikeAsk('Texas State Capitol, Austin', 'Texas State Capitol', null), false);
|
|
});
|
|
|
|
test('refineScope: entityKind refines only an unresolved (auto) scope', () => {
|
|
assert.equal(refineScope('auto', 'building'), 'building');
|
|
assert.equal(refineScope('auto', 'compound'), 'compound');
|
|
assert.equal(refineScope('auto', 'district'), 'neighborhood');
|
|
assert.equal(refineScope('auto', 'street'), 'street');
|
|
assert.equal(refineScope('auto', 'point_feature'), 'auto'); // point-first handled separately
|
|
assert.equal(refineScope('auto', undefined), 'auto');
|
|
// Real geocode types (data) always win over the model's claim.
|
|
assert.equal(refineScope('city', 'building'), 'city');
|
|
assert.equal(refineScope('neighborhood', 'street'), 'neighborhood');
|
|
});
|
|
|
|
function closeViewportViewer() {
|
|
return {
|
|
camera: {
|
|
positionCartographic: {
|
|
latitude: 30.2672 * Math.PI / 180,
|
|
longitude: -97.7431 * Math.PI / 180,
|
|
height: 1000,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function geocodePayload({ lat, lon, types, label }) {
|
|
return {
|
|
status: 'OK',
|
|
results: [{
|
|
formatted_address: label,
|
|
types,
|
|
address_components: [{ long_name: label.split(',')[0], types }],
|
|
geometry: { location: { lat, lng: lon } },
|
|
}],
|
|
};
|
|
}
|
|
|
|
function installGoogleMocks(t, handler) {
|
|
const originalWindow = globalThis.window;
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.window = {
|
|
__GOOGLE_MAPS_API_KEY__: 'unit-test-key',
|
|
setTimeout: globalThis.setTimeout,
|
|
clearTimeout: globalThis.clearTimeout,
|
|
};
|
|
globalThis.fetch = handler;
|
|
t.after(() => {
|
|
if (originalWindow === undefined) delete globalThis.window;
|
|
else globalThis.window = originalWindow;
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
}
|
|
|
|
test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a far state-typed geocode', async (t) => {
|
|
const calls = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
calls.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) {
|
|
return { json: async () => geocodePayload({
|
|
lat: 31.0000,
|
|
lon: -99.0000,
|
|
types: ['administrative_area_level_1', 'political'],
|
|
label: 'Texas, USA',
|
|
}) };
|
|
}
|
|
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ places: [{
|
|
latitude: 30.2747,
|
|
longitude: -97.7404,
|
|
name: 'Texas Capitol',
|
|
types: ['premise'],
|
|
}] }),
|
|
};
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: 'the Texas Capitol',
|
|
});
|
|
|
|
assert.ok(resolved);
|
|
assert.equal(resolved.source, 'places');
|
|
assert.deepEqual([resolved.lat, resolved.lon], [30.2747, -97.7404]);
|
|
assert.equal(calls.length, 2, 'admin result types must not suppress near-view recovery');
|
|
});
|
|
|
|
test('ask-side admin bypass: explicit "state of Texas" skips recovery and proximity gating', async (t) => {
|
|
const calls = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
calls.push(String(url));
|
|
assert.match(String(url), /^\/api\/google\/geocode\?/);
|
|
return { json: async () => geocodePayload({
|
|
lat: 31.0000,
|
|
lon: -99.0000,
|
|
types: ['administrative_area_level_1', 'political'],
|
|
label: 'Texas, USA',
|
|
}) };
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: 'state of Texas',
|
|
});
|
|
|
|
assert.ok(resolved, 'the explicit state ask keeps its legitimate far centroid');
|
|
assert.equal(resolved.source, 'geocode');
|
|
assert.deepEqual([resolved.lat, resolved.lon], [31, -99]);
|
|
assert.equal(calls.length, 1, 'explicit state scope bypasses near-view recovery');
|
|
});
|
|
|
|
for (const fixture of [
|
|
{
|
|
target: 'Empire State',
|
|
lat: 40.7484,
|
|
lon: -73.9857,
|
|
types: ['premise', 'tourist_attraction'],
|
|
},
|
|
{
|
|
target: 'Ohio State',
|
|
lat: 40.0067,
|
|
lon: -83.0305,
|
|
types: ['university'],
|
|
},
|
|
]) {
|
|
test(`ask-side admin bypass: trailing name "${fixture.target}" remains guarded`, async (t) => {
|
|
const calls = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
calls.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) {
|
|
return { json: async () => geocodePayload({
|
|
lat: fixture.lat,
|
|
lon: fixture.lon,
|
|
types: fixture.types,
|
|
label: `${fixture.target}, USA`,
|
|
}) };
|
|
}
|
|
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
|
return { ok: true, json: async () => ({ places: [] }) };
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: fixture.target,
|
|
});
|
|
|
|
assert.equal(resolved, null, 'a recovery miss continues through the proximity gate');
|
|
assert.equal(calls.length, 2, 'a proper name ending in State must try near-view recovery');
|
|
});
|
|
}
|
|
|
|
test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', async (t) => {
|
|
const calls = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
calls.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) {
|
|
return { json: async () => geocodePayload({
|
|
lat: 31.0000,
|
|
lon: -99.0000,
|
|
types: ['administrative_area_level_1', 'political'],
|
|
label: 'Texas, USA',
|
|
}) };
|
|
}
|
|
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
|
return { ok: true, json: async () => ({ places: [] }) };
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: 'Texas',
|
|
});
|
|
|
|
assert.equal(resolved, null, 'a recovery miss continues through the proximity gate');
|
|
assert.equal(calls.length, 2, 'bare state names are not an explicit admin-scoped ask');
|
|
});
|
|
|
|
test('ask-side admin bypass: admin level 2/3 result types never grant a township bypass', async (t) => {
|
|
const fixtures = new Map([
|
|
['FB-3 township level 2 fixture', ['administrative_area_level_2', 'locality', 'political']],
|
|
['FB-3 township level 3 fixture', ['administrative_area_level_3', 'locality', 'political']],
|
|
]);
|
|
const calls = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
calls.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) {
|
|
const query = new URL(String(url)).searchParams.get('address');
|
|
return { json: async () => geocodePayload({
|
|
lat: 39.7817,
|
|
lon: -89.6501,
|
|
types: fixtures.get(query),
|
|
label: `${query}, Illinois`,
|
|
}) };
|
|
}
|
|
assert.match(String(url), /^\/api\/google\/text-search\?/);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ places: [{
|
|
latitude: 30.2680,
|
|
longitude: -97.7425,
|
|
name: 'Local township fixture',
|
|
types: ['locality'],
|
|
}] }),
|
|
};
|
|
});
|
|
|
|
for (const target of fixtures.keys()) {
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target,
|
|
});
|
|
assert.ok(resolved);
|
|
assert.equal(resolved.source, 'places');
|
|
}
|
|
|
|
assert.equal(
|
|
calls.filter((url) => url.startsWith('/api/google/text-search')).length,
|
|
2,
|
|
'both township-level admin result types stay guarded',
|
|
);
|
|
});
|
|
|
|
// The Capitol geocoder response contains its coordinates but only ADDRESS names.
|
|
// Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs.
|
|
const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 };
|
|
function capitolViewer() {
|
|
return { camera: { positionCartographic: {
|
|
latitude: CAPITOL_ANCHOR.lat * Math.PI / 180,
|
|
longitude: CAPITOL_ANCHOR.lon * Math.PI / 180,
|
|
height: 1000,
|
|
} } };
|
|
}
|
|
function installCapitolMocks(t, elements, components = [
|
|
{ long_name: 'Washington', types: ['locality', 'political'] },
|
|
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
|
|
]) {
|
|
installGoogleMocks(t, async (url) => {
|
|
if (String(url).startsWith('/api/google/geocode')) {
|
|
return { json: async () => ({ status: 'OK', results: [{
|
|
formatted_address: 'Washington, DC 20004, USA',
|
|
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
|
|
address_components: components,
|
|
geometry: { location: { lat: CAPITOL_ANCHOR.lat, lng: CAPITOL_ANCHOR.lon } },
|
|
}] }) };
|
|
}
|
|
assert.equal(String(url), '/api/overpass');
|
|
return { ok: true, status: 200, json: async () => ({ elements }) };
|
|
});
|
|
}
|
|
|
|
for (const [target, deferFootprint] of [
|
|
['United States Capitol', true],
|
|
['United States Capitol, Washington, DC', false],
|
|
]) {
|
|
test(`address-only geocode retains landmark identity: ${target}`, async t => {
|
|
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
|
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
|
|
installCapitolMocks(t, [hotel, capitol]);
|
|
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint });
|
|
assert.ok(result);
|
|
const outline = deferFootprint ? await result.resolveOutline() : result;
|
|
assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
|
assert.equal(outline.footprintKind, 'building');
|
|
});
|
|
}
|
|
|
|
test('address-only landmark without a matching outline keeps its geocoded point', async t => {
|
|
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' });
|
|
installCapitolMocks(t, [hotel]);
|
|
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true });
|
|
assert.ok(result);
|
|
assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]);
|
|
assert.equal(await result.resolveOutline(), null);
|
|
});
|
|
|
|
test('missing address components do not turn a formatted city address into the landmark name', async t => {
|
|
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
|
const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' });
|
|
installCapitolMocks(t, [hotel, capitol], []);
|
|
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true });
|
|
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
|
});
|
|
|
|
test('a genuine geocoded feature name still canonicalizes an alternate user name', async t => {
|
|
const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' });
|
|
const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' });
|
|
installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]);
|
|
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true });
|
|
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
|
});
|
|
|
|
// ── Keyless geocoding ────────────────────────────────────────────────────────
|
|
//
|
|
// `geocodePlace` was Google-only: no key returned null, and so did a key whose
|
|
// Geocoding API is not enabled (Google answers HTTP 200 with REQUEST_DENIED).
|
|
// Either way the annotation silently failed to place. Both now fall through to
|
|
// Photon. These drive the second case, because it reaches the SAME fallback
|
|
// through a running Google branch — the no-key branch cannot be driven from
|
|
// `node --test`, since the key expression reads `import.meta.env`, which only
|
|
// Vite defines.
|
|
|
|
/** Photon's GeoJSON shape, trimmed to the properties the adapter consumes. */
|
|
function photonFeature({ name, lat, lon, tags = {}, extent = null, ...rest }) {
|
|
return {
|
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
|
properties: { name, ...tags, ...rest, ...(extent ? { extent } : {}) },
|
|
};
|
|
}
|
|
|
|
const GOOGLE_FOUND_NOTHING = { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
|
|
|
|
test('keyless: a key that geocodes to nothing still anchors the annotation', async (t) => {
|
|
const requests = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
requests.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
features: [photonFeature({
|
|
name: 'Lady Bird Lake',
|
|
lat: 30.25,
|
|
lon: -97.73,
|
|
city: 'Austin',
|
|
state: 'Texas',
|
|
country: 'United States',
|
|
tags: { osm_key: 'natural', osm_value: 'water' },
|
|
// Photon orders extent [west, north, east, south] — a naive [w,s,e,n]
|
|
// read would invert the box and still look like a valid viewport.
|
|
extent: [-97.8, 30.28, -97.68, 30.24],
|
|
})],
|
|
}),
|
|
};
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: 'Lady Bird Lake',
|
|
});
|
|
|
|
assert.ok(resolved, 'a Google miss must not leave the annotation unplaced');
|
|
assert.equal(resolved.source, 'geocode');
|
|
assert.deepEqual([resolved.lat, resolved.lon], [30.25, -97.73]);
|
|
// The label is shortened the same way a Google `formatted_address` is.
|
|
assert.equal(resolved.label, 'Lady Bird Lake');
|
|
// Photon's extent reaches the framing code in the Places `{low,high}` shape,
|
|
// right way up — this is what sizes a grounds disc and the flyTo box.
|
|
assert.deepEqual(resolved.viewport, {
|
|
low: { latitude: 30.24, longitude: -97.8 },
|
|
high: { latitude: 30.28, longitude: -97.68 },
|
|
});
|
|
// Google is asked first and exactly once; one Photon call answers it.
|
|
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
|
|
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
|
});
|
|
|
|
test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s words', async (t) => {
|
|
// The reason `primaryName` is carried out of Photon at all. The ask names a
|
|
// landmark AND its surroundings; the geocoded feature is called just "Tejano
|
|
// Monument". Score the OSM candidates on the raw utterance and the locality
|
|
// tokens win — this is the Thompson-Austin bug (field test 7) reached through
|
|
// the keyless path. Score them on the canonical name and the monument wins.
|
|
const decoy = squareWay(ANCHOR, -250, -250, 3000, {
|
|
building: 'yes', tourism: 'hotel', name: 'Texas Capitol Austin Visitor Center',
|
|
});
|
|
const monument = squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' });
|
|
|
|
installGoogleMocks(t, async (url, init) => {
|
|
const href = String(url);
|
|
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
|
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
|
|
if (href.startsWith('https://photon.komoot.io/')) {
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
features: [photonFeature({
|
|
name: 'Tejano Monument',
|
|
lat: ANCHOR.lat,
|
|
lon: ANCHOR.lon,
|
|
city: 'Austin',
|
|
tags: { osm_key: 'historic', osm_value: 'memorial' },
|
|
})],
|
|
}),
|
|
};
|
|
}
|
|
assert.equal(href, '/api/overpass');
|
|
assert.equal(init?.method, 'POST');
|
|
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ elements: [decoy, monument] }) };
|
|
});
|
|
|
|
const resolved = await resolveAnnotationTarget({
|
|
viewer: closeViewportViewer(),
|
|
target: 'the monument near the Texas Capitol, Austin',
|
|
footprint: true,
|
|
});
|
|
|
|
assert.ok(resolved.ring, 'the monument polygon is monument-scale and exactly named — it must outline');
|
|
// The anchor re-centres on the chosen polygon, so its position IS the verdict:
|
|
// the monument sits on the anchor, the decoy 354 m south-west of it.
|
|
assert.ok(
|
|
approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon) < 50,
|
|
`outlined a polygon ${Math.round(approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon))} m from the monument`,
|
|
);
|
|
});
|
|
|
|
/** Metres between two coordinates — enough precision for a few hundred metres. */
|
|
function approximateMetres(lat1, lon1, lat2, lon2) {
|
|
const mLat = 111320;
|
|
const dy = (lat1 - lat2) * mLat;
|
|
const dx = (lon1 - lon2) * mLat * Math.cos((lat1 * Math.PI) / 180);
|
|
return Math.hypot(dx, dy);
|
|
}
|
|
|
|
test('keyless: a Photon outage is not remembered as "no such place"', async (t) => {
|
|
// Google answering ZERO_RESULTS is a verdict about GOOGLE. Photon holds
|
|
// plenty of places Google does not — that asymmetry is why this fallback
|
|
// exists — so a Google verdict plus a Photon timeout must leave the query
|
|
// open. Caching it would keep an annotation unplaceable for the rest of the
|
|
// session, on a network that has since recovered.
|
|
const photonUp = {
|
|
ok: true,
|
|
json: async () => ({
|
|
features: [photonFeature({
|
|
name: 'Waller Creek', lat: 30.2665, lon: -97.7385, city: 'Austin',
|
|
tags: { osm_key: 'waterway', osm_value: 'stream' },
|
|
})],
|
|
}),
|
|
};
|
|
let photonReachable = false;
|
|
const requests = [];
|
|
installGoogleMocks(t, async (url) => {
|
|
requests.push(String(url));
|
|
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
|
|
if (!photonReachable) throw new Error('network down');
|
|
return photonUp;
|
|
});
|
|
|
|
const target = 'Waller Creek';
|
|
assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null);
|
|
const duringOutage = requests.length;
|
|
|
|
photonReachable = true;
|
|
const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target });
|
|
|
|
assert.ok(retried, 'the same target must resolve once the network is back');
|
|
assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]);
|
|
assert.ok(
|
|
requests.length > duringOutage,
|
|
'the retry was served from a poisoned cache instead of asking again',
|
|
);
|
|
});
|
|
|
|
test('a not-found is remembered only when every source consulted gave a verdict', async () => {
|
|
// The full truth table, because the no-key row is the one the behavioural
|
|
// tests above cannot reach: `import.meta.env` only exists under Vite, so
|
|
// `node --test` can never take the branch that skips Google entirely.
|
|
const cases = [
|
|
// no key: Photon is the only source, so Photon alone decides
|
|
[{ googleConsulted: false, photonAnswered: true }, true],
|
|
[{ googleConsulted: false, photonAnswered: false }, false],
|
|
// with a key: Google must have said "no such place", AND Photon must have replied
|
|
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: true }, true],
|
|
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: false }, false],
|
|
// Google declined (REQUEST_DENIED / OVER_QUERY_LIMIT) — never a verdict
|
|
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: true }, false],
|
|
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: false }, false],
|
|
];
|
|
for (const [sources, expected] of cases) {
|
|
const providers = [sources.googleConsulted ? Boolean(sources.googleSaysNoSuchPlace) : true, sources.photonAnswered]
|
|
.map((answered) => ({ geocode: async () => ({ place: null, answered }) }));
|
|
assert.equal((await createPlaceSearch({ providers }).geocode('miss')).answered, expected, JSON.stringify(sources));
|
|
}
|
|
// An omitted Google verdict defaults to "did not say no such place".
|
|
|
|
});
|
|
|
|
function resolveAnnotationTarget(options) {
|
|
return resolveTarget({ placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }), ...options });
|
|
}
|