From e9e7b7ed0e60a4604484bf9928a8822c1e935c01 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 23:02:35 +0200 Subject: [PATCH] fix(search): require complete business location evidence --- .changeset/business-location-intent.md | 5 + .../src/components/search/SearchBar.test.tsx | 64 +++++++++++ docs/docs/features/search.md | 11 ++ .../business-location-intent.test.ts | 102 ++++++++++++++++++ .../fixtures/business-location-intent.json | 56 ++++++++++ packages/core/src/utils/searchSuggestion.ts | 25 +++-- 6 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 .changeset/business-location-intent.md create mode 100644 packages/core/src/utils/__tests__/business-location-intent.test.ts create mode 100644 packages/core/src/utils/__tests__/fixtures/business-location-intent.json diff --git a/.changeset/business-location-intent.md b/.changeset/business-location-intent.md new file mode 100644 index 000000000..e483a67c2 --- /dev/null +++ b/.changeset/business-location-intent.md @@ -0,0 +1,5 @@ +--- +"@openmapx/core": patch +--- + +Require complete address-word evidence for exact autocomplete location intent while retaining weaker prefix matches and explicit remote destinations. diff --git a/apps/web/src/components/search/SearchBar.test.tsx b/apps/web/src/components/search/SearchBar.test.tsx index 70c7a9025..e583840c4 100644 --- a/apps/web/src/components/search/SearchBar.test.tsx +++ b/apps/web/src/components/search/SearchBar.test.tsx @@ -4,6 +4,7 @@ import { Profiler } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getMapObstructionInsets, publishMapObstruction } from "@/lib/mapObstructions"; import { act, createFakeMap, createQueryWrapper, fireEvent, render, screen, waitFor } from "@/test"; +import businessLocationFixture from "../../../../../packages/core/src/utils/__tests__/fixtures/business-location-intent.json"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); @@ -136,11 +137,13 @@ beforeEach(() => { useMediaQueryMock.mockReset().mockReturnValue(false); flyToMock.mockReset(); fakeMap.state.center = { lng: 0, lat: 0 }; + fakeMap.state.zoom = 10; launchExploreFromPlace.mockReset(); launchExploreTextSearch.mockReset(); launchTextSearch.mockReset(); useSearchStore.getState().reset(); useDirectionsStore.getState().close(); // SearchBar returns null while directions open + useCategorySearchStore.getState().clearCategory(); useCategorySearchStore.setState({ anchor: null, exploreBoxOpen: false, activeCategory: null }); usePlaceStore.setState({ selectedPlace: null }); useMapStore.setState({ userLocation: null }); @@ -466,6 +469,67 @@ describe("SearchBar", () => { expect(useRecentSearchStore.getState().entries).toEqual(["Berlin Hbf"]); }); + it("shows Berlin first for weak remote address-prefix evidence and plain Enter searches the area", async () => { + fakeMap.state.center = { lng: 13.416, lat: 52.5194 }; + fakeMap.state.zoom = 15; + const places = businessLocationFixture as AutocompleteResult[]; + useAutocompleteMock.mockReturnValue({ data: places, isFetching: false }); + // Forward retrieval is independently remote-first; it must not rescue a weak POI match. + useGeocodingMock.mockReturnValue({ + data: places.map((p) => ({ ...p, label: p.sublabel, confidence: 0.78 })), + }); + useSettingsStore.setState({ aiSearchEnabled: false }); + useSearchStore.setState({ query: "MediaMarkt Alexa", isFocused: true }); + renderBar(); + const input = screen.getByLabelText("search.ariaLabel"); + await screen.findByRole("option", { name: /Grunerstraße 20/ }); + const options = screen.getAllByRole("option"); + expect(options[0]).toHaveTextContent("Grunerstraße 20"); + expect(options[1]).toHaveTextContent("Pr Willem Alexander Prom"); + fireEvent.submit(input.closest("form") as HTMLFormElement); + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + expect(flyToMock).not.toHaveBeenCalled(); + expect(launchTextSearch).toHaveBeenCalledWith(fakeMap.map, "MediaMarkt Alexa"); + }); + + it.each([ + ["MediaMarkt Alexa", "maptiler:poi.15422100", "Grunerstraße 20"], + ["MediaMarkt Rijswijk", "maptiler:poi.15539884", "Pr Willem Alexander Prom"], + ])( + "selects the displayed first row for %s with ArrowDown + Enter", + async (query, id, address) => { + fakeMap.state.center = { lng: 13.416, lat: 52.5194 }; + fakeMap.state.zoom = 15; + useAutocompleteMock.mockReturnValue({ data: businessLocationFixture, isFetching: false }); + renderBar(); + const input = screen.getByLabelText("search.ariaLabel"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: query } }); + await screen.findByRole("option", { name: /Grunerstraße 20/ }); + expect(screen.getAllByRole("option")[0]).toHaveTextContent(address); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(usePlaceStore.getState().selectedPlace?.address).toContain(address); + expect(usePlaceStore.getState().selectedPlace?.ids.maptiler).toBe( + id.replace("maptiler:", ""), + ); + }, + ); + + it("plain Enter still opens the explicitly named remote business city", async () => { + fakeMap.state.center = { lng: 13.416, lat: 52.5194 }; + fakeMap.state.zoom = 15; + useAutocompleteMock.mockReturnValue({ data: businessLocationFixture, isFetching: false }); + useSearchStore.setState({ query: "MediaMarkt Rijswijk", isFocused: true }); + renderBar(); + const input = screen.getByLabelText("search.ariaLabel"); + await screen.findByRole("option", { name: /Pr Willem Alexander Prom/ }); + fireEvent.submit(input.closest("form") as HTMLFormElement); + await waitFor(() => + expect(usePlaceStore.getState().selectedPlace?.address).toContain("Rijswijk"), + ); + }); + it("records an explicitly submitted query without storing every keystroke", async () => { renderBar(); const input = screen.getByLabelText("search.ariaLabel"); diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index 66df0bb9b..0ecf4c326 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -42,6 +42,17 @@ Paris, while a word that names a kind of place nearby ("vegan", "döner") or a chain's branches ("aldi") search what you can currently see on the map. When several equally known places far away share the name ("springfield"), the list stays open with the first one highlighted, and a second Enter takes it. + +A complete name followed by complete normalized address words (for example, +"MediaMarkt Rijswijk") supplies explicit location evidence, even far from the +map. A partial address word remains a weaker autocomplete match: "Alexa" in a +street named "Alexander" does not establish exact remote business intent. +Ordinary name prefixes, explicit aliases and official codes keep their existing +confidence. When the returned rows do not confidently name the query, plain +Enter follows the existing natural-language or visible-area search path; an +explicit dropdown choice still opens that row. This can make the dropdown order +differ from the server's candidate order without changing candidate retrieval. + Coordinates and Plus Codes are detected client side and resolved without a round trip to a geocoder at all. diff --git a/packages/core/src/utils/__tests__/business-location-intent.test.ts b/packages/core/src/utils/__tests__/business-location-intent.test.ts new file mode 100644 index 000000000..37e59c912 --- /dev/null +++ b/packages/core/src/utils/__tests__/business-location-intent.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { AutocompleteResult } from "../../types/geocoding"; +import { queryNamesLocation, textMatchScore } from "../searchSuggestion"; +import { enterAction, rankAutocompleteRows } from "../suggestionRanking"; +import captured from "./fixtures/business-location-intent.json"; + +// Adapted hosted autocomplete, captured 2026-10-07. Not raw MapTiler data. +const places = captured as AutocompleteResult[]; +const [rijswijk, berlin] = places; +const context = { + query: "MediaMarkt Alexa", + proximity: [13.416, 52.5194] as [number, number], + zoom: 15, +}; + +describe("business location evidence", () => { + it("retains a partial street match without treating it as an exact destination", () => { + expect(textMatchScore(rijswijk, context.query)).toBeLessThan(0.75); + expect(textMatchScore(rijswijk, context.query)).toBeGreaterThan( + textMatchScore(berlin, context.query), + ); + expect(queryNamesLocation(rijswijk, context.query)).toBe(false); + }); + + it("ranks Berlin above the weak remote prefix despite Rijswijk-first retrieval", () => { + expect(places[0].id).toBe("maptiler:poi.15539884"); + const rows = rankAutocompleteRows({ places }, context); + expect(rows).toHaveLength(places.length); + expect(rows[0].id).toBe("maptiler:poi.15422100"); + expect(rows[1].id).toBe("maptiler:poi.15539884"); + expect(enterAction(rows, context)).toEqual({ kind: "search", weak: true }); + }); + + it.each([ + "MediaMarkt Rijswijk", + "MediaMarkt Alexander", + "MediaMarkt Pr Willem Alexander Prom 69-83", + "MediaMarkt 2284 DJ Rijswijk", + ])("retains complete remote city/address intent: %s", (query) => { + const remote = { ...context, query }; + expect(queryNamesLocation(rijswijk, query)).toBe(true); + expect(rankAutocompleteRows({ places }, remote)[0].id).toBe(rijswijk.id); + expect(enterAction(rankAutocompleteRows({ places }, remote), remote)).toMatchObject({ + kind: "open", + row: { id: rijswijk.id }, + }); + }); + + it.each(["MediaMarkt Rijs", "MediaMarkt Willem Alexa", "MediaMarkt 69-8"])( + "does not establish location intent from incomplete or repeated name words: %s", + (query) => { + expect(queryNamesLocation(rijswijk, query)).toBe(false); + expect(textMatchScore(rijswijk, query)).toBeLessThan(0.75); + }, + ); + + it("does not use the repeated display name as address corroboration", () => { + expect(queryNamesLocation(rijswijk, "MediaMarkt MediaMarkt")).toBe(false); + }); + + it("preserves normalized numbered street evidence", () => { + const row: AutocompleteResult = { + ...rijswijk, + sublabel: "MediaMarkt, Friedrichstraße 100, Berlin", + }; + expect(queryNamesLocation(row, "MediaMarkt Friedrichstr. 100")).toBe(true); + expect(textMatchScore(row, "MediaMarkt Friedrichstr. 100")).toBe(1); + }); + + it("keeps primary-name autocomplete confident without declaring an address", () => { + const row: AutocompleteResult = { ...rijswijk, label: "MediaMarkt Alexanderplatz" }; + expect(textMatchScore(row, context.query)).toBe(0.8); + expect(queryNamesLocation(row, context.query)).toBe(false); + }); + + it.each(["explicit_alias", "authoritative_code"] as const)( + "preserves exact remote %s evidence", + (kind) => { + const row: AutocompleteResult = { + ...rijswijk, + searchMatch: { kind, value: "MMA", normalized: "mma" }, + }; + const remote = { ...context, query: "MMA" }; + expect( + enterAction(rankAutocompleteRows({ places: [berlin, row] }, remote), remote), + ).toMatchObject({ kind: "open", row: { id: rijswijk.id } }); + }, + ); + + it("preserves exact station abbreviation and same-name proximity ranking", () => { + const station: AutocompleteResult = { + ...rijswijk, + label: "Neuss Hbf", + sublabel: "Neuss Hbf, Neuss", + rawCategory: "railway/station", + }; + expect(textMatchScore(station, "Neuss Hauptbahnhof")).toBe(1); + expect(rankAutocompleteRows({ places }, { ...context, query: "MediaMarkt" })[0].id).toBe( + berlin.id, + ); + }); +}); diff --git a/packages/core/src/utils/__tests__/fixtures/business-location-intent.json b/packages/core/src/utils/__tests__/fixtures/business-location-intent.json new file mode 100644 index 000000000..67b6fe223 --- /dev/null +++ b/packages/core/src/utils/__tests__/fixtures/business-location-intent.json @@ -0,0 +1,56 @@ +[ + { + "id": "maptiler:poi.15539884", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Pr Willem Alexander Prom 69-83, 2284 DJ Rijswijk, Netherlands", + "coordinates": [4.316869229078293, 52.03636822992996], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + }, + { + "id": "maptiler:poi.15422100", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Grunerstraße 20, 10179 Berlin, Germany", + "coordinates": [13.414790779352188, 52.51986159027454], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + }, + { + "id": "maptiler:poi.15388462", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Äußere Schneeberger Straße 95, 08056 Zwickau, Germany", + "coordinates": [12.499412186443806, 50.70416170071806], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + }, + { + "id": "maptiler:poi.15513302", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Waldershofer Straße 10, 95615 Marktredwitz, Germany", + "coordinates": [12.077845813448675, 49.99956173454102], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + }, + { + "id": "maptiler:poi.27301831", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Schloßplatz 8, 26122 Oldenburg, Germany", + "coordinates": [8.216576837003231, 53.13856416583732], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + }, + { + "id": "maptiler:poi.31290179", + "label": "MediaMarkt", + "sublabel": "MediaMarkt, Posthalterweg 15, 26129 Oldenburg, Germany", + "coordinates": [8.172727230560895, 53.16099834226386], + "type": "poi", + "rawCategory": "electronics", + "provider": "geocoding-maptiler" + } +] diff --git a/packages/core/src/utils/searchSuggestion.ts b/packages/core/src/utils/searchSuggestion.ts index 1ad2e1774..64a721042 100644 --- a/packages/core/src/utils/searchSuggestion.ts +++ b/packages/core/src/utils/searchSuggestion.ts @@ -323,10 +323,22 @@ function nameWithoutItsTown(item: AutocompleteResult): string | undefined { /** * Whether the query is the row's whole name followed by words of its address: * "10115 berlin" for the postcode 10115 in Berlin, "paris france". + * Only complete address words corroborate exact location intent. Prefixes + * remain autocomplete evidence, scored separately below, rather than turning + * "Alexa" in a street called "Alexander" into an explicit remote destination. */ -function isNameWithItsPlace(key: string, label: string, context: string): boolean { - if (!key.startsWith(`${label} `)) return false; - return everyTokenStartsAWord(words(key.slice(label.length + 1)), words(context)); +function isNameWithItsPlace(key: string, label: string, address: string): boolean { + if (!label || !key.startsWith(`${label} `)) return false; + // Provider sublabels often repeat the primary name before the address; that + // repetition supplies no independent evidence of where the place is. + const place = + address === label + ? "" + : address.startsWith(`${label} `) + ? address.slice(label.length + 1) + : address; + const placeWords = words(place); + return words(key.slice(label.length + 1)).every((token) => placeWords.includes(token)); } function exactMatchScore(kind: SearchMatchKind): number { @@ -401,7 +413,8 @@ export function textMatchScore(item: AutocompleteResult, query: string): number const key = matchKey(query); const label = matchKey(item.label); const context = matchKey(`${item.label} ${item.sublabel ?? ""}`); - if (label === key || isNameWithItsPlace(key, label, context)) return TEXT_SCORE.exact; + if (label === key || isNameWithItsPlace(key, label, matchKey(item.sublabel ?? ""))) + return TEXT_SCORE.exact; // A category's name in the singular names it as fully: "museum" for Museums. if (item.type === "category" && label === `${key}s`) return TEXT_SCORE.exact; if (label.startsWith(key)) return TEXT_SCORE.prefix; @@ -485,7 +498,7 @@ export function isSameDestination(a: AutocompleteResult, b: AutocompleteResult): /** * Whether the query names where the row is, beyond its name: a house number - * or postcode that is part of its name ("hauptstraße 5", "10115"), or a word + * or postcode that is part of its name ("hauptstraße 5", "10115"), or complete words * of its address typed after the name ("10115 berlin", "paris france"). */ export function queryNamesLocation(item: AutocompleteResult, query: string): boolean { @@ -493,7 +506,7 @@ export function queryNamesLocation(item: AutocompleteResult, query: string): boo const label = matchKey(item.label); const labelWords = words(label); if (words(key).some((token) => /\d/u.test(token) && labelWords.includes(token))) return true; - return isNameWithItsPlace(key, label, matchKey(`${item.label} ${item.sublabel ?? ""}`)); + return isNameWithItsPlace(key, label, matchKey(item.sublabel ?? "")); } /**