fix(maps): the projection, the labels and two unbounded reads

A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.

Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.

Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
This commit is contained in:
Maurice
2026-09-13 23:01:36 +02:00
parent 9deedb80ea
commit c8cb88133f
5 changed files with 137 additions and 13 deletions
+23
View File
@@ -256,6 +256,29 @@ describe('AmapPlacesProvider.searchText', () => {
expect(calledUrl()).toContain('/v3/place/text');
});
it('AMAP-015b: a pois field that is not a list answers empty instead of throwing a 500', async () => {
// `as T` only ever described what the answer was meant to look like. The
// base URL is configurable, and an object here used to reach `.map` and
// throw a TypeError nobody caught between the provider and the controller.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: { id: 'B3' } })));
await expect(provider().searchText('外滩')).resolves.toEqual([]);
});
it('AMAP-015c: a body larger than the cap is refused before it is read', async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n === 'content-length' ? String(50 * 1024 * 1024) : null) },
body: { getReader: () => ({ read: async () => ({ done: true }), cancel }), cancel },
json: async () => ({ status: '1', pois: [] }),
}));
await expect(provider().searchText('外滩')).rejects.toThrow(/more than/);
// The socket goes back rather than staying pinned on a body nobody reads.
expect(cancel).toHaveBeenCalled();
});
it('AMAP-016: keeps a POI that has no geometry rather than dropping it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B3', name: '无坐标', location: [] }] })));
const [place] = await provider().searchText('无坐标');