A wayfinder inspired map plugin for obisidian
at main 1153 lines 39 kB view raw
1/** 2 * mapRenderer.test.ts — Tests for all mapRenderer.ts behavioral contracts 3 * 4 * Tests mock Leaflet, DOM APIs, and CSS variable access. 5 * Each contract from the spec has its own describe block. 6 * 7 * @vitest-environment jsdom 8 */ 9import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 10import type { Place } from "../src/parser"; 11 12// ─── Helpers ────────────────────────────────────────────────────────── 13 14function makePlace( 15 name: string, 16 overrides: Partial<Place> = {} 17): Place { 18 return { 19 name, 20 fields: {}, 21 notes: [], 22 startLine: 0, 23 endLine: 0, 24 ...overrides, 25 }; 26} 27 28// ─── Leaflet Mock ───────────────────────────────────────────────────── 29 30// We build a comprehensive mock of the Leaflet APIs used by mapRenderer. 31// Each test gets a fresh set of mock objects. 32 33interface MockMarker { 34 setLatLng: ReturnType<typeof vi.fn>; 35 setIcon: ReturnType<typeof vi.fn>; 36 bindPopup: ReturnType<typeof vi.fn>; 37 openPopup: ReturnType<typeof vi.fn>; 38 on: ReturnType<typeof vi.fn>; 39 off: ReturnType<typeof vi.fn>; 40 addTo: ReturnType<typeof vi.fn>; 41 remove: ReturnType<typeof vi.fn>; 42 getLatLng: ReturnType<typeof vi.fn>; 43 _latlng: { lat: number; lng: number }; 44 _events: Record<string, ((...args: unknown[]) => void)[]>; 45} 46 47interface MockCircleMarker { 48 addTo: ReturnType<typeof vi.fn>; 49 remove: ReturnType<typeof vi.fn>; 50 setLatLng: ReturnType<typeof vi.fn>; 51 getElement: ReturnType<typeof vi.fn>; 52} 53 54interface MockMap { 55 setView: ReturnType<typeof vi.fn>; 56 fitBounds: ReturnType<typeof vi.fn>; 57 remove: ReturnType<typeof vi.fn>; 58 invalidateSize: ReturnType<typeof vi.fn>; 59 getZoom: ReturnType<typeof vi.fn>; 60 on: ReturnType<typeof vi.fn>; 61 off: ReturnType<typeof vi.fn>; 62 _events: Record<string, ((...args: unknown[]) => void)[]>; 63} 64 65interface MockTileLayer { 66 addTo: ReturnType<typeof vi.fn>; 67} 68 69interface MockLayerGroup { 70 addTo: ReturnType<typeof vi.fn>; 71 clearLayers: ReturnType<typeof vi.fn>; 72 addLayer: ReturnType<typeof vi.fn>; 73 getLayers: ReturnType<typeof vi.fn>; 74 eachLayer: ReturnType<typeof vi.fn>; 75} 76 77interface MockIcon { 78 options: Record<string, unknown>; 79} 80 81interface MockLatLngBounds { 82 extend: ReturnType<typeof vi.fn>; 83 isValid: ReturnType<typeof vi.fn>; 84 getCenter: ReturnType<typeof vi.fn>; 85 pad: ReturnType<typeof vi.fn>; 86} 87 88let mockMapInstance: MockMap; 89let mockMarkers: MockMarker[]; 90let mockCircleMarkers: MockCircleMarker[]; 91let mockTileLayer: MockTileLayer; 92let mockLayerGroup: MockLayerGroup; 93let mockIcons: MockIcon[]; 94let mockBounds: MockLatLngBounds; 95 96function createMockMarker(lat: number, lng: number): MockMarker { 97 const marker: MockMarker = { 98 setLatLng: vi.fn().mockReturnThis(), 99 setIcon: vi.fn().mockReturnThis(), 100 bindPopup: vi.fn().mockReturnThis(), 101 openPopup: vi.fn().mockReturnThis(), 102 on: vi.fn().mockImplementation(function (this: MockMarker, event: string, handler: (...args: unknown[]) => void) { 103 if (!this._events[event]) this._events[event] = []; 104 this._events[event].push(handler); 105 return this; 106 }), 107 off: vi.fn().mockReturnThis(), 108 addTo: vi.fn().mockReturnThis(), 109 remove: vi.fn().mockReturnThis(), 110 getLatLng: vi.fn().mockReturnValue({ lat, lng }), 111 _latlng: { lat, lng }, 112 _events: {}, 113 }; 114 mockMarkers.push(marker); 115 return marker; 116} 117 118function createMockCircleMarker(): MockCircleMarker { 119 const cm: MockCircleMarker = { 120 addTo: vi.fn().mockReturnThis(), 121 remove: vi.fn().mockReturnThis(), 122 setLatLng: vi.fn().mockReturnThis(), 123 getElement: vi.fn().mockReturnValue(document.createElement("div")), 124 }; 125 mockCircleMarkers.push(cm); 126 return cm; 127} 128 129function setupLeafletMock(): typeof import("leaflet") { 130 mockMapInstance = { 131 setView: vi.fn().mockReturnThis(), 132 fitBounds: vi.fn().mockReturnThis(), 133 remove: vi.fn(), 134 invalidateSize: vi.fn(), 135 getZoom: vi.fn().mockReturnValue(2), 136 on: vi.fn().mockImplementation(function (this: MockMap, event: string, handler: (...args: unknown[]) => void) { 137 if (!this._events[event]) this._events[event] = []; 138 this._events[event].push(handler); 139 return this; 140 }), 141 off: vi.fn().mockReturnThis(), 142 _events: {}, 143 }; 144 145 mockTileLayer = { 146 addTo: vi.fn().mockReturnThis(), 147 }; 148 149 mockLayerGroup = { 150 addTo: vi.fn().mockReturnThis(), 151 clearLayers: vi.fn(), 152 addLayer: vi.fn(), 153 getLayers: vi.fn().mockReturnValue([]), 154 eachLayer: vi.fn(), 155 }; 156 157 mockBounds = { 158 extend: vi.fn().mockReturnThis(), 159 isValid: vi.fn().mockReturnValue(true), 160 getCenter: vi.fn().mockReturnValue({ lat: 0, lng: 0 }), 161 pad: vi.fn().mockReturnThis(), 162 }; 163 164 const L = { 165 map: vi.fn().mockReturnValue(mockMapInstance), 166 tileLayer: vi.fn().mockReturnValue(mockTileLayer), 167 layerGroup: vi.fn().mockReturnValue(mockLayerGroup), 168 marker: vi.fn().mockImplementation((latlng: [number, number]) => { 169 return createMockMarker(latlng[0], latlng[1]); 170 }), 171 circleMarker: vi.fn().mockImplementation(() => { 172 return createMockCircleMarker(); 173 }), 174 divIcon: vi.fn().mockImplementation((opts: Record<string, unknown>) => { 175 const icon: MockIcon = { options: opts }; 176 mockIcons.push(icon); 177 return icon; 178 }), 179 icon: vi.fn().mockImplementation((opts: Record<string, unknown>) => { 180 const icon: MockIcon = { options: opts }; 181 mockIcons.push(icon); 182 return icon; 183 }), 184 latLngBounds: vi.fn().mockReturnValue(mockBounds), 185 latLng: vi.fn().mockImplementation((lat: number, lng: number) => ({ lat, lng })), 186 DomEvent: { 187 stopPropagation: vi.fn(), 188 stop: vi.fn(), 189 }, 190 Util: { 191 stamp: vi.fn().mockReturnValue(1), 192 }, 193 }; 194 195 return L as unknown as typeof import("leaflet"); 196} 197 198// ─── Mock ResizeObserver ────────────────────────────────────────────── 199 200let resizeObserverCallback: ResizeObserverCallback | null = null; 201let resizeObserverDisconnected = false; 202 203class MockResizeObserver { 204 callback: ResizeObserverCallback; 205 constructor(callback: ResizeObserverCallback) { 206 this.callback = callback; 207 resizeObserverCallback = callback; 208 resizeObserverDisconnected = false; 209 } 210 observe() {} 211 unobserve() {} 212 disconnect() { 213 resizeObserverDisconnected = true; 214 } 215} 216 217// ─── Test Setup ─────────────────────────────────────────────────────── 218 219let L: ReturnType<typeof setupLeafletMock>; 220let createMap: typeof import("../src/mapRenderer").createMap; 221 222beforeEach(async () => { 223 mockMarkers = []; 224 mockCircleMarkers = []; 225 mockIcons = []; 226 227 L = setupLeafletMock(); 228 229 // Mock leaflet module 230 vi.doMock("leaflet", () => ({ default: L, ...L })); 231 232 // Mock ResizeObserver globally 233 vi.stubGlobal("ResizeObserver", MockResizeObserver); 234 resizeObserverCallback = null; 235 resizeObserverDisconnected = false; 236 237 // Mock getComputedStyle for CSS variable access 238 vi.stubGlobal( 239 "getComputedStyle", 240 vi.fn().mockReturnValue({ 241 getPropertyValue: vi.fn().mockImplementation((prop: string) => { 242 if (prop === "--interactive-accent") return "#7b6cd9"; 243 return ""; 244 }), 245 }) 246 ); 247 248 // Import fresh module for each test 249 const mod = await import("../src/mapRenderer"); 250 createMap = mod.createMap; 251}); 252 253afterEach(() => { 254 vi.restoreAllMocks(); 255 vi.resetModules(); 256 vi.unstubAllGlobals(); 257}); 258 259// ─── Contract 1: Leaflet CSS injection ──────────────────────────────── 260 261describe("Contract 1: Leaflet CSS injection", () => { 262 it("injects a <style> tag with id='leaflet-css' into document.head", () => { 263 const container = document.createElement("div"); 264 createMap(container, [], {}); 265 266 const style = document.getElementById("leaflet-css"); 267 expect(style).not.toBeNull(); 268 expect(style?.tagName.toLowerCase()).toBe("style"); 269 }); 270 271 it("does not inject a second <style> tag on repeat calls (idempotent)", () => { 272 const container1 = document.createElement("div"); 273 const container2 = document.createElement("div"); 274 createMap(container1, [], {}); 275 const ctrl2 = createMap(container2, [], {}); 276 277 const styles = document.querySelectorAll("#leaflet-css"); 278 expect(styles.length).toBe(1); 279 280 ctrl2.destroy(); 281 }); 282}); 283 284// ─── Contract 2: Map container setup ────────────────────────────────── 285 286describe("Contract 2: Map container div", () => { 287 it("creates a child div with class 'map-viewer-map' inside container", () => { 288 const container = document.createElement("div"); 289 createMap(container, [], {}); 290 291 const mapDiv = container.querySelector(".map-viewer-map"); 292 expect(mapDiv).not.toBeNull(); 293 }); 294 295 it("passes the map div to L.map()", () => { 296 const container = document.createElement("div"); 297 createMap(container, [], {}); 298 299 expect(L.map).toHaveBeenCalledTimes(1); 300 const mapDiv = container.querySelector(".map-viewer-map"); 301 expect(L.map).toHaveBeenCalledWith(mapDiv, expect.anything()); 302 }); 303}); 304 305// ─── Contract 3: Tile layers ────────────────────────────────────────── 306 307describe("Contract 3: Tile layers", () => { 308 it("creates Cooper Hewitt Watercolor base layer", () => { 309 const container = document.createElement("div"); 310 createMap(container, [], {}); 311 312 const calls = (L.tileLayer as ReturnType<typeof vi.fn>).mock.calls; 313 const watercolorCall = calls.find( 314 (c: unknown[]) => 315 typeof c[0] === "string" && 316 c[0].includes("watercolor") 317 ); 318 expect(watercolorCall).toBeDefined(); 319 expect(watercolorCall![0]).toContain("watercolormaps.collection.cooperhewitt.org"); 320 }); 321 322 it("creates CartoDB Light Labels overlay", () => { 323 const container = document.createElement("div"); 324 createMap(container, [], {}); 325 326 const calls = (L.tileLayer as ReturnType<typeof vi.fn>).mock.calls; 327 const labelsCall = calls.find( 328 (c: unknown[]) => 329 typeof c[0] === "string" && 330 c[0].includes("light_only_labels") 331 ); 332 expect(labelsCall).toBeDefined(); 333 expect(labelsCall![0]).toContain("basemaps.cartocdn.com"); 334 }); 335 336 it("adds both tile layers to the map", () => { 337 const container = document.createElement("div"); 338 createMap(container, [], {}); 339 340 // tileLayer is called twice, each result gets addTo called 341 expect(L.tileLayer).toHaveBeenCalledTimes(2); 342 expect(mockTileLayer.addTo).toHaveBeenCalledTimes(2); 343 }); 344}); 345 346// ─── Contract 4: Marker grouping by identical lat/lng ───────────────── 347 348describe("Contract 4: Marker grouping by toFixed(6)", () => { 349 it("creates one marker for two places at the exact same coordinates", () => { 350 const container = document.createElement("div"); 351 const places = [ 352 makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 353 makePlace("Place B", { lat: 41.403600, lng: 2.174400, startLine: 3 }), 354 ]; 355 createMap(container, places, {}); 356 357 // Only one L.marker call despite two places 358 expect(L.marker).toHaveBeenCalledTimes(1); 359 }); 360 361 it("creates separate markers for places at different coordinates", () => { 362 const container = document.createElement("div"); 363 const places = [ 364 makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 365 makePlace("Place B", { lat: 48.860600, lng: 2.337600, startLine: 3 }), 366 ]; 367 createMap(container, places, {}); 368 369 expect(L.marker).toHaveBeenCalledTimes(2); 370 }); 371 372 it("groups using toFixed(6) — tiny differences below precision are merged", () => { 373 const container = document.createElement("div"); 374 const places = [ 375 makePlace("Place A", { lat: 41.4036001, lng: 2.1744001, startLine: 0 }), 376 makePlace("Place B", { lat: 41.4036002, lng: 2.1744002, startLine: 3 }), 377 ]; 378 createMap(container, places, {}); 379 380 // Both round to 41.403600, 2.174400 — should be one marker 381 expect(L.marker).toHaveBeenCalledTimes(1); 382 }); 383 384 it("does not group places where toFixed(6) differs", () => { 385 const container = document.createElement("div"); 386 const places = [ 387 makePlace("Place A", { lat: 41.403600, lng: 2.174400, startLine: 0 }), 388 makePlace("Place B", { lat: 41.403601, lng: 2.174400, startLine: 3 }), 389 ]; 390 createMap(container, places, {}); 391 392 // 41.403600 vs 41.403601 — different at 6th decimal 393 expect(L.marker).toHaveBeenCalledTimes(2); 394 }); 395}); 396 397// ─── Contract 5: Custom SVG teardrop pin icons ──────────────────────── 398 399describe("Contract 5: SVG teardrop pin icons", () => { 400 it("creates markers with a custom divIcon containing SVG", () => { 401 const container = document.createElement("div"); 402 const places = [ 403 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 404 ]; 405 createMap(container, places, {}); 406 407 // Should use divIcon for the marker 408 expect(L.divIcon).toHaveBeenCalled(); 409 const iconOpts = mockIcons[0]?.options; 410 expect(iconOpts).toBeDefined(); 411 // The icon HTML should contain an SVG 412 const html = iconOpts?.html as string; 413 expect(html).toBeDefined(); 414 expect(html.toLowerCase()).toContain("<svg"); 415 expect(html.toLowerCase()).toContain("teardrop"); 416 }); 417 418 it("uses --interactive-accent CSS variable for fill color", () => { 419 const container = document.createElement("div"); 420 const places = [ 421 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 422 ]; 423 createMap(container, places, {}); 424 425 const iconOpts = mockIcons[0]?.options; 426 const html = iconOpts?.html as string; 427 // Should contain the accent color from CSS variable 428 expect(html).toContain("#7b6cd9"); 429 }); 430 431 it("uses a darkened stroke color (25% darker than fill)", () => { 432 const container = document.createElement("div"); 433 const places = [ 434 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 435 ]; 436 createMap(container, places, {}); 437 438 const iconOpts = mockIcons[0]?.options; 439 const html = iconOpts?.html as string; 440 // Stroke should be present and different from fill 441 // The exact darkened color depends on implementation, but it should have a stroke 442 expect(html.toLowerCase()).toContain("stroke"); 443 }); 444}); 445 446// ─── Contract 6: Marker click → onPlaceSelect ──────────────────────── 447 448describe("Contract 6: Marker click calls onPlaceSelect", () => { 449 it("calls onPlaceSelect with all places at the marker's location on click", () => { 450 const container = document.createElement("div"); 451 const onPlaceSelect = vi.fn(); 452 const places = [ 453 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 454 makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 455 ]; 456 createMap(container, places, { onPlaceSelect }); 457 458 // Simulate click on the marker 459 const marker = mockMarkers[0]; 460 expect(marker.on).toHaveBeenCalled(); 461 const clickHandler = marker._events["click"]?.[0]; 462 expect(clickHandler).toBeDefined(); 463 clickHandler!({}); 464 465 expect(onPlaceSelect).toHaveBeenCalledTimes(1); 466 const calledWith = onPlaceSelect.mock.calls[0][0]; 467 expect(calledWith).toHaveLength(2); 468 expect(calledWith[0].name).toBe("Place A"); 469 expect(calledWith[1].name).toBe("Place B"); 470 }); 471 472 it("shows popup with place names on marker click", () => { 473 const container = document.createElement("div"); 474 const places = [ 475 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 476 ]; 477 createMap(container, places, {}); 478 479 // Marker should have popup bound 480 const marker = mockMarkers[0]; 481 expect(marker.bindPopup).toHaveBeenCalled(); 482 }); 483 484 it("popup shows linked name if place has url, plain text otherwise", () => { 485 const container = document.createElement("div"); 486 const places = [ 487 makePlace("Place A", { 488 lat: 41.4036, 489 lng: 2.1744, 490 startLine: 0, 491 url: "https://example.com", 492 }), 493 makePlace("Place B", { 494 lat: 41.4036, 495 lng: 2.1744, 496 startLine: 3, 497 }), 498 ]; 499 createMap(container, places, {}); 500 501 const marker = mockMarkers[0]; 502 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 503 504 // Should contain an <a> for Place A (has url) 505 expect(popupContent).toContain("<a"); 506 expect(popupContent).toContain("https://example.com"); 507 expect(popupContent).toContain("Place A"); 508 509 // Place B should be plain text (no url) 510 expect(popupContent).toContain("Place B"); 511 }); 512 513 it("uses L.DomEvent.stopPropagation on marker click", () => { 514 const container = document.createElement("div"); 515 const places = [ 516 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 517 ]; 518 createMap(container, places, {}); 519 520 const marker = mockMarkers[0]; 521 const clickHandler = marker._events["click"]?.[0]; 522 expect(clickHandler).toBeDefined(); 523 524 const fakeEvent = { originalEvent: new Event("click") }; 525 clickHandler!(fakeEvent); 526 527 expect(L.DomEvent.stopPropagation).toHaveBeenCalled(); 528 }); 529}); 530 531// ─── Contract 7: selectPlace by startLine ───────────────────────────── 532 533describe("Contract 7: selectPlace matches by startLine", () => { 534 it("pans/zooms to the marker matching the place's startLine", () => { 535 const container = document.createElement("div"); 536 const places = [ 537 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 538 makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 5 }), 539 ]; 540 const ctrl = createMap(container, places, {}); 541 542 ctrl.selectPlace(places[1]); 543 544 // Should pan to Place B 545 expect(mockMapInstance.setView).toHaveBeenCalled(); 546 }); 547 548 it("selects the group marker if the place is part of a group", () => { 549 const container = document.createElement("div"); 550 const places = [ 551 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 552 makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 553 ]; 554 const ctrl = createMap(container, places, {}); 555 556 // Select Place B — should select the shared marker 557 ctrl.selectPlace(places[1]); 558 559 // Should still pan to the grouped marker location 560 expect(mockMapInstance.setView).toHaveBeenCalled(); 561 }); 562 563 it("shows a highlight ring (CircleMarker) on selection", () => { 564 const container = document.createElement("div"); 565 const places = [ 566 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 567 ]; 568 const ctrl = createMap(container, places, {}); 569 570 ctrl.selectPlace(places[0]); 571 572 // A CircleMarker should be created for the highlight 573 expect(L.circleMarker).toHaveBeenCalled(); 574 expect(mockCircleMarkers.length).toBeGreaterThan(0); 575 expect(mockCircleMarkers[0].addTo).toHaveBeenCalled(); 576 }); 577}); 578 579// ─── Contract 8: selectPlace(null) removes highlight ────────────────── 580 581describe("Contract 8: selectPlace(null) deselects", () => { 582 it("removes highlight ring when selectPlace(null) is called", () => { 583 const container = document.createElement("div"); 584 const places = [ 585 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 586 ]; 587 const ctrl = createMap(container, places, {}); 588 589 ctrl.selectPlace(places[0]); 590 expect(mockCircleMarkers.length).toBeGreaterThan(0); 591 592 ctrl.selectPlace(null); 593 expect(mockCircleMarkers[0].remove).toHaveBeenCalled(); 594 }); 595 596 it("does nothing if no place is selected and selectPlace(null) is called", () => { 597 const container = document.createElement("div"); 598 const ctrl = createMap(container, [], {}); 599 600 // Should not throw 601 expect(() => ctrl.selectPlace(null)).not.toThrow(); 602 }); 603}); 604 605// ─── Contract 9: updateMarkers clears and recreates ─────────────────── 606 607describe("Contract 9: updateMarkers clears and recreates", () => { 608 it("clears all existing markers on updateMarkers", () => { 609 const container = document.createElement("div"); 610 const places = [ 611 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 612 ]; 613 const ctrl = createMap(container, places, {}); 614 615 const newPlaces = [ 616 makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 617 ]; 618 ctrl.updateMarkers(newPlaces); 619 620 // Old markers should be removed 621 expect(mockMarkers[0].remove).toHaveBeenCalled(); 622 }); 623 624 it("removes highlight ring on updateMarkers", () => { 625 const container = document.createElement("div"); 626 const places = [ 627 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 628 ]; 629 const ctrl = createMap(container, places, {}); 630 631 ctrl.selectPlace(places[0]); 632 expect(mockCircleMarkers.length).toBeGreaterThan(0); 633 634 ctrl.updateMarkers([]); 635 636 // Highlight should be removed 637 expect(mockCircleMarkers[0].remove).toHaveBeenCalled(); 638 }); 639 640 it("creates new markers from the provided places", () => { 641 const container = document.createElement("div"); 642 const ctrl = createMap(container, [], {}); 643 644 const markerCountBefore = mockMarkers.length; 645 646 const newPlaces = [ 647 makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 648 makePlace("Place D", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 649 ]; 650 ctrl.updateMarkers(newPlaces); 651 652 // New markers should be created 653 expect(mockMarkers.length).toBeGreaterThan(markerCountBefore); 654 }); 655 656 it("fits bounds by default (fitBounds=true)", () => { 657 const container = document.createElement("div"); 658 const ctrl = createMap(container, [], {}); 659 660 const newPlaces = [ 661 makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 662 makePlace("Place D", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 663 ]; 664 ctrl.updateMarkers(newPlaces); 665 666 expect(mockMapInstance.fitBounds).toHaveBeenCalled(); 667 }); 668 669 it("does not fit bounds when fitBounds=false", () => { 670 const container = document.createElement("div"); 671 const ctrl = createMap(container, [], {}); 672 673 // Reset any calls from initial createMap (which sets default world view) 674 mockMapInstance.fitBounds.mockClear(); 675 mockMapInstance.setView.mockClear(); 676 677 const newPlaces = [ 678 makePlace("Place C", { lat: 35.659, lng: 139.700, startLine: 0 }), 679 ]; 680 ctrl.updateMarkers(newPlaces, false); 681 682 // Neither fitBounds nor setView should be called by updateMarkers when fitBounds=false 683 expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 684 expect(mockMapInstance.setView).not.toHaveBeenCalled(); 685 }); 686}); 687 688// ─── Contract 10: fitBounds behavior ────────────────────────────────── 689 690describe("Contract 10: fitBounds behavior", () => { 691 it("does nothing with zero markers (preserves current view)", () => { 692 const container = document.createElement("div"); 693 const ctrl = createMap(container, [], {}); 694 695 // Clear initial calls 696 mockMapInstance.fitBounds.mockClear(); 697 mockMapInstance.setView.mockClear(); 698 699 ctrl.fitBounds(); 700 701 expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 702 expect(mockMapInstance.setView).not.toHaveBeenCalled(); 703 }); 704 705 it("centers on single marker at zoom 13", () => { 706 const container = document.createElement("div"); 707 const places = [ 708 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 709 ]; 710 const ctrl = createMap(container, places, {}); 711 712 // Clear initial calls 713 mockMapInstance.setView.mockClear(); 714 715 ctrl.fitBounds(); 716 717 expect(mockMapInstance.setView).toHaveBeenCalledWith( 718 expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 719 13 720 ); 721 }); 722 723 it("fits bounds to show all markers with multiple places", () => { 724 const container = document.createElement("div"); 725 const places = [ 726 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 727 makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 3 }), 728 ]; 729 const ctrl = createMap(container, places, {}); 730 731 // Clear initial calls 732 mockMapInstance.fitBounds.mockClear(); 733 734 ctrl.fitBounds(); 735 736 expect(mockMapInstance.fitBounds).toHaveBeenCalled(); 737 }); 738}); 739 740// ─── Contract 11: invalidateSize ────────────────────────────────────── 741 742describe("Contract 11: invalidateSize", () => { 743 it("calls Leaflet's invalidateSize()", () => { 744 const container = document.createElement("div"); 745 const ctrl = createMap(container, [], {}); 746 747 ctrl.invalidateSize(); 748 749 expect(mockMapInstance.invalidateSize).toHaveBeenCalled(); 750 }); 751}); 752 753// ─── Contract 12: destroy ───────────────────────────────────────────── 754 755describe("Contract 12: destroy", () => { 756 it("removes the Leaflet map instance", () => { 757 const container = document.createElement("div"); 758 const ctrl = createMap(container, [], {}); 759 760 ctrl.destroy(); 761 762 expect(mockMapInstance.remove).toHaveBeenCalled(); 763 }); 764 765 it("removes the map div from the container", () => { 766 const container = document.createElement("div"); 767 const ctrl = createMap(container, [], {}); 768 769 expect(container.querySelector(".map-viewer-map")).not.toBeNull(); 770 771 ctrl.destroy(); 772 773 expect(container.querySelector(".map-viewer-map")).toBeNull(); 774 }); 775 776 it("calling destroy() twice does not throw", () => { 777 const container = document.createElement("div"); 778 const ctrl = createMap(container, [], {}); 779 780 ctrl.destroy(); 781 expect(() => ctrl.destroy()).not.toThrow(); 782 }); 783}); 784 785// ─── Contract 13: ResizeObserver ────────────────────────────────────── 786 787describe("Contract 13: ResizeObserver", () => { 788 it("creates a ResizeObserver on the map container", () => { 789 const container = document.createElement("div"); 790 createMap(container, [], {}); 791 792 expect(resizeObserverCallback).not.toBeNull(); 793 }); 794 795 it("calls invalidateSize when the container resizes", () => { 796 const container = document.createElement("div"); 797 createMap(container, [], {}); 798 799 expect(resizeObserverCallback).not.toBeNull(); 800 801 // Simulate a resize event 802 resizeObserverCallback!( 803 [{ contentRect: { width: 500, height: 400 } }] as unknown as ResizeObserverEntry[], 804 {} as ResizeObserver 805 ); 806 807 expect(mockMapInstance.invalidateSize).toHaveBeenCalled(); 808 }); 809 810 it("disconnects ResizeObserver on destroy()", () => { 811 const container = document.createElement("div"); 812 const ctrl = createMap(container, [], {}); 813 814 ctrl.destroy(); 815 816 expect(resizeObserverDisconnected).toBe(true); 817 }); 818}); 819 820// ─── Edge Cases ─────────────────────────────────────────────────────── 821 822describe("Edge cases", () => { 823 it("zero places → map shows default world view (center [20, 0], zoom 2)", () => { 824 const container = document.createElement("div"); 825 createMap(container, [], {}); 826 827 expect(mockMapInstance.setView).toHaveBeenCalledWith( 828 [20, 0], 829 2 830 ); 831 }); 832 833 it("single place → map centers on it at zoom 13", () => { 834 const container = document.createElement("div"); 835 const places = [ 836 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 837 ]; 838 createMap(container, places, {}); 839 840 expect(mockMapInstance.setView).toHaveBeenCalledWith( 841 expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 842 13 843 ); 844 }); 845 846 it("places without lat/lng are silently excluded from markers", () => { 847 const container = document.createElement("div"); 848 const places = [ 849 makePlace("No Coords", { startLine: 0 }), 850 makePlace("Has Coords", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 851 ]; 852 createMap(container, places, {}); 853 854 // Only one marker should be created (for the place with coords) 855 expect(L.marker).toHaveBeenCalledTimes(1); 856 }); 857 858 it("all places at same location → single marker, popup lists all names", () => { 859 const container = document.createElement("div"); 860 const places = [ 861 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 862 makePlace("Place B", { lat: 41.4036, lng: 2.1744, startLine: 3 }), 863 makePlace("Place C", { lat: 41.4036, lng: 2.1744, startLine: 6 }), 864 ]; 865 createMap(container, places, {}); 866 867 expect(L.marker).toHaveBeenCalledTimes(1); 868 869 const marker = mockMarkers[0]; 870 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 871 expect(popupContent).toContain("Place A"); 872 expect(popupContent).toContain("Place B"); 873 expect(popupContent).toContain("Place C"); 874 }); 875 876 it("places with only lat but no lng are excluded from markers", () => { 877 const container = document.createElement("div"); 878 const places = [ 879 makePlace("Half Place", { lat: 41.4036, startLine: 0 }), 880 ]; 881 createMap(container, places, {}); 882 883 expect(L.marker).not.toHaveBeenCalled(); 884 }); 885 886 it("places with only lng but no lat are excluded from markers", () => { 887 const container = document.createElement("div"); 888 const places = [ 889 makePlace("Half Place", { lng: 2.1744, startLine: 0 }), 890 ]; 891 createMap(container, places, {}); 892 893 expect(L.marker).not.toHaveBeenCalled(); 894 }); 895 896 it("selectPlace with a place not in the current markers does nothing", () => { 897 const container = document.createElement("div"); 898 const places = [ 899 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 900 ]; 901 const ctrl = createMap(container, places, {}); 902 903 const unknownPlace = makePlace("Unknown", { 904 lat: 99, lng: 99, startLine: 999, 905 }); 906 907 // Should not throw 908 expect(() => ctrl.selectPlace(unknownPlace)).not.toThrow(); 909 }); 910}); 911 912// ─── Adversary-identified gaps: destroyed-state methods ─────────────── 913 914describe("Post-destroy safety", () => { 915 it("updateMarkers is a no-op after destroy", () => { 916 const container = document.createElement("div"); 917 const ctrl = createMap(container, [], {}); 918 ctrl.destroy(); 919 920 const markerCountBefore = mockMarkers.length; 921 ctrl.updateMarkers([ 922 makePlace("X", { lat: 10, lng: 20, startLine: 0 }), 923 ]); 924 925 // No new markers should be created 926 expect(mockMarkers.length).toBe(markerCountBefore); 927 }); 928 929 it("selectPlace is a no-op after destroy", () => { 930 const container = document.createElement("div"); 931 const places = [ 932 makePlace("A", { lat: 10, lng: 20, startLine: 0 }), 933 ]; 934 const ctrl = createMap(container, places, {}); 935 ctrl.destroy(); 936 937 // Should not throw or create highlight 938 const cmCountBefore = mockCircleMarkers.length; 939 expect(() => ctrl.selectPlace(places[0])).not.toThrow(); 940 expect(mockCircleMarkers.length).toBe(cmCountBefore); 941 }); 942 943 it("fitBounds is a no-op after destroy", () => { 944 const container = document.createElement("div"); 945 const ctrl = createMap(container, [], {}); 946 ctrl.destroy(); 947 948 mockMapInstance.fitBounds.mockClear(); 949 mockMapInstance.setView.mockClear(); 950 951 expect(() => ctrl.fitBounds()).not.toThrow(); 952 expect(mockMapInstance.fitBounds).not.toHaveBeenCalled(); 953 expect(mockMapInstance.setView).not.toHaveBeenCalled(); 954 }); 955 956 it("invalidateSize is a no-op after destroy", () => { 957 const container = document.createElement("div"); 958 const ctrl = createMap(container, [], {}); 959 ctrl.destroy(); 960 961 mockMapInstance.invalidateSize.mockClear(); 962 963 expect(() => ctrl.invalidateSize()).not.toThrow(); 964 expect(mockMapInstance.invalidateSize).not.toHaveBeenCalled(); 965 }); 966}); 967 968// ─── Adversary-identified gaps: XSS and HTML escaping ───────────────── 969 970describe("Popup security: XSS prevention", () => { 971 it("escapes HTML entities in place names in popup content", () => { 972 const container = document.createElement("div"); 973 const places = [ 974 makePlace('<script>alert("xss")</script>', { 975 lat: 41.4036, 976 lng: 2.1744, 977 startLine: 0, 978 }), 979 ]; 980 createMap(container, places, {}); 981 982 const marker = mockMarkers[0]; 983 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 984 985 expect(popupContent).not.toContain("<script>"); 986 expect(popupContent).toContain("&lt;script&gt;"); 987 }); 988 989 it("escapes URLs in popup href attributes to prevent attribute breakout", () => { 990 const container = document.createElement("div"); 991 const places = [ 992 makePlace("Evil Place", { 993 lat: 41.4036, 994 lng: 2.1744, 995 startLine: 0, 996 url: '" onclick="alert(1)', 997 }), 998 ]; 999 createMap(container, places, {}); 1000 1001 const marker = mockMarkers[0]; 1002 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1003 1004 // The double quote in the URL should be escaped 1005 expect(popupContent).not.toContain('href="" onclick'); 1006 expect(popupContent).toContain("&quot;"); 1007 }); 1008 1009 it("rejects javascript: protocol URLs in popup links", () => { 1010 const container = document.createElement("div"); 1011 const places = [ 1012 makePlace("Malicious", { 1013 lat: 41.4036, 1014 lng: 2.1744, 1015 startLine: 0, 1016 url: "javascript:alert(1)", 1017 }), 1018 ]; 1019 createMap(container, places, {}); 1020 1021 const marker = mockMarkers[0]; 1022 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1023 1024 // Should NOT contain an <a> tag with javascript: URL 1025 expect(popupContent).not.toContain("javascript:"); 1026 expect(popupContent).not.toContain("<a"); 1027 }); 1028 1029 it("escapes single quotes in popup content", () => { 1030 const container = document.createElement("div"); 1031 const places = [ 1032 makePlace("O'Malley's Pub", { 1033 lat: 41.4036, 1034 lng: 2.1744, 1035 startLine: 0, 1036 }), 1037 ]; 1038 createMap(container, places, {}); 1039 1040 const marker = mockMarkers[0]; 1041 const popupContent = marker.bindPopup.mock.calls[0][0] as string; 1042 1043 expect(popupContent).toContain("&#39;"); 1044 expect(popupContent).not.toContain("O'Malley"); 1045 }); 1046}); 1047 1048// ─── Adversary-identified gaps: click with no callback ──────────────── 1049 1050describe("Marker click with no onPlaceSelect callback", () => { 1051 it("does not throw when clicking a marker with no onPlaceSelect provided", () => { 1052 const container = document.createElement("div"); 1053 const places = [ 1054 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1055 ]; 1056 createMap(container, places, {}); 1057 1058 const marker = mockMarkers[0]; 1059 const clickHandler = marker._events["click"]?.[0]; 1060 expect(clickHandler).toBeDefined(); 1061 1062 // Click with empty callbacks — should not throw 1063 expect(() => clickHandler!({})).not.toThrow(); 1064 }); 1065}); 1066 1067// ─── Adversary-identified gaps: highlight ring className ────────────── 1068 1069describe("Highlight ring CSS class", () => { 1070 it("circleMarker is created with className 'map-marker-highlight'", () => { 1071 const container = document.createElement("div"); 1072 const places = [ 1073 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1074 ]; 1075 const ctrl = createMap(container, places, {}); 1076 1077 ctrl.selectPlace(places[0]); 1078 1079 expect(L.circleMarker).toHaveBeenCalled(); 1080 const cmCall = (L.circleMarker as ReturnType<typeof vi.fn>).mock.calls[0]; 1081 const options = cmCall[1]; 1082 expect(options.className).toBe("map-marker-highlight"); 1083 }); 1084}); 1085 1086// ─── Adversary-identified gaps: selectPlace zoom behavior ───────────── 1087 1088describe("selectPlace zoom behavior", () => { 1089 it("does not zoom out if already zoomed in past 13", () => { 1090 const container = document.createElement("div"); 1091 const places = [ 1092 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1093 ]; 1094 const ctrl = createMap(container, places, {}); 1095 1096 // Simulate the user being zoomed in to level 18 1097 mockMapInstance.getZoom.mockReturnValue(18); 1098 mockMapInstance.setView.mockClear(); 1099 1100 ctrl.selectPlace(places[0]); 1101 1102 // Should use zoom 18 (current), not 13 1103 expect(mockMapInstance.setView).toHaveBeenCalledWith( 1104 expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 1105 18 1106 ); 1107 }); 1108 1109 it("zooms in to 13 if currently zoomed out further", () => { 1110 const container = document.createElement("div"); 1111 const places = [ 1112 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1113 ]; 1114 const ctrl = createMap(container, places, {}); 1115 1116 // Simulate the user being zoomed out to level 5 1117 mockMapInstance.getZoom.mockReturnValue(5); 1118 mockMapInstance.setView.mockClear(); 1119 1120 ctrl.selectPlace(places[0]); 1121 1122 // Should use zoom 13 (minimum for selection) 1123 expect(mockMapInstance.setView).toHaveBeenCalledWith( 1124 expect.objectContaining({ lat: 41.4036, lng: 2.1744 }), 1125 13 1126 ); 1127 }); 1128}); 1129 1130// ─── Adversary-identified gaps: sequential selectPlace ──────────────── 1131 1132describe("Sequential selectPlace removes previous highlight", () => { 1133 it("removes old highlight ring before creating new one", () => { 1134 const container = document.createElement("div"); 1135 const places = [ 1136 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0 }), 1137 makePlace("Place B", { lat: 48.8606, lng: 2.3376, startLine: 5 }), 1138 ]; 1139 const ctrl = createMap(container, places, {}); 1140 1141 ctrl.selectPlace(places[0]); 1142 expect(mockCircleMarkers).toHaveLength(1); 1143 const firstRing = mockCircleMarkers[0]; 1144 1145 ctrl.selectPlace(places[1]); 1146 1147 // First ring should have been removed 1148 expect(firstRing.remove).toHaveBeenCalled(); 1149 // A new ring should have been created 1150 expect(mockCircleMarkers).toHaveLength(2); 1151 expect(mockCircleMarkers[1].addTo).toHaveBeenCalled(); 1152 }); 1153});