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