feat(maps): search places through the TREK Places API

Adds a provider for the project's own place index and puts it in front of
the existing ones for search, autocomplete, details and the explore pills.
It needs no key, has no quota, and what it returns may be stored, which is
true of neither of the two providers it sits in front of.

Nothing about the Google path changes. Every call site falls through to
exactly what it did before when the index has no answer or the service is
unreachable, so a working search keeps working.

Two details worth knowing while reading:

- Details for a place from the index also resolve its OpenStreetMap record
  by name and coordinate, because the index has no opening hours yet and
  never has cuisine or a menu link. Without that the hours a user saw while
  adding a place vanished from its card afterwards.
- The explore pills keep reporting source 'openstreetmap'. It is a typed
  contract the client switches on, and the places are the same places.

TREK_PLACES_URL points at a different instance; unset means the public one.
This commit is contained in:
Maurice
2026-09-05 23:08:56 +02:00
committed by Maurice
parent 2a0ceb2687
commit 7f2a63e7f7
5 changed files with 618 additions and 3 deletions
+6
View File
@@ -134,6 +134,12 @@ export function deriveManaged(raw: RawEnv) {
export function deriveMaps(raw: RawEnv) {
return {
placesApiBase: raw.PLACES_API_BASE || undefined,
/**
* Base URL of the TREK Places API. Unset means the public instance; an
* operator who runs their own copy of the index points at it here, and one
* who wants nothing to leave their network points at their own machine.
*/
trekPlacesUrl: raw.TREK_PLACES_URL || undefined,
placesApiKey: raw.PLACES_API_KEY || undefined,
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
+1
View File
@@ -143,6 +143,7 @@ export const envSchema = z.object({
ADMIN_PASSWORD: anyString,
TREK_MANAGED: boolStr,
PLACES_API_BASE: url,
TREK_PLACES_URL: url,
PLACES_API_KEY: anyString,
MAPBOX_ACCESS_TOKEN: anyString,
CARTO_API_KEY: anyString,
+193 -3
View File
@@ -15,6 +15,13 @@ import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
trekPlacesSearch,
trekPlacesById,
trekPlacesNearby,
toPlaceRecord,
POI_CATEGORY_TO_TREK,
} from './trek-places.client';
import {
UA,
SEARCH_TEXT_FIELD_MASK,
@@ -539,6 +546,19 @@ export class MapsService {
return row?.value === 'false';
}
/**
* Fail-OPEN, unlike the three Google switches: the index is the path we want
* people on, and an install that upgrades without visiting the admin panel
* should land on it rather than stay on Nominatim, whose usage policy forbids
* what TREK was doing with it.
*/
trekPlacesEnabled(): boolean {
const row = this.database.get<{ value: string }>(
"SELECT value FROM app_settings WHERE key = 'trek_places_enabled'",
);
return row?.value !== 'false';
}
autocompleteDisabled(): boolean {
return this.isSettingDisabled('places_autocomplete_enabled');
}
@@ -585,8 +605,70 @@ export class MapsService {
return this.resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
}
// OSM-only POI search by category within a viewport bbox (never calls Google).
pois(category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string) {
// POI search by category within a viewport. Never calls Google.
//
// The index answers first: it holds the same places, indexed in an R-Tree, and
// the public Overpass mirrors this used to depend on are regularly overloaded.
// A miss or a failure drops through to Overpass exactly as before.
async pois(
category: string,
bbox: { south: number; west: number; north: number; east: number },
lang?: string,
): Promise<PoiSearchResult> {
const terms = POI_CATEGORY_TO_TREK[category];
if (this.trekPlacesEnabled() && terms) {
try {
const lat = (bbox.south + bbox.north) / 2;
const lng = (bbox.west + bbox.east) / 2;
// Half the diagonal, so the circle covers the viewport corners rather
// than leaving the edges of the map empty.
const radius = Math.min(
20000,
Math.max(
300,
Math.round(
(Math.hypot(
(bbox.north - bbox.south) * 111_320,
(bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180),
) / 2),
),
),
);
const found = await trekPlacesNearby(lat, lng, {
radius,
limit: 50,
category: terms.join(','),
});
if (found.length > 0) {
// Same shape the Overpass path produces, so the client and the map
// renderer need no branch. `source` stays 'openstreetmap' because it
// is a typed contract the client switches on, and the places are the
// same places; where they came from is an implementation detail of
// this method, not something the pill should render differently.
return {
pois: found.map(p => ({
osm_id: `gers:${p.gers}`,
name: p.name,
lat: p.lat,
lng: p.lng,
category,
poi_type: p.category ?? category,
address: p.address?.freeform ?? null,
website: p.contact?.website ?? null,
phone: p.contact?.phone ?? null,
opening_hours: null,
cuisine: null,
source: 'openstreetmap' as const,
})),
source: 'openstreetmap' as const,
truncated: found.length >= 50,
clamped: false,
};
}
} catch (err: unknown) {
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
}
}
return this.searchOverpassPois(category, bbox, lang);
}
@@ -974,6 +1056,18 @@ export class MapsService {
* want a single image (fetchWikimediaPhoto, and through it getPlacePhoto) take
* the first entry and get exactly the file they got before this existed.
*/
/**
* Anything photographed near a coordinate. The bottom rung of the picture
* ladder, and the only one with no claim on the subject at all.
*
* 60 metres rather than the 300 it used to be. Three hundred metres in a city
* centre is a whole block: the town hall, the church and the underground
* entrance are all inside it, and every one of them outranks the doner shop
* we were actually asked about. Sixty is roughly "the same building and its
* neighbours", which is the widest a picture can be taken and still plausibly
* show the place. It does not fix the real problem, which is that nobody
* photographed the shop, but it stops the wrong answer from being confident.
*/
async fetchCommonsCandidates(lat: number, lng: number, limit = 5): Promise<CommonsCandidate[]> {
const params = new URLSearchParams({
action: 'query',
@@ -981,7 +1075,7 @@ export class MapsService {
generator: 'geosearch',
ggsprimary: 'all',
ggsnamespace: '6',
ggsradius: '300',
ggsradius: '60',
ggscoord: `${lat}|${lng}`,
// Deliberately more than the caller asked for. Around anything worth
// visiting the first few hits are survey tiles, passers-by and the
@@ -1463,6 +1557,30 @@ export class MapsService {
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
// The TREK index answers first, whether or not a Google key exists. It is
// the only source here that may be stored, works offline as a country
// package, and costs the caller nothing; a key buys ratings and photos on
// top of it, not a better search.
//
// It never throws upward. A search that used to work must keep working when
// the service is slow or down, so a failure drops through to exactly what
// this method did before.
if (this.trekPlacesEnabled()) {
try {
const found = await trekPlacesSearch(query, {
lat: locationBias?.lat,
lng: locationBias?.lng,
limit: 10,
});
if (found.length > 0) {
return { places: found.map(toPlaceRecord), source: 'trek-places' };
}
} catch (err: unknown) {
// Logged, not surfaced: the fallback below is a working answer.
console.warn('TREK Places search failed, falling back:', (err as Error).message);
}
}
if (!apiKey) {
const places = await this.searchNominatim(query, lang);
return { places, source: 'openstreetmap' };
@@ -1534,6 +1652,37 @@ export class MapsService {
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
// This is the path that mattered most. Nominatim's usage policy names
// autocomplete as unacceptable use in its own words, regardless of rate,
// and the whole TREK fleet shares one User-Agent there: one abusive install
// could get every instance blocked at once. The index removes that.
//
// Same contract as search: never throws upward, falls through to what this
// method did before.
if (this.trekPlacesEnabled()) {
try {
const centre = locationBias
? {
lat: (locationBias.low.lat + locationBias.high.lat) / 2,
lng: (locationBias.low.lng + locationBias.high.lng) / 2,
}
: undefined;
const found = await trekPlacesSearch(input, { lat: centre?.lat, lng: centre?.lng, limit: 8 });
if (found.length > 0) {
return {
suggestions: found.map(p => ({
placeId: `gers:${p.gers}`,
mainText: p.name,
secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '),
})),
source: 'trek-places',
};
}
} catch (err: unknown) {
console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message);
}
}
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
}
@@ -1620,6 +1769,47 @@ export class MapsService {
lang?: string,
sessionToken?: string,
): Promise<{ place: Record<string, unknown> | null }> {
// A place picked out of the TREK index. Checked BEFORE the generic
// colon branch below, which would otherwise read "gers" as an OSM type
// and ask Overpass for an element that does not exist.
if (placeId.startsWith('gers:')) {
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
if (!found) return { place: null };
// What the index does not have, the free sources still do. The index
// carries no opening hours yet and never carries cuisine, wheelchair
// access or a menu link; OpenStreetMap has all of them for the same
// building. Without this the hours a user saw while adding the place
// disappeared from its card afterwards, which reads like data loss.
//
// Matched by name and coordinate, with the same two gates
// resolveOsmIdentity applies everywhere: within range, and sharing a
// substantial word of the name. A confident description of the building
// next door is worse than none.
const osm = await this.resolveOsmIdentity(found.name, found.lat, found.lng, {
lang,
maxDistanceM: 150,
}).catch(() => null);
const record = toPlaceRecord(found);
if (!osm) return { place: record };
const osmDetails = buildOsmDetails(osm.tags, '', '');
return {
place: {
// Index first: its name, coordinate and contact details are the ones
// the user picked. OSM only fills what is still missing.
...osmDetails,
...record,
opening_hours: osmDetails.opening_hours ?? null,
website: record.website ?? osmDetails.website ?? null,
phone: record.phone ?? osmDetails.phone ?? null,
osm_id: placeId,
source: 'trek-places',
},
};
}
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
const [osmType, osmId] = placeId.split(':');
+229
View File
@@ -0,0 +1,229 @@
/**
* Client for the TREK Places API.
*
* The index behind it is Overture Places, built monthly and served from a
* server the project runs. It replaces the two things TREK used to do badly:
* searching through Nominatim, which its own usage policy forbids for
* autocomplete, and paying Google for a text search whose terms then forbid
* storing what comes back.
*
* Everything here is read-only, unauthenticated and cacheable. There is no key
* to configure and no quota to exhaust, so the failure modes are the network
* and the service being down, both of which fall back to what TREK did before.
*/
import { readEnv } from '../../app-config';
/** The public instance. An operator may point at their own copy instead. */
export const DEFAULT_TREK_PLACES_URL = 'https://places.liketrek.com';
/**
* Short on purpose. This sits in front of a keystroke, and a slow answer is
* worse than no answer: the caller falls back to the old path rather than
* leaving somebody watching a spinner.
*/
const TIMEOUT_MS = 3500;
/** A search answer is a few kB. A megabyte means something is wrong upstream. */
const MAX_BYTES = 1_000_000;
export interface TrekPlace {
gers: string;
name: string;
lat: number;
lng: number;
category: string | null;
categoryPath: string | null;
confidence: number | null;
address: {
freeform: string | null;
locality: string | null;
postcode: string | null;
region: string | null;
country: string | null;
};
contact: {
website: string | null;
phone: string | null;
email: string | null;
socials: string[] | null;
};
brand: { name: string | null; wikidata: string | null } | null;
source: string;
hours: unknown | null;
score?: number;
}
export interface TrekPlacesSearchResponse {
query: string;
results: TrekPlace[];
}
/**
* One opaque string per instance, so the service can rate-limit per caller
* instead of per IP. Derived from the instance URL rather than generated and
* stored: it must be stable across restarts, and it must not be anything that
* identifies a person. It is never resolved back to anything on the far side.
*/
function instanceToken(): string {
const url = readEnv().app.appUrl || 'unconfigured';
let hash = 0;
for (let i = 0; i < url.length; i++) {
hash = ((hash << 5) - hash + url.charCodeAt(i)) | 0;
}
return `trek-${(hash >>> 0).toString(36)}`;
}
export function trekPlacesBaseUrl(): string {
const configured = (readEnv().maps.trekPlacesUrl || '').trim();
return (configured || DEFAULT_TREK_PLACES_URL).replace(/\/+$/, '');
}
async function getJson<T>(path: string, params: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(trekPlacesBaseUrl() + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'application/json',
'X-TREK-Instance': instanceToken(),
},
});
if (!res.ok) {
const err = new Error(`TREK Places API ${res.status}`) as Error & { status: number };
err.status = res.status;
throw err;
}
// Read with a cap rather than res.json(): a hostile or broken upstream
// should not be able to hand us an unbounded body to buffer.
const text = await readCapped(res, MAX_BYTES);
return JSON.parse(text) as T;
} finally {
clearTimeout(timer);
}
}
async function readCapped(res: Response, max: number): Promise<string> {
const reader = res.body?.getReader();
if (!reader) return res.text();
const chunks: Uint8Array[] = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > max) {
await reader.cancel();
throw new Error('TREK Places API response too large');
}
chunks.push(value);
}
return Buffer.concat(chunks).toString('utf8');
}
export async function trekPlacesSearch(
query: string,
opts: { lat?: number; lng?: number; limit?: number } = {},
): Promise<TrekPlace[]> {
const body = await getJson<TrekPlacesSearchResponse>('/v1/search', {
q: query,
lat: opts.lat,
lng: opts.lng,
limit: opts.limit ?? 10,
});
return Array.isArray(body.results) ? body.results : [];
}
/**
* The planner's category pills, mapped onto Overture categories.
*
* Deliberately a hand-written map rather than a guess at runtime: the two
* taxonomies do not line up, and a pill that quietly returns the wrong kind of
* place is worse than one that returns none. Each entry is a substring matched
* against `category` and `category_path`, so `eat_and_drink>cafe` answers the
* cafe pill without listing every leaf.
*/
export const POI_CATEGORY_TO_TREK: Record<string, string[]> = {
restaurant: ['restaurant', 'casual_eatery', 'fast_food'],
cafe: ['cafe', 'coffee_shop'],
bar: ['bar', 'pub', 'nightclub', 'brewery'],
hotel: ['hotel', 'lodging', 'hostel', 'motel', 'bed_and_breakfast'],
sights: ['historic_site', 'landmark_and_historical_building', 'monument', 'castle', 'tourist_attraction'],
museum: ['museum', 'art_gallery', 'performing_arts', 'theater'],
nature: ['park', 'garden', 'beach', 'nature_preserve', 'mountain'],
activity: ['amusement_park', 'zoo', 'aquarium', 'water_park', 'theme_park'],
shopping: ['shopping', 'shopping_center', 'department_store', 'market'],
supermarket: ['grocery_store', 'food_and_beverage_store', 'convenience_store', 'supermarket'],
};
export interface TrekNearbyPlace extends TrekPlace {
distanceMetres: number;
}
/**
* Places around a coordinate, nearest first. Served from an R-Tree, so it
* answers in milliseconds where the Overpass mirrors this used to ask are
* regularly overloaded and answer in seconds or not at all.
*/
export async function trekPlacesNearby(
lat: number,
lng: number,
opts: { radius?: number; limit?: number; category?: string } = {},
): Promise<TrekNearbyPlace[]> {
const body = await getJson<{ results: TrekNearbyPlace[] }>('/v1/nearby', {
lat,
lng,
radius: Math.round(opts.radius ?? 1500),
limit: opts.limit ?? 50,
category: opts.category,
});
return Array.isArray(body.results) ? body.results : [];
}
export async function trekPlacesById(gers: string): Promise<TrekPlace | null> {
try {
const body = await getJson<{ place: TrekPlace }>(`/v1/place/${encodeURIComponent(gers)}`, {});
return body.place ?? null;
} catch (err) {
if ((err as { status?: number }).status === 404) return null;
throw err;
}
}
/**
* Map a TREK place onto the record shape the client already reads.
*
* Deliberately the same shape Nominatim and Google produce, so nothing on the
* client had to learn a third format. `osm_id` carries the GERS id prefixed
* with `gers:`, because that field is where the client already looks for "the
* provider's id for this place", and the prefix keeps it from being mistaken
* for an OSM reference by code that parses it.
*/
export function toPlaceRecord(p: TrekPlace): Record<string, unknown> {
const address = [p.address?.freeform, p.address?.postcode, p.address?.locality]
.filter(Boolean)
.join(', ');
return {
google_place_id: null,
google_ftid: null,
osm_id: `gers:${p.gers}`,
name: p.name,
address: address || p.address?.locality || '',
lat: Number.isFinite(p.lat) ? p.lat : null,
lng: Number.isFinite(p.lng) ? p.lng : null,
// Ratings exist nowhere in open data, and saying so with null is honest.
rating: null,
website: p.contact?.website ?? null,
phone: p.contact?.phone ?? null,
email: p.contact?.email ?? null,
category: p.category ?? null,
brand: p.brand?.name ?? null,
wikidata: p.brand?.wikidata ?? null,
source: 'trek-places',
};
}
@@ -0,0 +1,189 @@
/**
* The TREK Places client: what it sends, what it refuses, and how it maps an
* answer onto the record shape the rest of TREK already reads.
*
* fetch is stubbed rather than hit: the point is the contract, and a unit test
* that depends on a live service tells you about the service.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../../src/app-config', () => ({
readEnv: () => ({ maps: { trekPlacesUrl: '' }, app: { appUrl: 'https://trip.example.org' } }),
}));
import {
DEFAULT_TREK_PLACES_URL,
POI_CATEGORY_TO_TREK,
toPlaceRecord,
trekPlacesBaseUrl,
trekPlacesById,
trekPlacesNearby,
trekPlacesSearch,
type TrekPlace,
} from '../../../src/nest/maps/trek-places.client';
const PLACE: TrekPlace = {
gers: 'abc-123',
name: "L'Osteria",
lat: 54.0879,
lng: 12.1408,
category: 'restaurant',
categoryPath: 'eat_and_drink>restaurant',
confidence: 1,
address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: 'MV', country: 'DE' },
contact: { website: 'https://losteria.net/', phone: '+49381', email: 'r@losteria.de', socials: null },
brand: { name: "L'Osteria", wikidata: 'Q123' },
source: 'overture',
hours: null,
};
let calls: { url: string; init?: RequestInit }[] = [];
function stubFetch(body: unknown, status = 200) {
calls = [];
vi.stubGlobal('fetch', vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({ url: String(url), init });
return {
ok: status >= 200 && status < 300,
status,
body: null,
text: async () => JSON.stringify(body),
} as unknown as Response;
}));
}
beforeEach(() => { calls = []; });
afterEach(() => vi.unstubAllGlobals());
describe('trekPlacesBaseUrl', () => {
it('falls back to the public instance when nothing is configured', () => {
expect(trekPlacesBaseUrl()).toBe(DEFAULT_TREK_PLACES_URL);
});
});
describe('trekPlacesSearch', () => {
it('sends the query, the bias and a limit', async () => {
stubFetch({ query: 'x', results: [PLACE] });
const found = await trekPlacesSearch('losteria', { lat: 54.1, lng: 12.1, limit: 5 });
expect(found).toHaveLength(1);
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/v1/search');
expect(url.searchParams.get('q')).toBe('losteria');
expect(url.searchParams.get('lat')).toBe('54.1');
expect(url.searchParams.get('limit')).toBe('5');
});
it('identifies the instance without identifying a person', async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
const headers = calls[0].init?.headers as Record<string, string>;
const token = headers['X-TREK-Instance'];
// Stable, opaque, and derived from the instance URL rather than a user or
// an install id: it exists so one caller cannot exhaust everyone's limit.
expect(token).toMatch(/^trek-[a-z0-9]+$/);
expect(token).not.toContain('example.org');
});
it('omits parameters that were not given rather than sending empties', async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
const url = new URL(calls[0].url);
expect(url.searchParams.has('lat')).toBe(false);
expect(url.searchParams.has('lng')).toBe(false);
});
it('throws on an error status so the caller can fall back', async () => {
stubFetch({}, 503);
await expect(trekPlacesSearch('x')).rejects.toThrow(/503/);
});
it('survives a malformed results field', async () => {
stubFetch({ query: 'x', results: 'not an array' });
await expect(trekPlacesSearch('x')).resolves.toEqual([]);
});
});
describe('trekPlacesById', () => {
it('returns null for a place that does not exist instead of throwing', async () => {
stubFetch({}, 404);
await expect(trekPlacesById('nope')).resolves.toBeNull();
});
it('lets other failures through', async () => {
stubFetch({}, 500);
await expect(trekPlacesById('x')).rejects.toThrow();
});
it('escapes the id into the path', async () => {
stubFetch({ place: PLACE });
await trekPlacesById('a/b c');
expect(calls[0].url).toContain('/v1/place/a%2Fb%20c');
});
});
describe('trekPlacesNearby', () => {
it('rounds the radius and passes the category list through', async () => {
stubFetch({ results: [] });
await trekPlacesNearby(54.1, 12.1, { radius: 812.7, category: 'cafe,coffee_shop' });
const url = new URL(calls[0].url);
expect(url.searchParams.get('radius')).toBe('813');
expect(url.searchParams.get('category')).toBe('cafe,coffee_shop');
});
});
describe('toPlaceRecord', () => {
it('produces the same shape the other providers do', () => {
const r = toPlaceRecord(PLACE);
expect(r).toMatchObject({
google_place_id: null,
osm_id: 'gers:abc-123',
name: "L'Osteria",
address: 'Steinstrasse 9, 18055, Rostock',
lat: 54.0879,
website: 'https://losteria.net/',
phone: '+49381',
source: 'trek-places',
});
});
it('reports no rating rather than a made-up one', () => {
// Ratings exist in no open dataset. Null says so; 0 would be a claim.
expect(toPlaceRecord(PLACE).rating).toBeNull();
});
it('keeps a place on the null island rather than dropping its coordinate', () => {
const r = toPlaceRecord({ ...PLACE, lat: 0, lng: 0 });
expect(r.lat).toBe(0);
expect(r.lng).toBe(0);
});
it('leaves the coordinate null when it is not a number', () => {
const r = toPlaceRecord({ ...PLACE, lat: Number.NaN, lng: Number.NaN });
expect(r.lat).toBeNull();
});
it('builds an address from whatever parts exist', () => {
const r = toPlaceRecord({
...PLACE,
address: { freeform: null, locality: 'Rostock', postcode: null, region: null, country: 'DE' },
});
expect(r.address).toBe('Rostock');
});
});
describe('POI_CATEGORY_TO_TREK', () => {
it('covers every pill the planner offers', () => {
// The keys are the contract with the client's POI_CATEGORIES. A missing one
// silently drops that pill back to Overpass, which is the slow path.
expect(Object.keys(POI_CATEGORY_TO_TREK).sort()).toEqual([
'activity', 'bar', 'cafe', 'hotel', 'museum',
'nature', 'restaurant', 'shopping', 'sights', 'supermarket',
]);
});
it('gives every pill at least one category to match on', () => {
for (const [pill, terms] of Object.entries(POI_CATEGORY_TO_TREK)) {
expect(terms.length, pill).toBeGreaterThan(0);
}
});
});