insta-toc
Version:
Simultaneously generate, update, and maintain a table of contents for your notes in real time.
377 lines (331 loc) • 12.9 kB
text/typescript
import {
TFile,
type CachedMetadata,
type Editor,
type EditorPosition,
type HeadingCache,
type MarkdownView
} from "obsidian";
import { vi, type Mock } from "vitest";
import type InstaTocPlugin from "../../src/Plugin";
import type EditorService from "../../src/editorService";
import type { PluginSettingsManager } from "../../src/settings/PluginSettingManager";
import { DEFAULT_SETTINGS, type InstaTocSettings } from "../../src/settings/Settings";
import type { FileKey, FoldKey, LocalTocSettings } from "../../src/types";
import UiStateManager from "../../src/uiStateManager";
import type { Validator } from "../../src/validator";
type TestViewMode = "preview" | "source" | "live-preview";
type EditorDispatchSpec = { changes: { from: number; to: number; insert: string; }; scrollIntoView?: boolean; };
type TestEditor = Editor & {
cm: {
dispatch: (spec: EditorDispatchSpec) => void;
posToOffset: (pos: EditorPosition) => number;
replaceRange: (replacement: string, from: EditorPosition, to: EditorPosition, origin?: string) => void;
};
getValue: () => string;
setValue: (content: string) => void;
getCursor: () => EditorPosition;
setCursor: (position: EditorPosition) => void;
posToOffset: (pos: EditorPosition) => number;
replaceRange: (replacement: string, from: EditorPosition, to: EditorPosition, origin?: string) => void;
};
type PersistedUiStateOverrides = {
tocFoldState?: Map<FoldKey, boolean> | Partial<Record<FoldKey, boolean>>;
tocBlockCollapseState?: Map<FileKey, boolean> | Partial<Record<FileKey, boolean>>;
};
function normalizePersistedUiStateMap<TKey extends string>(
input: Map<TKey, boolean> | Partial<Record<TKey, boolean>> | undefined
): Map<TKey, boolean> {
if (input instanceof Map) {
return new Map(input);
}
const state = new Map<TKey, boolean>();
for (const [ key, value ] of Object.entries(input ?? {})) {
if (typeof value === "boolean") {
state.set(key as TKey, value);
}
}
return state;
}
function getOffset(content: string, pos: EditorPosition): number {
const lines = content.split("\n");
let offset = 0;
for (let line = 0; line < pos.line; line += 1) {
offset += (lines[line] ?? "").length + 1;
}
return offset + pos.ch;
}
function replaceContent(content: string, replacement: string, from: EditorPosition, to: EditorPosition): string {
const startOffset = getOffset(content, from);
const endOffset = getOffset(content, to);
return content.slice(0, startOffset) + replacement + content.slice(endOffset);
}
function getEditorText(editor: Editor): string {
return (editor as Partial<TestEditor>).getValue?.() ?? "";
}
function setEditorText(editor: Editor, content: string): void {
(editor as Partial<TestEditor>).setValue?.(content);
}
function createFile(path: string): TFile {
return Object.assign(new TFile(), { path });
}
export function createUiStateManagerMock(
overrides: PersistedUiStateOverrides = {}
): { savePersistedDataSpy: Mock<() => Promise<undefined>>; uiStateManager: UiStateManager; } {
const savePersistedDataSpy = vi.fn(async () => undefined);
const settingsManager = { savePersistedData: savePersistedDataSpy } as unknown as PluginSettingsManager;
const uiStateManager = new UiStateManager(settingsManager);
uiStateManager.setPersistedUiState(
normalizePersistedUiStateMap(overrides.tocFoldState),
normalizePersistedUiStateMap(overrides.tocBlockCollapseState)
);
return { savePersistedDataSpy, uiStateManager };
}
function createEditorChangeApplier(getEditor: () => Editor) {
return async (from: EditorPosition, to: EditorPosition, insert: string): Promise<void> => {
const editor = getEditor();
const content = getEditorText(editor);
const updated = replaceContent(content, insert, from, to);
setEditorText(editor, updated);
};
}
export function createPluginMock(
overrides?: Partial<InstaTocPlugin["settings"]>,
initialEditor: Editor = createEditor([])
): {
plugin: InstaTocPlugin;
getCapturedContent: () => string;
setEditor: (editor: Editor) => void;
setMetadataCache: (cache: CachedMetadata | null) => void;
setActiveFilePath: (path: string) => void;
setViewMode: (viewMode: TestViewMode) => void;
} {
let activeEditor = initialEditor;
let activeFile = createFile("test.md");
let activeMetadataCache: CachedMetadata | null = null;
let activeViewMode: TestViewMode = "live-preview";
const { uiStateManager } = createUiStateManagerMock();
const activeView = Object.assign({} as MarkdownView, {
get data(): string {
return getEditorText(activeEditor);
},
get editor(): Editor {
return activeEditor;
},
getState(): { mode: "preview" | "source"; source: boolean; } {
if (activeViewMode === "preview") {
return { mode: "preview", source: false };
}
return { mode: "source", source: activeViewMode === "live-preview" };
},
previewMode: {
rerender: vi.fn(),
renderer: {
set: (text: string) => {
setEditorText(activeEditor, text);
}
}
}
});
const applyEditorChange = createEditorChangeApplier(() => activeEditor);
const workspace = {
activeEditor: {
get editor(): Editor {
return activeEditor;
},
set editor(editor: Editor) {
activeEditor = editor;
}
},
getActiveFile: () => activeFile,
getActiveViewOfType: () => activeView
};
const editorService = {
get editor(): Editor {
return workspace.activeEditor.editor;
},
get file(): TFile {
return workspace.getActiveFile();
},
get cache(): CachedMetadata | null {
return activeMetadataCache;
},
get view(): MarkdownView {
return workspace.getActiveViewOfType();
},
get viewMode(): TestViewMode {
return activeViewMode;
},
syncState: vi.fn((cache?: CachedMetadata | null) => {
if (cache !== undefined) {
activeMetadataCache = cache;
}
}),
applyEditorChange
} as unknown as EditorService;
const plugin = {
settings: { ...DEFAULT_SETTINGS, ...overrides },
consoleDebug: console.debug,
app: {
workspace,
metadataCache: {
getFileCache: vi.fn((file: TFile) => {
return file.path === activeFile.path ? activeMetadataCache : null;
}),
fileToLinktext: vi.fn((_file: TFile, path: string) => path.replace(/\.md$/u, ""))
},
vault: {
getAbstractFileByPath: vi.fn((path: string) => {
return path === activeFile.path
? activeFile
: createFile(path);
}),
process: vi.fn(async (_file: unknown, fn: (content: string) => string) => {
setEditorText(activeEditor, fn(getEditorText(activeEditor)));
}),
read: vi.fn(async () => getEditorText(activeEditor))
}
},
uiStateManager,
getViewState() {
return "live-preview";
},
get editor(): Editor {
return workspace.activeEditor.editor;
},
editorService,
applyEditorChange
} as unknown as InstaTocPlugin;
return {
plugin,
getCapturedContent: () => getEditorText(activeEditor),
setEditor: (editor: Editor) => {
workspace.activeEditor.editor = editor;
},
setMetadataCache: (cache: CachedMetadata | null) => {
activeMetadataCache = cache;
},
setActiveFilePath: (path: string) => {
activeFile = createFile(path);
},
setViewMode: (viewMode: TestViewMode) => {
activeViewMode = viewMode;
}
};
}
export function createTocModelPluginMock(
settingsOverrides: Partial<InstaTocSettings> = {},
persistedUiState: PersistedUiStateOverrides = {}
): {
app: InstaTocPlugin["app"];
fileToLinktextSpy: Mock<(_file: TFile, path: string) => string>;
getAbstractFileByPathSpy: Mock<(path: string) => TFile>;
pruneSpy: Mock<
(sourcePath: string, opts: { replacementFile?: FileKey; activeModernFoldKeys?: Set<FoldKey>; }) => void
>;
savePersistedDataSpy: Mock<() => Promise<undefined>>;
settings: InstaTocSettings;
uiStateManager: UiStateManager;
} {
const { savePersistedDataSpy, uiStateManager } = createUiStateManagerMock(persistedUiState);
const pruneSpy = vi.spyOn(uiStateManager, "pruneTocFoldStateForPath");
const getAbstractFileByPathSpy = vi.fn((path: string) => createFile(path));
const fileToLinktextSpy = vi.fn((_file: TFile, path: string) => path.replace(/\.md$/u, ""));
const settings: InstaTocSettings = { ...DEFAULT_SETTINGS, ...settingsOverrides };
const app = {
vault: { getAbstractFileByPath: getAbstractFileByPathSpy },
metadataCache: { fileToLinktext: fileToLinktextSpy }
} as unknown as InstaTocPlugin["app"];
return {
app,
fileToLinktextSpy,
getAbstractFileByPathSpy,
pruneSpy,
savePersistedDataSpy,
settings,
uiStateManager
};
}
export function createValidatorMock(
localTocSettings: LocalTocSettings,
fileHeadings: HeadingCache[]
): { validator: Validator; } {
const validator = {
localTocSettings,
fileHeadings,
tocInsertPos: { from: { line: 0, ch: 0 }, to: { line: 0, ch: 0 } },
insureLocalTocSetting(
settingKey: keyof LocalTocSettings,
subKeyOrCb?: string | ((value: unknown) => unknown),
cbOrDefault?: ((value: unknown) => unknown) | unknown,
defaultVal?: unknown
): unknown {
const value = localTocSettings[settingKey];
if (typeof subKeyOrCb === "function") {
if (value === null || value === undefined) {
return cbOrDefault !== undefined ? cbOrDefault : null;
}
return subKeyOrCb(value);
}
if (typeof subKeyOrCb === "string") {
const subValue = (value as Record<string, unknown>)?.[subKeyOrCb];
if (typeof cbOrDefault === "function") {
if (subValue === null || subValue === undefined) {
return defaultVal !== undefined ? defaultVal : null;
}
return cbOrDefault(subValue);
}
return subValue === undefined ? null : subValue;
}
return value ?? null;
}
} as unknown as Validator;
return { validator };
}
export function createEditor(lines: string[]): Editor {
let capturedContent = lines.join("\n");
let cursorPos: EditorPosition = { line: 0, ch: 0 };
const posToOffset = (pos: EditorPosition): number => {
return getOffset(capturedContent, pos);
};
const replaceRange = (replacement: string, from: EditorPosition, to: EditorPosition, _origin?: string): void => {
capturedContent = replaceContent(capturedContent, replacement, from, to);
};
const editor = {
cm: {
dispatch: (spec: EditorDispatchSpec) => {
capturedContent = [
capturedContent.slice(0, spec.changes.from),
spec.changes.insert,
capturedContent.slice(spec.changes.to)
]
.join("");
},
posToOffset,
replaceRange
},
getValue(): string {
return capturedContent;
},
setValue(content: string): void {
capturedContent = content;
},
getCursor(): EditorPosition {
return cursorPos;
},
setCursor(position: EditorPosition): void {
cursorPos = position;
},
posToOffset,
replaceRange,
getLine(line: number): string {
return capturedContent.split("\n")[line] ?? "";
},
getRange(from: EditorPosition, to: EditorPosition): string {
const startOffset = posToOffset(from);
const endOffset = posToOffset(to);
return capturedContent.slice(startOffset, endOffset);
}
} as unknown as TestEditor;
return editor as unknown as Editor;
}