A wayfinder inspired map plugin for obisidian
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("<script>");
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(""");
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("'");
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});