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