UNPKG

insta-toc

Version:

Simultaneously generate, update, and maintain a table of contents for your notes in real time.

254 lines (213 loc) 9.39 kB
import { App, TFile, type CachedMetadata, type Editor, type EventRef, type MarkdownView } from "obsidian"; import { describe, expect, test, vi } from "vitest"; import EditorService from "../src/editorService"; import { createEditor } from "./mocks/pluginClassMocks"; type EventCallback = (...args: unknown[]) => unknown; type TestViewMode = "preview" | "source" | "live-preview"; type Emitter = { on: ReturnType<typeof vi.fn>; emit: (name: string, ...args: unknown[]) => void; }; type ViewHarness = { rerender: ReturnType<typeof vi.fn>; view: MarkdownView; }; type Harness = { app: App; metadataEmitter: Emitter; registerEventHandle: (eventRef: EventRef) => void; registerEventSpy: ReturnType<typeof vi.fn>; setActiveFile: (path: string, cache?: CachedMetadata | null) => TFile; setEditor: (lines: string[]) => void; setViewMode: (mode: TestViewMode) => void; workspaceEmitter: Emitter; getActiveCache: () => CachedMetadata | null; getEditor: () => Editor; getRerenderSpy: () => ReturnType<typeof vi.fn>; getVaultContent: () => string; }; function createEmitter(): Emitter { const handlers = new Map<string, EventCallback>(); return { on: vi.fn((name: string, callback: EventCallback): EventRef => { handlers.set(name, callback); return { name, callback } as unknown as EventRef; }), emit: (name: string, ...args: unknown[]): void => { handlers.get(name)?.(...args); } }; } function createFile(path: string): TFile { return Object.assign(new TFile(), { path }); } function createView(editor: Editor, mode: TestViewMode): ViewHarness { const rerender = vi.fn(); return { rerender, view: Object.assign({} as MarkdownView, { get editor(): Editor { return editor; }, getState(): { mode: "preview" | "source"; source: boolean; } { if (mode === "preview") { return { mode: "preview", source: false }; } return { mode: "source", source: mode === "source" }; }, previewMode: { rerender } }) }; } function createHarness(initialMode: TestViewMode = "live-preview"): Harness { const app = new App(); const workspaceEmitter = createEmitter(); const metadataEmitter = createEmitter(); const registerEventSpy = vi.fn((eventRef: EventRef): void => { void eventRef; }); const registerEventHandle = (eventRef: EventRef): void => { registerEventSpy(eventRef); }; let activeFile = createFile("notes/current.md"); let activeCache: CachedMetadata | null = { headings: [] }; let activeEditor = createEditor([ "# Current heading" ]); let activeViewMode = initialMode; let viewHarness = createView(activeEditor, activeViewMode); let vaultContent = activeEditor.getValue(); app.workspace = { on: workspaceEmitter.on, getActiveFile: () => activeFile, getActiveViewOfType: () => viewHarness.view } as unknown as App["workspace"]; app.metadataCache = { on: metadataEmitter.on, getFileCache: vi.fn((file: TFile) => { return file.path === activeFile.path ? activeCache : null; }) } as unknown as App["metadataCache"]; app.vault = { process: vi.fn(async (_file: TFile, update: (content: string) => string) => { vaultContent = update(vaultContent); return vaultContent; }) } as unknown as App["vault"]; return { app: app as App, metadataEmitter, registerEventHandle, registerEventSpy, setActiveFile: (path: string, cache: CachedMetadata | null = activeCache): TFile => { activeFile = createFile(path); activeCache = cache; return activeFile; }, setEditor: (lines: string[]): void => { activeEditor = createEditor(lines); vaultContent = activeEditor.getValue(); viewHarness = createView(activeEditor, activeViewMode); }, setViewMode: (mode: TestViewMode): void => { activeViewMode = mode; viewHarness = createView(activeEditor, activeViewMode); }, workspaceEmitter, getActiveCache: (): CachedMetadata | null => activeCache, getEditor: (): Editor => activeEditor, getRerenderSpy: (): ReturnType<typeof vi.fn> => viewHarness.rerender, getVaultContent: (): string => vaultContent }; } describe("EditorService", () => { test("hydrates from the current workspace state during construction", () => { // Arrange const harness = createHarness(); // Act const service = new EditorService(harness.app, harness.registerEventHandle, []); // Assert expect(service.file?.path).toBe("notes/current.md"); expect(service.cache).toBe(harness.getActiveCache()); expect(service.editor).toBe(harness.getEditor()); expect(service.viewMode).toBe("live-preview"); expect(harness.registerEventSpy).toHaveBeenCalledTimes(3); }); test("runs built-in file-open synchronization before addon callbacks", () => { // Arrange const harness = createHarness(); const nextCache: CachedMetadata = { headings: [] }; const observed: Array<{ filePath: string | undefined; cache: CachedMetadata | null; }> = []; const service = new EditorService(harness.app, harness.registerEventHandle, [ { name: "file-open", callback: () => { observed.push({ filePath: service.file?.path, cache: service.cache }); } } ]); const nextFile = harness.setActiveFile("notes/next.md", nextCache); // Act harness.workspaceEmitter.emit("file-open", nextFile); // Assert expect(observed).toEqual([ { filePath: "notes/next.md", cache: nextCache } ]); }); test("registers metadata cache events on the metadata cache emitter", () => { // Arrange const harness = createHarness(); const changedSpy = vi.fn(); const changedCache: CachedMetadata = { headings: [] }; const file = harness.setActiveFile("notes/changed.md", changedCache); new EditorService(harness.app, harness.registerEventHandle, [ { name: "changed", callback: changedSpy } ]); // Act harness.metadataEmitter.emit("changed", file, "# body", changedCache); // Assert expect(harness.metadataEmitter.on).toHaveBeenCalledWith("changed", expect.any(Function)); expect(changedSpy).toHaveBeenCalledWith(file, "# body", changedCache); }); test("syncState accepts an explicit metadata cache override", () => { // Arrange const harness = createHarness(); const service = new EditorService(harness.app, harness.registerEventHandle, []); const overrideCache: CachedMetadata = { headings: [] }; // Act service.syncState(overrideCache); // Assert expect(service.cache).toBe(overrideCache); }); test("syncs state when the active leaf changes", () => { // Arrange const harness = createHarness(); const service = new EditorService(harness.app, harness.registerEventHandle, []); const nextCache: CachedMetadata = { headings: [] }; harness.setActiveFile("notes/other.md", nextCache); // Act harness.workspaceEmitter.emit("active-leaf-change", null); // Assert expect(service.file?.path).toBe("notes/other.md"); expect(service.cache).toBe(nextCache); }); test("syncs state when the workspace layout changes", () => { // Arrange const harness = createHarness(); const service = new EditorService(harness.app, harness.registerEventHandle, []); const nextCache: CachedMetadata = { headings: [] }; harness.setActiveFile("notes/layout.md", nextCache); // Act harness.workspaceEmitter.emit("layout-change"); // Assert expect(service.file?.path).toBe("notes/layout.md"); expect(service.cache).toBe(nextCache); }); test("applyEditorChange skips vault writes outside preview mode", async () => { // Arrange const harness = createHarness("live-preview"); const service = new EditorService(harness.app, harness.registerEventHandle, []); // Act await service.applyEditorChange({ line: 0, ch: 0 }, { line: 0, ch: 0 }, "Intro\n"); // Assert expect(harness.getEditor().getValue()).toContain("Intro"); expect(harness.getVaultContent()).not.toContain("Intro"); expect(harness.getRerenderSpy()).not.toHaveBeenCalled(); }); test("applyEditorChange mirrors updates into the vault in preview mode", async () => { // Arrange const harness = createHarness("preview"); const service = new EditorService(harness.app, harness.registerEventHandle, []); // Act await service.applyEditorChange({ line: 0, ch: 0 }, { line: 0, ch: 0 }, "Intro\n"); // Assert expect(harness.getVaultContent()).toContain("Intro"); expect(harness.getRerenderSpy()).toHaveBeenCalledWith(true); }); });