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
text/typescript
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);
});
});