A wayfinder inspired map plugin for obisidian
at main 834 lines 28 kB view raw
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});