Something went wrong. Try again.
A wayfinder inspired map plugin for obisidian
Something went wrong. Try again.
62 kB · 1820 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821/** * 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> = {}): Place { return { name, fields: {}, notes: [], startLine: 0, endLine: 0, ...overrides, };}
// ─── Mock Types ───────────────────────────────────────────────────────
interface MockEditor { getCursor: ReturnType<typeof vi.fn>; setCursor: ReturnType<typeof vi.fn>; scrollIntoView: ReturnType<typeof vi.fn>;}
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 classtype EventCallback = (...args: unknown[]) => unknown;
class MockEvents { private _handlers: Map<string, Set<EventCallback>> = 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<typeof vi.fn>; process: ReturnType<typeof vi.fn>;};
let mockWorkspace: MockEvents & { getActiveViewOfType: ReturnType<typeof vi.fn>; getLeavesOfType: ReturnType<typeof vi.fn>; getLeaf: ReturnType<typeof vi.fn>; revealLeaf: ReturnType<typeof vi.fn>; detachLeavesOfType: ReturnType<typeof vi.fn>; onLayoutReady: ReturnType<typeof vi.fn>;};
let mockApp: { workspace: typeof mockWorkspace; vault: typeof mockVault;};
let mockLeaf: { view: unknown; getViewState: ReturnType<typeof vi.fn>; setViewState: ReturnType<typeof vi.fn>; detach: ReturnType<typeof vi.fn>;};
let mockEditor: MockEditor;let mockFile: MockTFile;let mockMarkdownView: MockMarkdownView;
// Parser mocklet mockParsePlaces: ReturnType<typeof vi.fn>;
// Geocoder mocklet mockGeocodePlaces: ReturnType<typeof vi.fn>;
// MapRenderer mocklet mockMapController: { updateMarkers: ReturnType<typeof vi.fn>; selectPlace: ReturnType<typeof vi.fn>; fitBounds: ReturnType<typeof vi.fn>; invalidateSize: ReturnType<typeof vi.fn>; destroy: ReturnType<typeof vi.fn>;};let mockCreateMap: ReturnType<typeof vi.fn>;
// Notice mocklet mockNoticeInstances: Array<{ message: string; duration?: number }>;
// Track registerEvent and registerInterval callslet 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<void> { // 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 groupasync function importMapView() { const mod = await import("../src/mapView"); return mod;}
// Helper to create a MapViewerView instance for testingasync 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 onOpenasync 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: <lat>,<lng> 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 });});