A wayfinder inspired map plugin for obisidian

feat: geocoder

+946
.chainlink/issues.db

This is a binary file and will not be displayed.

+4
CHANGELOG.md
··· 11 11 ### Fixed 12 12 13 13 ### Changed 14 + - geocoder.ts — TDD (#3) 15 + - Refactor geocoder (#13) 16 + - Implement geocoder (#12) 17 + - Write geocoder tests (#11) 14 18 - Adversarial review: parser.ts (#23) 15 19 - parser.ts — TDD (#2) 16 20 - Refactor parser (#10)
+222
src/geocoder.ts
··· 1 + /** 2 + * geocoder.ts — Nominatim Geocoder (Effectful) 3 + * 4 + * Convert place names to lat/lng coordinates via OpenStreetMap Nominatim API. 5 + * Handles rate limiting, deduplication, cancellation, and progressive updates. 6 + */ 7 + 8 + import type { Place } from "./parser"; 9 + 10 + export interface GeoResult { 11 + lat: number; 12 + lng: number; 13 + } 14 + 15 + export interface GeocodeCallbacks { 16 + onProgress?: (place: Place, result: GeoResult | null) => void; 17 + } 18 + 19 + /** Minimum delay between sequential Nominatim requests (ms). */ 20 + const RATE_LIMIT_MS = 1100; 21 + 22 + /** Per-request fetch timeout (ms). */ 23 + const FETCH_TIMEOUT_MS = 10_000; 24 + 25 + /** Consecutive failure threshold for showing an Obsidian Notice. */ 26 + const CONSECUTIVE_FAILURE_NOTICE_THRESHOLD = 3; 27 + 28 + const NOMINATIM_BASE = "https://nominatim.openstreetmap.org/search"; 29 + const USER_AGENT = "ObsidianMapViewer/1.0"; 30 + 31 + /** 32 + * Geocode an array of places via Nominatim. 33 + * 34 + * - Only geocodes places where lat AND lng are both undefined/null. 35 + * - Deduplicates by case-insensitive trimmed name. 36 + * - Rate-limits to 1100ms between sequential requests. 37 + * - Supports external cancellation via AbortSignal. 38 + * - Reports progress via onProgress callback. 39 + */ 40 + export async function geocodePlaces( 41 + places: Place[], 42 + callbacks?: GeocodeCallbacks, 43 + signal?: AbortSignal 44 + ): Promise<Place[]> { 45 + // Contract 13: empty array short-circuit 46 + if (places.length === 0) return []; 47 + 48 + // Build dedup map: normalized name -> { queryName, indices } 49 + const dedupMap = new Map< 50 + string, 51 + { queryName: string; indices: number[] } 52 + >(); 53 + 54 + // Collect which places need geocoding and build dedup groups 55 + const geocodeOrder: string[] = []; // order of unique normalized names to geocode 56 + 57 + for (let i = 0; i < places.length; i++) { 58 + const place = places[i]; 59 + 60 + // Contract 1: skip places with existing coordinates 61 + if (place.lat != null && place.lng != null) { 62 + continue; 63 + } 64 + 65 + const normalizedName = place.name.trim().toLowerCase(); 66 + 67 + if (dedupMap.has(normalizedName)) { 68 + // Contract 5: deduplication — add index to existing group 69 + dedupMap.get(normalizedName)!.indices.push(i); 70 + } else { 71 + // First encounter of this name 72 + dedupMap.set(normalizedName, { 73 + queryName: place.name.trim(), 74 + indices: [i], 75 + }); 76 + geocodeOrder.push(normalizedName); 77 + } 78 + } 79 + 80 + let consecutiveFailures = 0; 81 + 82 + // Process each unique name sequentially 83 + for (let g = 0; g < geocodeOrder.length; g++) { 84 + const normalizedName = geocodeOrder[g]; 85 + const group = dedupMap.get(normalizedName)!; 86 + 87 + // Contract 11: check external abort signal before making request 88 + if (signal?.aborted) { 89 + break; 90 + } 91 + 92 + // Contract 4: rate limiting — wait 1100ms between requests (not before first) 93 + // Contract 11: delay is cancellable — signal aborts immediately, not after full delay 94 + if (g > 0) { 95 + await delay(RATE_LIMIT_MS, signal); 96 + if (signal?.aborted) { 97 + break; 98 + } 99 + } 100 + 101 + // Perform the geocode 102 + const result = await fetchGeocode(group.queryName, signal); 103 + 104 + if (result === null) { 105 + consecutiveFailures++; 106 + // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) 107 + if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { 108 + try { 109 + // eslint-disable-next-line no-undef 110 + new (globalThis as any).Notice( 111 + "Map Viewer: Geocoding issues — check your network connection" 112 + ); 113 + } catch { 114 + // Notice may not exist outside Obsidian — swallow silently 115 + } 116 + } 117 + } else { 118 + consecutiveFailures = 0; 119 + } 120 + 121 + // Contract 7 & 9: apply result to all places in this dedup group 122 + for (const idx of group.indices) { 123 + if (result) { 124 + places[idx].lat = result.lat; 125 + places[idx].lng = result.lng; 126 + } 127 + } 128 + 129 + // Contract 7: call onProgress for the first place in the group 130 + if (callbacks?.onProgress) { 131 + const firstIdx = group.indices[0]; 132 + callbacks.onProgress(places[firstIdx], result); 133 + } 134 + } 135 + 136 + return places; 137 + } 138 + 139 + /** 140 + * Fetch geocode result for a single place name from Nominatim. 141 + * Returns GeoResult on success, null on failure/empty/timeout. 142 + */ 143 + async function fetchGeocode( 144 + name: string, 145 + externalSignal?: AbortSignal 146 + ): Promise<GeoResult | null> { 147 + // Contract 10: 10-second timeout via AbortController 148 + const timeoutController = new AbortController(); 149 + const timeoutId = setTimeout(() => timeoutController.abort(), FETCH_TIMEOUT_MS); 150 + 151 + // Combine external signal and timeout signal 152 + const combinedController = new AbortController(); 153 + 154 + // If external signal aborts, abort combined 155 + const onExternalAbort = () => combinedController.abort(); 156 + if (externalSignal) { 157 + if (externalSignal.aborted) { 158 + clearTimeout(timeoutId); 159 + return null; 160 + } 161 + externalSignal.addEventListener("abort", onExternalAbort); 162 + } 163 + 164 + // If timeout aborts, abort combined 165 + timeoutController.signal.addEventListener("abort", () => 166 + combinedController.abort() 167 + ); 168 + 169 + // Contract 2: build Nominatim URL 170 + const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; 171 + 172 + try { 173 + // Contract 6: User-Agent header 174 + const response = await fetch(url, { 175 + headers: { "User-Agent": USER_AGENT }, 176 + signal: combinedController.signal, 177 + }); 178 + 179 + const data = await response.json(); 180 + 181 + // Empty results 182 + if (!Array.isArray(data) || data.length === 0) { 183 + // Contract 12: warn for empty results 184 + console.warn("[MapViewer] No results for:", name); 185 + return null; 186 + } 187 + 188 + const lat = parseFloat(data[0].lat); 189 + const lng = parseFloat(data[0].lon); 190 + 191 + if (isNaN(lat) || isNaN(lng)) { 192 + console.warn("[MapViewer] Invalid coordinates for:", name); 193 + return null; 194 + } 195 + 196 + return { lat, lng }; 197 + } catch (err) { 198 + // Contract 8 & 12: network failures logged, place skipped 199 + console.warn("[MapViewer] Geocode failed for:", name, err); 200 + return null; 201 + } finally { 202 + clearTimeout(timeoutId); 203 + if (externalSignal) { 204 + externalSignal.removeEventListener("abort", onExternalAbort); 205 + } 206 + } 207 + } 208 + 209 + /** Promise-based delay that can be cancelled via AbortSignal. */ 210 + function delay(ms: number, signal?: AbortSignal): Promise<void> { 211 + return new Promise((resolve) => { 212 + if (signal?.aborted) { 213 + resolve(); 214 + return; 215 + } 216 + const timerId = setTimeout(resolve, ms); 217 + signal?.addEventListener("abort", () => { 218 + clearTimeout(timerId); 219 + resolve(); 220 + }, { once: true }); 221 + }); 222 + }
+720
tests/geocoder.test.ts
··· 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 + */ 6 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 7 + import { geocodePlaces } from "../src/geocoder"; 8 + import type { Place } from "../src/parser"; 9 + 10 + // ─── Helpers ────────────────────────────────────────────────────────── 11 + 12 + /** Create a Place with sensible defaults */ 13 + function 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 */ 28 + function 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) */ 36 + function nominatimEmpty(): Response { 37 + return new Response(JSON.stringify([]), { 38 + status: 200, 39 + headers: { "Content-Type": "application/json" }, 40 + }); 41 + } 42 + 43 + // ─── Mock Setup ─────────────────────────────────────────────────────── 44 + 45 + let mockFetch: ReturnType<typeof vi.fn>; 46 + 47 + beforeEach(() => { 48 + mockFetch = vi.fn(); 49 + vi.stubGlobal("fetch", mockFetch); 50 + vi.stubGlobal("Notice", vi.fn()); 51 + vi.spyOn(console, "warn").mockImplementation(() => {}); 52 + }); 53 + 54 + afterEach(() => { 55 + vi.restoreAllMocks(); 56 + vi.unstubAllGlobals(); 57 + }); 58 + 59 + // ─── Contract 13: Empty array short-circuit ─────────────────────────── 60 + 61 + describe("Contract 13: empty array short-circuit", () => { 62 + it("returns [] immediately for empty input, no API calls", async () => { 63 + const result = await geocodePlaces([]); 64 + expect(result).toEqual([]); 65 + expect(mockFetch).not.toHaveBeenCalled(); 66 + }); 67 + }); 68 + 69 + // ─── Contract 1: Skip places with existing coordinates ──────────────── 70 + 71 + describe("Contract 1: skip places with existing coordinates", () => { 72 + it("returns places with lat/lng unchanged, no API calls", async () => { 73 + const places = [ 74 + makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), 75 + ]; 76 + const result = await geocodePlaces(places); 77 + expect(result).toHaveLength(1); 78 + expect(result[0].lat).toBe(41.4036); 79 + expect(result[0].lng).toBe(2.1744); 80 + expect(mockFetch).not.toHaveBeenCalled(); 81 + }); 82 + 83 + it("only geocodes places missing coordinates", async () => { 84 + mockFetch.mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 85 + 86 + const places = [ 87 + makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), 88 + makePlace("The Louvre"), 89 + ]; 90 + const result = await geocodePlaces(places); 91 + expect(result).toHaveLength(2); 92 + expect(result[0].lat).toBe(41.4036); // unchanged 93 + expect(result[1].lat).toBe(48.8606); // geocoded 94 + expect(mockFetch).toHaveBeenCalledTimes(1); 95 + }); 96 + 97 + it("treats lat: 0, lng: 0 as valid existing coordinates (not falsy)", async () => { 98 + const places = [ 99 + makePlace("Null Island", { lat: 0, lng: 0 }), 100 + ]; 101 + const result = await geocodePlaces(places); 102 + expect(result[0].lat).toBe(0); 103 + expect(result[0].lng).toBe(0); 104 + expect(mockFetch).not.toHaveBeenCalled(); 105 + }); 106 + 107 + it("geocodes places with only lat set (half-populated)", async () => { 108 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 109 + 110 + const places = [makePlace("Half Place", { lat: 41.4036 })]; 111 + const result = await geocodePlaces(places); 112 + // Only lat was set, lng was undefined — should geocode 113 + expect(result[0].lat).toBe(41.4036); 114 + expect(result[0].lng).toBe(2.1744); 115 + expect(mockFetch).toHaveBeenCalledTimes(1); 116 + }); 117 + 118 + it("geocodes places with only lng set (half-populated)", async () => { 119 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 120 + 121 + const places = [makePlace("Half Place", { lng: 2.1744 })]; 122 + const result = await geocodePlaces(places); 123 + expect(result[0].lat).toBe(41.4036); 124 + expect(result[0].lng).toBe(2.1744); 125 + expect(mockFetch).toHaveBeenCalledTimes(1); 126 + }); 127 + }); 128 + 129 + // ─── Contract 2 & 3: Nominatim URL and query string ────────────────── 130 + 131 + describe("Contract 2 & 3: Nominatim URL and query params", () => { 132 + it("queries Nominatim with correct URL, format, limit, and encoded name", async () => { 133 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 134 + 135 + await geocodePlaces([makePlace("Sagrada Familia")]); 136 + 137 + expect(mockFetch).toHaveBeenCalledTimes(1); 138 + const [url, options] = mockFetch.mock.calls[0]; 139 + const parsedUrl = new URL(url); 140 + 141 + expect(parsedUrl.origin + parsedUrl.pathname).toBe( 142 + "https://nominatim.openstreetmap.org/search" 143 + ); 144 + expect(parsedUrl.searchParams.get("format")).toBe("json"); 145 + expect(parsedUrl.searchParams.get("limit")).toBe("1"); 146 + expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); 147 + }); 148 + }); 149 + 150 + // ─── Contract 6: User-Agent header ──────────────────────────────────── 151 + 152 + describe("Contract 6: User-Agent header", () => { 153 + it("sets User-Agent to ObsidianMapViewer/1.0", async () => { 154 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 155 + 156 + await geocodePlaces([makePlace("Sagrada Familia")]); 157 + 158 + const [, options] = mockFetch.mock.calls[0]; 159 + expect(options.headers["User-Agent"]).toBe("ObsidianMapViewer/1.0"); 160 + }); 161 + }); 162 + 163 + // ─── Contract 4: Rate limiting ──────────────────────────────────────── 164 + 165 + describe("Contract 4: rate limiting (1100ms between requests)", () => { 166 + it("waits at least 1100ms between sequential API calls", async () => { 167 + vi.useFakeTimers(); 168 + 169 + mockFetch 170 + .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) 171 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 172 + 173 + const places = [makePlace("Place A"), makePlace("Place B")]; 174 + const promise = geocodePlaces(places); 175 + 176 + // First fetch fires immediately 177 + await vi.advanceTimersByTimeAsync(0); 178 + expect(mockFetch).toHaveBeenCalledTimes(1); 179 + 180 + // At 1099ms, second fetch should not have fired yet 181 + await vi.advanceTimersByTimeAsync(1099); 182 + expect(mockFetch).toHaveBeenCalledTimes(1); 183 + 184 + // At 1100ms, second fetch should fire 185 + await vi.advanceTimersByTimeAsync(1); 186 + expect(mockFetch).toHaveBeenCalledTimes(2); 187 + 188 + // Let the promise resolve 189 + await vi.advanceTimersByTimeAsync(0); 190 + await promise; 191 + 192 + vi.useRealTimers(); 193 + }); 194 + 195 + it("requests are fully sequential — waits for response before delaying", async () => { 196 + vi.useFakeTimers(); 197 + 198 + let resolveFirst: (value: Response) => void; 199 + const firstFetchPromise = new Promise<Response>((resolve) => { 200 + resolveFirst = resolve; 201 + }); 202 + 203 + mockFetch 204 + .mockReturnValueOnce(firstFetchPromise) 205 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 206 + 207 + const places = [makePlace("Place A"), makePlace("Place B")]; 208 + const promise = geocodePlaces(places); 209 + 210 + // First fetch fires 211 + await vi.advanceTimersByTimeAsync(0); 212 + expect(mockFetch).toHaveBeenCalledTimes(1); 213 + 214 + // Even after 2000ms, second fetch shouldn't fire because first hasn't resolved 215 + await vi.advanceTimersByTimeAsync(2000); 216 + expect(mockFetch).toHaveBeenCalledTimes(1); 217 + 218 + // Resolve first fetch 219 + resolveFirst!(nominatimOk(41.4036, 2.1744)); 220 + await vi.advanceTimersByTimeAsync(0); 221 + 222 + // Still need to wait 1100ms after response 223 + expect(mockFetch).toHaveBeenCalledTimes(1); 224 + await vi.advanceTimersByTimeAsync(1100); 225 + expect(mockFetch).toHaveBeenCalledTimes(2); 226 + 227 + await vi.advanceTimersByTimeAsync(0); 228 + await promise; 229 + 230 + vi.useRealTimers(); 231 + }); 232 + }); 233 + 234 + // ─── Contract 5: Deduplication ──────────────────────────────────────── 235 + 236 + describe("Contract 5: deduplication (case-insensitive, trimmed)", () => { 237 + it("makes only one API call for duplicate names", async () => { 238 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 239 + 240 + const places = [ 241 + makePlace("Sagrada Familia"), 242 + makePlace("sagrada familia"), 243 + ]; 244 + const result = await geocodePlaces(places); 245 + 246 + expect(mockFetch).toHaveBeenCalledTimes(1); 247 + // Both places get the same result 248 + expect(result[0].lat).toBe(41.4036); 249 + expect(result[1].lat).toBe(41.4036); 250 + }); 251 + 252 + it("uses the first-encountered variant for the API call", async () => { 253 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 254 + 255 + const places = [ 256 + makePlace("Sagrada Familia"), 257 + makePlace("SAGRADA FAMILIA"), 258 + ]; 259 + await geocodePlaces(places); 260 + 261 + const [url] = mockFetch.mock.calls[0]; 262 + const parsedUrl = new URL(url); 263 + expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); 264 + }); 265 + 266 + it("trims names for dedup comparison", async () => { 267 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 268 + 269 + const places = [ 270 + makePlace(" Sagrada Familia "), 271 + makePlace("Sagrada Familia"), 272 + ]; 273 + const result = await geocodePlaces(places); 274 + 275 + expect(mockFetch).toHaveBeenCalledTimes(1); 276 + expect(result[0].lat).toBe(41.4036); 277 + expect(result[1].lat).toBe(41.4036); 278 + }); 279 + }); 280 + 281 + // ─── Contract 7: onProgress callback ───────────────────────────────── 282 + 283 + describe("Contract 7: onProgress callback", () => { 284 + it("calls onProgress after each unique geocode completes (success)", async () => { 285 + vi.useFakeTimers(); 286 + mockFetch 287 + .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) 288 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 289 + 290 + const onProgress = vi.fn(); 291 + const places = [makePlace("Place A"), makePlace("Place B")]; 292 + const promise = geocodePlaces(places, { onProgress }); 293 + await vi.runAllTimersAsync(); 294 + await promise; 295 + 296 + expect(onProgress).toHaveBeenCalledTimes(2); 297 + expect(onProgress).toHaveBeenCalledWith( 298 + expect.objectContaining({ name: "Place A" }), 299 + { lat: 41.4036, lng: 2.1744 } 300 + ); 301 + expect(onProgress).toHaveBeenCalledWith( 302 + expect.objectContaining({ name: "Place B" }), 303 + { lat: 48.8606, lng: 2.3376 } 304 + ); 305 + vi.useRealTimers(); 306 + }); 307 + 308 + it("calls onProgress with null for failed geocodes", async () => { 309 + mockFetch.mockRejectedValueOnce(new Error("Network error")); 310 + 311 + const onProgress = vi.fn(); 312 + await geocodePlaces([makePlace("Bad Place")], { onProgress }); 313 + 314 + expect(onProgress).toHaveBeenCalledTimes(1); 315 + expect(onProgress).toHaveBeenCalledWith( 316 + expect.objectContaining({ name: "Bad Place" }), 317 + null 318 + ); 319 + }); 320 + 321 + it("calls onProgress with null for empty Nominatim results", async () => { 322 + mockFetch.mockResolvedValueOnce(nominatimEmpty()); 323 + 324 + const onProgress = vi.fn(); 325 + await geocodePlaces([makePlace("Nonexistent Place")], { onProgress }); 326 + 327 + expect(onProgress).toHaveBeenCalledTimes(1); 328 + expect(onProgress).toHaveBeenCalledWith( 329 + expect.objectContaining({ name: "Nonexistent Place" }), 330 + null 331 + ); 332 + }); 333 + 334 + it("calls onProgress for all duplicate places, not just the first", async () => { 335 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 336 + 337 + const onProgress = vi.fn(); 338 + const places = [ 339 + makePlace("Sagrada Familia"), 340 + makePlace("sagrada familia"), 341 + ]; 342 + await geocodePlaces(places, { onProgress }); 343 + 344 + // onProgress should be called once for the unique geocode, but reported 345 + // for each place that shares the name 346 + expect(onProgress).toHaveBeenCalledTimes(1); 347 + // The first place triggers the call 348 + expect(onProgress).toHaveBeenCalledWith( 349 + expect.objectContaining({ name: "Sagrada Familia" }), 350 + { lat: 41.4036, lng: 2.1744 } 351 + ); 352 + }); 353 + }); 354 + 355 + // ─── Contract 8: Network failure resilience ─────────────────────────── 356 + 357 + describe("Contract 8: network failures don't abort batch", () => { 358 + it("skips failed place and continues with remaining", async () => { 359 + vi.useFakeTimers(); 360 + mockFetch 361 + .mockRejectedValueOnce(new Error("Network error")) 362 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 363 + 364 + const places = [makePlace("Bad Place"), makePlace("Good Place")]; 365 + const promise = geocodePlaces(places); 366 + await vi.runAllTimersAsync(); 367 + const result = await promise; 368 + 369 + expect(result).toHaveLength(2); 370 + expect(result[0].lat).toBeUndefined(); 371 + expect(result[0].lng).toBeUndefined(); 372 + expect(result[1].lat).toBe(48.8606); 373 + expect(result[1].lng).toBe(2.3376); 374 + vi.useRealTimers(); 375 + }); 376 + 377 + it("keeps lat/lng undefined for places with no Nominatim results", async () => { 378 + mockFetch.mockResolvedValueOnce(nominatimEmpty()); 379 + 380 + const result = await geocodePlaces([makePlace("Nonexistent")]); 381 + expect(result[0].lat).toBeUndefined(); 382 + expect(result[0].lng).toBeUndefined(); 383 + }); 384 + 385 + it("handles non-200 HTTP responses gracefully (e.g., 429 rate limit)", async () => { 386 + mockFetch.mockResolvedValueOnce( 387 + new Response(JSON.stringify({ error: "Rate limit exceeded" }), { 388 + status: 429, 389 + }) 390 + ); 391 + 392 + const result = await geocodePlaces([makePlace("Rate Limited")]); 393 + expect(result[0].lat).toBeUndefined(); 394 + expect(result[0].lng).toBeUndefined(); 395 + }); 396 + 397 + it("handles invalid JSON response body gracefully", async () => { 398 + mockFetch.mockResolvedValueOnce( 399 + new Response("<html>Server Error</html>", { 400 + status: 200, 401 + headers: { "Content-Type": "text/html" }, 402 + }) 403 + ); 404 + 405 + const result = await geocodePlaces([makePlace("Bad Response")]); 406 + expect(result[0].lat).toBeUndefined(); 407 + expect(result[0].lng).toBeUndefined(); 408 + }); 409 + }); 410 + 411 + // ─── Contract 9: Return full array with geocoded lat/lng ────────────── 412 + 413 + describe("Contract 9: returns full array with geocoded results", () => { 414 + it("returns all places with successfully geocoded lat/lng set", async () => { 415 + vi.useFakeTimers(); 416 + mockFetch 417 + .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) 418 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 419 + 420 + const places = [makePlace("Place A"), makePlace("Place B")]; 421 + const promise = geocodePlaces(places); 422 + await vi.runAllTimersAsync(); 423 + const result = await promise; 424 + 425 + expect(result).toHaveLength(2); 426 + expect(result[0]).toEqual( 427 + expect.objectContaining({ name: "Place A", lat: 41.4036, lng: 2.1744 }) 428 + ); 429 + expect(result[1]).toEqual( 430 + expect.objectContaining({ name: "Place B", lat: 48.8606, lng: 2.3376 }) 431 + ); 432 + vi.useRealTimers(); 433 + }); 434 + 435 + it("preserves all original place properties", async () => { 436 + mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); 437 + 438 + const places = [ 439 + makePlace("Sagrada Familia", { 440 + url: "https://example.com", 441 + fields: { category: "Architecture" }, 442 + notes: ["Amazing"], 443 + startLine: 5, 444 + endLine: 8, 445 + }), 446 + ]; 447 + const result = await geocodePlaces(places); 448 + 449 + expect(result[0].url).toBe("https://example.com"); 450 + expect(result[0].fields).toEqual({ category: "Architecture" }); 451 + expect(result[0].notes).toEqual(["Amazing"]); 452 + expect(result[0].startLine).toBe(5); 453 + expect(result[0].endLine).toBe(8); 454 + expect(result[0].lat).toBe(41.4036); 455 + expect(result[0].lng).toBe(2.1744); 456 + }); 457 + }); 458 + 459 + // ─── Contract 10: 10-second fetch timeout ───────────────────────────── 460 + 461 + describe("Contract 10: 10-second fetch timeout", () => { 462 + it("aborts fetch after 10 seconds and treats as network failure", async () => { 463 + vi.useFakeTimers(); 464 + 465 + // A fetch that never resolves 466 + mockFetch.mockImplementationOnce( 467 + (_url: string, options: { signal: AbortSignal }) => { 468 + return new Promise<Response>((resolve, reject) => { 469 + options.signal.addEventListener("abort", () => { 470 + reject(new DOMException("The operation was aborted.", "AbortError")); 471 + }); 472 + }); 473 + } 474 + ); 475 + 476 + const places = [makePlace("Slow Place")]; 477 + const promise = geocodePlaces(places); 478 + 479 + // Advance past the 10s timeout 480 + await vi.advanceTimersByTimeAsync(10_000); 481 + const result = await promise; 482 + 483 + expect(result[0].lat).toBeUndefined(); 484 + expect(result[0].lng).toBeUndefined(); 485 + 486 + vi.useRealTimers(); 487 + }); 488 + 489 + it("fetch timeout is treated as network failure (place skipped, batch continues)", async () => { 490 + vi.useFakeTimers(); 491 + 492 + // First fetch times out, second succeeds 493 + mockFetch 494 + .mockImplementationOnce( 495 + (_url: string, options: { signal: AbortSignal }) => { 496 + return new Promise<Response>((resolve, reject) => { 497 + options.signal.addEventListener("abort", () => { 498 + reject( 499 + new DOMException("The operation was aborted.", "AbortError") 500 + ); 501 + }); 502 + }); 503 + } 504 + ) 505 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 506 + 507 + const places = [makePlace("Slow Place"), makePlace("Fast Place")]; 508 + const promise = geocodePlaces(places); 509 + 510 + // Advance past timeout + rate limit 511 + await vi.advanceTimersByTimeAsync(10_000 + 1100); 512 + const result = await promise; 513 + 514 + expect(result[0].lat).toBeUndefined(); 515 + expect(result[1].lat).toBe(48.8606); 516 + 517 + vi.useRealTimers(); 518 + }); 519 + }); 520 + 521 + // ─── Contract 11: External AbortSignal cancellation ─────────────────── 522 + 523 + describe("Contract 11: external AbortSignal cancellation", () => { 524 + it("stops processing when signal fires, returns partial results", async () => { 525 + const controller = new AbortController(); 526 + 527 + mockFetch.mockImplementation(() => { 528 + // After first fetch, abort the signal 529 + controller.abort(); 530 + return Promise.resolve(nominatimOk(41.4036, 2.1744)); 531 + }); 532 + 533 + const places = [makePlace("Place A"), makePlace("Place B")]; 534 + const result = await geocodePlaces(places, {}, controller.signal); 535 + 536 + // Place A should be geocoded, Place B should not (signal was aborted after first fetch) 537 + expect(result[0].lat).toBe(41.4036); 538 + expect(result[1].lat).toBeUndefined(); 539 + expect(mockFetch).toHaveBeenCalledTimes(1); 540 + }); 541 + 542 + it("returns results obtained so far when signal is already aborted", async () => { 543 + const controller = new AbortController(); 544 + controller.abort(); 545 + 546 + const places = [makePlace("Place A")]; 547 + const result = await geocodePlaces(places, {}, controller.signal); 548 + 549 + expect(result[0].lat).toBeUndefined(); 550 + expect(mockFetch).not.toHaveBeenCalled(); 551 + }); 552 + 553 + it("aborts in-flight fetch requests when signal fires", async () => { 554 + vi.useFakeTimers(); 555 + const controller = new AbortController(); 556 + 557 + let fetchSignal: AbortSignal | undefined; 558 + mockFetch.mockImplementationOnce( 559 + (_url: string, options: { signal: AbortSignal }) => { 560 + fetchSignal = options.signal; 561 + return new Promise<Response>((resolve, reject) => { 562 + options.signal.addEventListener("abort", () => { 563 + reject( 564 + new DOMException("The operation was aborted.", "AbortError") 565 + ); 566 + }); 567 + }); 568 + } 569 + ); 570 + 571 + const places = [makePlace("Place A")]; 572 + const promise = geocodePlaces(places, {}, controller.signal); 573 + 574 + // Let fetch start 575 + await vi.advanceTimersByTimeAsync(0); 576 + expect(mockFetch).toHaveBeenCalledTimes(1); 577 + 578 + // Fire the external signal 579 + controller.abort(); 580 + await vi.advanceTimersByTimeAsync(0); 581 + 582 + const result = await promise; 583 + expect(result[0].lat).toBeUndefined(); 584 + expect(fetchSignal!.aborted).toBe(true); 585 + 586 + vi.useRealTimers(); 587 + }); 588 + 589 + it("cancels immediately during rate-limit delay, does not wait full 1100ms", async () => { 590 + vi.useFakeTimers(); 591 + const controller = new AbortController(); 592 + 593 + mockFetch 594 + .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) 595 + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)); 596 + 597 + const places = [makePlace("Place A"), makePlace("Place B")]; 598 + const promise = geocodePlaces(places, {}, controller.signal); 599 + 600 + // Let first fetch complete 601 + await vi.advanceTimersByTimeAsync(0); 602 + expect(mockFetch).toHaveBeenCalledTimes(1); 603 + 604 + // We're now in the 1100ms rate-limit delay. Abort at 500ms. 605 + await vi.advanceTimersByTimeAsync(500); 606 + controller.abort(); 607 + await vi.advanceTimersByTimeAsync(0); 608 + 609 + const result = await promise; 610 + 611 + // Place A was geocoded, Place B was not (aborted during delay) 612 + expect(result[0].lat).toBe(41.4036); 613 + expect(result[1].lat).toBeUndefined(); 614 + // Second fetch never fires 615 + expect(mockFetch).toHaveBeenCalledTimes(1); 616 + 617 + vi.useRealTimers(); 618 + }); 619 + }); 620 + 621 + // ─── Contract 12: Error reporting ───────────────────────────────────── 622 + 623 + describe("Contract 12: error reporting", () => { 624 + it("logs console.warn with [MapViewer] prefix for failures", async () => { 625 + mockFetch.mockRejectedValueOnce(new Error("fail")); 626 + 627 + await geocodePlaces([makePlace("Bad Place")]); 628 + 629 + expect(console.warn).toHaveBeenCalled(); 630 + const warnCall = (console.warn as ReturnType<typeof vi.fn>).mock.calls[0]; 631 + expect(warnCall[0]).toContain("[MapViewer]"); 632 + }); 633 + 634 + it("shows Obsidian Notice after 3 consecutive failures", async () => { 635 + vi.useFakeTimers(); 636 + mockFetch 637 + .mockRejectedValueOnce(new Error("fail 1")) 638 + .mockRejectedValueOnce(new Error("fail 2")) 639 + .mockRejectedValueOnce(new Error("fail 3")); 640 + 641 + const places = [ 642 + makePlace("Bad 1"), 643 + makePlace("Bad 2"), 644 + makePlace("Bad 3"), 645 + ]; 646 + const promise = geocodePlaces(places); 647 + await vi.runAllTimersAsync(); 648 + await promise; 649 + 650 + expect(Notice).toHaveBeenCalledWith( 651 + "Map Viewer: Geocoding issues — check your network connection" 652 + ); 653 + vi.useRealTimers(); 654 + }); 655 + 656 + it("shows Notice exactly once even with more than 3 consecutive failures", async () => { 657 + vi.useFakeTimers(); 658 + mockFetch 659 + .mockRejectedValueOnce(new Error("fail 1")) 660 + .mockRejectedValueOnce(new Error("fail 2")) 661 + .mockRejectedValueOnce(new Error("fail 3")) 662 + .mockRejectedValueOnce(new Error("fail 4")) 663 + .mockRejectedValueOnce(new Error("fail 5")); 664 + 665 + const places = [ 666 + makePlace("Bad 1"), 667 + makePlace("Bad 2"), 668 + makePlace("Bad 3"), 669 + makePlace("Bad 4"), 670 + makePlace("Bad 5"), 671 + ]; 672 + const promise = geocodePlaces(places); 673 + await vi.runAllTimersAsync(); 674 + await promise; 675 + 676 + // Notice should fire exactly once (on the 3rd failure), not on 4th and 5th 677 + expect(Notice).toHaveBeenCalledTimes(1); 678 + vi.useRealTimers(); 679 + }); 680 + 681 + it("does NOT show Notice for fewer than 3 consecutive failures", async () => { 682 + vi.useFakeTimers(); 683 + mockFetch 684 + .mockRejectedValueOnce(new Error("fail 1")) 685 + .mockRejectedValueOnce(new Error("fail 2")); 686 + 687 + const places = [makePlace("Bad 1"), makePlace("Bad 2")]; 688 + const promise = geocodePlaces(places); 689 + await vi.runAllTimersAsync(); 690 + await promise; 691 + 692 + expect(Notice).not.toHaveBeenCalled(); 693 + vi.useRealTimers(); 694 + }); 695 + 696 + it("resets consecutive failure counter on success", async () => { 697 + vi.useFakeTimers(); 698 + mockFetch 699 + .mockRejectedValueOnce(new Error("fail 1")) 700 + .mockRejectedValueOnce(new Error("fail 2")) 701 + .mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)) // success resets counter 702 + .mockRejectedValueOnce(new Error("fail 3")) 703 + .mockRejectedValueOnce(new Error("fail 4")); 704 + 705 + const places = [ 706 + makePlace("Bad 1"), 707 + makePlace("Bad 2"), 708 + makePlace("Good"), 709 + makePlace("Bad 3"), 710 + makePlace("Bad 4"), 711 + ]; 712 + const promise = geocodePlaces(places); 713 + await vi.runAllTimersAsync(); 714 + await promise; 715 + 716 + // Counter was reset by "Good", so only 2 consecutive after that 717 + expect(Notice).not.toHaveBeenCalled(); 718 + vi.useRealTimers(); 719 + }); 720 + });