mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
51af575c9a
A right-click, click or drag on a repeated world copy reported the longitude it has on that copy (274.9 for -85.1). The place was saved there, the geocoder found nothing, and a route to a place on the real world ran across the whole map (#2976). Longitudes are now brought back onto [-180, 180] where the map hands them out (Leaflet and GL), where they are stored (places, booking endpoints, collection places, so REST, MCP, plugin RPC and importers alike) and before reverse geocoding. A migration moves the ones already stored. Leaflet draws a layer at its literal longitude only, so a trip from Los Angeles to Auckland had its pins at opposite ends of the world while the flight ran off the edge to the next copy (#2975). A trip that crosses the antimeridian is now drawn in one longitude frame, seamed in the widest stretch it leaves empty, so pins, routes, flights and every overlay lie on one copy and the map opens across the Pacific. Every other trip has no frame and draws exactly as before. The GL day fit also takes the short way. A new install now starts with Journey and Collections switched on, next to the trip addons (all but Road trip), Atlas, Vacay and the Naver import that already were. Existing installs keep their addons as they are. Closes #2975 Closes #2976
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/**
|
|
* MAPS-NEARBY-001..010 — places around a point, nearest first (#976).
|
|
*
|
|
* Three sources in the search's own order: the index, Google with a key, then
|
|
* OpenStreetMap. What matters is that each answer reads the same (the search
|
|
* record plus `distance_m`, nearest first), and that Google, which bills per
|
|
* call, is asked only when the index had nothing and never twice for one spot.
|
|
*/
|
|
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import {
|
|
nearbyCacheKey,
|
|
nearbyOverpassQuery,
|
|
nearestFirst,
|
|
overpassNearbyRecords,
|
|
} from '../../../src/nest/maps/maps-nearby.helpers';
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
import { noGoogleQuota } from '../../helpers/google-quota';
|
|
import { buildMapsService } from '../../helpers/maps-service';
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
const { mockNearby } = vi.hoisted(() => ({
|
|
mockNearby: vi.fn(
|
|
async (_lat: number, _lng: number, _opts?: { radius?: number; limit?: number }): Promise<unknown[]> => [],
|
|
),
|
|
}));
|
|
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
|
trekPlacesNearby: mockNearby,
|
|
}));
|
|
|
|
const indexRow = (gers: string, name: string, lat: number, lng: number) => ({
|
|
gers,
|
|
name,
|
|
lat,
|
|
lng,
|
|
category: 'cafe',
|
|
categoryPath: null,
|
|
confidence: 0.9,
|
|
address: { freeform: 'Pariser Platz 1', locality: 'Berlin', postcode: '10117', region: null, country: 'DE' },
|
|
contact: null,
|
|
brand: null,
|
|
source: 'overture',
|
|
hours: null,
|
|
distanceMetres: 0,
|
|
});
|
|
|
|
const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED;
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Every case asks about its own spot: the answers are cached per process, and a
|
|
// shared point would make the second case read the first one's answer.
|
|
let spot = 0;
|
|
function nextPoint() {
|
|
spot += 1;
|
|
return { lat: 10 + spot / 10, lng: 20 + spot / 10 };
|
|
}
|
|
|
|
function make(opts: { index?: boolean; google?: boolean } = {}) {
|
|
if (opts.index === false) process.env.TREK_PLACES_ENABLED = 'false';
|
|
else delete process.env.TREK_PLACES_ENABLED;
|
|
// No app_settings row and no per-user key: every setting reads as absent.
|
|
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
|
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
|
const svc = buildMapsService(
|
|
{} as PlacePhotoCacheService,
|
|
noAppSettings,
|
|
noUsers,
|
|
{} as never,
|
|
{} as never,
|
|
noGoogleQuota,
|
|
);
|
|
vi.spyOn(svc, 'keyedProvider').mockResolvedValue(
|
|
opts.google
|
|
? ({ id: 'google', key: 'test-key', source: 'user-row' } as Awaited<ReturnType<MapsService['keyedProvider']>>)
|
|
: null,
|
|
);
|
|
return svc;
|
|
}
|
|
|
|
const jsonResponse = (body: unknown, ok = true, status = 200) => ({ ok, status, json: async () => body });
|
|
|
|
beforeEach(() => {
|
|
mockNearby.mockReset();
|
|
mockNearby.mockResolvedValue([]);
|
|
warn.mockClear();
|
|
error.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED;
|
|
else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES;
|
|
});
|
|
|
|
describe('MapsService.nearbyPlaces', () => {
|
|
it('MAPS-NEARBY-001: the index answers first, nearest first, with the distance on each record', async () => {
|
|
const p = nextPoint();
|
|
mockNearby.mockResolvedValue([
|
|
indexRow('far', 'Far Cafe', p.lat + 0.003, p.lng),
|
|
indexRow('near', 'Near Cafe', p.lat + 0.0005, p.lng),
|
|
]);
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await make({ google: true }).nearbyPlaces(1, p.lat, p.lng, { radius: 800, limit: 5 });
|
|
|
|
// The language of the answer, English when none was asked for, the same
|
|
// default Google and OpenStreetMap get below (#2592).
|
|
expect(mockNearby).toHaveBeenCalledWith(p.lat, p.lng, { radius: 800, limit: 5, lang: 'en' });
|
|
expect(out.source).toBe('trek-places');
|
|
expect(out.places.map((r) => r.name)).toEqual(['Near Cafe', 'Far Cafe']);
|
|
expect(out.places[0].distance_m).toBe(56);
|
|
expect(out.places[0].osm_id).toBe('gers:near');
|
|
// A key is there, and still not spent.
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MAPS-NEARBY-002: with nothing in the index, Google is asked by distance inside the circle', async () => {
|
|
const p = nextPoint();
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
places: [
|
|
{ id: 'g-far', displayName: { text: 'Far' }, location: { latitude: p.lat + 0.002, longitude: p.lng } },
|
|
{
|
|
id: 'g-shut',
|
|
displayName: { text: 'Shut' },
|
|
location: { latitude: p.lat, longitude: p.lng },
|
|
businessStatus: 'CLOSED_PERMANENTLY',
|
|
},
|
|
{
|
|
id: 'g-near',
|
|
displayName: { text: 'Near' },
|
|
formattedAddress: 'Somewhere 1',
|
|
location: { latitude: p.lat + 0.0001, longitude: p.lng },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await make({ google: true }).nearbyPlaces(1, p.lat, p.lng, { lang: 'de' });
|
|
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toContain('places:searchNearby');
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
maxResultCount: 20,
|
|
rankPreference: 'DISTANCE',
|
|
languageCode: 'de',
|
|
locationRestriction: { circle: { center: { latitude: p.lat, longitude: p.lng }, radius: 500 } },
|
|
});
|
|
expect(out.source).toBe('google');
|
|
expect(out.places.map((r) => r.google_place_id)).toEqual(['g-near', 'g-far']);
|
|
expect(out.places[0]).toMatchObject({ name: 'Near', address: 'Somewhere 1', distance_m: 11, source: 'google' });
|
|
});
|
|
|
|
it('MAPS-NEARBY-003: a Google refusal is an error with its status, not an empty list', async () => {
|
|
const p = nextPoint();
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(jsonResponse({ error: { message: 'Quota exceeded' } }, false, 429)),
|
|
);
|
|
|
|
await expect(make({ google: true }).nearbyPlaces(1, p.lat, p.lng)).rejects.toMatchObject({
|
|
message: 'Quota exceeded',
|
|
status: 429,
|
|
});
|
|
expect(error).toHaveBeenCalledWith(
|
|
expect.stringContaining('searchNearby failed with 429 userId=1 keySource=user-row'),
|
|
);
|
|
});
|
|
|
|
it('MAPS-NEARBY-011: the index names the localities in the language asked for (#2592)', async () => {
|
|
const p = nextPoint();
|
|
mockNearby.mockResolvedValue([indexRow('near', 'Near Cafe', p.lat + 0.0005, p.lng)]);
|
|
|
|
await make().nearbyPlaces(1, p.lat, p.lng, { lang: 'br' });
|
|
|
|
expect(mockNearby).toHaveBeenCalledWith(p.lat, p.lng, expect.objectContaining({ lang: 'pt-BR' }));
|
|
});
|
|
|
|
it('MAPS-NEARBY-004: without a key OpenStreetMap answers, in the asked language, nearest first', async () => {
|
|
const p = nextPoint();
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
jsonResponse({
|
|
elements: [
|
|
{
|
|
type: 'node',
|
|
id: 2,
|
|
lat: p.lat + 0.001,
|
|
lon: p.lng,
|
|
tags: { name: 'Museum', 'name:de': 'Das Museum', tourism: 'museum' },
|
|
},
|
|
{ type: 'way', id: 3, center: { lat: p.lat + 0.0002, lon: p.lng }, tags: { name: 'Bakery', shop: 'bakery' } },
|
|
],
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await make().nearbyPlaces(1, p.lat, p.lng, { radius: 300, lang: 'de' });
|
|
|
|
const body = decodeURIComponent(String((fetchMock.mock.calls[0][1] as RequestInit).body));
|
|
expect(body).toContain(`around:300,${p.lat},${p.lng}`);
|
|
expect(out.source).toBe('openstreetmap');
|
|
expect(out.places.map((r) => r.name)).toEqual(['Bakery', 'Das Museum']);
|
|
expect(out.places[0]).toMatchObject({ osm_id: 'way:3', category: 'shop_bakery', distance_m: 22 });
|
|
});
|
|
|
|
it('MAPS-NEARBY-005: a failing index drops through to the next source and says so', async () => {
|
|
const p = nextPoint();
|
|
mockNearby.mockRejectedValue(new Error('index down'));
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ elements: [] })));
|
|
|
|
const out = await make().nearbyPlaces(1, p.lat, p.lng);
|
|
|
|
expect(out).toEqual({ places: [], source: 'openstreetmap' });
|
|
expect(warn).toHaveBeenCalledWith('TREK Places nearby failed, falling back:', 'index down');
|
|
});
|
|
|
|
it('MAPS-NEARBY-006: with the index switched off it is never asked', async () => {
|
|
const p = nextPoint();
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ elements: [] })));
|
|
|
|
await make({ index: false }).nearbyPlaces(1, p.lat, p.lng);
|
|
|
|
expect(mockNearby).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MAPS-NEARBY-007: the same spot asked twice costs one lookup', async () => {
|
|
const p = nextPoint();
|
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ places: [] }));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const svc = make({ google: true });
|
|
|
|
await svc.nearbyPlaces(1, p.lat, p.lng);
|
|
await svc.nearbyPlaces(1, p.lat + 0.00001, p.lng);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(mockNearby).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('nearby helpers', () => {
|
|
it('MAPS-NEARBY-008: the Overpass question is a named place of a visitable kind inside the circle, capped', () => {
|
|
const q = nearbyOverpassQuery(52.5, 13.4, 250.4, 10);
|
|
expect(q).toContain('nwr(around:250,52.5,13.4)[name][~"^(amenity|tourism|shop|leisure|historic)$"~"."];');
|
|
expect(q).toContain('out center tags 30;');
|
|
});
|
|
|
|
it('MAPS-NEARBY-009: unnamed, placeless and shut places are left out; the kind reads like a search category', () => {
|
|
const origin = { lat: 0, lng: 0 };
|
|
const out = overpassNearbyRecords(
|
|
[
|
|
{ type: 'node', id: 1, lat: 0, lon: 0, tags: { amenity: 'bench' } },
|
|
{ type: 'node', id: 2, tags: { name: 'Nowhere', amenity: 'cafe' } },
|
|
{ type: 'node', id: 3, lat: 0, lon: 0, tags: { name: 'Gone', amenity: 'bar', disused: 'yes' } },
|
|
{
|
|
type: 'node',
|
|
id: 4,
|
|
lat: 0,
|
|
lon: 0.001,
|
|
tags: {
|
|
int_name: 'Park',
|
|
leisure: 'park',
|
|
website: 'park.example',
|
|
'addr:street': 'Main',
|
|
'addr:housenumber': '5',
|
|
},
|
|
},
|
|
{ type: 'node', id: 5, lat: 0, lon: 0, tags: { name: 'Shopish', shop: 'yes', tourism: 'artwork' } },
|
|
],
|
|
origin,
|
|
'en',
|
|
10,
|
|
);
|
|
expect(out.map((r) => r.name)).toEqual(['Shopish', 'Park']);
|
|
expect(out[0].category).toBe('artwork');
|
|
expect(out[1]).toMatchObject({
|
|
address: 'Main 5',
|
|
website: 'https://park.example',
|
|
category: 'park',
|
|
distance_m: 111,
|
|
});
|
|
});
|
|
|
|
it('MAPS-NEARBY-010: records without a position are dropped and the list keeps to the limit; the cache key rounds to ten metres', () => {
|
|
const out = nearestFirst(
|
|
[
|
|
{ name: 'a', lat: 0, lng: 0.002 },
|
|
{ name: 'b', lat: null, lng: null },
|
|
{ name: 'c', lat: 0, lng: 0.001 },
|
|
],
|
|
{ lat: 0, lng: 0 },
|
|
1,
|
|
);
|
|
expect(out).toEqual([{ name: 'c', lat: 0, lng: 0.001, distance_m: 111 }]);
|
|
expect(nearbyCacheKey(52.516312, 13.377712, 500, 20, 'de')).toBe(nearbyCacheKey(52.51634, 13.37768, 500, 20, 'de'));
|
|
expect(nearbyCacheKey(52.5163, 13.3777, 500, 20, 'de')).not.toBe(nearbyCacheKey(52.5163, 13.3777, 500, 20, 'en'));
|
|
});
|
|
});
|
|
|
|
describe('MapsService.reverseGeocode on a repeated world copy (#2976)', () => {
|
|
it('MAPS-NEARBY-COPY-002: a right-click on a world copy is reverse geocoded on the real world, a real point as it came', async () => {
|
|
const svc = make();
|
|
const osm = (svc as unknown as { osm: { reverse: (...args: unknown[]) => Promise<unknown> } }).osm;
|
|
const reverse = vi.spyOn(osm, 'reverse').mockResolvedValue({ name: 'Manzanillo', address: null });
|
|
|
|
expect(await svc.reverseGeocode('10.14', '274.921875')).toEqual({ name: 'Manzanillo', address: null });
|
|
expect(reverse).toHaveBeenLastCalledWith('10.14', '-85.078125', undefined, undefined);
|
|
|
|
await svc.reverseGeocode('48.85', '2.3500');
|
|
expect(reverse).toHaveBeenLastCalledWith('48.85', '2.3500', undefined, undefined);
|
|
});
|
|
});
|