diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 1b4b63fbe..5cfd651e4 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -17,6 +17,7 @@ import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/p import { AMAP_SHORT_HOSTS, AmapPlacesProvider, + AmapTipStash, isAmapHost, isAmapPlaceId, parseAmapUrl, @@ -702,6 +703,10 @@ export class MapsService { * oldest entry is the one evicted when it fills up. */ private readonly brandLogoCache = new Map(); + /** Amap autocomplete tips for the details fallback. Here rather than on the provider: + * a provider is built per request, and a pick is two requests. */ + private readonly amapTips = new AmapTipStash(); + private isSettingDisabled(key: string): boolean { const row = this.database.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', @@ -1073,7 +1078,7 @@ export class MapsService { const amap = this.resolveAmapKey(userId); return amap.key - ? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) } + ? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }, this.amapTips) } : null; } @@ -1127,7 +1132,7 @@ export class MapsService { private providerForPlaceId(userId: number, placeId: string): AmapPlacesProvider | null { if (!isAmapPlaceId(placeId)) return null; const amap = this.resolveAmapKey(userId); - return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) : null; + return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }, this.amapTips) : null; } /** diff --git a/server/src/nest/maps/providers/amap.provider.ts b/server/src/nest/maps/providers/amap.provider.ts index 8b3348435..f16e41460 100644 --- a/server/src/nest/maps/providers/amap.provider.ts +++ b/server/src/nest/maps/providers/amap.provider.ts @@ -130,43 +130,45 @@ interface AmapTip { } /** - * A short-lived memory of the tips autocomplete just served, keyed by bare POI - * id. inputtips indexes places place/detail does not — villages, lanes and - * other 地名地址 entries (typecode 19xxxx) come back from the completer with an - * id and coordinates that the detail endpoint then knows nothing about. The - * picker asks for details right after the user picks a suggestion, so the tip - * itself is the only place those coordinates survive; stashing it lets - * placeDetails answer with a plain name-and-pin place instead of null. + * The tips autocomplete just served, by bare POI id, for ten minutes. + * + * inputtips knows places place/detail does not: villages, lanes and other + * 地名地址 entries (typecode 19xxxx) come back from the completer with an id and + * a coordinate, and the detail endpoint then answers count=0 for that id. The + * picker asks for details right after the pick, so the tip is the only place + * that coordinate survives. + * + * One instance lives on MapsService, the DI singleton. A provider is built per + * request, and the suggestion and the details lookup are two requests. */ -const TIP_STASH_TTL = 10 * 60 * 1000; -const TIP_STASH_MAX = 500; -const tipStash = new Map(); +export class AmapTipStash { + static readonly TTL_MS = 10 * 60 * 1000; + static readonly MAX = 500; + private readonly entries = new Map(); -function stashTips(tips: AmapTip[]): void { - const now = Date.now(); - for (const [id, entry] of tipStash) { - if (entry.expiresAt <= now) tipStash.delete(id); + remember(tips: AmapTip[], now = Date.now()): void { + for (const tip of tips) { + const id = amapText(tip.id); + // Deleted first, so a tip served again moves to the back of the queue. + this.entries.delete(id); + this.entries.set(id, { tip, expiresAt: now + AmapTipStash.TTL_MS }); + } + // A Map iterates in insertion order: the front is the oldest. + for (const id of this.entries.keys()) { + if (this.entries.size <= AmapTipStash.MAX) break; + this.entries.delete(id); + } } - for (const tip of tips) { - const id = amapText(tip.id); - if (id) tipStash.set(id, { tip, expiresAt: now + TIP_STASH_TTL }); - } - // FIFO eviction: Map iterates in insertion order, so the front is oldest. - while (tipStash.size > TIP_STASH_MAX) { - const oldest = tipStash.keys().next(); - if (oldest.done) break; - tipStash.delete(oldest.value); - } -} -function stashedTip(poiId: string): AmapTip | null { - const entry = tipStash.get(poiId); - if (!entry) return null; - if (entry.expiresAt <= Date.now()) { - tipStash.delete(poiId); - return null; + recall(poiId: string, now = Date.now()): AmapTip | null { + const entry = this.entries.get(poiId); + if (!entry) return null; + if (entry.expiresAt <= now) { + this.entries.delete(poiId); + return null; + } + return entry.tip; } - return entry.tip; } interface AmapPoi { @@ -205,40 +207,70 @@ function amapNumber(value: unknown): number | null { return Number.isFinite(n) ? n : null; } +/** 周一 … 周日, and the same after 星期, as the OSM two-letter day. 日 and 天 are both Sunday. */ +const AMAP_WEEKDAY: Record = { 一: 'Mo', 二: 'Tu', 三: 'We', 四: 'Th', 五: 'Fr', 六: 'Sa', 日: 'Su', 天: 'Su' }; + /** - * Amap writes opening hours in Chinese ("周一至周四,周日 09:30-22:00;周五至周六 - * 09:30-22:30"), which the OSM-dialect parser cannot read. Translated into that - * dialect ("Mo-Th,Su 09:30-22:00; Fr-Sa 09:30-22:30") it can, and the place then - * gets real weekday lines and an open/closed badge instead of seven "?". - * - * Segments carrying a specific date range ("2026-05-01至2026-05-05 …") are - * dropped: they describe a holiday exception, not the weekly pattern, and the - * parser has no concept of them. + * A segment parseOpeningHours reads in full: its day grammar, one space, then + * time ranges and nothing else. Anything looser and the parser takes the days + * it recognises and silently skips the rest. */ -export function amapOpeningToOsm(text: string): string { - return text +const OSM_WEEK_SEGMENT = + /^(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:-(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:,(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:-(?:Mo|Tu|We|Th|Fr|Sa|Su))?)* \d{1,2}:\d{2}-\d{1,2}:\d{2}(?:,\d{1,2}:\d{2}-\d{1,2}:\d{2})*$/; + +/** "Mo闭馆": a day named only to say it is closed. It gets no period, which is what closed means. */ +const AMAP_CLOSED_SEGMENT = /^[A-Za-z,-]+ ?(?:闭馆|休馆|闭店|休息|歇业|不营业|关闭)$/; + +/** + * Amap's Chinese opening hours ("周一至周四,周日 09:30-22:00;周五至周六 09:30-22:30") + * in the OSM dialect parseOpeningHours reads ("Mo-Th,Su 09:30-22:00; Fr-Sa 09:30-22:30"), + * or null when any part of it cannot be read. + * + * All or nothing, on purpose: a day the parser does not understand ends up + * without a period, and the client reads a day without a period as closed. No + * hours is honest; a shop marked closed on the Saturday it is open is not. + * + * Remarks in brackets and segments carrying a date (a holiday exception) are + * dropped: neither is the weekly pattern. + */ +export function amapOpeningToOsm(text: string): string | null { + const segments = text + // Amap's own example nests a ; inside a remark: "(延时服务时间:08:30-09:00;12:00-13:30)". + .replace(/[((][^()()]*[))]/g, '') + .replace(/\s+/g, ' ') + .replace(/:/g, ':') + .replace(/[~~〜-]/g, '-') + .replace(/次日/g, '') + .replace(/全天|24小时(?:营业)?/g, '00:00-24:00') + .replace(/(?:周|星期)([一二三四五六日天])/g, (_, day: string) => AMAP_WEEKDAY[day]) + .replace(/每[天日]/g, 'Mo-Su') + .replace(/周末/g, 'Sa-Su') + .replace(/工作日/g, 'Mo-Fr') + .replace(/[至到]/g, '-') + // A comma between a time and the next day list ends a segment, it does not join two days. + .replace(/(\d) ?[,,、] ?(?=[A-Z])/g, '$1;') + .replace(/ ?[,,、] ?/g, ',') + .replace(/ ?- ?/g, '-') + // A split shift written with a gap: "09:00-12:00 14:00-18:00". + .replace(/(\d) (?=\d)/g, '$1,') .split(/[;;]/) .map((segment) => segment.trim()) - .filter((segment) => segment && !/\d{4}-\d{1,2}-\d{1,2}/.test(segment)) - .map((segment) => - segment - .replace(/周一/g, 'Mo') - .replace(/周二/g, 'Tu') - .replace(/周三/g, 'We') - .replace(/周四/g, 'Th') - .replace(/周五/g, 'Fr') - .replace(/周六/g, 'Sa') - .replace(/周[日天]/g, 'Su') - .replace(/至/g, '-') - .replace(/[,,]\s*/g, ','), - ) - .join('; '); + .filter((segment) => segment && !/\d{4}-\d{1,2}-\d{1,2}/.test(segment) && !AMAP_CLOSED_SEGMENT.test(segment)) + // "Mo-Fr:08:30-17:30" (Amap's documented form) and "Mo-Fr08:30" both become "Mo-Fr 08:30". + .map((segment) => segment.replace(/^([A-Za-z,-]+)[ :]*(?=\d)/, '$1 ')); + // A bare range as the whole text is every day: "10:00-22:00", "24小时营业". + if (segments.length === 1 && /^\d/.test(segments[0])) segments[0] = `Mo-Su ${segments[0]}`; + if (!segments.length || !segments.every((segment) => OSM_WEEK_SEGMENT.test(segment))) return null; + return segments.join('; '); } export class AmapPlacesProvider implements PlacesProvider { readonly id = 'amap' as const; - constructor(private readonly credential: ProviderCredential) {} + constructor( + private readonly credential: ProviderCredential, + private readonly tips: AmapTipStash, + ) {} // ── Outbound plumbing ────────────────────────────────────────────────────── @@ -354,11 +386,11 @@ export class AmapPlacesProvider implements PlacesProvider { .filter((part, i, all) => part && all.indexOf(part) === i); const address = [...region, street].filter(Boolean).join(''); const openText = amapText(business.opentime_week) || amapText(bizExt.open_time); - // Amap phrases hours in Chinese; translate to the OSM dialect first so the - // shared parser can read them. When even the translation does not parse, - // the verbatim line is still worth showing — seven "?" lines are not. - const openParsed = openText ? parseOpeningHours(amapOpeningToOsm(openText)) : null; - const openParsedOk = openParsed && openParsed.periods.length > 0; + // Amap phrases hours in Chinese. Translated, the shared OSM-dialect parser + // reads them; text the translation cannot fully read gives no hours at all, + // so the enrichment can fall back to OpenStreetMap's. + const osmHours = openText ? amapOpeningToOsm(openText) : null; + const hours = osmHours ? parseOpeningHours(osmHours) : null; return { amap_poi_id: poi.id ? `${AMAP_PLACE_ID_PREFIX}${poi.id}` : null, @@ -376,11 +408,9 @@ export class AmapPlacesProvider implements PlacesProvider { // Amap's type is a slash-separated taxonomy ("餐饮服务;中餐厅;川菜"); split so // it reads like the string array every other provider returns. types: amapText(poi.type).split(/[;|]/).map((t) => t.trim()).filter(Boolean), - // Hours arrive as Chinese free text; amapOpeningToOsm gives the shared - // parser a dialect it reads, and unparseable text is shown verbatim. - opening_hours: openParsedOk ? openParsed.weekdayDescriptions : openText ? [openText] : null, - open_now: openParsedOk ? openParsed.openNow : null, - opening_periods: openParsedOk ? openParsed.periods : null, + opening_hours: hours ? hours.weekdayDescriptions : null, + open_now: hours ? hours.openNow : null, + opening_periods: hours ? hours.periods : null, opening_special_days: null, summary: null, reviews: [], @@ -456,20 +486,16 @@ export class AmapPlacesProvider implements PlacesProvider { 'inputtips', ); - // Kept before the slice: a tip the user never saw is still the details - // fallback for one that shares its id, and the stash is cheap. - stashTips(data.tips ?? []); + // A tip without an id cannot be looked up afterwards, and inputtips does + // return those (a district name, a road). + const served = asArray(data.tips).filter((tip) => amapText(tip.id)).slice(0, 5); + this.tips.remember(served); - return (data.tips ?? []) - // A tip without an id cannot be looked up afterwards, and inputtips does - // return those (a district name, a road). - .filter((tip) => amapText(tip.id)) - .slice(0, 5) - .map((tip) => ({ - placeId: `${AMAP_PLACE_ID_PREFIX}${amapText(tip.id)}`, - mainText: amapText(tip.name), - secondaryText: [amapText(tip.district), amapText(tip.address)].filter(Boolean).join(' '), - })); + return served.map((tip) => ({ + placeId: `${AMAP_PLACE_ID_PREFIX}${amapText(tip.id)}`, + mainText: amapText(tip.name), + secondaryText: [amapText(tip.district), amapText(tip.address)].filter(Boolean).join(' '), + })); } async placeDetails(placeId: string, lang?: string): Promise { @@ -494,33 +520,20 @@ export class AmapPlacesProvider implements PlacesProvider { if (poi) return { ...this.toPlace(poi), cached_at: Date.now() }; // place/detail indexes fewer places than inputtips: a village (typecode - // 190108) can be suggested, clicked, and then answer count=0 here. The tip - // autocomplete served still holds the name and coordinates, so answer with - // a plain name-and-pin place rather than nothing — the caller treats null - // as "this id does not exist" and the user gets a failed search. - const tip = stashedTip(poiId); + // 190108) can be suggested, picked, and then answer count=0 here. The tip + // autocomplete served still holds its name and coordinate, so it answers as + // a plain name-and-pin place. Without a coordinate there is nothing to pin, + // and null lets the client fall back to its text search. + const tip = this.tips.recall(poiId); if (!tip) return null; - const coords = fromAmapLocation(tip.location); - return { - amap_poi_id: `${AMAP_PLACE_ID_PREFIX}${poiId}`, - name: amapText(tip.name), - address: [amapText(tip.district), amapText(tip.address)].filter(Boolean).join(''), - lat: coords?.lat ?? null, - lng: coords?.lng ?? null, - rating: null, - rating_count: null, - website: null, - phone: null, - types: [], - opening_hours: null, - open_now: null, - opening_periods: null, - opening_special_days: null, - summary: null, - reviews: [], - source: 'amap' as const, - cached_at: Date.now(), - }; + const place = this.toPlace({ + id: poiId, + name: tip.name, + adname: amapText(tip.district), + address: tip.address, + location: tip.location, + }); + return place.lat == null ? null : { ...place, cached_at: Date.now() }; } /** diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts index b4126c322..5ed94774d 100644 --- a/server/tests/unit/nest/maps.amap.test.ts +++ b/server/tests/unit/nest/maps.amap.test.ts @@ -65,6 +65,8 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { AmapPlacesProvider, + AmapTipStash, + amapOpeningToOsm, amapPoiId, isAmapHost, isAmapPlaceId, @@ -86,8 +88,8 @@ const photoCacheStub = { const svc = new MapsService(new DatabaseService(db as never), photoCacheStub); /** A provider over a fixed key, which is all these cases need. */ -function provider(): AmapPlacesProvider { - return new AmapPlacesProvider({ key: 'amap-test-key', source: 'instance', userId: 3 }); +function provider(tips = new AmapTipStash()): AmapPlacesProvider { + return new AmapPlacesProvider({ key: 'amap-test-key', source: 'instance', userId: 3 }, tips); } /** Amap's success envelope. */ @@ -290,7 +292,7 @@ describe('AmapPlacesProvider.searchText', () => { it('AMAP-017: an empty place/around falls through to place/text, which can read a region out of the keywords', async () => { // The client's details-miss fallback searches for "name, region". around // matches that literally against POI names and finds nothing; text parses - // the region and answers. Invented fixtures — the id and the place do not + // the region and answers. Invented fixtures: the id and the place do not // exist. const villageGcj = wgs84ToGcj02(30.0, 120.0); const fetchSpy = vi @@ -310,47 +312,15 @@ describe('AmapPlacesProvider.searchText', () => { expect(calls[1]).toContain('/v3/place/text'); }); - it('AMAP-018: Chinese opening hours are translated before the OSM-dialect parser sees them', async () => { - // "周一至周日 10:00-22:00" fed to the parser as-is produced seven "?" lines - // and no periods, so the place never got an open/closed badge. - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - ok({ pois: [{ id: 'B0TESTHRS1', name: 'Test Shop', location: [], business: { opentime_week: '周一至周日 10:00-22:00' } }] }), - ), - ); + it('AMAP-017b: a place/around with hits answers alone, place/text is not asked', async () => { + const fetchSpy = vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B1', name: '咖啡', location: TIANANMEN_LOCATION }] })); + vi.stubGlobal('fetch', fetchSpy); - const [place] = await provider().searchText('Test Shop'); + const places = await provider().searchText('咖啡', 'zh', { lat: 39.9, lng: 116.4, radius: 3000 }); - expect(place.opening_hours).toEqual([ - 'Monday: 10:00-22:00', 'Tuesday: 10:00-22:00', 'Wednesday: 10:00-22:00', - 'Thursday: 10:00-22:00', 'Friday: 10:00-22:00', 'Saturday: 10:00-22:00', 'Sunday: 10:00-22:00', - ]); - expect(place.opening_periods).toHaveLength(7); - }); - - it('AMAP-019: a holiday segment carrying a date range is dropped, not fed to the weekly parser', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - ok({ - pois: [ - { - id: 'B0TESTHRS2', - name: 'Test Shop', - location: [], - business: { opentime_week: '周一至周五 09:00-18:00;2026-10-01至2026-10-07 10:00-22:00' }, - }, - ], - }), - ), - ); - - const [place] = await provider().searchText('Test Shop'); - - // Five weekdays parsed; the dated exception did not leak into the week. - expect(place.opening_periods).toHaveLength(5); - expect((place.opening_hours as string[])[5]).toBe('Saturday: ?'); + expect(places).toHaveLength(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(calledUrl()).toContain('/v3/place/around'); }); }); @@ -480,7 +450,7 @@ describe('AmapPlacesProvider.placeDetails', () => { }); it('AMAP-043: answers from the tip autocomplete served when the detail index does not know the id', async () => { - // inputtips indexes 地名地址 entries (typecode 19xxxx — villages, lanes) + // inputtips indexes 地名地址 entries (typecode 19xxxx: villages, lanes) // that place/detail then answers with count=0. Without the stash the pick // ends in a failed search. Invented fixtures throughout. const villageGcj = wgs84ToGcj02(30.0, 120.0); @@ -502,10 +472,12 @@ describe('AmapPlacesProvider.placeDetails', () => { .mockResolvedValueOnce(ok({ count: '0', pois: [] })); vi.stubGlobal('fetch', fetchSpy); - const [suggestion] = await provider().autocomplete('Test Vil'); + // Two provider instances, as two requests make them, sharing one stash. + const tips = new AmapTipStash(); + const [suggestion] = await provider(tips).autocomplete('Test Vil'); expect(suggestion.placeId).toBe('amap:B0TESTVIL2'); - const place = await provider().placeDetails('amap:B0TESTVIL2'); + const place = await provider(tips).placeDetails('amap:B0TESTVIL2'); expect(place).not.toBeNull(); expect(place!.name).toBe('Test Village'); expect(place!.address).toBe('Test Province Test CityTest Hamlet'); @@ -522,6 +494,76 @@ describe('AmapPlacesProvider.placeDetails', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ count: '0', pois: [] }))); expect(await provider().placeDetails('amap:B0TESTNONE')).toBeNull(); }); + + it('AMAP-045: Chinese hours from v5 place/detail become weekday lines and periods', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ + id: 'B0TESTHRS1', name: 'Test Shop', location: TIANANMEN_LOCATION, + business: { opentime_week: '周一至周五 09:00-18:00;周六、周日 10:00-16:00' }, + }] }))); + + const place = await provider().placeDetails('amap:B0TESTHRS1'); + + expect(place!.opening_hours).toEqual([ + 'Monday: 09:00-18:00', 'Tuesday: 09:00-18:00', 'Wednesday: 09:00-18:00', 'Thursday: 09:00-18:00', + 'Friday: 09:00-18:00', 'Saturday: 10:00-16:00', 'Sunday: 10:00-16:00', + ]); + expect(place!.opening_periods).toHaveLength(7); + }); + + it('AMAP-046: a dated holiday segment is dropped, not allowed to void the week', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ + id: 'B0TESTHRS2', name: 'Test Shop', location: TIANANMEN_LOCATION, + business: { opentime_week: '周一至周五 09:00-18:00;2026-10-01至2026-10-07 10:00-22:00' }, + }] }))); + + const place = await provider().placeDetails('amap:B0TESTHRS2'); + + expect(place!.opening_periods).toHaveLength(5); + expect((place!.opening_hours as string[])[5]).toBe('Saturday: ?'); + }); + + it('AMAP-047: hours the translation cannot fully read give no hours, not a verbatim line', async () => { + // Amap's own documented example: a service remark with a nested ;, and a + // Saturday segment that is prose. Partly parsed, Saturday would read as closed. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ + id: 'B0TESTHRS3', name: 'Test Bank', location: TIANANMEN_LOCATION, + business: { opentime_week: '周一至周五:08:30-17:30(延时服务时间:08:30-09:00;12:00-13:30);周六延时服务时间:09:00-13:00(法定节假日除外)' }, + }] }))); + + const place = await provider().placeDetails('amap:B0TESTHRS3'); + + expect(place!.opening_hours).toBeNull(); + expect(place!.opening_periods).toBeNull(); + expect(place!.open_now).toBeNull(); + }); + + it('AMAP-048: amapOpeningToOsm reads the common spellings and refuses a partial read', () => { + const rows: [string, string | null][] = [ + ['周一至周日 10:00-22:00', 'Mo-Su 10:00-22:00'], + ['周一至周四,周日 09:30-22:00;周五至周六 09:30-22:30', 'Mo-Th,Su 09:30-22:00; Fr-Sa 09:30-22:30'], + ['周一至周五 09:00-18:00;周六、周日 10:00-16:00', 'Mo-Fr 09:00-18:00; Sa,Su 10:00-16:00'], + ['周一至周五 10:00-14:00,周六至周日 17:00-22:00', 'Mo-Fr 10:00-14:00; Sa-Su 17:00-22:00'], + ['每天 10:00~22:00', 'Mo-Su 10:00-22:00'], + ['周一至周日:10:00-22:00', 'Mo-Su 10:00-22:00'], + ['星期一到星期五 09:00-12:00 14:00-18:00', 'Mo-Fr 09:00-12:00,14:00-18:00'], + ['周一至周日 18:00-次日02:00', 'Mo-Su 18:00-02:00'], + ['24小时营业', 'Mo-Su 00:00-24:00'], + ['周二至周日 09:00-17:00(周一闭馆)', 'Tu-Su 09:00-17:00'], + ['周二至周日 09:00-17:00;周一闭馆', 'Tu-Su 09:00-17:00'], + ['周一至周五 09:00-18:00;法定节假日 10:00-16:00', null], + ['Open daily 10:00-22:00', null], + ['2026-10-01至2026-10-07 10:00-22:00', null], + ]; + for (const [input, expected] of rows) expect(amapOpeningToOsm(input), input).toBe(expected); + }); + + it('AMAP-049: a stashed tip without a coordinate is still null, so the client falls back to its text search', async () => { + const tips = new AmapTipStash(); + tips.remember([{ id: 'B0TESTVIL3', name: 'Test Village', location: [] }]); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ count: '0', pois: [] }))); + + expect(await provider(tips).placeDetails('amap:B0TESTVIL3')).toBeNull(); + }); }); // ── reverse ────────────────────────────────────────────────────────────────── @@ -731,6 +773,26 @@ describe('MapsService with Amap in the keyed slot', () => { expect(result.places[0].amap_poi_id).toBe('amap:B1'); }); + it('AMAP-085: the tip a suggestion came from answers the details request that follows it', async () => { + // Two requests, two provider instances: the stash has to live on the service. + amapSelected(); + const villageGcj = wgs84ToGcj02(30.0, 120.0); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: unknown) => + String(url).includes('/v3/assistant/inputtips') + ? ok({ tips: [{ id: 'B0TESTVIL4', name: 'Test Village', district: 'Test District', location: `${villageGcj.lng},${villageGcj.lat}` }] }) + : ok({ count: '0', pois: [] }), + ), + ); + + await svc.autocompletePlaces(1, 'Test Vil'); + const { place } = await svc.getPlaceDetails(1, 'amap:B0TESTVIL4'); + + expect(place!.name).toBe('Test Village'); + expect(place!.lat as number).toBeCloseTo(30.0, 4); + }); + it('AMAP-081: autocomplete goes to inputtips instead of Nominatim', async () => { amapSelected(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [{ id: 'T1', name: '外滩', district: '上海市黄浦区' }] }))); @@ -798,3 +860,25 @@ describe('MapsService with Amap in the keyed slot', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe('AmapTipStash', () => { + it('AMAP-090: a tip is forgotten once its ten minutes are up', () => { + const tips = new AmapTipStash(); + tips.remember([{ id: 'T1', name: 'x' }], 1_000); + expect(tips.recall('T1', 1_000 + AmapTipStash.TTL_MS - 1)?.name).toBe('x'); + expect(tips.recall('T1', 1_000 + AmapTipStash.TTL_MS)).toBeNull(); + // Dropped on the expired read, not merely hidden. + expect(tips.recall('T1', 0)).toBeNull(); + expect(tips.recall('nope')).toBeNull(); + }); + + it('AMAP-091: holds at most 500 tips, oldest out first, and a tip served again counts as new', () => { + const tips = new AmapTipStash(); + tips.remember(Array.from({ length: AmapTipStash.MAX }, (_, i) => ({ id: `T${i}` })), 0); + tips.remember([{ id: 'T0' }], 0); + tips.remember([{ id: 'NEW' }], 0); + expect(tips.recall('T0', 0)).not.toBeNull(); + expect(tips.recall('T1', 0)).toBeNull(); + expect(tips.recall('NEW', 0)).not.toBeNull(); + }); +});