UNPKG

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
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; }