A wayfinder inspired map plugin for obisidian
1/**
2 * mapView.test.ts — Tests for all mapView.ts behavioral contracts
3 *
4 * Mocks Obsidian API (Vault, Workspace, MarkdownView, Editor),
5 * parser, geocoder, and mapRenderer modules. Tests debounce, fingerprinting,
6 * geo write-back, write guards, geocoding mutex, cursor sync, and error handling.
7 *
8 * @vitest-environment jsdom
9 */
10import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11import type { Place } from "../src/parser";
12import type { MapController, MapCallbacks } from "../src/mapRenderer";
13
14// ─── Helpers ──────────────────────────────────────────────────────────
15
16function makePlace(
17 name: string,
18 overrides: Partial<Place> = {}
19): Place {
20 return {
21 name,
22 fields: {},
23 notes: [],
24 startLine: 0,
25 endLine: 0,
26 ...overrides,
27 };
28}
29
30// ─── Mock Types ───────────────────────────────────────────────────────
31
32interface MockEditor {
33 getCursor: ReturnType<typeof vi.fn>;
34 setCursor: ReturnType<typeof vi.fn>;
35 scrollIntoView: ReturnType<typeof vi.fn>;
36}
37
38interface MockTFile {
39 path: string;
40 name: string;
41 basename: string;
42 extension: string;
43 stat: { ctime: number; mtime: number; size: number };
44 parent: null;
45 vault: unknown;
46}
47
48interface MockMarkdownView {
49 editor: MockEditor;
50 file: MockTFile | null;
51 getViewType: () => string;
52 // Used for instanceof checks — we tag it
53 _isMarkdownView: true;
54}
55
56interface MockWorkspaceLeaf {
57 view: MockMarkdownView | { getViewType: () => string };
58}
59
60// ─── Obsidian Mock Infrastructure ─────────────────────────────────────
61
62// Event system mock to emulate Obsidian's Events class
63type EventCallback = (...args: unknown[]) => unknown;
64
65class MockEvents {
66 private _handlers: Map<string, Set<EventCallback>> = new Map();
67
68 on(name: string, callback: EventCallback): { id: string } {
69 if (!this._handlers.has(name)) {
70 this._handlers.set(name, new Set());
71 }
72 this._handlers.get(name)!.add(callback);
73 return { id: `${name}-${Math.random()}` };
74 }
75
76 off(name: string, callback: EventCallback): void {
77 this._handlers.get(name)?.delete(callback);
78 }
79
80 offref(_ref: unknown): void {
81 // In tests we don't track refs to handlers
82 }
83
84 trigger(name: string, ...data: unknown[]): void {
85 const handlers = this._handlers.get(name);
86 if (handlers) {
87 for (const handler of handlers) {
88 handler(...data);
89 }
90 }
91 }
92}
93
94// ─── Module-level mock state ──────────────────────────────────────────
95
96let mockVault: MockEvents & {
97 cachedRead: ReturnType<typeof vi.fn>;
98 process: ReturnType<typeof vi.fn>;
99};
100
101let mockWorkspace: MockEvents & {
102 getActiveViewOfType: ReturnType<typeof vi.fn>;
103 getLeavesOfType: ReturnType<typeof vi.fn>;
104 getLeaf: ReturnType<typeof vi.fn>;
105 revealLeaf: ReturnType<typeof vi.fn>;
106 detachLeavesOfType: ReturnType<typeof vi.fn>;
107 onLayoutReady: ReturnType<typeof vi.fn>;
108};
109
110let mockApp: {
111 workspace: typeof mockWorkspace;
112 vault: typeof mockVault;
113};
114
115let mockLeaf: {
116 view: unknown;
117 getViewState: ReturnType<typeof vi.fn>;
118 setViewState: ReturnType<typeof vi.fn>;
119 detach: ReturnType<typeof vi.fn>;
120};
121
122let mockEditor: MockEditor;
123let mockFile: MockTFile;
124let mockMarkdownView: MockMarkdownView;
125
126// Parser mock
127let mockParsePlaces: ReturnType<typeof vi.fn>;
128
129// Geocoder mock
130let mockGeocodePlaces: ReturnType<typeof vi.fn>;
131
132// MapRenderer mock
133let mockMapController: {
134 updateMarkers: ReturnType<typeof vi.fn>;
135 selectPlace: ReturnType<typeof vi.fn>;
136 fitBounds: ReturnType<typeof vi.fn>;
137 invalidateSize: ReturnType<typeof vi.fn>;
138 destroy: ReturnType<typeof vi.fn>;
139};
140let mockCreateMap: ReturnType<typeof vi.fn>;
141
142// Notice mock
143let mockNoticeInstances: Array<{ message: string; duration?: number }>;
144
145// Track registerEvent and registerInterval calls
146let registeredEvents: Array<{ id: string }>;
147let registeredIntervals: number[];
148let registeredCleanups: Array<() => void>;
149
150// ─── Setup / Teardown ─────────────────────────────────────────────────
151
152function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile {
153 return {
154 path,
155 name,
156 basename: name.replace(/\.md$/, ""),
157 extension: "md",
158 stat: { ctime: Date.now(), mtime: Date.now(), size: 100 },
159 parent: null,
160 vault: mockVault,
161 };
162}
163
164function createMockEditor(): MockEditor {
165 return {
166 getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }),
167 setCursor: vi.fn(),
168 scrollIntoView: vi.fn(),
169 };
170}
171
172function createMockMarkdownView(
173 editor: MockEditor,
174 file: MockTFile | null
175): MockMarkdownView {
176 return {
177 editor,
178 file,
179 getViewType: () => "markdown",
180 _isMarkdownView: true,
181 };
182}
183
184beforeEach(() => {
185 vi.useFakeTimers();
186 mockNoticeInstances = [];
187 registeredEvents = [];
188 registeredIntervals = [];
189 registeredCleanups = [];
190
191 // Build vault mock
192 const vaultEvents = new MockEvents();
193 mockVault = Object.assign(vaultEvents, {
194 cachedRead: vi.fn().mockResolvedValue(""),
195 process: vi.fn().mockImplementation(
196 async (_file: MockTFile, fn: (data: string) => string) => {
197 const result = fn("");
198 return result;
199 }
200 ),
201 });
202
203 // Build workspace mock
204 const workspaceEvents = new MockEvents();
205 mockWorkspace = Object.assign(workspaceEvents, {
206 getActiveViewOfType: vi.fn().mockReturnValue(null),
207 getLeavesOfType: vi.fn().mockReturnValue([]),
208 getLeaf: vi.fn(),
209 revealLeaf: vi.fn().mockResolvedValue(undefined),
210 detachLeavesOfType: vi.fn(),
211 onLayoutReady: vi.fn().mockImplementation((cb: () => void) => cb()),
212 });
213
214 // Build editor and view
215 mockFile = createMockFile();
216 mockEditor = createMockEditor();
217 mockMarkdownView = createMockMarkdownView(mockEditor, mockFile);
218
219 // Build leaf
220 mockLeaf = {
221 view: mockMarkdownView,
222 getViewState: vi.fn(),
223 setViewState: vi.fn(),
224 detach: vi.fn(),
225 };
226
227 // Build app
228 mockApp = {
229 workspace: mockWorkspace,
230 vault: mockVault,
231 };
232
233 // Build map controller mock
234 mockMapController = {
235 updateMarkers: vi.fn(),
236 selectPlace: vi.fn(),
237 fitBounds: vi.fn(),
238 invalidateSize: vi.fn(),
239 destroy: vi.fn(),
240 };
241 mockCreateMap = vi.fn().mockReturnValue(mockMapController);
242
243 // Parser mock — returns empty by default
244 mockParsePlaces = vi.fn().mockReturnValue([]);
245
246 // Geocoder mock — resolves immediately, returns the input places
247 mockGeocodePlaces = vi.fn().mockImplementation(
248 async (places: Place[]) => places
249 );
250
251 // ── Module mocks ──
252
253 vi.doMock("../src/parser", () => ({
254 parsePlaces: mockParsePlaces,
255 GEO_LINE_RE: /^[\t ]+[*-] geo: .*/,
256 }));
257
258 vi.doMock("../src/geocoder", () => ({
259 geocodePlaces: mockGeocodePlaces,
260 }));
261
262 vi.doMock("../src/mapRenderer", () => ({
263 createMap: mockCreateMap,
264 }));
265
266 // Mock the obsidian module
267 vi.doMock("obsidian", () => {
268 // We provide a MarkdownView constructor that we can use for instanceof checks
269 class MarkdownView {
270 static _isMarkdownView = true;
271 editor: MockEditor;
272 file: MockTFile | null;
273 constructor() {
274 this.editor = createMockEditor();
275 this.file = null;
276 }
277 getViewType() {
278 return "markdown";
279 }
280 }
281
282 class ItemView {
283 app: unknown;
284 leaf: unknown;
285 containerEl: HTMLElement;
286 contentEl: HTMLElement;
287
288 constructor(leaf: unknown) {
289 this.leaf = leaf;
290 this.app = mockApp;
291 this.containerEl = document.createElement("div");
292 this.contentEl = document.createElement("div");
293 this.containerEl.appendChild(this.contentEl);
294 }
295 getViewType() {
296 return "";
297 }
298 getDisplayText() {
299 return "";
300 }
301 getIcon() {
302 return "";
303 }
304 register(cb: () => void) {
305 registeredCleanups.push(cb);
306 }
307 registerEvent(ref: { id: string }) {
308 registeredEvents.push(ref);
309 }
310 registerInterval(id: number) {
311 registeredIntervals.push(id);
312 return id;
313 }
314 addChild(_c: unknown) {
315 return _c;
316 }
317 removeChild(_c: unknown) {
318 return _c;
319 }
320 onload() {}
321 onunload() {}
322 }
323
324 class Notice {
325 message: string;
326 duration?: number;
327 noticeEl: HTMLElement;
328 constructor(message: string, duration?: number) {
329 this.message = message;
330 this.duration = duration;
331 this.noticeEl = document.createElement("div");
332 mockNoticeInstances.push({ message, duration });
333 }
334 hide() {}
335 }
336
337 class Plugin {
338 app: unknown;
339 manifest: unknown;
340 constructor(app: unknown, manifest: unknown) {
341 this.app = app;
342 this.manifest = manifest;
343 }
344 registerView() {}
345 addCommand() {}
346 register() {}
347 registerEvent() {}
348 registerInterval() {
349 return 0;
350 }
351 }
352
353 class TFile {
354 path = "";
355 name = "";
356 basename = "";
357 extension = "md";
358 stat = { ctime: 0, mtime: 0, size: 0 };
359 parent = null;
360 }
361
362 return {
363 ItemView,
364 MarkdownView,
365 Notice,
366 Plugin,
367 TFile,
368 };
369 });
370});
371
372afterEach(() => {
373 vi.useRealTimers();
374 vi.restoreAllMocks();
375 vi.resetModules();
376});
377
378// ─── Microtask Flush Helper ───────────────────────────────────────────
379
380/**
381 * Flush pending microtasks (resolved promises) without advancing fake timers.
382 * This avoids triggering infinite setInterval loops from cursor sync polling.
383 * Call this instead of vi.runAllTimersAsync() when you need promises to settle.
384 */
385async function flushMicrotasks(): Promise<void> {
386 // Multiple rounds to handle chained promises
387 for (let i = 0; i < 10; i++) {
388 await Promise.resolve();
389 }
390}
391
392// ─── Import Helper ────────────────────────────────────────────────────
393
394// Import the module fresh for each test group
395async function importMapView() {
396 const mod = await import("../src/mapView");
397 return mod;
398}
399
400// Helper to create a MapViewerView instance for testing
401async function createTestView() {
402 const mod = await importMapView();
403 const ViewClass = mod.MapViewerView;
404 // Construct with our mock leaf
405 const view = new ViewClass(mockLeaf as any);
406 // Manually set app since super(leaf) uses it
407 (view as any).app = mockApp;
408 return { view, mod };
409}
410
411// Helper to create a view and call onOpen
412async function createAndOpenView() {
413 const { view, mod } = await createTestView();
414 await (view as any).onOpen();
415 return { view, mod };
416}
417
418// ─── Contract 1: View type registration ───────────────────────────────
419
420describe("Contract 1: Registered as view type 'map-viewer'", () => {
421 it("getViewType() returns 'map-viewer'", async () => {
422 const { view } = await createTestView();
423 expect(view.getViewType()).toBe("map-viewer");
424 });
425
426 it("getDisplayText() returns 'Map'", async () => {
427 const { view } = await createTestView();
428 expect(view.getDisplayText()).toBe("Map");
429 });
430
431 it("getIcon() returns 'map-pin'", async () => {
432 const { view } = await createTestView();
433 expect(view.getIcon()).toBe("map-pin");
434 });
435});
436
437// ─── Contract 2: On open — map container and initialization ───────────
438
439describe("Contract 2: On open creates map container", () => {
440 it("creates a full-height map container div on open", async () => {
441 const { view } = await createAndOpenView();
442
443 // createMap should have been called
444 expect(mockCreateMap).toHaveBeenCalledTimes(1);
445
446 // First argument is the container element
447 const container = mockCreateMap.mock.calls[0][0];
448 expect(container).toBeInstanceOf(HTMLElement);
449 });
450
451 it("passes initial places (empty for no file) to createMap", async () => {
452 // No active markdown view
453 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
454
455 const { view } = await createAndOpenView();
456
457 // createMap called with empty places array
458 expect(mockCreateMap).toHaveBeenCalledWith(
459 expect.any(HTMLElement),
460 expect.any(Array),
461 expect.any(Object)
462 );
463 });
464
465 it("initializes map via createMap()", async () => {
466 const { view } = await createAndOpenView();
467 expect(mockCreateMap).toHaveBeenCalledTimes(1);
468 });
469});
470
471// ─── Contract 3: Debounced refresh (trailing edge, 300ms) ─────────────
472
473describe("Contract 3: refresh() is debounced with trailing edge, 300ms", () => {
474 it("does not refresh immediately when called", async () => {
475 const { view } = await createAndOpenView();
476
477 // Set up active markdown view with content
478 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
479 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
480 mockParsePlaces.mockReturnValue([
481 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
482 ]);
483
484 // Trigger refresh (e.g., via file modify event)
485 mockVault.trigger("modify", mockFile);
486
487 // Should NOT have called parsePlaces yet (debounce not elapsed)
488 expect(mockParsePlaces).not.toHaveBeenCalled();
489 });
490
491 it("refreshes after 300ms debounce period", async () => {
492 const { view } = await createAndOpenView();
493
494 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
495 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
496 mockParsePlaces.mockReturnValue([
497 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
498 ]);
499
500 mockVault.trigger("modify", mockFile);
501
502 // Advance past debounce
503 await vi.advanceTimersByTimeAsync(300);
504
505 expect(mockParsePlaces).toHaveBeenCalled();
506 });
507
508 it("coalesces rapid triggers into a single refresh", async () => {
509 const { view } = await createAndOpenView();
510
511 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
512 mockVault.cachedRead.mockResolvedValue("* Place A");
513 mockParsePlaces.mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]);
514
515 // Trigger multiple modify events rapidly
516 mockVault.trigger("modify", mockFile);
517 await vi.advanceTimersByTimeAsync(100);
518 mockVault.trigger("modify", mockFile);
519 await vi.advanceTimersByTimeAsync(100);
520 mockVault.trigger("modify", mockFile);
521
522 // Advance past debounce from last trigger
523 await vi.advanceTimersByTimeAsync(300);
524
525 // Should only have been called once (trailing edge)
526 expect(mockParsePlaces).toHaveBeenCalledTimes(1);
527 });
528});
529
530// ─── Contract 4: refresh reads active file and parses ─────────────────
531
532describe("Contract 4: refresh reads active file content and parses", () => {
533 it("calls vault.cachedRead with the active file", async () => {
534 const { view } = await createAndOpenView();
535
536 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
537 mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1");
538 mockParsePlaces.mockReturnValue([
539 makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
540 ]);
541
542 mockVault.trigger("modify", mockFile);
543 await vi.advanceTimersByTimeAsync(300);
544
545 expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
546 });
547
548 it("calls parsePlaces with the file content", async () => {
549 const { view } = await createAndOpenView();
550
551 const content = "* Sagrada Familia\n\t* geo: 41.4,2.1";
552 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
553 mockVault.cachedRead.mockResolvedValue(content);
554 mockParsePlaces.mockReturnValue([]);
555
556 mockVault.trigger("modify", mockFile);
557 await vi.advanceTimersByTimeAsync(300);
558
559 expect(mockParsePlaces).toHaveBeenCalledWith(content);
560 });
561
562 it("calls updateMarkers on the map controller", async () => {
563 const { view } = await createAndOpenView();
564
565 const places = [
566 makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
567 ];
568 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
569 mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1");
570 mockParsePlaces.mockReturnValue(places);
571
572 mockVault.trigger("modify", mockFile);
573 await vi.advanceTimersByTimeAsync(300);
574
575 expect(mockMapController.updateMarkers).toHaveBeenCalled();
576 });
577});
578
579// ─── Contract 5: Fingerprinting for map rebuild ───────────────────────
580
581describe("Contract 5: Fingerprint skip — name::lat::lng joined by |", () => {
582 it("skips updateMarkers if fingerprint is unchanged", async () => {
583 const { view } = await createAndOpenView();
584
585 const places = [
586 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
587 ];
588 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
589 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
590 mockParsePlaces.mockReturnValue(places);
591
592 // First refresh
593 mockVault.trigger("modify", mockFile);
594 await vi.advanceTimersByTimeAsync(300);
595 expect(mockMapController.updateMarkers).toHaveBeenCalledTimes(1);
596
597 mockMapController.updateMarkers.mockClear();
598
599 // Same places again (same fingerprint)
600 mockParsePlaces.mockReturnValue([
601 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
602 ]);
603 mockVault.trigger("modify", mockFile);
604 await vi.advanceTimersByTimeAsync(300);
605
606 // Should NOT call updateMarkers again — fingerprint unchanged
607 expect(mockMapController.updateMarkers).not.toHaveBeenCalled();
608 });
609
610 it("calls updateMarkers if fingerprint changes", async () => {
611 const { view } = await createAndOpenView();
612
613 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
614 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
615 mockParsePlaces.mockReturnValue([
616 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
617 ]);
618
619 // First refresh
620 mockVault.trigger("modify", mockFile);
621 await vi.advanceTimersByTimeAsync(300);
622 mockMapController.updateMarkers.mockClear();
623
624 // Different places (different fingerprint)
625 mockParsePlaces.mockReturnValue([
626 makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 0, endLine: 1 }),
627 ]);
628 mockVault.trigger("modify", mockFile);
629 await vi.advanceTimersByTimeAsync(300);
630
631 expect(mockMapController.updateMarkers).toHaveBeenCalled();
632 });
633
634 it("always re-parses even when fingerprint unchanged (fresh line ranges)", async () => {
635 const { view } = await createAndOpenView();
636
637 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
638 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
639 mockParsePlaces.mockReturnValue([
640 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
641 ]);
642
643 // First refresh
644 mockVault.trigger("modify", mockFile);
645 await vi.advanceTimersByTimeAsync(300);
646 expect(mockParsePlaces).toHaveBeenCalledTimes(1);
647
648 // Second refresh (same fingerprint)
649 mockParsePlaces.mockReturnValue([
650 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
651 ]);
652 mockVault.trigger("modify", mockFile);
653 await vi.advanceTimersByTimeAsync(300);
654
655 // Parser ALWAYS called, even though map won't update
656 expect(mockParsePlaces).toHaveBeenCalledTimes(2);
657 });
658});
659
660// ─── Contract 6: Geo write-back via vault.process ─────────────────────
661
662describe("Contract 6: Geo write-back after geocoding", () => {
663 it("calls vault.process after successful geocoding", async () => {
664 const { view } = await createAndOpenView();
665
666 const placesBeforeGeocode = [
667 makePlace("Place A", { startLine: 0, endLine: 0 }),
668 ];
669 const placesAfterGeocode = [
670 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 0 }),
671 ];
672
673 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
674 mockVault.cachedRead.mockResolvedValue("* Place A");
675 mockParsePlaces.mockReturnValue(placesBeforeGeocode);
676
677 // Geocoder resolves with coordinates
678 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
679 places[0].lat = 41.4036;
680 places[0].lng = 2.1744;
681 return places;
682 });
683
684 mockVault.trigger("modify", mockFile);
685 await vi.advanceTimersByTimeAsync(300);
686
687 // Let geocoding resolve
688 await flushMicrotasks();
689
690 expect(mockVault.process).toHaveBeenCalledWith(
691 mockFile,
692 expect.any(Function)
693 );
694 });
695
696 it("re-parses CURRENT content inside vault.process callback", async () => {
697 const { view } = await createAndOpenView();
698
699 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
700 mockVault.cachedRead.mockResolvedValue("* Place A");
701 mockParsePlaces
702 .mockReturnValueOnce([makePlace("Place A", { startLine: 0, endLine: 0 })])
703 .mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]);
704
705 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
706 places[0].lat = 41.4036;
707 places[0].lng = 2.1744;
708 return places;
709 });
710
711 // vault.process calls fn with current content — capture the fn
712 mockVault.process.mockImplementation(
713 async (_file: unknown, fn: (data: string) => string) => {
714 const currentContent = "* Place A";
715 return fn(currentContent);
716 }
717 );
718
719 mockVault.trigger("modify", mockFile);
720 await vi.advanceTimersByTimeAsync(300);
721 await flushMicrotasks();
722
723 // parsePlaces should be called again INSIDE vault.process (re-parse)
724 // First call from refresh, second call from inside vault.process
725 expect(mockParsePlaces).toHaveBeenCalledTimes(2);
726 });
727
728 it("inserts geo: sub-bullet after endLine for places without existing geo", async () => {
729 const { view } = await createAndOpenView();
730
731 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
732 mockVault.cachedRead.mockResolvedValue("* Place A");
733
734 // First parse: no geo
735 mockParsePlaces.mockReturnValue([
736 makePlace("Place A", { startLine: 0, endLine: 0 }),
737 ]);
738
739 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
740 places[0].lat = 41.403600;
741 places[0].lng = 2.174400;
742 return places;
743 });
744
745 // Capture what vault.process does with the content
746 let processedContent = "";
747 mockVault.process.mockImplementation(
748 async (_file: unknown, fn: (data: string) => string) => {
749 // Re-parse inside vault.process returns place at line 0
750 mockParsePlaces.mockReturnValueOnce([
751 makePlace("Place A", { startLine: 0, endLine: 0 }),
752 ]);
753 processedContent = fn("* Place A");
754 return processedContent;
755 }
756 );
757
758 mockVault.trigger("modify", mockFile);
759 await vi.advanceTimersByTimeAsync(300);
760 await flushMicrotasks();
761
762 // Should have inserted a geo line after the place
763 expect(processedContent).toContain("\t* geo: 41.403600,2.174400");
764 });
765
766 it("replaces existing geo: line when place already has one", async () => {
767 const { view } = await createAndOpenView();
768
769 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
770 // Place has an INVALID geo line — parser leaves lat/lng undefined
771 const content = "* Place A\n\t* geo: invalid";
772 mockVault.cachedRead.mockResolvedValue(content);
773
774 // Parse returns place with geo field but no valid lat/lng (needs re-geocode)
775 mockParsePlaces.mockReturnValue([
776 makePlace("Place A", {
777 startLine: 0,
778 endLine: 1,
779 fields: { geo: "invalid" },
780 // lat and lng are undefined — parser couldn't parse "invalid"
781 }),
782 ]);
783
784 // Geocoder gives new coordinates
785 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
786 places[0].lat = 41.403600;
787 places[0].lng = 2.174400;
788 return places;
789 });
790
791 let processedContent = "";
792 mockVault.process.mockImplementation(
793 async (_file: unknown, fn: (data: string) => string) => {
794 // Re-parse inside vault.process shows the place still has the invalid geo line
795 mockParsePlaces.mockReturnValueOnce([
796 makePlace("Place A", {
797 startLine: 0,
798 endLine: 1,
799 fields: { geo: "invalid" },
800 }),
801 ]);
802 processedContent = fn(content);
803 return processedContent;
804 }
805 );
806
807 mockVault.trigger("modify", mockFile);
808 await vi.advanceTimersByTimeAsync(300);
809 await flushMicrotasks();
810
811 // The old geo line should be replaced with new coordinates
812 expect(processedContent).toContain("\t* geo: 41.403600,2.174400");
813 expect(processedContent).not.toContain("invalid");
814 });
815
816 it("writes geo with 6 decimal places (toFixed(6))", async () => {
817 const { view } = await createAndOpenView();
818
819 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
820 mockVault.cachedRead.mockResolvedValue("* Place A");
821 mockParsePlaces.mockReturnValue([
822 makePlace("Place A", { startLine: 0, endLine: 0 }),
823 ]);
824
825 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
826 places[0].lat = 41.40359999;
827 places[0].lng = 2.17440001;
828 return places;
829 });
830
831 let processedContent = "";
832 mockVault.process.mockImplementation(
833 async (_file: unknown, fn: (data: string) => string) => {
834 mockParsePlaces.mockReturnValueOnce([
835 makePlace("Place A", { startLine: 0, endLine: 0 }),
836 ]);
837 processedContent = fn("* Place A");
838 return processedContent;
839 }
840 );
841
842 mockVault.trigger("modify", mockFile);
843 await vi.advanceTimersByTimeAsync(300);
844 await flushMicrotasks();
845
846 expect(processedContent).toContain("41.403600,2.174400");
847 });
848
849 it("matches geocoded places to parsed places by name (case-insensitive)", async () => {
850 const { view } = await createAndOpenView();
851
852 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
853 mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B");
854
855 // Initial parse
856 mockParsePlaces.mockReturnValue([
857 makePlace("Place A", { startLine: 0, endLine: 0 }),
858 makePlace("Place B", { startLine: 1, endLine: 1 }),
859 ]);
860
861 // Only geocode Place A
862 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
863 for (const p of places) {
864 if (p.name.toLowerCase() === "place a") {
865 p.lat = 41.4036;
866 p.lng = 2.1744;
867 }
868 }
869 return places;
870 });
871
872 let processedContent = "";
873 mockVault.process.mockImplementation(
874 async (_file: unknown, fn: (data: string) => string) => {
875 mockParsePlaces.mockReturnValueOnce([
876 makePlace("Place A", { startLine: 0, endLine: 0 }),
877 makePlace("Place B", { startLine: 1, endLine: 1 }),
878 ]);
879 processedContent = fn("* Place A\n* Place B");
880 return processedContent;
881 }
882 );
883
884 mockVault.trigger("modify", mockFile);
885 await vi.advanceTimersByTimeAsync(300);
886 await flushMicrotasks();
887
888 // Only Place A should get a geo line inserted
889 const lines = processedContent.split("\n");
890 const geoLines = lines.filter((l: string) => l.includes("geo:"));
891 expect(geoLines).toHaveLength(1);
892 expect(geoLines[0]).toContain("41.403600,2.174400");
893 });
894
895 it("processes insertions from bottom-to-top to preserve line numbers", async () => {
896 const { view } = await createAndOpenView();
897
898 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
899 const content = "* Place A\n* Place B";
900 mockVault.cachedRead.mockResolvedValue(content);
901
902 mockParsePlaces.mockReturnValue([
903 makePlace("Place A", { startLine: 0, endLine: 0 }),
904 makePlace("Place B", { startLine: 1, endLine: 1 }),
905 ]);
906
907 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
908 places[0].lat = 41.4036;
909 places[0].lng = 2.1744;
910 places[1].lat = 48.8606;
911 places[1].lng = 2.3376;
912 return places;
913 });
914
915 let processedContent = "";
916 mockVault.process.mockImplementation(
917 async (_file: unknown, fn: (data: string) => string) => {
918 mockParsePlaces.mockReturnValueOnce([
919 makePlace("Place A", { startLine: 0, endLine: 0 }),
920 makePlace("Place B", { startLine: 1, endLine: 1 }),
921 ]);
922 processedContent = fn(content);
923 return processedContent;
924 }
925 );
926
927 mockVault.trigger("modify", mockFile);
928 await vi.advanceTimersByTimeAsync(300);
929 await flushMicrotasks();
930
931 // Both places should have geo lines and content should be well-formed
932 expect(processedContent).toContain("41.403600,2.174400");
933 expect(processedContent).toContain("48.860600,2.337600");
934
935 // Place A should come before Place B in the output
936 const idxA = processedContent.indexOf("41.403600");
937 const idxB = processedContent.indexOf("48.860600");
938 expect(idxA).toBeLessThan(idxB);
939 });
940
941 it("write-back format: tab-indented bullet with geo prefix", async () => {
942 const { view } = await createAndOpenView();
943
944 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
945 mockVault.cachedRead.mockResolvedValue("* Place A");
946 mockParsePlaces.mockReturnValue([
947 makePlace("Place A", { startLine: 0, endLine: 0 }),
948 ]);
949
950 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
951 places[0].lat = 41.4036;
952 places[0].lng = 2.1744;
953 return places;
954 });
955
956 let processedContent = "";
957 mockVault.process.mockImplementation(
958 async (_file: unknown, fn: (data: string) => string) => {
959 mockParsePlaces.mockReturnValueOnce([
960 makePlace("Place A", { startLine: 0, endLine: 0 }),
961 ]);
962 processedContent = fn("* Place A");
963 return processedContent;
964 }
965 );
966
967 mockVault.trigger("modify", mockFile);
968 await vi.advanceTimersByTimeAsync(300);
969 await flushMicrotasks();
970
971 // Check exact format: \t* geo: <lat>,<lng>
972 expect(processedContent).toMatch(/\t\* geo: \d+\.\d{6},\d+\.\d{6}/);
973 });
974});
975
976// ─── Contract 7: Write guard counter mechanism ────────────────────────
977
978describe("Contract 7: Write guard prevents self-triggered refresh", () => {
979 it("increments write guard before vault.process and decrements after 500ms", async () => {
980 const { view } = await createAndOpenView();
981
982 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
983 mockVault.cachedRead.mockResolvedValue("* Place A");
984 mockParsePlaces.mockReturnValue([
985 makePlace("Place A", { startLine: 0, endLine: 0 }),
986 ]);
987
988 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
989 places[0].lat = 41.4036;
990 places[0].lng = 2.1744;
991 return places;
992 });
993
994 mockVault.process.mockImplementation(
995 async (_file: unknown, fn: (data: string) => string) => {
996 mockParsePlaces.mockReturnValueOnce([
997 makePlace("Place A", { startLine: 0, endLine: 0 }),
998 ]);
999 return fn("* Place A");
1000 }
1001 );
1002
1003 // Trigger initial refresh
1004 mockVault.trigger("modify", mockFile);
1005 await vi.advanceTimersByTimeAsync(300);
1006 await flushMicrotasks();
1007
1008 // Now the write guard should be active (counter > 0)
1009 // A modify event during this window should be skipped
1010 mockParsePlaces.mockClear();
1011 mockVault.trigger("modify", mockFile);
1012 await vi.advanceTimersByTimeAsync(300);
1013
1014 // The refresh should have been suppressed by write guard
1015 expect(mockParsePlaces).not.toHaveBeenCalled();
1016 });
1017
1018 it("allows refresh after 500ms write guard window expires", async () => {
1019 const { view } = await createAndOpenView();
1020
1021 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1022 mockVault.cachedRead.mockResolvedValue("* Place A");
1023 mockParsePlaces.mockReturnValue([
1024 makePlace("Place A", { startLine: 0, endLine: 0 }),
1025 ]);
1026
1027 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1028 places[0].lat = 41.4036;
1029 places[0].lng = 2.1744;
1030 return places;
1031 });
1032
1033 mockVault.process.mockImplementation(
1034 async (_file: unknown, fn: (data: string) => string) => {
1035 mockParsePlaces.mockReturnValueOnce([
1036 makePlace("Place A", { startLine: 0, endLine: 0 }),
1037 ]);
1038 return fn("* Place A");
1039 }
1040 );
1041
1042 // Trigger initial refresh with geocoding + write-back
1043 mockVault.trigger("modify", mockFile);
1044 await vi.advanceTimersByTimeAsync(300);
1045 await flushMicrotasks();
1046
1047 // Wait for write guard to expire (500ms)
1048 await vi.advanceTimersByTimeAsync(500);
1049
1050 // Now a modify event should be allowed through
1051 mockParsePlaces.mockClear();
1052 mockParsePlaces.mockReturnValue([
1053 makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 1 }),
1054 ]);
1055 mockVault.trigger("modify", mockFile);
1056 await vi.advanceTimersByTimeAsync(300);
1057
1058 expect(mockParsePlaces).toHaveBeenCalled();
1059 });
1060});
1061
1062// ─── Contract 8: Geocoding concurrency — AbortController mutex ────────
1063
1064describe("Contract 8: Only one geocoding operation in-flight at a time", () => {
1065 it("passes an AbortSignal to geocodePlaces", async () => {
1066 const { view } = await createAndOpenView();
1067
1068 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1069 mockVault.cachedRead.mockResolvedValue("* Place A");
1070 mockParsePlaces.mockReturnValue([
1071 makePlace("Place A", { startLine: 0, endLine: 0 }),
1072 ]);
1073
1074 mockVault.trigger("modify", mockFile);
1075 await vi.advanceTimersByTimeAsync(300);
1076
1077 // Geocoder should have been called with an AbortSignal as third argument
1078 expect(mockGeocodePlaces).toHaveBeenCalled();
1079 const args = mockGeocodePlaces.mock.calls[0];
1080 // args: [places, callbacks, signal]
1081 expect(args[2]).toBeInstanceOf(AbortSignal);
1082 });
1083
1084 it("aborts previous geocoding when a new refresh starts", async () => {
1085 const { view } = await createAndOpenView();
1086
1087 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1088 mockVault.cachedRead.mockResolvedValue("* Place A");
1089 mockParsePlaces.mockReturnValue([
1090 makePlace("Place A", { startLine: 0, endLine: 0 }),
1091 ]);
1092
1093 // First geocode: hang forever (never resolve)
1094 let firstSignal: AbortSignal | undefined;
1095 mockGeocodePlaces.mockImplementationOnce(
1096 async (places: Place[], _callbacks: unknown, signal?: AbortSignal) => {
1097 firstSignal = signal;
1098 return new Promise(() => {}); // Never resolves
1099 }
1100 );
1101
1102 // Trigger first refresh
1103 mockVault.trigger("modify", mockFile);
1104 await vi.advanceTimersByTimeAsync(300);
1105
1106 expect(firstSignal).toBeDefined();
1107 expect(firstSignal!.aborted).toBe(false);
1108
1109 // Second geocode
1110 mockGeocodePlaces.mockImplementation(async (places: Place[]) => places);
1111 mockParsePlaces.mockReturnValue([
1112 makePlace("Place B", { startLine: 0, endLine: 0 }),
1113 ]);
1114
1115 // Trigger second refresh
1116 mockVault.trigger("modify", mockFile);
1117 await vi.advanceTimersByTimeAsync(300);
1118
1119 // First signal should now be aborted
1120 expect(firstSignal!.aborted).toBe(true);
1121 });
1122});
1123
1124// ─── Contract 9: Cursor sync (editor → map) ──────────────────────────
1125
1126describe("Contract 9: Cursor sync — editor to map", () => {
1127 it("registers a polling interval for cursor position", async () => {
1128 const { view } = await createAndOpenView();
1129
1130 // Should have registered at least one interval (the 200ms cursor poll)
1131 expect(registeredIntervals.length).toBeGreaterThan(0);
1132 });
1133
1134 it("selects the place whose line range contains the cursor", async () => {
1135 const { view } = await createAndOpenView();
1136
1137 // Set up places with line ranges
1138 const places = [
1139 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 2 }),
1140 makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 6 }),
1141 ];
1142
1143 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1144 mockVault.cachedRead.mockResolvedValue("...");
1145 mockParsePlaces.mockReturnValue(places);
1146
1147 // Trigger refresh
1148 mockVault.trigger("modify", mockFile);
1149 await vi.advanceTimersByTimeAsync(300);
1150
1151 // Cursor is on line 5 — within Place B range [4, 6]
1152 mockEditor.getCursor.mockReturnValue({ line: 5, ch: 0 });
1153
1154 // Advance to trigger cursor poll (200ms)
1155 await vi.advanceTimersByTimeAsync(200);
1156
1157 // mapController.selectPlace should be called with Place B
1158 expect(mockMapController.selectPlace).toHaveBeenCalledWith(
1159 expect.objectContaining({ name: "Place B" })
1160 );
1161 });
1162
1163 it("deselects when cursor is in a dead zone (outside all place ranges)", async () => {
1164 const { view } = await createAndOpenView();
1165
1166 const places = [
1167 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1168 makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 5 }),
1169 ];
1170
1171 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1172 mockVault.cachedRead.mockResolvedValue("...");
1173 mockParsePlaces.mockReturnValue(places);
1174
1175 mockVault.trigger("modify", mockFile);
1176 await vi.advanceTimersByTimeAsync(300);
1177
1178 // First, place cursor inside Place A so something is selected
1179 mockEditor.getCursor.mockReturnValue({ line: 0, ch: 0 });
1180 await vi.advanceTimersByTimeAsync(200);
1181
1182 mockMapController.selectPlace.mockClear();
1183
1184 // Now move cursor to line 3 — between Place A and Place B (dead zone)
1185 mockEditor.getCursor.mockReturnValue({ line: 3, ch: 0 });
1186
1187 await vi.advanceTimersByTimeAsync(200);
1188
1189 expect(mockMapController.selectPlace).toHaveBeenCalledWith(null);
1190 });
1191});
1192
1193// ─── Contract 10: Cursor sync (map → editor) ─────────────────────────
1194
1195describe("Contract 10: Cursor sync — map to editor (marker click)", () => {
1196 it("scrolls editor to startLine of first place when marker is clicked", async () => {
1197 const { view } = await createAndOpenView();
1198
1199 // Get the onPlaceSelect callback that was passed to createMap
1200 expect(mockCreateMap).toHaveBeenCalled();
1201 const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2];
1202 expect(callbacks.onPlaceSelect).toBeDefined();
1203
1204 // Set up active markdown view
1205 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1206
1207 // Simulate marker click
1208 const clickedPlaces = [
1209 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 3, endLine: 5 }),
1210 makePlace("Place B", { lat: 41.4, lng: 2.1, startLine: 7, endLine: 9 }),
1211 ];
1212 callbacks.onPlaceSelect!(clickedPlaces);
1213
1214 // Editor should be scrolled to startLine of the FIRST place
1215 expect(mockEditor.setCursor).toHaveBeenCalledWith(
1216 expect.objectContaining({ line: 3, ch: 0 })
1217 );
1218 });
1219
1220 it("calls editor.scrollIntoView after setCursor", async () => {
1221 const { view } = await createAndOpenView();
1222
1223 const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2];
1224 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1225
1226 callbacks.onPlaceSelect!([
1227 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 10, endLine: 12 }),
1228 ]);
1229
1230 expect(mockEditor.scrollIntoView).toHaveBeenCalled();
1231 });
1232});
1233
1234// ─── Contract 11: Active file null → clear markers ────────────────────
1235
1236describe("Contract 11: Active file null — clear all markers", () => {
1237 it("clears markers when active file is null", async () => {
1238 const { view } = await createAndOpenView();
1239
1240 // Set up with some places first
1241 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1242 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
1243 mockParsePlaces.mockReturnValue([
1244 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1245 ]);
1246 mockVault.trigger("modify", mockFile);
1247 await vi.advanceTimersByTimeAsync(300);
1248
1249 mockMapController.updateMarkers.mockClear();
1250
1251 // Now no active markdown view (file switched to non-markdown or closed)
1252 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
1253
1254 // Trigger active-leaf-change with null
1255 mockWorkspace.trigger("active-leaf-change", null);
1256 await vi.advanceTimersByTimeAsync(300);
1257
1258 // Map should be cleared (updateMarkers with empty array)
1259 expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]);
1260 });
1261});
1262
1263// ─── Contract 12: Active file with no places → empty map ─────────────
1264
1265describe("Contract 12: File with no places — empty map", () => {
1266 it("calls updateMarkers with empty array for a file with no bullets", async () => {
1267 const { view } = await createAndOpenView();
1268
1269 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1270 mockVault.cachedRead.mockResolvedValue("Just some plain text, no bullets");
1271 mockParsePlaces.mockReturnValue([]);
1272
1273 mockVault.trigger("modify", mockFile);
1274 await vi.advanceTimersByTimeAsync(300);
1275
1276 expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]);
1277 });
1278});
1279
1280// ─── Contract 13: Only respond to MarkdownView ───────────────────────
1281
1282describe("Contract 13: Only respond to MarkdownView active-leaf-change", () => {
1283 it("does not refresh when active leaf is not a MarkdownView", async () => {
1284 const { view } = await createAndOpenView();
1285
1286 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
1287
1288 // Trigger active-leaf-change with a non-markdown leaf
1289 const nonMdLeaf = {
1290 view: { getViewType: () => "map-viewer" },
1291 };
1292 mockWorkspace.trigger("active-leaf-change", nonMdLeaf);
1293 await vi.advanceTimersByTimeAsync(300);
1294
1295 // Should not have tried to read file content via cachedRead
1296 // (beyond any initial setup reads)
1297 expect(mockVault.cachedRead).not.toHaveBeenCalled();
1298 });
1299
1300 it("refreshes when active leaf is a MarkdownView", async () => {
1301 const { view } = await createAndOpenView();
1302
1303 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1304 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
1305 mockParsePlaces.mockReturnValue([
1306 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1307 ]);
1308
1309 // Trigger active-leaf-change with a markdown leaf
1310 mockWorkspace.trigger("active-leaf-change", {
1311 view: mockMarkdownView,
1312 });
1313 await vi.advanceTimersByTimeAsync(300);
1314
1315 expect(mockVault.cachedRead).toHaveBeenCalled();
1316 expect(mockParsePlaces).toHaveBeenCalled();
1317 });
1318});
1319
1320// ─── Contract 14: Error reporting ─────────────────────────────────────
1321
1322describe("Contract 14: Error reporting for geocoding failures", () => {
1323 it("logs geocoding failures via console.warn with [MapViewer] prefix", async () => {
1324 const { view } = await createAndOpenView();
1325 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1326
1327 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1328 mockVault.cachedRead.mockResolvedValue("* Nonexistent Place");
1329 mockParsePlaces.mockReturnValue([
1330 makePlace("Nonexistent Place", { startLine: 0, endLine: 0 }),
1331 ]);
1332
1333 mockGeocodePlaces.mockRejectedValue(new Error("Network error"));
1334
1335 mockVault.trigger("modify", mockFile);
1336 await vi.advanceTimersByTimeAsync(300);
1337 await flushMicrotasks();
1338
1339 expect(warnSpy).toHaveBeenCalledWith(
1340 expect.stringContaining("[MapViewer]"),
1341 expect.anything()
1342 );
1343
1344 warnSpy.mockRestore();
1345 });
1346
1347 it("shows an Obsidian Notice when geocoding batch produces zero results", async () => {
1348 const { view } = await createAndOpenView();
1349
1350 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1351 mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B");
1352 mockParsePlaces.mockReturnValue([
1353 makePlace("Place A", { startLine: 0, endLine: 0 }),
1354 makePlace("Place B", { startLine: 1, endLine: 1 }),
1355 ]);
1356
1357 // Geocoder returns places with no coordinates set (all failed)
1358 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1359 // Intentionally leave lat/lng undefined — zero successful results
1360 return places;
1361 });
1362
1363 mockVault.trigger("modify", mockFile);
1364 await vi.advanceTimersByTimeAsync(300);
1365 await flushMicrotasks();
1366
1367 // Should have shown a Notice
1368 expect(mockNoticeInstances.length).toBeGreaterThan(0);
1369 });
1370});
1371
1372// ─── Edge Cases ───────────────────────────────────────────────────────
1373
1374describe("Edge cases", () => {
1375 it("file change while geocoding is in progress — aborts and restarts", async () => {
1376 const { view } = await createAndOpenView();
1377
1378 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1379 mockVault.cachedRead.mockResolvedValue("* Place A");
1380 mockParsePlaces.mockReturnValue([
1381 makePlace("Place A", { startLine: 0, endLine: 0 }),
1382 ]);
1383
1384 let firstAbortSignal: AbortSignal | undefined;
1385 mockGeocodePlaces.mockImplementationOnce(
1386 async (_places: Place[], _cb: unknown, signal?: AbortSignal) => {
1387 firstAbortSignal = signal;
1388 return new Promise(() => {}); // Hang forever
1389 }
1390 );
1391
1392 // First modify
1393 mockVault.trigger("modify", mockFile);
1394 await vi.advanceTimersByTimeAsync(300);
1395
1396 // While first geocode is pending, simulate file change
1397 mockParsePlaces.mockReturnValue([
1398 makePlace("Place B", { startLine: 0, endLine: 0 }),
1399 ]);
1400 mockGeocodePlaces.mockImplementation(async (places: Place[]) => places);
1401
1402 mockVault.trigger("modify", mockFile);
1403 await vi.advanceTimersByTimeAsync(300);
1404
1405 // First signal should be aborted
1406 expect(firstAbortSignal!.aborted).toBe(true);
1407 // Second geocode should have started
1408 expect(mockGeocodePlaces).toHaveBeenCalledTimes(2);
1409 });
1410
1411 it("rapid file switching — debounce prevents excessive re-parsing", async () => {
1412 const { view } = await createAndOpenView();
1413
1414 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1415 mockVault.cachedRead.mockResolvedValue("* Place A");
1416 mockParsePlaces.mockReturnValue([]);
1417
1418 // Rapidly switch files (trigger active-leaf-change multiple times)
1419 for (let i = 0; i < 5; i++) {
1420 mockWorkspace.trigger("active-leaf-change", { view: mockMarkdownView });
1421 await vi.advanceTimersByTimeAsync(50);
1422 }
1423
1424 // Only wait for debounce from the last one
1425 await vi.advanceTimersByTimeAsync(300);
1426
1427 // Parser should only be called once (debounce coalesced)
1428 expect(mockParsePlaces).toHaveBeenCalledTimes(1);
1429 });
1430
1431 it("onClose destroys the map controller", async () => {
1432 const { view } = await createAndOpenView();
1433
1434 await (view as any).onClose();
1435
1436 expect(mockMapController.destroy).toHaveBeenCalled();
1437 });
1438});
1439
1440// ─── Adversary Finding Tests ──────────────────────────────────────────
1441
1442describe("Adversary finding #1: onFileModify checks file identity", () => {
1443 it("ignores modify events for files other than the active file", async () => {
1444 const { view } = await createAndOpenView();
1445
1446 // Drain the initial refresh triggered by onOpen
1447 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1448 mockVault.cachedRead.mockResolvedValue("");
1449 mockParsePlaces.mockReturnValue([]);
1450 await vi.advanceTimersByTimeAsync(300);
1451 await flushMicrotasks();
1452
1453 // Clear mocks so we can check that modify with a different file does NOT trigger reads
1454 mockVault.cachedRead.mockClear();
1455 mockParsePlaces.mockClear();
1456
1457 mockVault.cachedRead.mockResolvedValue("* Place A");
1458 mockParsePlaces.mockReturnValue([
1459 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }),
1460 ]);
1461
1462 // Trigger modify with a DIFFERENT file (not the active one)
1463 const otherFile = createMockFile("other-note.md", "other-note.md");
1464 mockVault.trigger("modify", otherFile);
1465 await vi.advanceTimersByTimeAsync(300);
1466
1467 // Should NOT have called cachedRead since the file doesn't match
1468 expect(mockVault.cachedRead).not.toHaveBeenCalled();
1469 });
1470
1471 it("responds to modify events for the active file", async () => {
1472 const { view } = await createAndOpenView();
1473
1474 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1475 mockVault.cachedRead.mockResolvedValue("* Place A");
1476 mockParsePlaces.mockReturnValue([
1477 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }),
1478 ]);
1479
1480 // Trigger modify with the SAME file as the active view
1481 mockVault.trigger("modify", mockFile);
1482 await vi.advanceTimersByTimeAsync(300);
1483
1484 expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile);
1485 });
1486});
1487
1488describe("Adversary finding #2: Notice for mixed batches checks only attempted places", () => {
1489 it("does not show Notice when pre-geocoded places exist but new ones also succeed", async () => {
1490 const { view } = await createAndOpenView();
1491
1492 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1493 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B");
1494
1495 // Place A already has coordinates, Place B does not
1496 mockParsePlaces.mockReturnValue([
1497 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1498 makePlace("Place B", { startLine: 2, endLine: 2 }),
1499 ]);
1500
1501 // Geocoder gives coordinates to Place B
1502 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1503 for (const p of places) {
1504 if (p.name === "Place B") {
1505 p.lat = 48.8606;
1506 p.lng = 2.3376;
1507 }
1508 }
1509 return places;
1510 });
1511
1512 mockVault.process.mockImplementation(
1513 async (_file: unknown, fn: (data: string) => string) => {
1514 mockParsePlaces.mockReturnValueOnce([
1515 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1516 makePlace("Place B", { startLine: 2, endLine: 2 }),
1517 ]);
1518 return fn("* Place A\n\t* geo: 41.400000,2.100000\n* Place B");
1519 }
1520 );
1521
1522 mockVault.trigger("modify", mockFile);
1523 await vi.advanceTimersByTimeAsync(300);
1524 await flushMicrotasks();
1525
1526 // No Notice should be shown since Place B was successfully geocoded
1527 expect(mockNoticeInstances).toHaveLength(0);
1528 });
1529
1530 it("shows Notice when attempted places all fail, even if pre-geocoded ones exist", async () => {
1531 const { view } = await createAndOpenView();
1532
1533 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1534 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B");
1535
1536 // Place A already has coordinates, Place B does not
1537 mockParsePlaces.mockReturnValue([
1538 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1539 makePlace("Place B", { startLine: 2, endLine: 2 }),
1540 ]);
1541
1542 // Geocoder returns places but does NOT set coordinates on Place B
1543 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1544 // Place B remains ungeocoded
1545 return places;
1546 });
1547
1548 mockVault.trigger("modify", mockFile);
1549 await vi.advanceTimersByTimeAsync(300);
1550 await flushMicrotasks();
1551
1552 // Notice should fire because the ATTEMPTED place (Place B) got zero results
1553 expect(mockNoticeInstances.length).toBeGreaterThan(0);
1554 expect(mockNoticeInstances[0].message).toContain("No places could be geocoded");
1555 });
1556});
1557
1558describe("Adversary finding #5: only ungeocoded places trigger geocoding", () => {
1559 it("does not call geocodePlaces when all places already have coordinates", async () => {
1560 const { view } = await createAndOpenView();
1561
1562 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1563 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1");
1564 mockParsePlaces.mockReturnValue([
1565 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }),
1566 ]);
1567
1568 mockVault.trigger("modify", mockFile);
1569 await vi.advanceTimersByTimeAsync(300);
1570 await flushMicrotasks();
1571
1572 // Geocoder should NOT have been called
1573 expect(mockGeocodePlaces).not.toHaveBeenCalled();
1574 });
1575
1576 it("only passes ungeocoded places to the geocoder in a mixed batch", async () => {
1577 const { view } = await createAndOpenView();
1578
1579 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1580 mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B");
1581
1582 const placeA = makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 });
1583 const placeB = makePlace("Place B", { startLine: 2, endLine: 2 });
1584 mockParsePlaces.mockReturnValue([placeA, placeB]);
1585
1586 mockGeocodePlaces.mockImplementation(async (places: Place[]) => places);
1587
1588 mockVault.trigger("modify", mockFile);
1589 await vi.advanceTimersByTimeAsync(300);
1590 await flushMicrotasks();
1591
1592 // Geocoder should be called — but the allPlaces array includes both
1593 // (geocodePlaces receives allPlaces because it may need context, but
1594 // the implementation filters placesToGeocode for Notice logic)
1595 expect(mockGeocodePlaces).toHaveBeenCalledTimes(1);
1596 });
1597});
1598
1599describe("Adversary finding #7: duplicate place name write-back safety", () => {
1600 it("writes geo to ALL occurrences of a duplicate name", async () => {
1601 const { view } = await createAndOpenView();
1602
1603 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1604 const content = "* Cafe\n\t* Morning visit\n* Cafe\n\t* Evening visit";
1605 mockVault.cachedRead.mockResolvedValue(content);
1606
1607 // Both parsed as separate places with the same name
1608 mockParsePlaces.mockReturnValue([
1609 makePlace("Cafe", { startLine: 0, endLine: 1 }),
1610 makePlace("Cafe", { startLine: 2, endLine: 3 }),
1611 ]);
1612
1613 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1614 // Geocoder sets coords on both (since they share a name, Nominatim would return the same result)
1615 for (const p of places) {
1616 if (p.name === "Cafe") {
1617 p.lat = 40.0;
1618 p.lng = -74.0;
1619 }
1620 }
1621 return places;
1622 });
1623
1624 let processedContent = "";
1625 mockVault.process.mockImplementation(
1626 async (_file: unknown, fn: (data: string) => string) => {
1627 mockParsePlaces.mockReturnValueOnce([
1628 makePlace("Cafe", { startLine: 0, endLine: 1 }),
1629 makePlace("Cafe", { startLine: 2, endLine: 3 }),
1630 ]);
1631 processedContent = fn(content);
1632 return processedContent;
1633 }
1634 );
1635
1636 mockVault.trigger("modify", mockFile);
1637 await vi.advanceTimersByTimeAsync(300);
1638 await flushMicrotasks();
1639
1640 // Count geo lines — BOTH occurrences should get geo written back
1641 // to prevent infinite re-geocoding of the second occurrence
1642 const geoLines = processedContent.split("\n").filter(
1643 (line: string) => line.includes("geo:")
1644 );
1645 expect(geoLines).toHaveLength(2);
1646
1647 // Both should have the same coordinates
1648 for (const line of geoLines) {
1649 expect(line).toContain("40.000000,-74.000000");
1650 }
1651 });
1652});
1653
1654describe("Adversary finding #9: cachedRead rejection handled gracefully", () => {
1655 it("does not throw when cachedRead rejects", async () => {
1656 const { view } = await createAndOpenView();
1657 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1658
1659 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1660 mockVault.cachedRead.mockRejectedValue(new Error("File deleted"));
1661
1662 // This should not throw an unhandled rejection
1663 mockVault.trigger("modify", mockFile);
1664 await vi.advanceTimersByTimeAsync(300);
1665 await flushMicrotasks();
1666
1667 // The error should be caught and logged
1668 expect(warnSpy).toHaveBeenCalledWith(
1669 expect.stringContaining("[MapViewer]"),
1670 expect.anything()
1671 );
1672
1673 warnSpy.mockRestore();
1674 });
1675});
1676
1677describe("Adversary finding #17: VIEW_TYPE is exported", () => {
1678 it("exports VIEW_TYPE constant", async () => {
1679 const mod = await importMapView();
1680 expect(mod.VIEW_TYPE).toBe("map-viewer");
1681 });
1682});
1683
1684// ─── onClose lifecycle tests ──────────────────────────────────────────
1685
1686describe("onClose lifecycle", () => {
1687 it("cancels pending debounce timer on close", async () => {
1688 const { view } = await createAndOpenView();
1689
1690 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1691 mockVault.cachedRead.mockResolvedValue("* Place A");
1692 mockParsePlaces.mockReturnValue([
1693 makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }),
1694 ]);
1695
1696 // Trigger a refresh that hasn't fired yet (still in debounce window)
1697 mockVault.trigger("modify", mockFile);
1698
1699 // Close before debounce fires
1700 await (view as any).onClose();
1701
1702 // Now advance past debounce — the refresh should NOT happen
1703 mockParsePlaces.mockClear();
1704 await vi.advanceTimersByTimeAsync(300);
1705 await flushMicrotasks();
1706
1707 expect(mockParsePlaces).not.toHaveBeenCalled();
1708 });
1709
1710 it("aborts in-flight geocoding on close", async () => {
1711 const { view } = await createAndOpenView();
1712
1713 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1714 mockVault.cachedRead.mockResolvedValue("* Place A");
1715 mockParsePlaces.mockReturnValue([
1716 makePlace("Place A", { startLine: 0, endLine: 0 }),
1717 ]);
1718
1719 let capturedSignal: AbortSignal | undefined;
1720 mockGeocodePlaces.mockImplementationOnce(
1721 async (_places: Place[], _cb: unknown, signal?: AbortSignal) => {
1722 capturedSignal = signal;
1723 return new Promise(() => {}); // Hang forever
1724 }
1725 );
1726
1727 // Trigger refresh — starts geocoding
1728 mockVault.trigger("modify", mockFile);
1729 await vi.advanceTimersByTimeAsync(300);
1730
1731 expect(capturedSignal).toBeDefined();
1732 expect(capturedSignal!.aborted).toBe(false);
1733
1734 // Close the view — should abort the geocoding signal
1735 await (view as any).onClose();
1736
1737 expect(capturedSignal!.aborted).toBe(true);
1738 });
1739
1740 it("post-close doRefresh is a no-op due to destroyed flag", async () => {
1741 const { view } = await createAndOpenView();
1742
1743 // Drain the initial refresh
1744 await vi.advanceTimersByTimeAsync(300);
1745 await flushMicrotasks();
1746
1747 // Close the view
1748 await (view as any).onClose();
1749
1750 // Set up mocks for a refresh that shouldn't happen
1751 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1752 mockVault.cachedRead.mockClear();
1753 mockParsePlaces.mockClear();
1754
1755 // Directly invoke doRefresh on the closed view
1756 await (view as any).doRefresh();
1757
1758 // cachedRead should NOT have been called — destroyed flag prevents it
1759 expect(mockVault.cachedRead).not.toHaveBeenCalled();
1760 });
1761});
1762
1763// ─── Write-back safety: content divergence ────────────────────────────
1764
1765describe("Write-back safety: content changes between cachedRead and vault.process", () => {
1766 it("re-parses inside vault.process and only writes to places found in current content", async () => {
1767 const { view } = await createAndOpenView();
1768
1769 mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView);
1770
1771 // cachedRead returns original content with one place
1772 const originalContent = "* Cafe\n\t* Morning visit";
1773 mockVault.cachedRead.mockResolvedValue(originalContent);
1774
1775 mockParsePlaces.mockReturnValue([
1776 makePlace("Cafe", { startLine: 0, endLine: 1 }),
1777 ]);
1778
1779 mockGeocodePlaces.mockImplementation(async (places: Place[]) => {
1780 for (const p of places) {
1781 p.lat = 40.0;
1782 p.lng = -74.0;
1783 }
1784 return places;
1785 });
1786
1787 // By the time vault.process runs, content has changed — new place added
1788 const changedContent = "* Restaurant\n\t* Dinner spot\n* Cafe\n\t* Morning visit";
1789 let processedContent = "";
1790 mockVault.process.mockImplementation(
1791 async (_file: unknown, fn: (data: string) => string) => {
1792 // Re-parse returns new content's places — Cafe is now at different lines
1793 mockParsePlaces.mockReturnValueOnce([
1794 makePlace("Restaurant", { startLine: 0, endLine: 1 }),
1795 makePlace("Cafe", { startLine: 2, endLine: 3 }),
1796 ]);
1797 processedContent = fn(changedContent);
1798 return processedContent;
1799 }
1800 );
1801
1802 mockVault.trigger("modify", mockFile);
1803 await vi.advanceTimersByTimeAsync(300);
1804 await flushMicrotasks();
1805
1806 // Should have written geo for Cafe (the geocoded place) at its NEW line position
1807 const lines = processedContent.split("\n");
1808 const geoLines = lines.filter((line: string) => line.includes("geo:"));
1809 expect(geoLines).toHaveLength(1);
1810 expect(geoLines[0]).toContain("40.000000,-74.000000");
1811
1812 // Restaurant should NOT have a geo line (it wasn't geocoded)
1813 // Check that no geo line appears in Restaurant's line range (lines 0-1)
1814 expect(lines[0]).not.toContain("geo:");
1815 expect(lines[1]).not.toContain("geo:");
1816 // Geo line should appear after Cafe's block, not after Restaurant
1817 const geoLineIndex = lines.findIndex((line: string) => line.includes("geo:"));
1818 expect(geoLineIndex).toBeGreaterThan(2); // After Cafe's startLine
1819 });
1820});