A wayfinder inspired map plugin for obisidian
1/**
2 * geocoder.test.ts — Tests for all geocoder.ts behavioral contracts
3 *
4 * Tests mock global fetch via vi.fn() and use fake timers for rate-limit testing.
5 */
6import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
7import { geocodePlaces } from "../src/geocoder";
8import type { Place } from "../src/parser";
9
10// ─── Helpers ──────────────────────────────────────────────────────────
11
12/** Create a Place with sensible defaults */
13function makePlace(
14 name: string,
15 overrides: Partial<Place> = {}
16): Place {
17 return {
18 name,
19 fields: {},
20 notes: [],
21 startLine: 0,
22 endLine: 0,
23 ...overrides,
24 };
25}
26
27/** Build a successful Nominatim JSON response */
28function nominatimOk(lat: number, lng: number): Response {
29 return new Response(JSON.stringify([{ lat: String(lat), lon: String(lng) }]), {
30 status: 200,
31 headers: { "Content-Type": "application/json" },
32 });
33}
34
35/** Build an empty Nominatim response (no results) */
36function nominatimEmpty(): Response {
37 return new Response(JSON.stringify([]), {
38 status: 200,
39 headers: { "Content-Type": "application/json" },
40 });
41}
42
43// ─── Mock Setup ───────────────────────────────────────────────────────
44
45let mockFetch: ReturnType<typeof vi.fn>;
46
47beforeEach(() => {
48 mockFetch = vi.fn();
49 vi.stubGlobal("fetch", mockFetch);
50 vi.spyOn(console, "warn").mockImplementation(() => {});
51});
52
53afterEach(() => {
54 vi.restoreAllMocks();
55 vi.unstubAllGlobals();
56});
57
58// ─── Contract 13: Empty array short-circuit ───────────────────────────
59
60describe("Contract 13: empty array short-circuit", () => {
61 it("returns [] immediately for empty input, no API calls", async () => {
62 const result = await geocodePlaces([]);
63 expect(result).toEqual([]);
64 expect(mockFetch).not.toHaveBeenCalled();
65 });
66});
67
68// ─── Contract 1: Skip places with existing coordinates ────────────────
69
70describe("Contract 1: skip places with existing coordinates", () => {
71 it("returns places with lat/lng unchanged, no API calls", async () => {
72 const places = [
73 makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }),
74 ];
75 const result = await geocodePlaces(places);
76 expect(result).toHaveLength(1);
77 expect(result[0].lat).toBe(41.4036);
78 expect(result[0].lng).toBe(2.1744);
79 expect(mockFetch).not.toHaveBeenCalled();
80 });
81
82 it("only geocodes places missing coordinates", async () => {
83 mockFetch.mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
84
85 const places = [
86 makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }),
87 makePlace("The Louvre"),
88 ];
89 const result = await geocodePlaces(places);
90 expect(result).toHaveLength(2);
91 expect(result[0].lat).toBe(41.4036); // unchanged
92 expect(result[1].lat).toBe(48.8606); // geocoded
93 expect(mockFetch).toHaveBeenCalledTimes(1);
94 });
95
96 it("treats lat: 0, lng: 0 as valid existing coordinates (not falsy)", async () => {
97 const places = [
98 makePlace("Null Island", { lat: 0, lng: 0 }),
99 ];
100 const result = await geocodePlaces(places);
101 expect(result[0].lat).toBe(0);
102 expect(result[0].lng).toBe(0);
103 expect(mockFetch).not.toHaveBeenCalled();
104 });
105
106 it("geocodes places with only lat set (half-populated)", async () => {
107 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
108
109 const places = [makePlace("Half Place", { lat: 41.4036 })];
110 const result = await geocodePlaces(places);
111 // Only lat was set, lng was undefined — should geocode
112 expect(result[0].lat).toBe(41.4036);
113 expect(result[0].lng).toBe(2.1744);
114 expect(mockFetch).toHaveBeenCalledTimes(1);
115 });
116
117 it("geocodes places with only lng set (half-populated)", async () => {
118 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
119
120 const places = [makePlace("Half Place", { lng: 2.1744 })];
121 const result = await geocodePlaces(places);
122 expect(result[0].lat).toBe(41.4036);
123 expect(result[0].lng).toBe(2.1744);
124 expect(mockFetch).toHaveBeenCalledTimes(1);
125 });
126});
127
128// ─── Contract 2 & 3: Nominatim URL and query string ──────────────────
129
130describe("Contract 2 & 3: Nominatim URL and query params", () => {
131 it("queries Nominatim with correct URL, format, limit, and encoded name", async () => {
132 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
133
134 await geocodePlaces([makePlace("Sagrada Familia")]);
135
136 expect(mockFetch).toHaveBeenCalledTimes(1);
137 const [url, options] = mockFetch.mock.calls[0];
138 const parsedUrl = new URL(url);
139
140 expect(parsedUrl.origin + parsedUrl.pathname).toBe(
141 "https://nominatim.openstreetmap.org/search"
142 );
143 expect(parsedUrl.searchParams.get("format")).toBe("json");
144 expect(parsedUrl.searchParams.get("limit")).toBe("1");
145 expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia");
146 });
147
148 it("properly encodes special characters and unicode in place names", async () => {
149 mockFetch
150 .mockResolvedValueOnce(nominatimOk(48.137, 11.575))
151 .mockResolvedValueOnce(nominatimOk(48.856, 2.352))
152 .mockResolvedValueOnce(nominatimOk(35.659, 139.700));
153
154 vi.useFakeTimers();
155 const places = [
156 makePlace("München"),
157 makePlace("Café & Bar"),
158 makePlace("東京タワー"),
159 ];
160 const promise = geocodePlaces(places);
161 await vi.runAllTimersAsync();
162 await promise;
163
164 // Verify each name was correctly passed through URL encoding
165 const call0Url = new URL(mockFetch.mock.calls[0][0]);
166 expect(call0Url.searchParams.get("q")).toBe("München");
167
168 const call1Url = new URL(mockFetch.mock.calls[1][0]);
169 expect(call1Url.searchParams.get("q")).toBe("Café & Bar");
170
171 const call2Url = new URL(mockFetch.mock.calls[2][0]);
172 expect(call2Url.searchParams.get("q")).toBe("東京タワー");
173
174 vi.useRealTimers();
175 });
176});
177
178// ─── Contract 6: User-Agent header ────────────────────────────────────
179
180describe("Contract 6: User-Agent header", () => {
181 it("sets User-Agent to ObsidianMapViewer/1.0", async () => {
182 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
183
184 await geocodePlaces([makePlace("Sagrada Familia")]);
185
186 const [, options] = mockFetch.mock.calls[0];
187 expect(options.headers["User-Agent"]).toBe("ObsidianMapViewer/1.0");
188 });
189});
190
191// ─── Contract 4: Rate limiting ────────────────────────────────────────
192
193describe("Contract 4: rate limiting (1100ms between requests)", () => {
194 it("waits at least 1100ms between sequential API calls", async () => {
195 vi.useFakeTimers();
196
197 mockFetch
198 .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744))
199 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
200
201 const places = [makePlace("Place A"), makePlace("Place B")];
202 const promise = geocodePlaces(places);
203
204 // First fetch fires immediately
205 await vi.advanceTimersByTimeAsync(0);
206 expect(mockFetch).toHaveBeenCalledTimes(1);
207
208 // At 1099ms, second fetch should not have fired yet
209 await vi.advanceTimersByTimeAsync(1099);
210 expect(mockFetch).toHaveBeenCalledTimes(1);
211
212 // At 1100ms, second fetch should fire
213 await vi.advanceTimersByTimeAsync(1);
214 expect(mockFetch).toHaveBeenCalledTimes(2);
215
216 // Let the promise resolve
217 await vi.advanceTimersByTimeAsync(0);
218 await promise;
219
220 vi.useRealTimers();
221 });
222
223 it("requests are fully sequential — waits for response before delaying", async () => {
224 vi.useFakeTimers();
225
226 let resolveFirst: (value: Response) => void;
227 const firstFetchPromise = new Promise<Response>((resolve) => {
228 resolveFirst = resolve;
229 });
230
231 mockFetch
232 .mockReturnValueOnce(firstFetchPromise)
233 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
234
235 const places = [makePlace("Place A"), makePlace("Place B")];
236 const promise = geocodePlaces(places);
237
238 // First fetch fires
239 await vi.advanceTimersByTimeAsync(0);
240 expect(mockFetch).toHaveBeenCalledTimes(1);
241
242 // Even after 2000ms, second fetch shouldn't fire because first hasn't resolved
243 await vi.advanceTimersByTimeAsync(2000);
244 expect(mockFetch).toHaveBeenCalledTimes(1);
245
246 // Resolve first fetch
247 resolveFirst!(nominatimOk(41.4036, 2.1744));
248 await vi.advanceTimersByTimeAsync(0);
249
250 // Still need to wait 1100ms after response
251 expect(mockFetch).toHaveBeenCalledTimes(1);
252 await vi.advanceTimersByTimeAsync(1100);
253 expect(mockFetch).toHaveBeenCalledTimes(2);
254
255 await vi.advanceTimersByTimeAsync(0);
256 await promise;
257
258 vi.useRealTimers();
259 });
260});
261
262// ─── Contract 5: Deduplication ────────────────────────────────────────
263
264describe("Contract 5: deduplication (case-insensitive, trimmed)", () => {
265 it("makes only one API call for duplicate names", async () => {
266 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
267
268 const places = [
269 makePlace("Sagrada Familia"),
270 makePlace("sagrada familia"),
271 ];
272 const result = await geocodePlaces(places);
273
274 expect(mockFetch).toHaveBeenCalledTimes(1);
275 // Both places get the same result
276 expect(result[0].lat).toBe(41.4036);
277 expect(result[1].lat).toBe(41.4036);
278 });
279
280 it("uses the first-encountered variant for the API call", async () => {
281 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
282
283 const places = [
284 makePlace("Sagrada Familia"),
285 makePlace("SAGRADA FAMILIA"),
286 ];
287 await geocodePlaces(places);
288
289 const [url] = mockFetch.mock.calls[0];
290 const parsedUrl = new URL(url);
291 expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia");
292 });
293
294 it("trims names for dedup comparison", async () => {
295 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
296
297 const places = [
298 makePlace(" Sagrada Familia "),
299 makePlace("Sagrada Familia"),
300 ];
301 const result = await geocodePlaces(places);
302
303 expect(mockFetch).toHaveBeenCalledTimes(1);
304 expect(result[0].lat).toBe(41.4036);
305 expect(result[1].lat).toBe(41.4036);
306 });
307});
308
309// ─── Contract 7: onProgress callback ─────────────────────────────────
310
311describe("Contract 7: onProgress callback", () => {
312 it("calls onProgress after each unique geocode completes (success)", async () => {
313 vi.useFakeTimers();
314 mockFetch
315 .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744))
316 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
317
318 const onProgress = vi.fn();
319 const places = [makePlace("Place A"), makePlace("Place B")];
320 const promise = geocodePlaces(places, { onProgress });
321 await vi.runAllTimersAsync();
322 await promise;
323
324 expect(onProgress).toHaveBeenCalledTimes(2);
325 expect(onProgress).toHaveBeenCalledWith(
326 expect.objectContaining({ name: "Place A" }),
327 { lat: 41.4036, lng: 2.1744 }
328 );
329 expect(onProgress).toHaveBeenCalledWith(
330 expect.objectContaining({ name: "Place B" }),
331 { lat: 48.8606, lng: 2.3376 }
332 );
333 vi.useRealTimers();
334 });
335
336 it("calls onProgress with null for failed geocodes", async () => {
337 mockFetch.mockRejectedValueOnce(new Error("Network error"));
338
339 const onProgress = vi.fn();
340 await geocodePlaces([makePlace("Bad Place")], { onProgress });
341
342 expect(onProgress).toHaveBeenCalledTimes(1);
343 expect(onProgress).toHaveBeenCalledWith(
344 expect.objectContaining({ name: "Bad Place" }),
345 null
346 );
347 });
348
349 it("calls onProgress with null for empty Nominatim results", async () => {
350 mockFetch.mockResolvedValueOnce(nominatimEmpty());
351
352 const onProgress = vi.fn();
353 await geocodePlaces([makePlace("Nonexistent Place")], { onProgress });
354
355 expect(onProgress).toHaveBeenCalledTimes(1);
356 expect(onProgress).toHaveBeenCalledWith(
357 expect.objectContaining({ name: "Nonexistent Place" }),
358 null
359 );
360 });
361
362 it("calls onProgress once per unique geocode, not per duplicate place", async () => {
363 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
364
365 const onProgress = vi.fn();
366 const places = [
367 makePlace("Sagrada Familia"),
368 makePlace("sagrada familia"),
369 ];
370 await geocodePlaces(places, { onProgress });
371
372 // One API call = one onProgress call, reported with the first place in the group.
373 // Duplicate places still get their lat/lng set, but onProgress fires once per unique query.
374 expect(onProgress).toHaveBeenCalledTimes(1);
375 expect(onProgress).toHaveBeenCalledWith(
376 expect.objectContaining({ name: "Sagrada Familia" }),
377 { lat: 41.4036, lng: 2.1744 }
378 );
379 });
380});
381
382// ─── Contract 8: Network failure resilience ───────────────────────────
383
384describe("Contract 8: network failures don't abort batch", () => {
385 it("skips failed place and continues with remaining", async () => {
386 vi.useFakeTimers();
387 mockFetch
388 .mockRejectedValueOnce(new Error("Network error"))
389 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
390
391 const places = [makePlace("Bad Place"), makePlace("Good Place")];
392 const promise = geocodePlaces(places);
393 await vi.runAllTimersAsync();
394 const result = await promise;
395
396 expect(result).toHaveLength(2);
397 expect(result[0].lat).toBeUndefined();
398 expect(result[0].lng).toBeUndefined();
399 expect(result[1].lat).toBe(48.8606);
400 expect(result[1].lng).toBe(2.3376);
401 vi.useRealTimers();
402 });
403
404 it("keeps lat/lng undefined for places with no Nominatim results", async () => {
405 mockFetch.mockResolvedValueOnce(nominatimEmpty());
406
407 const result = await geocodePlaces([makePlace("Nonexistent")]);
408 expect(result[0].lat).toBeUndefined();
409 expect(result[0].lng).toBeUndefined();
410 });
411
412 it("handles non-200 HTTP responses gracefully (e.g., 429 rate limit)", async () => {
413 mockFetch.mockResolvedValueOnce(
414 new Response(JSON.stringify({ error: "Rate limit exceeded" }), {
415 status: 429,
416 })
417 );
418
419 const result = await geocodePlaces([makePlace("Rate Limited")]);
420 expect(result[0].lat).toBeUndefined();
421 expect(result[0].lng).toBeUndefined();
422 });
423
424 it("rejects non-200 responses even if body contains valid lat/lon JSON", async () => {
425 // A proxy or CDN could return valid-looking JSON with a non-200 status
426 mockFetch.mockResolvedValueOnce(
427 new Response(
428 JSON.stringify([{ lat: "99.999", lon: "99.999" }]),
429 { status: 500 }
430 )
431 );
432
433 const result = await geocodePlaces([makePlace("Server Error Place")]);
434 expect(result[0].lat).toBeUndefined();
435 expect(result[0].lng).toBeUndefined();
436 });
437
438 it("handles invalid JSON response body gracefully", async () => {
439 mockFetch.mockResolvedValueOnce(
440 new Response("<html>Server Error</html>", {
441 status: 200,
442 headers: { "Content-Type": "text/html" },
443 })
444 );
445
446 const result = await geocodePlaces([makePlace("Bad Response")]);
447 expect(result[0].lat).toBeUndefined();
448 expect(result[0].lng).toBeUndefined();
449 });
450
451 it("rejects out-of-range coordinates from Nominatim", async () => {
452 mockFetch.mockResolvedValueOnce(
453 new Response(JSON.stringify([{ lat: "999", lon: "999" }]), {
454 status: 200,
455 headers: { "Content-Type": "application/json" },
456 })
457 );
458
459 const result = await geocodePlaces([makePlace("Bad Coords")]);
460 expect(result[0].lat).toBeUndefined();
461 expect(result[0].lng).toBeUndefined();
462 });
463});
464
465// ─── Contract 9: Return full array with geocoded lat/lng ──────────────
466
467describe("Contract 9: returns full array with geocoded results", () => {
468 it("returns all places with successfully geocoded lat/lng set", async () => {
469 vi.useFakeTimers();
470 mockFetch
471 .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744))
472 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
473
474 const places = [makePlace("Place A"), makePlace("Place B")];
475 const promise = geocodePlaces(places);
476 await vi.runAllTimersAsync();
477 const result = await promise;
478
479 expect(result).toHaveLength(2);
480 expect(result[0]).toEqual(
481 expect.objectContaining({ name: "Place A", lat: 41.4036, lng: 2.1744 })
482 );
483 expect(result[1]).toEqual(
484 expect.objectContaining({ name: "Place B", lat: 48.8606, lng: 2.3376 })
485 );
486 vi.useRealTimers();
487 });
488
489 it("preserves all original place properties", async () => {
490 mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744));
491
492 const places = [
493 makePlace("Sagrada Familia", {
494 url: "https://example.com",
495 fields: { category: "Architecture" },
496 notes: ["Amazing"],
497 startLine: 5,
498 endLine: 8,
499 }),
500 ];
501 const result = await geocodePlaces(places);
502
503 expect(result[0].url).toBe("https://example.com");
504 expect(result[0].fields).toEqual({ category: "Architecture" });
505 expect(result[0].notes).toEqual(["Amazing"]);
506 expect(result[0].startLine).toBe(5);
507 expect(result[0].endLine).toBe(8);
508 expect(result[0].lat).toBe(41.4036);
509 expect(result[0].lng).toBe(2.1744);
510 });
511
512 it("handles mixed array: already-geocoded, needs-geocoding, and duplicates", async () => {
513 vi.useFakeTimers();
514 // Only 2 unique names need geocoding: "The Louvre" and "Blue Bottle Coffee"
515 // "Sagrada Familia" already has coords, "the louvre" is a duplicate
516 mockFetch
517 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)) // The Louvre
518 .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); // Blue Bottle Coffee
519
520 const places = [
521 makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }),
522 makePlace("The Louvre"),
523 makePlace("the louvre"), // duplicate, case-insensitive
524 makePlace("Blue Bottle Coffee"),
525 ];
526 const promise = geocodePlaces(places);
527 await vi.runAllTimersAsync();
528 const result = await promise;
529
530 expect(result).toHaveLength(4);
531 // Already geocoded — unchanged
532 expect(result[0].lat).toBe(41.4036);
533 expect(result[0].lng).toBe(2.1744);
534 // Geocoded
535 expect(result[1].lat).toBe(48.8606);
536 expect(result[1].lng).toBe(2.3376);
537 // Duplicate — same result as The Louvre
538 expect(result[2].lat).toBe(48.8606);
539 expect(result[2].lng).toBe(2.3376);
540 // Geocoded
541 expect(result[3].lat).toBe(35.659);
542 expect(result[3].lng).toBe(139.700);
543 // Only 2 API calls (skip already-geocoded, dedup duplicate)
544 expect(mockFetch).toHaveBeenCalledTimes(2);
545
546 vi.useRealTimers();
547 });
548});
549
550// ─── Contract 10: 10-second fetch timeout ─────────────────────────────
551
552describe("Contract 10: 10-second fetch timeout", () => {
553 it("aborts fetch after 10 seconds and treats as network failure", async () => {
554 vi.useFakeTimers();
555
556 // A fetch that never resolves
557 mockFetch.mockImplementationOnce(
558 (_url: string, options: { signal: AbortSignal }) => {
559 return new Promise<Response>((resolve, reject) => {
560 options.signal.addEventListener("abort", () => {
561 reject(new DOMException("The operation was aborted.", "AbortError"));
562 });
563 });
564 }
565 );
566
567 const places = [makePlace("Slow Place")];
568 const promise = geocodePlaces(places);
569
570 // Advance past the 10s timeout
571 await vi.advanceTimersByTimeAsync(10_000);
572 const result = await promise;
573
574 expect(result[0].lat).toBeUndefined();
575 expect(result[0].lng).toBeUndefined();
576
577 vi.useRealTimers();
578 });
579
580 it("fetch timeout is treated as network failure (place skipped, batch continues)", async () => {
581 vi.useFakeTimers();
582
583 // First fetch times out, second succeeds
584 mockFetch
585 .mockImplementationOnce(
586 (_url: string, options: { signal: AbortSignal }) => {
587 return new Promise<Response>((resolve, reject) => {
588 options.signal.addEventListener("abort", () => {
589 reject(
590 new DOMException("The operation was aborted.", "AbortError")
591 );
592 });
593 });
594 }
595 )
596 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
597
598 const places = [makePlace("Slow Place"), makePlace("Fast Place")];
599 const promise = geocodePlaces(places);
600
601 // Advance past timeout + rate limit
602 await vi.advanceTimersByTimeAsync(10_000 + 1100);
603 const result = await promise;
604
605 expect(result[0].lat).toBeUndefined();
606 expect(result[1].lat).toBe(48.8606);
607
608 vi.useRealTimers();
609 });
610});
611
612// ─── Contract 11: External AbortSignal cancellation ───────────────────
613
614describe("Contract 11: external AbortSignal cancellation", () => {
615 it("stops processing when signal fires, returns partial results", async () => {
616 const controller = new AbortController();
617
618 mockFetch.mockImplementation(() => {
619 // After first fetch, abort the signal
620 controller.abort();
621 return Promise.resolve(nominatimOk(41.4036, 2.1744));
622 });
623
624 const places = [makePlace("Place A"), makePlace("Place B")];
625 const result = await geocodePlaces(places, {}, controller.signal);
626
627 // Place A should be geocoded, Place B should not (signal was aborted after first fetch)
628 expect(result[0].lat).toBe(41.4036);
629 expect(result[1].lat).toBeUndefined();
630 expect(mockFetch).toHaveBeenCalledTimes(1);
631 });
632
633 it("returns results obtained so far when signal is already aborted", async () => {
634 const controller = new AbortController();
635 controller.abort();
636
637 const places = [makePlace("Place A")];
638 const result = await geocodePlaces(places, {}, controller.signal);
639
640 expect(result[0].lat).toBeUndefined();
641 expect(mockFetch).not.toHaveBeenCalled();
642 });
643
644 it("aborts in-flight fetch requests when signal fires", async () => {
645 vi.useFakeTimers();
646 const controller = new AbortController();
647
648 let fetchSignal: AbortSignal | undefined;
649 mockFetch.mockImplementationOnce(
650 (_url: string, options: { signal: AbortSignal }) => {
651 fetchSignal = options.signal;
652 return new Promise<Response>((resolve, reject) => {
653 options.signal.addEventListener("abort", () => {
654 reject(
655 new DOMException("The operation was aborted.", "AbortError")
656 );
657 });
658 });
659 }
660 );
661
662 const places = [makePlace("Place A")];
663 const promise = geocodePlaces(places, {}, controller.signal);
664
665 // Let fetch start
666 await vi.advanceTimersByTimeAsync(0);
667 expect(mockFetch).toHaveBeenCalledTimes(1);
668
669 // Fire the external signal
670 controller.abort();
671 await vi.advanceTimersByTimeAsync(0);
672
673 const result = await promise;
674 expect(result[0].lat).toBeUndefined();
675 expect(fetchSignal!.aborted).toBe(true);
676
677 vi.useRealTimers();
678 });
679
680 it("cancels immediately during rate-limit delay, does not wait full 1100ms", async () => {
681 vi.useFakeTimers();
682 const controller = new AbortController();
683
684 mockFetch
685 .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744))
686 .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376));
687
688 const places = [makePlace("Place A"), makePlace("Place B")];
689 const promise = geocodePlaces(places, {}, controller.signal);
690
691 // Let first fetch complete
692 await vi.advanceTimersByTimeAsync(0);
693 expect(mockFetch).toHaveBeenCalledTimes(1);
694
695 // We're now in the 1100ms rate-limit delay. Abort at 500ms.
696 await vi.advanceTimersByTimeAsync(500);
697 controller.abort();
698 await vi.advanceTimersByTimeAsync(0);
699
700 const result = await promise;
701
702 // Place A was geocoded, Place B was not (aborted during delay)
703 expect(result[0].lat).toBe(41.4036);
704 expect(result[1].lat).toBeUndefined();
705 // Second fetch never fires
706 expect(mockFetch).toHaveBeenCalledTimes(1);
707
708 vi.useRealTimers();
709 });
710});
711
712// ─── Contract 12: Error reporting ─────────────────────────────────────
713
714describe("Contract 12: error reporting", () => {
715 it("logs console.warn with [MapViewer] prefix for failures", async () => {
716 mockFetch.mockRejectedValueOnce(new Error("fail"));
717
718 await geocodePlaces([makePlace("Bad Place")]);
719
720 expect(console.warn).toHaveBeenCalled();
721 const warnCall = (console.warn as ReturnType<typeof vi.fn>).mock.calls[0];
722 expect(warnCall[0]).toContain("[MapViewer]");
723 });
724
725 it("calls onNotice after 3 consecutive failures", async () => {
726 vi.useFakeTimers();
727 mockFetch
728 .mockRejectedValueOnce(new Error("fail 1"))
729 .mockRejectedValueOnce(new Error("fail 2"))
730 .mockRejectedValueOnce(new Error("fail 3"));
731
732 const onNotice = vi.fn();
733 const places = [
734 makePlace("Bad 1"),
735 makePlace("Bad 2"),
736 makePlace("Bad 3"),
737 ];
738 const promise = geocodePlaces(places, { onNotice });
739 await vi.runAllTimersAsync();
740 await promise;
741
742 expect(onNotice).toHaveBeenCalledWith(
743 "Map Viewer: Geocoding issues — check your network connection"
744 );
745 vi.useRealTimers();
746 });
747
748 it("calls onNotice exactly once even with more than 3 consecutive failures", async () => {
749 vi.useFakeTimers();
750 mockFetch
751 .mockRejectedValueOnce(new Error("fail 1"))
752 .mockRejectedValueOnce(new Error("fail 2"))
753 .mockRejectedValueOnce(new Error("fail 3"))
754 .mockRejectedValueOnce(new Error("fail 4"))
755 .mockRejectedValueOnce(new Error("fail 5"));
756
757 const onNotice = vi.fn();
758 const places = [
759 makePlace("Bad 1"),
760 makePlace("Bad 2"),
761 makePlace("Bad 3"),
762 makePlace("Bad 4"),
763 makePlace("Bad 5"),
764 ];
765 const promise = geocodePlaces(places, { onNotice });
766 await vi.runAllTimersAsync();
767 await promise;
768
769 expect(onNotice).toHaveBeenCalledTimes(1);
770 vi.useRealTimers();
771 });
772
773 it("does NOT call onNotice for fewer than 3 consecutive failures", async () => {
774 vi.useFakeTimers();
775 mockFetch
776 .mockRejectedValueOnce(new Error("fail 1"))
777 .mockRejectedValueOnce(new Error("fail 2"));
778
779 const onNotice = vi.fn();
780 const places = [makePlace("Bad 1"), makePlace("Bad 2")];
781 const promise = geocodePlaces(places, { onNotice });
782 await vi.runAllTimersAsync();
783 await promise;
784
785 expect(onNotice).not.toHaveBeenCalled();
786 vi.useRealTimers();
787 });
788
789 it("resets consecutive failure counter on success", async () => {
790 vi.useFakeTimers();
791 mockFetch
792 .mockRejectedValueOnce(new Error("fail 1"))
793 .mockRejectedValueOnce(new Error("fail 2"))
794 .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) // success resets counter
795 .mockRejectedValueOnce(new Error("fail 3"))
796 .mockRejectedValueOnce(new Error("fail 4"));
797
798 const onNotice = vi.fn();
799 const places = [
800 makePlace("Bad 1"),
801 makePlace("Bad 2"),
802 makePlace("Good"),
803 makePlace("Bad 3"),
804 makePlace("Bad 4"),
805 ];
806 const promise = geocodePlaces(places, { onNotice });
807 await vi.runAllTimersAsync();
808 await promise;
809
810 // Counter was reset by "Good", so only 2 consecutive after that
811 expect(onNotice).not.toHaveBeenCalled();
812 vi.useRealTimers();
813 });
814
815 it("does not crash if onNotice is not provided", async () => {
816 vi.useFakeTimers();
817 mockFetch
818 .mockRejectedValueOnce(new Error("fail 1"))
819 .mockRejectedValueOnce(new Error("fail 2"))
820 .mockRejectedValueOnce(new Error("fail 3"));
821
822 const places = [
823 makePlace("Bad 1"),
824 makePlace("Bad 2"),
825 makePlace("Bad 3"),
826 ];
827 // No callbacks at all — should not throw
828 const promise = geocodePlaces(places);
829 await vi.runAllTimersAsync();
830 await promise;
831
832 vi.useRealTimers();
833 });
834});