mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(maps): amap hours are read whole or not at all, and the tip stash lives on the service
The translated hours were taken as soon as any segment parsed. A day the parser skipped, such as a weekend written 周六、周日, then had no period, and the client reads a day without a period as closed, so a shop open on Saturday showed Closed there. Text that did not parse went out as a one-line list, which the client reads as Monday and splits at the first colon, and which kept the enrichment from falling back to OpenStreetMap's hours. amapOpeningToOsm now also reads Amap's documented form (a colon after the days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期, 到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named only as closed. It returns null unless every weekly segment reads, and no hours are sent then. The autocomplete tips move from a module-level map into AmapTipStash, one instance on MapsService: a provider is built per request, and the pick and its details lookup are two requests. Only the tips actually served are kept, and a stashed tip without a coordinate answers null, so the client falls back to its text search instead of receiving a place it cannot pin.
This commit is contained in:
@@ -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<string, { at: number; logo: BrandLogo | null }>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, { tip: AmapTip; expiresAt: number }>();
|
||||
export class AmapTipStash {
|
||||
static readonly TTL_MS = 10 * 60 * 1000;
|
||||
static readonly MAX = 500;
|
||||
private readonly entries = new Map<string, { tip: AmapTip; expiresAt: number }>();
|
||||
|
||||
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<string, string> = { 一: '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<ProviderPlace | null> {
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user