mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(search): finish the keyless fallback at every geocode call site
The keyless fallback landed on the LOCATION search box only. Two other places forward-geocode a name and neither had it: the annotation resolver returned null, so an annotation silently failed to anchor, and the Radio layer THREW, which surfaced as a failed voice turn rather than as a station it could not place. src/keylessGeocoder.test.mjs now COUNTS the call sites instead of checking them file by file, so a second seam added to a file that already has a fallback fails the suite too. The annotation path carries the feature's own name out of Photon. Footprint scoring matches OSM on that canonical name rather than on the user's words, so incidental locality tokens cannot win: "the monument near the Texas Capitol, Austin" scored on the raw utterance picks a downtown building that shares only "Texas", "Capitol" and "Austin". Photon's country is deliberately NOT re-exported. It comes back in the feature's own language — "Việt Nam", "日本", "Россия" — and normalizeRadioCountryInput fails CLOSED on a name it cannot map, so forwarding it would filter the station list to nothing for exactly the places the lookup had just resolved. The composed label still names the country. A keyless lookup must not apply a filter the keyed one never applies. A not-found is now remembered only when every source consulted returned a verdict. Photon not replying is a network blip, not "no such place": memoizing it kept the query answering not-found for the rest of the session on a network that had since recovered — the same conflation the Overpass disk cache had to be repaired for. geocodeMissIsDefinitive states the rule once and both entry points use it, and the adapter's own memo follows it as well. Reverse geocoding stays out of scope: the street-label reader consumes Google's ranked address_components, and Photon's /reverse cannot reproduce that shape.
This commit is contained in:
1 parent
acac1e4842
commit
e21c174f76
8 files changed
+681
-43
No files matched your search
+31
-12
@@ -17,6 +17,7 @@ import militaryAwarenessLayer, {
|
||||
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
|
||||
import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { geocodeKeyless } from '../keylessGeocoder.js';
|
||||
import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js';
|
||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||
@@ -1265,25 +1266,43 @@ async function resolveRadioLocation(args = {}, coordinates = radioCoordinatePair
|
||||
if (known) return known;
|
||||
if (!query) return null;
|
||||
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey) throw new Error('No Google Maps API key available for Radio location search');
|
||||
const controller = new AbortController();
|
||||
const cancelFromTurn = () => controller.abort();
|
||||
if (options.signal?.aborted) throw radioAbortError();
|
||||
options.signal?.addEventListener('abort', cancelFromTurn, { once: true });
|
||||
const timer = setTimeout(() => controller.abort(), 6000);
|
||||
try {
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`;
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
const body = await response.json();
|
||||
if (apiKey) {
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`;
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
const body = await response.json();
|
||||
if (!radioActionIsCurrent(options)) throw radioAbortError();
|
||||
const result = body.status === 'OK' ? body.results?.[0] : null;
|
||||
if (result?.geometry?.location) {
|
||||
return {
|
||||
lat: result.geometry.location.lat,
|
||||
lon: result.geometry.location.lng,
|
||||
label: result.formatted_address || query,
|
||||
country: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
// Same fallback rule as the LOCATION search box: reached with no key, and
|
||||
// also when a key exists but Google returned nothing. A missing key used to
|
||||
// throw here, which surfaced as a failed voice turn rather than as a radio
|
||||
// station that could not be placed.
|
||||
// Unbiased, matching the Google call above: a radio location is named
|
||||
// outright ("radio near Hanoi"), not relative to what the camera shows.
|
||||
const keyless = await geocodeKeyless(query);
|
||||
if (!radioActionIsCurrent(options)) throw radioAbortError();
|
||||
const result = body.status === 'OK' ? body.results?.[0] : null;
|
||||
if (!result?.geometry?.location) return null;
|
||||
return {
|
||||
lat: result.geometry.location.lat,
|
||||
lon: result.geometry.location.lng,
|
||||
label: result.formatted_address || query,
|
||||
country: '',
|
||||
};
|
||||
if (!keyless) return null;
|
||||
// `country` stays empty, exactly as the Google branch above leaves it. Photon
|
||||
// does report a country, but in the feature's own language ("Việt Nam",
|
||||
// "日本"), and `normalizeRadioCountryInput` fails CLOSED on a name it cannot
|
||||
// map — so forwarding it would turn "radio near Ha Long" into an empty
|
||||
// station list rather than the nearest station. A keyless lookup must not
|
||||
// apply a filter the keyed one never applies.
|
||||
return { lat: keyless.lat, lon: keyless.lng, label: keyless.label || query, country: '' };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener('abort', cancelFromTurn);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getContextStore, registerEntityContext } from '../data/contextStore.js'
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { getActiveCameraMotion, interruptCameraMotion, moveCamera } from '../cameraVerbs.js';
|
||||
import { reassertNavigationHandoff, runExplicitNavigation } from '../navigationPolicy.js';
|
||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||
import {
|
||||
controlCctv,
|
||||
@@ -3020,3 +3021,120 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it',
|
||||
assert.equal(result.window.centeredOn, 'N546PC');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Keyless Radio location ───────────────────────────────────────────────────
|
||||
//
|
||||
// `resolveRadioLocation` used to be Google-only: no key threw, and a key that
|
||||
// geocoded to nothing returned null, which the caller reports as "Could not
|
||||
// resolve Radio location". 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 here, 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, city = '', country = '' }) {
|
||||
return {
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: { name, city, country, osm_key: 'place', osm_value: 'city' },
|
||||
};
|
||||
}
|
||||
|
||||
/** A Radio layer that records what it was asked to select. */
|
||||
function radioSelectionHarness() {
|
||||
let enabled = false;
|
||||
const calls = [];
|
||||
const state = {
|
||||
stationCount: 4, filter: 'all', selected: null,
|
||||
audioState: 'stopped', volume: 0.8, voiceDucked: false,
|
||||
};
|
||||
const radio = {
|
||||
getUIState: () => ({ ...state }),
|
||||
selectRequestedStation(criteria, options) {
|
||||
calls.push({ criteria, options });
|
||||
state.selected = { id: 'kl-1', name: 'Keyless FM' };
|
||||
return state.selected;
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
dataManager: {
|
||||
layers: new Map([['radio', { module: radio }]]),
|
||||
isEnabled: () => enabled,
|
||||
async setEnabled(_id, value) { enabled = value; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Install a Google key plus a fetch stub, restoring both afterwards. */
|
||||
function installKeyedFetch(t, handler) {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const priorKey = globalThis.window.__GOOGLE_MAPS_API_KEY__;
|
||||
const priorFetch = globalThis.fetch;
|
||||
globalThis.window.__GOOGLE_MAPS_API_KEY__ = 'unit-test-key';
|
||||
globalThis.fetch = handler;
|
||||
t.after(() => {
|
||||
globalThis.fetch = priorFetch;
|
||||
if (priorKey === undefined) delete globalThis.window.__GOOGLE_MAPS_API_KEY__;
|
||||
else globalThis.window.__GOOGLE_MAPS_API_KEY__ = priorKey;
|
||||
});
|
||||
}
|
||||
|
||||
test('voice Radio: a key that geocodes to nothing still places the station, keylessly', async (t) => {
|
||||
const { calls, dataManager } = radioSelectionHarness();
|
||||
const requests = [];
|
||||
installKeyedFetch(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
|
||||
}
|
||||
assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [photonFeature({
|
||||
name: 'Hạ Long Bay', lat: 20.9101, lon: 107.1839, city: 'Hạ Long', country: 'Việt Nam',
|
||||
})],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await controlRadio({}, dataManager, {
|
||||
action: 'select', locationQuery: 'Hạ Long Bay',
|
||||
});
|
||||
|
||||
// Before the fallback existed this was `ok: false, "Could not resolve Radio location"`.
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.requestedLocation, 'Hạ Long Bay, Hạ Long, Việt Nam');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9);
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9);
|
||||
// Google is asked first and exactly once; Photon answers unbiased, in one call.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/);
|
||||
assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased');
|
||||
});
|
||||
|
||||
test('voice Radio: the keyless path applies no country filter the keyed path would not', async (t) => {
|
||||
// Photon reports the country in the feature's own language ("Việt Nam"), and
|
||||
// `rankRadioStationsForRequest` fails CLOSED on a country it cannot map —
|
||||
// returning NO stations. Forwarding it would make a keyless install answer
|
||||
// "No Radio station matched" for exactly the places it just resolved, while a
|
||||
// keyed install placed a station. The label may carry it; the filter may not.
|
||||
const { calls, dataManager } = radioSelectionHarness();
|
||||
installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/')
|
||||
? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }
|
||||
: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [photonFeature({ name: 'Kraków', lat: 50.0614, lon: 19.9366, country: 'Polska' })],
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await controlRadio({}, dataManager, { action: 'select', locationQuery: 'Kraków' });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls[0].criteria.country, '', 'a localized country name must never reach the station filter');
|
||||
assert.equal(normalizeRadioCountryInput('Polska').valid, false, 'and this is why: it would match nothing');
|
||||
assert.equal(result.requestedLocation, 'Kraków, Polska', 'the label still names the country honestly');
|
||||
});
|
||||
Reference in new issue
Block a user