/** * main.test.ts — Tests for all main.ts behavioral contracts * * Mocks Obsidian Plugin API (registerView, addRibbonIcon, addCommand, * workspace.getRightLeaf, workspace.on, vault.on), MapViewerView, and * tests view registration, activateView singleton logic, active-leaf-change * filtering for MarkdownView, vault modify event wiring, and lastActiveFilePath dedup. * * @vitest-environment jsdom */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ─── Mock Types ─────────────────────────────────────────────────────── interface MockEditor { getCursor: ReturnType; setCursor: ReturnType; scrollIntoView: ReturnType; } interface MockTFile { path: string; name: string; basename: string; extension: string; stat: { ctime: number; mtime: number; size: number }; parent: null; vault: unknown; } interface MockMarkdownView { editor: MockEditor; file: MockTFile | null; getViewType: () => string; _isMarkdownView: true; } // ─── Obsidian Mock Infrastructure ───────────────────────────────────── type EventCallback = (...args: unknown[]) => unknown; class MockEvents { private _handlers: Map> = new Map(); on(name: string, callback: EventCallback): { id: string } { if (!this._handlers.has(name)) { this._handlers.set(name, new Set()); } this._handlers.get(name)!.add(callback); return { id: `${name}-${Math.random()}` }; } off(name: string, callback: EventCallback): void { this._handlers.get(name)?.delete(callback); } offref(_ref: unknown): void {} trigger(name: string, ...data: unknown[]): void { const handlers = this._handlers.get(name); if (handlers) { for (const handler of handlers) { handler(...data); } } } } // ─── Module-level mock state ────────────────────────────────────────── let mockVault: MockEvents & { cachedRead: ReturnType; process: ReturnType; }; let mockWorkspace: MockEvents & { getActiveViewOfType: ReturnType; getLeavesOfType: ReturnType; getRightLeaf: ReturnType; revealLeaf: ReturnType; }; let mockApp: { workspace: typeof mockWorkspace; vault: typeof mockVault; }; // Track plugin API calls let registeredViews: Array<{ type: string; factory: (leaf: unknown) => unknown }>; let registeredCommands: Array<{ id: string; name: string; callback: () => void }>; let ribbonIcons: Array<{ icon: string; title: string; callback: () => void }>; let registeredEvents: Array<{ id: string }>; // Mock MapViewerView let mockRefresh: ReturnType; let MockMapViewerViewInstances: Array<{ refresh: ReturnType; getViewType: () => string }>; // Mock leaf for sidebar let mockRightLeaf: { view: unknown; setViewState: ReturnType; }; // ─── Helpers ────────────────────────────────────────────────────────── 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, }; } // ─── Setup / Teardown ───────────────────────────────────────────────── beforeEach(() => { registeredViews = []; registeredCommands = []; ribbonIcons = []; registeredEvents = []; MockMapViewerViewInstances = []; mockRefresh = vi.fn(); // 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) => fn("") ), }); // Build workspace mock const workspaceEvents = new MockEvents(); mockWorkspace = Object.assign(workspaceEvents, { getActiveViewOfType: vi.fn().mockReturnValue(null), getLeavesOfType: vi.fn().mockReturnValue([]), getRightLeaf: vi.fn(), revealLeaf: vi.fn().mockResolvedValue(undefined), }); // Build right leaf mockRightLeaf = { view: null, setViewState: vi.fn().mockResolvedValue(undefined), }; mockWorkspace.getRightLeaf.mockReturnValue(mockRightLeaf); // Build app mockApp = { workspace: mockWorkspace, vault: mockVault, }; // ── Module mocks ── vi.doMock("../src/mapView", () => { class MapViewerView { leaf: unknown; refresh: ReturnType; constructor(leaf: unknown) { this.leaf = leaf; this.refresh = mockRefresh; MockMapViewerViewInstances.push(this); } getViewType() { return "map-viewer"; } } return { VIEW_TYPE: "map-viewer", MapViewerView, }; }); vi.doMock("obsidian", () => { 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() {} registerEvent(ref: { id: string }) { registeredEvents.push(ref); } registerInterval() { return 0; } 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"); } hide() {} } class Plugin { app: unknown; manifest: unknown; constructor(app: unknown, manifest: unknown) { this.app = app; this.manifest = manifest; } registerView(type: string, factory: (leaf: unknown) => unknown) { registeredViews.push({ type, factory }); } addCommand(cmd: { id: string; name: string; callback: () => void }) { registeredCommands.push(cmd); return cmd; } addRibbonIcon(icon: string, title: string, callback: () => void) { ribbonIcons.push({ icon, title, callback }); return document.createElement("div"); } register() {} registerEvent(ref: { id: string }) { registeredEvents.push(ref); } 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.restoreAllMocks(); vi.resetModules(); }); // ─── Import Helper ──────────────────────────────────────────────────── async function importMain() { const mod = await import("../src/main"); return mod; } async function createPlugin() { const mod = await importMain(); const PluginClass = mod.default; const plugin = new PluginClass(mockApp, { id: "map-viewer", name: "Map Viewer" }); // Ensure plugin.app is set (Plugin constructor should do this) (plugin as any).app = mockApp; return { plugin, mod }; } async function createAndLoadPlugin() { const { plugin, mod } = await createPlugin(); await plugin.onload(); return { plugin, mod }; } // ─── Tests ──────────────────────────────────────────────────────────── describe("main.ts — Plugin Entry", () => { // ── Contract #1: View Registration ── describe("Contract #1: View Registration", () => { it("registers view type 'map-viewer' during onload", async () => { await createAndLoadPlugin(); expect(registeredViews).toHaveLength(1); expect(registeredViews[0].type).toBe("map-viewer"); }); it("view factory creates a MapViewerView instance, passing the leaf", async () => { await createAndLoadPlugin(); const factory = registeredViews[0].factory; const fakeleaf = { id: "test-leaf" }; const view = factory(fakeleaf); expect(MockMapViewerViewInstances).toHaveLength(1); expect(MockMapViewerViewInstances[0].leaf).toBe(fakeleaf); expect(view).toBe(MockMapViewerViewInstances[0]); }); }); // ── Contract #2: Ribbon Icon ── describe("Contract #2: Ribbon Icon", () => { it("adds a ribbon icon with 'map-pin' icon", async () => { await createAndLoadPlugin(); expect(ribbonIcons).toHaveLength(1); expect(ribbonIcons[0].icon).toBe("map-pin"); }); it("ribbon icon click calls activateView()", async () => { await createAndLoadPlugin(); // No existing map leaves mockWorkspace.getLeavesOfType.mockReturnValue([]); await ribbonIcons[0].callback(); // Should try to find existing leaves first expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); // Should create new leaf since none exists expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ type: "map-viewer", active: true, }); expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); }); }); // ── Contract #3: Command Registration ── describe("Contract #3: Command Registration", () => { it("registers command with id 'open-map-view' and name 'Open map view'", async () => { await createAndLoadPlugin(); expect(registeredCommands).toHaveLength(1); expect(registeredCommands[0].id).toBe("open-map-view"); expect(registeredCommands[0].name).toBe("Open map view"); }); it("command callback calls activateView()", async () => { await createAndLoadPlugin(); mockWorkspace.getLeavesOfType.mockReturnValue([]); await registeredCommands[0].callback(); expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ type: "map-viewer", active: true, }); }); }); // ── Contract #4: activateView() Singleton Logic ── describe("Contract #4: activateView() — Singleton Logic", () => { it("reveals existing leaf if map-viewer leaf already exists", async () => { await createAndLoadPlugin(); const existingLeaf = { view: { getViewType: () => "map-viewer" } }; mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); await ribbonIcons[0].callback(); expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); // Should NOT create a new leaf expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); }); it("creates new right leaf if no map-viewer leaf exists", async () => { await createAndLoadPlugin(); mockWorkspace.getLeavesOfType.mockReturnValue([]); await ribbonIcons[0].callback(); expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ type: "map-viewer", active: true, }); expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); }); }); // ── Contract #5: active-leaf-change Event ── describe("Contract #5: active-leaf-change Event", () => { it("registers workspace active-leaf-change event", async () => { await createAndLoadPlugin(); // The plugin should have registered an event handler for active-leaf-change // We verify by triggering the event and checking behavior const file = createMockFile(); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, file); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); // Find the map-viewer leaf so we can get the view's refresh method const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); mockWorkspace.trigger("active-leaf-change", { view: mdView }); expect(mockRefresh).toHaveBeenCalled(); }); it("only processes event when new leaf is a MarkdownView", async () => { await createAndLoadPlugin(); // Set up a map-viewer leaf with refresh const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // Trigger with a non-MarkdownView leaf (e.g., the map sidebar itself) mockWorkspace.getActiveViewOfType.mockReturnValue(null); mockWorkspace.trigger("active-leaf-change", { view: { getViewType: () => "some-other-view" }, }); expect(mockRefresh).not.toHaveBeenCalled(); }); it("uses lastActiveFilePath to avoid redundant refreshes for same file", async () => { await createAndLoadPlugin(); const file = createMockFile("notes.md", "notes.md"); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, file); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // First trigger — should call refresh mockWorkspace.trigger("active-leaf-change", { view: mdView }); expect(mockRefresh).toHaveBeenCalledTimes(1); // Second trigger with same file — should NOT call refresh (dedup) mockWorkspace.trigger("active-leaf-change", { view: mdView }); expect(mockRefresh).toHaveBeenCalledTimes(1); }); it("calls refresh when switching to a different file", async () => { await createAndLoadPlugin(); const file1 = createMockFile("file1.md", "file1.md"); const file2 = createMockFile("file2.md", "file2.md"); const editor = createMockEditor(); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // First file const mdView1 = createMockMarkdownView(editor, file1); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView1); mockWorkspace.trigger("active-leaf-change", { view: mdView1 }); expect(mockRefresh).toHaveBeenCalledTimes(1); // Different file — should call refresh const mdView2 = createMockMarkdownView(editor, file2); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView2); mockWorkspace.trigger("active-leaf-change", { view: mdView2 }); expect(mockRefresh).toHaveBeenCalledTimes(2); }); }); // ── Contract #6: vault modify Event ── describe("Contract #6: vault modify Event", () => { it("registers vault modify event", async () => { await createAndLoadPlugin(); const file = createMockFile(); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, file); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // Trigger vault modify with the active file mockVault.trigger("modify", file); expect(mockRefresh).toHaveBeenCalled(); }); it("does not refresh if modified file is not the active file", async () => { await createAndLoadPlugin(); const activeFile = createMockFile("active.md", "active.md"); const otherFile = createMockFile("other.md", "other.md"); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, activeFile); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // Trigger vault modify with a different file mockVault.trigger("modify", otherFile); expect(mockRefresh).not.toHaveBeenCalled(); }); it("does not refresh if no MarkdownView is active", async () => { await createAndLoadPlugin(); mockWorkspace.getActiveViewOfType.mockReturnValue(null); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); const file = createMockFile(); mockVault.trigger("modify", file); expect(mockRefresh).not.toHaveBeenCalled(); }); }); // ── Contract #7: onunload() ── describe("Contract #7: onunload()", () => { it("onunload exists and does not throw", async () => { const { plugin } = await createAndLoadPlugin(); // Obsidian handles view deregistration automatically // Just verify onunload doesn't throw expect(() => plugin.onunload()).not.toThrow(); }); }); // ── Edge Cases ── describe("Edge Cases", () => { it("plugin loaded with no files open — view shows empty map (no errors)", async () => { mockWorkspace.getActiveViewOfType.mockReturnValue(null); // Should not throw await createAndLoadPlugin(); // No refresh should have been called since there's no active view // (The view itself handles empty state) }); it("activateView called multiple times only reveals one leaf", async () => { await createAndLoadPlugin(); mockWorkspace.getLeavesOfType.mockReturnValue([]); // First activation creates a leaf await ribbonIcons[0].callback(); expect(mockWorkspace.getRightLeaf).toHaveBeenCalledTimes(1); // Now there's an existing leaf const existingLeaf = { view: { getViewType: () => "map-viewer" } }; mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); mockWorkspace.getRightLeaf.mockClear(); // Second activation should reveal existing, not create new await ribbonIcons[0].callback(); expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); }); it("activateView handles getRightLeaf returning null gracefully", async () => { await createAndLoadPlugin(); mockWorkspace.getLeavesOfType.mockReturnValue([]); mockWorkspace.getRightLeaf.mockReturnValue(null); // Should not throw await ribbonIcons[0].callback(); // Should not try to call setViewState on null expect(mockRightLeaf.setViewState).not.toHaveBeenCalled(); }); it("active-leaf-change with MarkdownView where file is null always refreshes (no dedup)", async () => { await createAndLoadPlugin(); const editor = createMockEditor(); const mdViewNoFile = createMockMarkdownView(editor, null); mockWorkspace.getActiveViewOfType.mockReturnValue(mdViewNoFile); const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); // First trigger with null file — should call refresh mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); expect(mockRefresh).toHaveBeenCalledTimes(1); // Second trigger with null file — should also refresh (no dedup for null paths) mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); expect(mockRefresh).toHaveBeenCalledTimes(2); }); it("active-leaf-change with MarkdownView but no map-viewer leaf does not crash", async () => { await createAndLoadPlugin(); const file = createMockFile(); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, file); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); mockWorkspace.getLeavesOfType.mockReturnValue([]); // no map view leaf // Should not throw, and refresh should not be called (no view to refresh) mockWorkspace.trigger("active-leaf-change", { view: mdView }); expect(mockRefresh).not.toHaveBeenCalled(); }); it("does not crash if map-viewer leaf exists but view has no refresh method", async () => { await createAndLoadPlugin(); const file = createMockFile(); const editor = createMockEditor(); const mdView = createMockMarkdownView(editor, file); mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); // Leaf exists but view has no refresh method (e.g., stale or initializing view) const brokenLeaf = { view: { getViewType: () => "map-viewer" } }; mockWorkspace.getLeavesOfType.mockReturnValue([brokenLeaf]); // Should not throw mockWorkspace.trigger("active-leaf-change", { view: mdView }); expect(mockRefresh).not.toHaveBeenCalled(); }); }); });