/** * mapView.test.ts — Tests for all mapView.ts behavioral contracts * * Mocks Obsidian API (Vault, Workspace, MarkdownView, Editor), * parser, geocoder, and mapRenderer modules. Tests debounce, fingerprinting, * geo write-back, write guards, geocoding mutex, cursor sync, and error handling. * * @vitest-environment jsdom */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { Place } from "../src/parser"; import type { MapController, MapCallbacks } from "../src/mapRenderer"; // ─── Helpers ────────────────────────────────────────────────────────── function makePlace( name: string, overrides: Partial = {} ): Place { return { name, fields: {}, notes: [], startLine: 0, endLine: 0, ...overrides, }; } // ─── Mock Types ─────────────────────────────────────────────────────── interface MockEditor { getCursor: ReturnType; setCursor: ReturnType; scrollIntoView: ReturnType; } interface MockTFile { path: string; name: string; basename: string; extension: string; stat: { ctime: number; mtime: number; size: number }; parent: null; vault: unknown; } interface MockMarkdownView { editor: MockEditor; file: MockTFile | null; getViewType: () => string; // Used for instanceof checks — we tag it _isMarkdownView: true; } interface MockWorkspaceLeaf { view: MockMarkdownView | { getViewType: () => string }; } // ─── Obsidian Mock Infrastructure ───────────────────────────────────── // Event system mock to emulate Obsidian's Events class type EventCallback = (...args: unknown[]) => unknown; class MockEvents { private _handlers: Map> = new Map(); on(name: string, callback: EventCallback): { id: string } { if (!this._handlers.has(name)) { this._handlers.set(name, new Set()); } this._handlers.get(name)!.add(callback); return { id: `${name}-${Math.random()}` }; } off(name: string, callback: EventCallback): void { this._handlers.get(name)?.delete(callback); } offref(_ref: unknown): void { // In tests we don't track refs to handlers } trigger(name: string, ...data: unknown[]): void { const handlers = this._handlers.get(name); if (handlers) { for (const handler of handlers) { handler(...data); } } } } // ─── Module-level mock state ────────────────────────────────────────── let mockVault: MockEvents & { cachedRead: ReturnType; process: ReturnType; }; let mockWorkspace: MockEvents & { getActiveViewOfType: ReturnType; getLeavesOfType: ReturnType; getLeaf: ReturnType; revealLeaf: ReturnType; detachLeavesOfType: ReturnType; onLayoutReady: ReturnType; }; let mockApp: { workspace: typeof mockWorkspace; vault: typeof mockVault; }; let mockLeaf: { view: unknown; getViewState: ReturnType; setViewState: ReturnType; detach: ReturnType; }; let mockEditor: MockEditor; let mockFile: MockTFile; let mockMarkdownView: MockMarkdownView; // Parser mock let mockParsePlaces: ReturnType; // Geocoder mock let mockGeocodePlaces: ReturnType; // MapRenderer mock let mockMapController: { updateMarkers: ReturnType; selectPlace: ReturnType; fitBounds: ReturnType; invalidateSize: ReturnType; destroy: ReturnType; }; let mockCreateMap: ReturnType; // Notice mock let mockNoticeInstances: Array<{ message: string; duration?: number }>; // Track registerEvent and registerInterval calls let registeredEvents: Array<{ id: string }>; let registeredIntervals: number[]; let registeredCleanups: Array<() => void>; // ─── Setup / Teardown ───────────────────────────────────────────────── function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile { return { path, name, basename: name.replace(/\.md$/, ""), extension: "md", stat: { ctime: Date.now(), mtime: Date.now(), size: 100 }, parent: null, vault: mockVault, }; } function createMockEditor(): MockEditor { return { getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }), setCursor: vi.fn(), scrollIntoView: vi.fn(), }; } function createMockMarkdownView( editor: MockEditor, file: MockTFile | null ): MockMarkdownView { return { editor, file, getViewType: () => "markdown", _isMarkdownView: true, }; } beforeEach(() => { vi.useFakeTimers(); mockNoticeInstances = []; registeredEvents = []; registeredIntervals = []; registeredCleanups = []; // Build vault mock const vaultEvents = new MockEvents(); mockVault = Object.assign(vaultEvents, { cachedRead: vi.fn().mockResolvedValue(""), process: vi.fn().mockImplementation( async (_file: MockTFile, fn: (data: string) => string) => { const result = fn(""); return result; } ), }); // Build workspace mock const workspaceEvents = new MockEvents(); mockWorkspace = Object.assign(workspaceEvents, { getActiveViewOfType: vi.fn().mockReturnValue(null), getLeavesOfType: vi.fn().mockReturnValue([]), getLeaf: vi.fn(), revealLeaf: vi.fn().mockResolvedValue(undefined), detachLeavesOfType: vi.fn(), onLayoutReady: vi.fn().mockImplementation((cb: () => void) => cb()), }); // Build editor and view mockFile = createMockFile(); mockEditor = createMockEditor(); mockMarkdownView = createMockMarkdownView(mockEditor, mockFile); // Build leaf mockLeaf = { view: mockMarkdownView, getViewState: vi.fn(), setViewState: vi.fn(), detach: vi.fn(), }; // Build app mockApp = { workspace: mockWorkspace, vault: mockVault, }; // Build map controller mock mockMapController = { updateMarkers: vi.fn(), selectPlace: vi.fn(), fitBounds: vi.fn(), invalidateSize: vi.fn(), destroy: vi.fn(), }; mockCreateMap = vi.fn().mockReturnValue(mockMapController); // Parser mock — returns empty by default mockParsePlaces = vi.fn().mockReturnValue([]); // Geocoder mock — resolves immediately, returns the input places mockGeocodePlaces = vi.fn().mockImplementation( async (places: Place[]) => places ); // ── Module mocks ── vi.doMock("../src/parser", () => ({ parsePlaces: mockParsePlaces, GEO_LINE_RE: /^[\t ]+[*-] geo: .*/, })); vi.doMock("../src/geocoder", () => ({ geocodePlaces: mockGeocodePlaces, })); vi.doMock("../src/mapRenderer", () => ({ createMap: mockCreateMap, })); // Mock the obsidian module vi.doMock("obsidian", () => { // We provide a MarkdownView constructor that we can use for instanceof checks class MarkdownView { static _isMarkdownView = true; editor: MockEditor; file: MockTFile | null; constructor() { this.editor = createMockEditor(); this.file = null; } getViewType() { return "markdown"; } } class ItemView { app: unknown; leaf: unknown; containerEl: HTMLElement; contentEl: HTMLElement; constructor(leaf: unknown) { this.leaf = leaf; this.app = mockApp; this.containerEl = document.createElement("div"); this.contentEl = document.createElement("div"); this.containerEl.appendChild(this.contentEl); } getViewType() { return ""; } getDisplayText() { return ""; } getIcon() { return ""; } register(cb: () => void) { registeredCleanups.push(cb); } registerEvent(ref: { id: string }) { registeredEvents.push(ref); } registerInterval(id: number) { registeredIntervals.push(id); return id; } addChild(_c: unknown) { return _c; } removeChild(_c: unknown) { return _c; } onload() {} onunload() {} } class Notice { message: string; duration?: number; noticeEl: HTMLElement; constructor(message: string, duration?: number) { this.message = message; this.duration = duration; this.noticeEl = document.createElement("div"); mockNoticeInstances.push({ message, duration }); } hide() {} } class Plugin { app: unknown; manifest: unknown; constructor(app: unknown, manifest: unknown) { this.app = app; this.manifest = manifest; } registerView() {} addCommand() {} register() {} registerEvent() {} registerInterval() { return 0; } } class TFile { path = ""; name = ""; basename = ""; extension = "md"; stat = { ctime: 0, mtime: 0, size: 0 }; parent = null; } return { ItemView, MarkdownView, Notice, Plugin, TFile, }; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.resetModules(); }); // ─── Microtask Flush Helper ─────────────────────────────────────────── /** * Flush pending microtasks (resolved promises) without advancing fake timers. * This avoids triggering infinite setInterval loops from cursor sync polling. * Call this instead of vi.runAllTimersAsync() when you need promises to settle. */ async function flushMicrotasks(): Promise { // Multiple rounds to handle chained promises for (let i = 0; i < 10; i++) { await Promise.resolve(); } } // ─── Import Helper ──────────────────────────────────────────────────── // Import the module fresh for each test group async function importMapView() { const mod = await import("../src/mapView"); return mod; } // Helper to create a MapViewerView instance for testing async function createTestView() { const mod = await importMapView(); const ViewClass = mod.MapViewerView; // Construct with our mock leaf const view = new ViewClass(mockLeaf as any); // Manually set app since super(leaf) uses it (view as any).app = mockApp; return { view, mod }; } // Helper to create a view and call onOpen async function createAndOpenView() { const { view, mod } = await createTestView(); await (view as any).onOpen(); return { view, mod }; } // ─── Contract 1: View type registration ─────────────────────────────── describe("Contract 1: Registered as view type 'map-viewer'", () => { it("getViewType() returns 'map-viewer'", async () => { const { view } = await createTestView(); expect(view.getViewType()).toBe("map-viewer"); }); it("getDisplayText() returns 'Map'", async () => { const { view } = await createTestView(); expect(view.getDisplayText()).toBe("Map"); }); it("getIcon() returns 'map-pin'", async () => { const { view } = await createTestView(); expect(view.getIcon()).toBe("map-pin"); }); }); // ─── Contract 2: On open — map container and initialization ─────────── describe("Contract 2: On open creates map container", () => { it("creates a full-height map container div on open", async () => { const { view } = await createAndOpenView(); // createMap should have been called expect(mockCreateMap).toHaveBeenCalledTimes(1); // First argument is the container element const container = mockCreateMap.mock.calls[0][0]; expect(container).toBeInstanceOf(HTMLElement); }); it("passes initial places (empty for no file) to createMap", async () => { // No active markdown view mockWorkspace.getActiveViewOfType.mockReturnValue(null); const { view } = await createAndOpenView(); // createMap called with empty places array expect(mockCreateMap).toHaveBeenCalledWith( expect.any(HTMLElement), expect.any(Array), expect.any(Object) ); }); it("initializes map via createMap()", async () => { const { view } = await createAndOpenView(); expect(mockCreateMap).toHaveBeenCalledTimes(1); }); }); // ─── Contract 3: Debounced refresh (trailing edge, 300ms) ───────────── describe("Contract 3: refresh() is debounced with trailing edge, 300ms", () => { it("does not refresh immediately when called", async () => { const { view } = await createAndOpenView(); // Set up active markdown view with content mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); // Trigger refresh (e.g., via file modify event) mockVault.trigger("modify", mockFile); // Should NOT have called parsePlaces yet (debounce not elapsed) expect(mockParsePlaces).not.toHaveBeenCalled(); }); it("refreshes after 300ms debounce period", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); // Advance past debounce await vi.advanceTimersByTimeAsync(300); expect(mockParsePlaces).toHaveBeenCalled(); }); it("coalesces rapid triggers into a single refresh", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]); // Trigger multiple modify events rapidly mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(100); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(100); mockVault.trigger("modify", mockFile); // Advance past debounce from last trigger await vi.advanceTimersByTimeAsync(300); // Should only have been called once (trailing edge) expect(mockParsePlaces).toHaveBeenCalledTimes(1); }); }); // ─── Contract 4: refresh reads active file and parses ───────────────── describe("Contract 4: refresh reads active file content and parses", () => { it("calls vault.cachedRead with the active file", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile); }); it("calls parsePlaces with the file content", async () => { const { view } = await createAndOpenView(); const content = "* Sagrada Familia\n\t* geo: 41.4,2.1"; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue(content); mockParsePlaces.mockReturnValue([]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockParsePlaces).toHaveBeenCalledWith(content); }); it("calls updateMarkers on the map controller", async () => { const { view } = await createAndOpenView(); const places = [ makePlace("Sagrada Familia", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Sagrada Familia\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue(places); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockMapController.updateMarkers).toHaveBeenCalled(); }); }); // ─── Contract 5: Fingerprinting for map rebuild ─────────────────────── describe("Contract 5: Fingerprint skip — name::lat::lng joined by |", () => { it("skips updateMarkers if fingerprint is unchanged", async () => { const { view } = await createAndOpenView(); const places = [ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue(places); // First refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockMapController.updateMarkers).toHaveBeenCalledTimes(1); mockMapController.updateMarkers.mockClear(); // Same places again (same fingerprint) mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // Should NOT call updateMarkers again — fingerprint unchanged expect(mockMapController.updateMarkers).not.toHaveBeenCalled(); }); it("calls updateMarkers if fingerprint changes", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); // First refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); mockMapController.updateMarkers.mockClear(); // Different places (different fingerprint) mockParsePlaces.mockReturnValue([ makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockMapController.updateMarkers).toHaveBeenCalled(); }); it("always re-parses even when fingerprint unchanged (fresh line ranges)", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); // First refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockParsePlaces).toHaveBeenCalledTimes(1); // Second refresh (same fingerprint) mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // Parser ALWAYS called, even though map won't update expect(mockParsePlaces).toHaveBeenCalledTimes(2); }); }); // ─── Contract 6: Geo write-back via vault.process ───────────────────── describe("Contract 6: Geo write-back after geocoding", () => { it("calls vault.process after successful geocoding", async () => { const { view } = await createAndOpenView(); const placesBeforeGeocode = [ makePlace("Place A", { startLine: 0, endLine: 0 }), ]; const placesAfterGeocode = [ makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 0 }), ]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue(placesBeforeGeocode); // Geocoder resolves with coordinates mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; return places; }); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // Let geocoding resolve await flushMicrotasks(); expect(mockVault.process).toHaveBeenCalledWith( mockFile, expect.any(Function) ); }); it("re-parses CURRENT content inside vault.process callback", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces .mockReturnValueOnce([makePlace("Place A", { startLine: 0, endLine: 0 })]) .mockReturnValue([makePlace("Place A", { startLine: 0, endLine: 0 })]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; return places; }); // vault.process calls fn with current content — capture the fn mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { const currentContent = "* Place A"; return fn(currentContent); } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // parsePlaces should be called again INSIDE vault.process (re-parse) // First call from refresh, second call from inside vault.process expect(mockParsePlaces).toHaveBeenCalledTimes(2); }); it("inserts geo: sub-bullet after endLine for places without existing geo", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); // First parse: no geo mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.403600; places[0].lng = 2.174400; return places; }); // Capture what vault.process does with the content let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { // Re-parse inside vault.process returns place at line 0 mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); processedContent = fn("* Place A"); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Should have inserted a geo line after the place expect(processedContent).toContain("\t* geo: 41.403600,2.174400"); }); it("replaces existing geo: line when place already has one", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); // Place has an INVALID geo line — parser leaves lat/lng undefined const content = "* Place A\n\t* geo: invalid"; mockVault.cachedRead.mockResolvedValue(content); // Parse returns place with geo field but no valid lat/lng (needs re-geocode) mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 1, fields: { geo: "invalid" }, // lat and lng are undefined — parser couldn't parse "invalid" }), ]); // Geocoder gives new coordinates mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.403600; places[0].lng = 2.174400; return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { // Re-parse inside vault.process shows the place still has the invalid geo line mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 1, fields: { geo: "invalid" }, }), ]); processedContent = fn(content); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // The old geo line should be replaced with new coordinates expect(processedContent).toContain("\t* geo: 41.403600,2.174400"); expect(processedContent).not.toContain("invalid"); }); it("writes geo with 6 decimal places (toFixed(6))", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.40359999; places[0].lng = 2.17440001; return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); processedContent = fn("* Place A"); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); expect(processedContent).toContain("41.403600,2.174400"); }); it("matches geocoded places to parsed places by name (case-insensitive)", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B"); // Initial parse mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), makePlace("Place B", { startLine: 1, endLine: 1 }), ]); // Only geocode Place A mockGeocodePlaces.mockImplementation(async (places: Place[]) => { for (const p of places) { if (p.name.toLowerCase() === "place a") { p.lat = 41.4036; p.lng = 2.1744; } } return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), makePlace("Place B", { startLine: 1, endLine: 1 }), ]); processedContent = fn("* Place A\n* Place B"); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Only Place A should get a geo line inserted const lines = processedContent.split("\n"); const geoLines = lines.filter((l: string) => l.includes("geo:")); expect(geoLines).toHaveLength(1); expect(geoLines[0]).toContain("41.403600,2.174400"); }); it("processes insertions from bottom-to-top to preserve line numbers", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); const content = "* Place A\n* Place B"; mockVault.cachedRead.mockResolvedValue(content); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), makePlace("Place B", { startLine: 1, endLine: 1 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; places[1].lat = 48.8606; places[1].lng = 2.3376; return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), makePlace("Place B", { startLine: 1, endLine: 1 }), ]); processedContent = fn(content); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Both places should have geo lines and content should be well-formed expect(processedContent).toContain("41.403600,2.174400"); expect(processedContent).toContain("48.860600,2.337600"); // Place A should come before Place B in the output const idxA = processedContent.indexOf("41.403600"); const idxB = processedContent.indexOf("48.860600"); expect(idxA).toBeLessThan(idxB); }); it("write-back format: tab-indented bullet with geo prefix", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); processedContent = fn("* Place A"); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Check exact format: \t* geo: , expect(processedContent).toMatch(/\t\* geo: \d+\.\d{6},\d+\.\d{6}/); }); }); // ─── Contract 7: Write guard counter mechanism ──────────────────────── describe("Contract 7: Write guard prevents self-triggered refresh", () => { it("increments write guard before vault.process and decrements after 500ms", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; return places; }); mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); return fn("* Place A"); } ); // Trigger initial refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Now the write guard should be active (counter > 0) // A modify event during this window should be skipped mockParsePlaces.mockClear(); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // The refresh should have been suppressed by write guard expect(mockParsePlaces).not.toHaveBeenCalled(); }); it("allows refresh after 500ms write guard window expires", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { places[0].lat = 41.4036; places[0].lng = 2.1744; return places; }); mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); return fn("* Place A"); } ); // Trigger initial refresh with geocoding + write-back mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Wait for write guard to expire (500ms) await vi.advanceTimersByTimeAsync(500); // Now a modify event should be allowed through mockParsePlaces.mockClear(); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4036, lng: 2.1744, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockParsePlaces).toHaveBeenCalled(); }); }); // ─── Contract 8: Geocoding concurrency — AbortController mutex ──────── describe("Contract 8: Only one geocoding operation in-flight at a time", () => { it("passes an AbortSignal to geocodePlaces", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // Geocoder should have been called with an AbortSignal as third argument expect(mockGeocodePlaces).toHaveBeenCalled(); const args = mockGeocodePlaces.mock.calls[0]; // args: [places, callbacks, signal] expect(args[2]).toBeInstanceOf(AbortSignal); }); it("aborts previous geocoding when a new refresh starts", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); // First geocode: hang forever (never resolve) let firstSignal: AbortSignal | undefined; mockGeocodePlaces.mockImplementationOnce( async (places: Place[], _callbacks: unknown, signal?: AbortSignal) => { firstSignal = signal; return new Promise(() => {}); // Never resolves } ); // Trigger first refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(firstSignal).toBeDefined(); expect(firstSignal!.aborted).toBe(false); // Second geocode mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); mockParsePlaces.mockReturnValue([ makePlace("Place B", { startLine: 0, endLine: 0 }), ]); // Trigger second refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // First signal should now be aborted expect(firstSignal!.aborted).toBe(true); }); }); // ─── Contract 9: Cursor sync (editor → map) ────────────────────────── describe("Contract 9: Cursor sync — editor to map", () => { it("registers a polling interval for cursor position", async () => { const { view } = await createAndOpenView(); // Should have registered at least one interval (the 200ms cursor poll) expect(registeredIntervals.length).toBeGreaterThan(0); }); it("selects the place whose line range contains the cursor", async () => { const { view } = await createAndOpenView(); // Set up places with line ranges const places = [ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 2 }), makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 6 }), ]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("..."); mockParsePlaces.mockReturnValue(places); // Trigger refresh mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // Cursor is on line 5 — within Place B range [4, 6] mockEditor.getCursor.mockReturnValue({ line: 5, ch: 0 }); // Advance to trigger cursor poll (200ms) await vi.advanceTimersByTimeAsync(200); // mapController.selectPlace should be called with Place B expect(mockMapController.selectPlace).toHaveBeenCalledWith( expect.objectContaining({ name: "Place B" }) ); }); it("deselects when cursor is in a dead zone (outside all place ranges)", async () => { const { view } = await createAndOpenView(); const places = [ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), makePlace("Place B", { lat: 48.8, lng: 2.3, startLine: 4, endLine: 5 }), ]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("..."); mockParsePlaces.mockReturnValue(places); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // First, place cursor inside Place A so something is selected mockEditor.getCursor.mockReturnValue({ line: 0, ch: 0 }); await vi.advanceTimersByTimeAsync(200); mockMapController.selectPlace.mockClear(); // Now move cursor to line 3 — between Place A and Place B (dead zone) mockEditor.getCursor.mockReturnValue({ line: 3, ch: 0 }); await vi.advanceTimersByTimeAsync(200); expect(mockMapController.selectPlace).toHaveBeenCalledWith(null); }); }); // ─── Contract 10: Cursor sync (map → editor) ───────────────────────── describe("Contract 10: Cursor sync — map to editor (marker click)", () => { it("scrolls editor to startLine of first place when marker is clicked", async () => { const { view } = await createAndOpenView(); // Get the onPlaceSelect callback that was passed to createMap expect(mockCreateMap).toHaveBeenCalled(); const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2]; expect(callbacks.onPlaceSelect).toBeDefined(); // Set up active markdown view mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); // Simulate marker click const clickedPlaces = [ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 3, endLine: 5 }), makePlace("Place B", { lat: 41.4, lng: 2.1, startLine: 7, endLine: 9 }), ]; callbacks.onPlaceSelect!(clickedPlaces); // Editor should be scrolled to startLine of the FIRST place expect(mockEditor.setCursor).toHaveBeenCalledWith( expect.objectContaining({ line: 3, ch: 0 }) ); }); it("calls editor.scrollIntoView after setCursor", async () => { const { view } = await createAndOpenView(); const callbacks: MapCallbacks = mockCreateMap.mock.calls[0][2]; mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); callbacks.onPlaceSelect!([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 10, endLine: 12 }), ]); expect(mockEditor.scrollIntoView).toHaveBeenCalled(); }); }); // ─── Contract 11: Active file null → clear markers ──────────────────── describe("Contract 11: Active file null — clear all markers", () => { it("clears markers when active file is null", async () => { const { view } = await createAndOpenView(); // Set up with some places first mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); mockMapController.updateMarkers.mockClear(); // Now no active markdown view (file switched to non-markdown or closed) mockWorkspace.getActiveViewOfType.mockReturnValue(null); // Trigger active-leaf-change with null mockWorkspace.trigger("active-leaf-change", null); await vi.advanceTimersByTimeAsync(300); // Map should be cleared (updateMarkers with empty array) expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]); }); }); // ─── Contract 12: Active file with no places → empty map ───────────── describe("Contract 12: File with no places — empty map", () => { it("calls updateMarkers with empty array for a file with no bullets", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("Just some plain text, no bullets"); mockParsePlaces.mockReturnValue([]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockMapController.updateMarkers).toHaveBeenCalledWith([]); }); }); // ─── Contract 13: Only respond to MarkdownView ─────────────────────── describe("Contract 13: Only respond to MarkdownView active-leaf-change", () => { it("does not refresh when active leaf is not a MarkdownView", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(null); // Trigger active-leaf-change with a non-markdown leaf const nonMdLeaf = { view: { getViewType: () => "map-viewer" }, }; mockWorkspace.trigger("active-leaf-change", nonMdLeaf); await vi.advanceTimersByTimeAsync(300); // Should not have tried to read file content via cachedRead // (beyond any initial setup reads) expect(mockVault.cachedRead).not.toHaveBeenCalled(); }); it("refreshes when active leaf is a MarkdownView", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); // Trigger active-leaf-change with a markdown leaf mockWorkspace.trigger("active-leaf-change", { view: mockMarkdownView, }); await vi.advanceTimersByTimeAsync(300); expect(mockVault.cachedRead).toHaveBeenCalled(); expect(mockParsePlaces).toHaveBeenCalled(); }); }); // ─── Contract 14: Error reporting ───────────────────────────────────── describe("Contract 14: Error reporting for geocoding failures", () => { it("logs geocoding failures via console.warn with [MapViewer] prefix", async () => { const { view } = await createAndOpenView(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Nonexistent Place"); mockParsePlaces.mockReturnValue([ makePlace("Nonexistent Place", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockRejectedValue(new Error("Network error")); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("[MapViewer]"), expect.anything() ); warnSpy.mockRestore(); }); it("shows an Obsidian Notice when geocoding batch produces zero results", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n* Place B"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), makePlace("Place B", { startLine: 1, endLine: 1 }), ]); // Geocoder returns places with no coordinates set (all failed) mockGeocodePlaces.mockImplementation(async (places: Place[]) => { // Intentionally leave lat/lng undefined — zero successful results return places; }); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Should have shown a Notice expect(mockNoticeInstances.length).toBeGreaterThan(0); }); }); // ─── Edge Cases ─────────────────────────────────────────────────────── describe("Edge cases", () => { it("file change while geocoding is in progress — aborts and restarts", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); let firstAbortSignal: AbortSignal | undefined; mockGeocodePlaces.mockImplementationOnce( async (_places: Place[], _cb: unknown, signal?: AbortSignal) => { firstAbortSignal = signal; return new Promise(() => {}); // Hang forever } ); // First modify mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // While first geocode is pending, simulate file change mockParsePlaces.mockReturnValue([ makePlace("Place B", { startLine: 0, endLine: 0 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); // First signal should be aborted expect(firstAbortSignal!.aborted).toBe(true); // Second geocode should have started expect(mockGeocodePlaces).toHaveBeenCalledTimes(2); }); it("rapid file switching — debounce prevents excessive re-parsing", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([]); // Rapidly switch files (trigger active-leaf-change multiple times) for (let i = 0; i < 5; i++) { mockWorkspace.trigger("active-leaf-change", { view: mockMarkdownView }); await vi.advanceTimersByTimeAsync(50); } // Only wait for debounce from the last one await vi.advanceTimersByTimeAsync(300); // Parser should only be called once (debounce coalesced) expect(mockParsePlaces).toHaveBeenCalledTimes(1); }); it("onClose destroys the map controller", async () => { const { view } = await createAndOpenView(); await (view as any).onClose(); expect(mockMapController.destroy).toHaveBeenCalled(); }); }); // ─── Adversary Finding Tests ────────────────────────────────────────── describe("Adversary finding #1: onFileModify checks file identity", () => { it("ignores modify events for files other than the active file", async () => { const { view } = await createAndOpenView(); // Drain the initial refresh triggered by onOpen mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue(""); mockParsePlaces.mockReturnValue([]); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Clear mocks so we can check that modify with a different file does NOT trigger reads mockVault.cachedRead.mockClear(); mockParsePlaces.mockClear(); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), ]); // Trigger modify with a DIFFERENT file (not the active one) const otherFile = createMockFile("other-note.md", "other-note.md"); mockVault.trigger("modify", otherFile); await vi.advanceTimersByTimeAsync(300); // Should NOT have called cachedRead since the file doesn't match expect(mockVault.cachedRead).not.toHaveBeenCalled(); }); it("responds to modify events for the active file", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), ]); // Trigger modify with the SAME file as the active view mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(mockVault.cachedRead).toHaveBeenCalledWith(mockFile); }); }); describe("Adversary finding #2: Notice for mixed batches checks only attempted places", () => { it("does not show Notice when pre-geocoded places exist but new ones also succeed", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); // Place A already has coordinates, Place B does not mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), makePlace("Place B", { startLine: 2, endLine: 2 }), ]); // Geocoder gives coordinates to Place B mockGeocodePlaces.mockImplementation(async (places: Place[]) => { for (const p of places) { if (p.name === "Place B") { p.lat = 48.8606; p.lng = 2.3376; } } return places; }); mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), makePlace("Place B", { startLine: 2, endLine: 2 }), ]); return fn("* Place A\n\t* geo: 41.400000,2.100000\n* Place B"); } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // No Notice should be shown since Place B was successfully geocoded expect(mockNoticeInstances).toHaveLength(0); }); it("shows Notice when attempted places all fail, even if pre-geocoded ones exist", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); // Place A already has coordinates, Place B does not mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), makePlace("Place B", { startLine: 2, endLine: 2 }), ]); // Geocoder returns places but does NOT set coordinates on Place B mockGeocodePlaces.mockImplementation(async (places: Place[]) => { // Place B remains ungeocoded return places; }); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Notice should fire because the ATTEMPTED place (Place B) got zero results expect(mockNoticeInstances.length).toBeGreaterThan(0); expect(mockNoticeInstances[0].message).toContain("No places could be geocoded"); }); }); describe("Adversary finding #5: only ungeocoded places trigger geocoding", () => { it("does not call geocodePlaces when all places already have coordinates", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }), ]); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Geocoder should NOT have been called expect(mockGeocodePlaces).not.toHaveBeenCalled(); }); it("only passes ungeocoded places to the geocoder in a mixed batch", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A\n\t* geo: 41.4,2.1\n* Place B"); const placeA = makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 1 }); const placeB = makePlace("Place B", { startLine: 2, endLine: 2 }); mockParsePlaces.mockReturnValue([placeA, placeB]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => places); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Geocoder should be called — but the allPlaces array includes both // (geocodePlaces receives allPlaces because it may need context, but // the implementation filters placesToGeocode for Notice logic) expect(mockGeocodePlaces).toHaveBeenCalledTimes(1); }); }); describe("Adversary finding #7: duplicate place name write-back safety", () => { it("writes geo to ALL occurrences of a duplicate name", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); const content = "* Cafe\n\t* Morning visit\n* Cafe\n\t* Evening visit"; mockVault.cachedRead.mockResolvedValue(content); // Both parsed as separate places with the same name mockParsePlaces.mockReturnValue([ makePlace("Cafe", { startLine: 0, endLine: 1 }), makePlace("Cafe", { startLine: 2, endLine: 3 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { // Geocoder sets coords on both (since they share a name, Nominatim would return the same result) for (const p of places) { if (p.name === "Cafe") { p.lat = 40.0; p.lng = -74.0; } } return places; }); let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { mockParsePlaces.mockReturnValueOnce([ makePlace("Cafe", { startLine: 0, endLine: 1 }), makePlace("Cafe", { startLine: 2, endLine: 3 }), ]); processedContent = fn(content); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Count geo lines — BOTH occurrences should get geo written back // to prevent infinite re-geocoding of the second occurrence const geoLines = processedContent.split("\n").filter( (line: string) => line.includes("geo:") ); expect(geoLines).toHaveLength(2); // Both should have the same coordinates for (const line of geoLines) { expect(line).toContain("40.000000,-74.000000"); } }); }); describe("Adversary finding #9: cachedRead rejection handled gracefully", () => { it("does not throw when cachedRead rejects", async () => { const { view } = await createAndOpenView(); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockRejectedValue(new Error("File deleted")); // This should not throw an unhandled rejection mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // The error should be caught and logged expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("[MapViewer]"), expect.anything() ); warnSpy.mockRestore(); }); }); describe("Adversary finding #17: VIEW_TYPE is exported", () => { it("exports VIEW_TYPE constant", async () => { const mod = await importMapView(); expect(mod.VIEW_TYPE).toBe("map-viewer"); }); }); // ─── onClose lifecycle tests ────────────────────────────────────────── describe("onClose lifecycle", () => { it("cancels pending debounce timer on close", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { lat: 41.4, lng: 2.1, startLine: 0, endLine: 0 }), ]); // Trigger a refresh that hasn't fired yet (still in debounce window) mockVault.trigger("modify", mockFile); // Close before debounce fires await (view as any).onClose(); // Now advance past debounce — the refresh should NOT happen mockParsePlaces.mockClear(); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); expect(mockParsePlaces).not.toHaveBeenCalled(); }); it("aborts in-flight geocoding on close", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockResolvedValue("* Place A"); mockParsePlaces.mockReturnValue([ makePlace("Place A", { startLine: 0, endLine: 0 }), ]); let capturedSignal: AbortSignal | undefined; mockGeocodePlaces.mockImplementationOnce( async (_places: Place[], _cb: unknown, signal?: AbortSignal) => { capturedSignal = signal; return new Promise(() => {}); // Hang forever } ); // Trigger refresh — starts geocoding mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); expect(capturedSignal).toBeDefined(); expect(capturedSignal!.aborted).toBe(false); // Close the view — should abort the geocoding signal await (view as any).onClose(); expect(capturedSignal!.aborted).toBe(true); }); it("post-close doRefresh is a no-op due to destroyed flag", async () => { const { view } = await createAndOpenView(); // Drain the initial refresh await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Close the view await (view as any).onClose(); // Set up mocks for a refresh that shouldn't happen mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); mockVault.cachedRead.mockClear(); mockParsePlaces.mockClear(); // Directly invoke doRefresh on the closed view await (view as any).doRefresh(); // cachedRead should NOT have been called — destroyed flag prevents it expect(mockVault.cachedRead).not.toHaveBeenCalled(); }); }); // ─── Write-back safety: content divergence ──────────────────────────── describe("Write-back safety: content changes between cachedRead and vault.process", () => { it("re-parses inside vault.process and only writes to places found in current content", async () => { const { view } = await createAndOpenView(); mockWorkspace.getActiveViewOfType.mockReturnValue(mockMarkdownView); // cachedRead returns original content with one place const originalContent = "* Cafe\n\t* Morning visit"; mockVault.cachedRead.mockResolvedValue(originalContent); mockParsePlaces.mockReturnValue([ makePlace("Cafe", { startLine: 0, endLine: 1 }), ]); mockGeocodePlaces.mockImplementation(async (places: Place[]) => { for (const p of places) { p.lat = 40.0; p.lng = -74.0; } return places; }); // By the time vault.process runs, content has changed — new place added const changedContent = "* Restaurant\n\t* Dinner spot\n* Cafe\n\t* Morning visit"; let processedContent = ""; mockVault.process.mockImplementation( async (_file: unknown, fn: (data: string) => string) => { // Re-parse returns new content's places — Cafe is now at different lines mockParsePlaces.mockReturnValueOnce([ makePlace("Restaurant", { startLine: 0, endLine: 1 }), makePlace("Cafe", { startLine: 2, endLine: 3 }), ]); processedContent = fn(changedContent); return processedContent; } ); mockVault.trigger("modify", mockFile); await vi.advanceTimersByTimeAsync(300); await flushMicrotasks(); // Should have written geo for Cafe (the geocoded place) at its NEW line position const lines = processedContent.split("\n"); const geoLines = lines.filter((line: string) => line.includes("geo:")); expect(geoLines).toHaveLength(1); expect(geoLines[0]).toContain("40.000000,-74.000000"); // Restaurant should NOT have a geo line (it wasn't geocoded) // Check that no geo line appears in Restaurant's line range (lines 0-1) expect(lines[0]).not.toContain("geo:"); expect(lines[1]).not.toContain("geo:"); // Geo line should appear after Cafe's block, not after Restaurant const geoLineIndex = lines.findIndex((line: string) => line.includes("geo:")); expect(geoLineIndex).toBeGreaterThan(2); // After Cafe's startLine }); });