Files
TREK/shared/src/place/place-match.spec.ts
T
michael-bohr 84b51a8af5 refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.

The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.

Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".

No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.

COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
2026-08-29 12:22:46 +02:00

75 lines
2.9 KiB
TypeScript

import { COORD_DEDUP_TOLERANCE, externalIdsOf, normalizePlaceName, placeMatchStrategies } from './place-match';
import { describe, it, expect } from 'vitest';
describe('normalizePlaceName', () => {
it('trims and lowercases', () => {
expect(normalizePlaceName(' Eiffel Tower ')).toBe('eiffel tower');
});
it('treats blank, whitespace, null and undefined alike as no name', () => {
expect(normalizePlaceName('')).toBeNull();
expect(normalizePlaceName(' ')).toBeNull();
expect(normalizePlaceName(null)).toBeNull();
expect(normalizePlaceName(undefined)).toBeNull();
});
});
describe('externalIdsOf', () => {
it('collects the provider ids that carry a value, trimmed', () => {
expect(externalIdsOf({ google_place_id: ' ChIJ_a ', google_ftid: '0x1:0x2', osm_id: null })).toEqual([
'ChIJ_a',
'0x1:0x2',
]);
});
it('ignores blank ids', () => {
expect(externalIdsOf({ google_ftid: ' ', osm_id: '' })).toEqual([]);
});
it('keeps provider order: place_id, ftid, osm', () => {
expect(externalIdsOf({ osm_id: 'node/42', google_ftid: 'f', google_place_id: 'p' })).toEqual(['p', 'f', 'node/42']);
});
});
describe('placeMatchStrategies', () => {
it('puts every provider id first, one strategy each, in provider order', () => {
expect(placeMatchStrategies({ name: 'Trattoria', google_place_id: 'p', osm_id: 'node/42' })).toEqual([
{ by: 'externalId', id: 'p' },
{ by: 'externalId', id: 'node/42' },
{ by: 'name', name: 'trattoria' },
]);
});
it('never offers coordinates for a NAMED candidate, even when it has them', () => {
// The whole point of the rule: widening the coordinate check to named places
// would merge the restaurant and the bar in the same building.
const strategies = placeMatchStrategies({ name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 });
expect(strategies).toEqual([{ by: 'name', name: 'ground floor diner' }]);
expect(strategies.some((s) => s.by === 'coords')).toBe(false);
});
it('falls back to coordinates only when there is no name', () => {
expect(placeMatchStrategies({ name: null, lat: 48.85, lng: 2.35 })).toEqual([
{ by: 'coords', lat: 48.85, lng: 2.35, tolerance: COORD_DEDUP_TOLERANCE },
]);
});
it('offers provider ids then coordinates for an unnamed candidate that has both', () => {
expect(placeMatchStrategies({ name: ' ', google_ftid: 'f', lat: 1, lng: 2 })).toEqual([
{ by: 'externalId', id: 'f' },
{ by: 'coords', lat: 1, lng: 2, tolerance: COORD_DEDUP_TOLERANCE },
]);
});
it('offers nothing for a candidate with no name, no ids and no coordinates', () => {
expect(placeMatchStrategies({ name: null, lat: null, lng: null })).toEqual([]);
});
it('needs both halves of a coordinate pair', () => {
expect(placeMatchStrategies({ name: null, lat: 48.85, lng: null })).toEqual([]);
expect(placeMatchStrategies({ name: null, lat: null, lng: 2.35 })).toEqual([]);
});
});