A wayfinder inspired map plugin for obisidian
1/**
2 * main.test.ts — Tests for all main.ts behavioral contracts
3 *
4 * Mocks Obsidian Plugin API (registerView, addRibbonIcon, addCommand,
5 * workspace.getRightLeaf, workspace.on, vault.on), MapViewerView, and
6 * tests view registration, activateView singleton logic, active-leaf-change
7 * filtering for MarkdownView, vault modify event wiring, and lastActiveFilePath dedup.
8 *
9 * @vitest-environment jsdom
10 */
11import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12
13// ─── Mock Types ───────────────────────────────────────────────────────
14
15interface MockEditor {
16 getCursor: ReturnType<typeof vi.fn>;
17 setCursor: ReturnType<typeof vi.fn>;
18 scrollIntoView: ReturnType<typeof vi.fn>;
19}
20
21interface MockTFile {
22 path: string;
23 name: string;
24 basename: string;
25 extension: string;
26 stat: { ctime: number; mtime: number; size: number };
27 parent: null;
28 vault: unknown;
29}
30
31interface MockMarkdownView {
32 editor: MockEditor;
33 file: MockTFile | null;
34 getViewType: () => string;
35 _isMarkdownView: true;
36}
37
38// ─── Obsidian Mock Infrastructure ─────────────────────────────────────
39
40type EventCallback = (...args: unknown[]) => unknown;
41
42class MockEvents {
43 private _handlers: Map<string, Set<EventCallback>> = new Map();
44
45 on(name: string, callback: EventCallback): { id: string } {
46 if (!this._handlers.has(name)) {
47 this._handlers.set(name, new Set());
48 }
49 this._handlers.get(name)!.add(callback);
50 return { id: `${name}-${Math.random()}` };
51 }
52
53 off(name: string, callback: EventCallback): void {
54 this._handlers.get(name)?.delete(callback);
55 }
56
57 offref(_ref: unknown): void {}
58
59 trigger(name: string, ...data: unknown[]): void {
60 const handlers = this._handlers.get(name);
61 if (handlers) {
62 for (const handler of handlers) {
63 handler(...data);
64 }
65 }
66 }
67}
68
69// ─── Module-level mock state ──────────────────────────────────────────
70
71let mockVault: MockEvents & {
72 cachedRead: ReturnType<typeof vi.fn>;
73 process: ReturnType<typeof vi.fn>;
74};
75
76let mockWorkspace: MockEvents & {
77 getActiveViewOfType: ReturnType<typeof vi.fn>;
78 getLeavesOfType: ReturnType<typeof vi.fn>;
79 getRightLeaf: ReturnType<typeof vi.fn>;
80 revealLeaf: ReturnType<typeof vi.fn>;
81};
82
83let mockApp: {
84 workspace: typeof mockWorkspace;
85 vault: typeof mockVault;
86};
87
88// Track plugin API calls
89let registeredViews: Array<{ type: string; factory: (leaf: unknown) => unknown }>;
90let registeredCommands: Array<{ id: string; name: string; callback: () => void }>;
91let ribbonIcons: Array<{ icon: string; title: string; callback: () => void }>;
92let registeredEvents: Array<{ id: string }>;
93
94// Mock MapViewerView
95let mockRefresh: ReturnType<typeof vi.fn>;
96let MockMapViewerViewInstances: Array<{ refresh: ReturnType<typeof vi.fn>; getViewType: () => string }>;
97
98// Mock leaf for sidebar
99let mockRightLeaf: {
100 view: unknown;
101 setViewState: ReturnType<typeof vi.fn>;
102};
103
104// ─── Helpers ──────────────────────────────────────────────────────────
105
106function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile {
107 return {
108 path,
109 name,
110 basename: name.replace(/\.md$/, ""),
111 extension: "md",
112 stat: { ctime: Date.now(), mtime: Date.now(), size: 100 },
113 parent: null,
114 vault: mockVault,
115 };
116}
117
118function createMockEditor(): MockEditor {
119 return {
120 getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }),
121 setCursor: vi.fn(),
122 scrollIntoView: vi.fn(),
123 };
124}
125
126function createMockMarkdownView(
127 editor: MockEditor,
128 file: MockTFile | null
129): MockMarkdownView {
130 return {
131 editor,
132 file,
133 getViewType: () => "markdown",
134 _isMarkdownView: true,
135 };
136}
137
138// ─── Setup / Teardown ─────────────────────────────────────────────────
139
140beforeEach(() => {
141 registeredViews = [];
142 registeredCommands = [];
143 ribbonIcons = [];
144 registeredEvents = [];
145 MockMapViewerViewInstances = [];
146 mockRefresh = vi.fn();
147
148 // Build vault mock
149 const vaultEvents = new MockEvents();
150 mockVault = Object.assign(vaultEvents, {
151 cachedRead: vi.fn().mockResolvedValue(""),
152 process: vi.fn().mockImplementation(
153 async (_file: MockTFile, fn: (data: string) => string) => fn("")
154 ),
155 });
156
157 // Build workspace mock
158 const workspaceEvents = new MockEvents();
159 mockWorkspace = Object.assign(workspaceEvents, {
160 getActiveViewOfType: vi.fn().mockReturnValue(null),
161 getLeavesOfType: vi.fn().mockReturnValue([]),
162 getRightLeaf: vi.fn(),
163 revealLeaf: vi.fn().mockResolvedValue(undefined),
164 });
165
166 // Build right leaf
167 mockRightLeaf = {
168 view: null,
169 setViewState: vi.fn().mockResolvedValue(undefined),
170 };
171 mockWorkspace.getRightLeaf.mockReturnValue(mockRightLeaf);
172
173 // Build app
174 mockApp = {
175 workspace: mockWorkspace,
176 vault: mockVault,
177 };
178
179 // ── Module mocks ──
180
181 vi.doMock("../src/mapView", () => {
182 class MapViewerView {
183 leaf: unknown;
184 refresh: ReturnType<typeof vi.fn>;
185 constructor(leaf: unknown) {
186 this.leaf = leaf;
187 this.refresh = mockRefresh;
188 MockMapViewerViewInstances.push(this);
189 }
190 getViewType() {
191 return "map-viewer";
192 }
193 }
194 return {
195 VIEW_TYPE: "map-viewer",
196 MapViewerView,
197 };
198 });
199
200 vi.doMock("obsidian", () => {
201 class MarkdownView {
202 static _isMarkdownView = true;
203 editor: MockEditor;
204 file: MockTFile | null;
205 constructor() {
206 this.editor = createMockEditor();
207 this.file = null;
208 }
209 getViewType() {
210 return "markdown";
211 }
212 }
213
214 class ItemView {
215 app: unknown;
216 leaf: unknown;
217 containerEl: HTMLElement;
218 contentEl: HTMLElement;
219
220 constructor(leaf: unknown) {
221 this.leaf = leaf;
222 this.app = mockApp;
223 this.containerEl = document.createElement("div");
224 this.contentEl = document.createElement("div");
225 this.containerEl.appendChild(this.contentEl);
226 }
227 getViewType() { return ""; }
228 getDisplayText() { return ""; }
229 getIcon() { return ""; }
230 register() {}
231 registerEvent(ref: { id: string }) {
232 registeredEvents.push(ref);
233 }
234 registerInterval() { return 0; }
235 addChild(_c: unknown) { return _c; }
236 removeChild(_c: unknown) { return _c; }
237 onload() {}
238 onunload() {}
239 }
240
241 class Notice {
242 message: string;
243 duration?: number;
244 noticeEl: HTMLElement;
245 constructor(message: string, duration?: number) {
246 this.message = message;
247 this.duration = duration;
248 this.noticeEl = document.createElement("div");
249 }
250 hide() {}
251 }
252
253 class Plugin {
254 app: unknown;
255 manifest: unknown;
256 constructor(app: unknown, manifest: unknown) {
257 this.app = app;
258 this.manifest = manifest;
259 }
260 registerView(type: string, factory: (leaf: unknown) => unknown) {
261 registeredViews.push({ type, factory });
262 }
263 addCommand(cmd: { id: string; name: string; callback: () => void }) {
264 registeredCommands.push(cmd);
265 return cmd;
266 }
267 addRibbonIcon(icon: string, title: string, callback: () => void) {
268 ribbonIcons.push({ icon, title, callback });
269 return document.createElement("div");
270 }
271 register() {}
272 registerEvent(ref: { id: string }) {
273 registeredEvents.push(ref);
274 }
275 registerInterval() { return 0; }
276 }
277
278 class TFile {
279 path = "";
280 name = "";
281 basename = "";
282 extension = "md";
283 stat = { ctime: 0, mtime: 0, size: 0 };
284 parent = null;
285 }
286
287 return {
288 ItemView,
289 MarkdownView,
290 Notice,
291 Plugin,
292 TFile,
293 };
294 });
295});
296
297afterEach(() => {
298 vi.restoreAllMocks();
299 vi.resetModules();
300});
301
302// ─── Import Helper ────────────────────────────────────────────────────
303
304async function importMain() {
305 const mod = await import("../src/main");
306 return mod;
307}
308
309async function createPlugin() {
310 const mod = await importMain();
311 const PluginClass = mod.default;
312 const plugin = new PluginClass(mockApp, { id: "map-viewer", name: "Map Viewer" });
313 // Ensure plugin.app is set (Plugin constructor should do this)
314 (plugin as any).app = mockApp;
315 return { plugin, mod };
316}
317
318async function createAndLoadPlugin() {
319 const { plugin, mod } = await createPlugin();
320 await plugin.onload();
321 return { plugin, mod };
322}
323
324// ─── Tests ────────────────────────────────────────────────────────────
325
326describe("main.ts — Plugin Entry", () => {
327 // ── Contract #1: View Registration ──
328
329 describe("Contract #1: View Registration", () => {
330 it("registers view type 'map-viewer' during onload", async () => {
331 await createAndLoadPlugin();
332
333 expect(registeredViews).toHaveLength(1);
334 expect(registeredViews[0].type).toBe("map-viewer");
335 });
336
337 it("view factory creates a MapViewerView instance, passing the leaf", async () => {
338 await createAndLoadPlugin();
339
340 const factory = registeredViews[0].factory;
341 const fakeleaf = { id: "test-leaf" };
342 const view = factory(fakeleaf);
343
344 expect(MockMapViewerViewInstances).toHaveLength(1);
345 expect(MockMapViewerViewInstances[0].leaf).toBe(fakeleaf);
346 expect(view).toBe(MockMapViewerViewInstances[0]);
347 });
348 });
349
350 // ── Contract #2: Ribbon Icon ──
351
352 describe("Contract #2: Ribbon Icon", () => {
353 it("adds a ribbon icon with 'map-pin' icon", async () => {
354 await createAndLoadPlugin();
355
356 expect(ribbonIcons).toHaveLength(1);
357 expect(ribbonIcons[0].icon).toBe("map-pin");
358 });
359
360 it("ribbon icon click calls activateView()", async () => {
361 await createAndLoadPlugin();
362
363 // No existing map leaves
364 mockWorkspace.getLeavesOfType.mockReturnValue([]);
365
366 await ribbonIcons[0].callback();
367
368 // Should try to find existing leaves first
369 expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer");
370 // Should create new leaf since none exists
371 expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false);
372 expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({
373 type: "map-viewer",
374 active: true,
375 });
376 expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf);
377 });
378 });
379
380 // ── Contract #3: Command Registration ──
381
382 describe("Contract #3: Command Registration", () => {
383 it("registers command with id 'open-map-view' and name 'Open map view'", async () => {
384 await createAndLoadPlugin();
385
386 expect(registeredCommands).toHaveLength(1);
387 expect(registeredCommands[0].id).toBe("open-map-view");
388 expect(registeredCommands[0].name).toBe("Open map view");
389 });
390
391 it("command callback calls activateView()", async () => {
392 await createAndLoadPlugin();
393
394 mockWorkspace.getLeavesOfType.mockReturnValue([]);
395
396 await registeredCommands[0].callback();
397
398 expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer");
399 expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false);
400 expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({
401 type: "map-viewer",
402 active: true,
403 });
404 });
405 });
406
407 // ── Contract #4: activateView() Singleton Logic ──
408
409 describe("Contract #4: activateView() — Singleton Logic", () => {
410 it("reveals existing leaf if map-viewer leaf already exists", async () => {
411 await createAndLoadPlugin();
412
413 const existingLeaf = { view: { getViewType: () => "map-viewer" } };
414 mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]);
415
416 await ribbonIcons[0].callback();
417
418 expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer");
419 expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf);
420 // Should NOT create a new leaf
421 expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled();
422 });
423
424 it("creates new right leaf if no map-viewer leaf exists", async () => {
425 await createAndLoadPlugin();
426
427 mockWorkspace.getLeavesOfType.mockReturnValue([]);
428
429 await ribbonIcons[0].callback();
430
431 expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false);
432 expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({
433 type: "map-viewer",
434 active: true,
435 });
436 expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf);
437 });
438 });
439
440 // ── Contract #5: active-leaf-change Event ──
441
442 describe("Contract #5: active-leaf-change Event", () => {
443 it("registers workspace active-leaf-change event", async () => {
444 await createAndLoadPlugin();
445
446 // The plugin should have registered an event handler for active-leaf-change
447 // We verify by triggering the event and checking behavior
448 const file = createMockFile();
449 const editor = createMockEditor();
450 const mdView = createMockMarkdownView(editor, file);
451 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
452
453 // Find the map-viewer leaf so we can get the view's refresh method
454 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
455 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
456
457 mockWorkspace.trigger("active-leaf-change", { view: mdView });
458
459 expect(mockRefresh).toHaveBeenCalled();
460 });
461
462 it("only processes event when new leaf is a MarkdownView", async () => {
463 await createAndLoadPlugin();
464
465 // Set up a map-viewer leaf with refresh
466 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
467 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
468
469 // Trigger with a non-MarkdownView leaf (e.g., the map sidebar itself)
470 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
471 mockWorkspace.trigger("active-leaf-change", {
472 view: { getViewType: () => "some-other-view" },
473 });
474
475 expect(mockRefresh).not.toHaveBeenCalled();
476 });
477
478 it("uses lastActiveFilePath to avoid redundant refreshes for same file", async () => {
479 await createAndLoadPlugin();
480
481 const file = createMockFile("notes.md", "notes.md");
482 const editor = createMockEditor();
483 const mdView = createMockMarkdownView(editor, file);
484 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
485
486 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
487 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
488
489 // First trigger — should call refresh
490 mockWorkspace.trigger("active-leaf-change", { view: mdView });
491 expect(mockRefresh).toHaveBeenCalledTimes(1);
492
493 // Second trigger with same file — should NOT call refresh (dedup)
494 mockWorkspace.trigger("active-leaf-change", { view: mdView });
495 expect(mockRefresh).toHaveBeenCalledTimes(1);
496 });
497
498 it("calls refresh when switching to a different file", async () => {
499 await createAndLoadPlugin();
500
501 const file1 = createMockFile("file1.md", "file1.md");
502 const file2 = createMockFile("file2.md", "file2.md");
503 const editor = createMockEditor();
504
505 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
506 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
507
508 // First file
509 const mdView1 = createMockMarkdownView(editor, file1);
510 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView1);
511 mockWorkspace.trigger("active-leaf-change", { view: mdView1 });
512 expect(mockRefresh).toHaveBeenCalledTimes(1);
513
514 // Different file — should call refresh
515 const mdView2 = createMockMarkdownView(editor, file2);
516 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView2);
517 mockWorkspace.trigger("active-leaf-change", { view: mdView2 });
518 expect(mockRefresh).toHaveBeenCalledTimes(2);
519 });
520 });
521
522 // ── Contract #6: vault modify Event ──
523
524 describe("Contract #6: vault modify Event", () => {
525 it("registers vault modify event", async () => {
526 await createAndLoadPlugin();
527
528 const file = createMockFile();
529 const editor = createMockEditor();
530 const mdView = createMockMarkdownView(editor, file);
531 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
532
533 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
534 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
535
536 // Trigger vault modify with the active file
537 mockVault.trigger("modify", file);
538
539 expect(mockRefresh).toHaveBeenCalled();
540 });
541
542 it("does not refresh if modified file is not the active file", async () => {
543 await createAndLoadPlugin();
544
545 const activeFile = createMockFile("active.md", "active.md");
546 const otherFile = createMockFile("other.md", "other.md");
547 const editor = createMockEditor();
548 const mdView = createMockMarkdownView(editor, activeFile);
549 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
550
551 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
552 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
553
554 // Trigger vault modify with a different file
555 mockVault.trigger("modify", otherFile);
556
557 expect(mockRefresh).not.toHaveBeenCalled();
558 });
559
560 it("does not refresh if no MarkdownView is active", async () => {
561 await createAndLoadPlugin();
562
563 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
564
565 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
566 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
567
568 const file = createMockFile();
569 mockVault.trigger("modify", file);
570
571 expect(mockRefresh).not.toHaveBeenCalled();
572 });
573 });
574
575 // ── Contract #7: onunload() ──
576
577 describe("Contract #7: onunload()", () => {
578 it("onunload exists and does not throw", async () => {
579 const { plugin } = await createAndLoadPlugin();
580
581 // Obsidian handles view deregistration automatically
582 // Just verify onunload doesn't throw
583 expect(() => plugin.onunload()).not.toThrow();
584 });
585 });
586
587 // ── Edge Cases ──
588
589 describe("Edge Cases", () => {
590 it("plugin loaded with no files open — view shows empty map (no errors)", async () => {
591 mockWorkspace.getActiveViewOfType.mockReturnValue(null);
592
593 // Should not throw
594 await createAndLoadPlugin();
595
596 // No refresh should have been called since there's no active view
597 // (The view itself handles empty state)
598 });
599
600 it("activateView called multiple times only reveals one leaf", async () => {
601 await createAndLoadPlugin();
602
603 mockWorkspace.getLeavesOfType.mockReturnValue([]);
604
605 // First activation creates a leaf
606 await ribbonIcons[0].callback();
607 expect(mockWorkspace.getRightLeaf).toHaveBeenCalledTimes(1);
608
609 // Now there's an existing leaf
610 const existingLeaf = { view: { getViewType: () => "map-viewer" } };
611 mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]);
612 mockWorkspace.getRightLeaf.mockClear();
613
614 // Second activation should reveal existing, not create new
615 await ribbonIcons[0].callback();
616 expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled();
617 expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf);
618 });
619
620 it("activateView handles getRightLeaf returning null gracefully", async () => {
621 await createAndLoadPlugin();
622
623 mockWorkspace.getLeavesOfType.mockReturnValue([]);
624 mockWorkspace.getRightLeaf.mockReturnValue(null);
625
626 // Should not throw
627 await ribbonIcons[0].callback();
628
629 // Should not try to call setViewState on null
630 expect(mockRightLeaf.setViewState).not.toHaveBeenCalled();
631 });
632
633 it("active-leaf-change with MarkdownView where file is null always refreshes (no dedup)", async () => {
634 await createAndLoadPlugin();
635
636 const editor = createMockEditor();
637 const mdViewNoFile = createMockMarkdownView(editor, null);
638 mockWorkspace.getActiveViewOfType.mockReturnValue(mdViewNoFile);
639
640 const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } };
641 mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]);
642
643 // First trigger with null file — should call refresh
644 mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile });
645 expect(mockRefresh).toHaveBeenCalledTimes(1);
646
647 // Second trigger with null file — should also refresh (no dedup for null paths)
648 mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile });
649 expect(mockRefresh).toHaveBeenCalledTimes(2);
650 });
651
652 it("active-leaf-change with MarkdownView but no map-viewer leaf does not crash", async () => {
653 await createAndLoadPlugin();
654
655 const file = createMockFile();
656 const editor = createMockEditor();
657 const mdView = createMockMarkdownView(editor, file);
658 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
659 mockWorkspace.getLeavesOfType.mockReturnValue([]); // no map view leaf
660
661 // Should not throw, and refresh should not be called (no view to refresh)
662 mockWorkspace.trigger("active-leaf-change", { view: mdView });
663 expect(mockRefresh).not.toHaveBeenCalled();
664 });
665
666 it("does not crash if map-viewer leaf exists but view has no refresh method", async () => {
667 await createAndLoadPlugin();
668
669 const file = createMockFile();
670 const editor = createMockEditor();
671 const mdView = createMockMarkdownView(editor, file);
672 mockWorkspace.getActiveViewOfType.mockReturnValue(mdView);
673
674 // Leaf exists but view has no refresh method (e.g., stale or initializing view)
675 const brokenLeaf = { view: { getViewType: () => "map-viewer" } };
676 mockWorkspace.getLeavesOfType.mockReturnValue([brokenLeaf]);
677
678 // Should not throw
679 mockWorkspace.trigger("active-leaf-change", { view: mdView });
680 expect(mockRefresh).not.toHaveBeenCalled();
681 });
682 });
683});