From eeeb7746683573470c5f434aee7a951c35080867 Mon Sep 17 00:00:00 2001 From: hjonasson Date: Fri, 17 Nov 2023 11:06:07 +1300 Subject: [PATCH] Remove reliance on great but complicated mocks --- __mocks__/obsidian.js | 1 + main.test.ts | 8 +- testHelpers/AppBuilder.ts | 174 ------------------------ testHelpers/FileBuilder.ts | 247 ---------------------------------- testHelpers/MockVault.ts | 265 ------------------------------------- 5 files changed, 3 insertions(+), 692 deletions(-) delete mode 100644 testHelpers/AppBuilder.ts delete mode 100644 testHelpers/FileBuilder.ts delete mode 100644 testHelpers/MockVault.ts diff --git a/__mocks__/obsidian.js b/__mocks__/obsidian.js index 1494721..1a6fb0d 100644 --- a/__mocks__/obsidian.js +++ b/__mocks__/obsidian.js @@ -13,6 +13,7 @@ module.exports = { Modal: class { }, PluginSettingTab: class { }, TFolder: class { }, + App: class { } }; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/main.test.ts b/main.test.ts index 44c0ff3..7202703 100644 --- a/main.test.ts +++ b/main.test.ts @@ -1,16 +1,13 @@ -import { MockAppBuilder } from "./testHelpers/AppBuilder"; import MyPlugin from "./main"; -import { PluginManifest } from "obsidian"; +import { App, PluginManifest } from "obsidian"; jest.mock("obsidian"); (window.setInterval as unknown) = jest.fn(); -const app = MockAppBuilder.make(); - describe("MyPlugin", () => { let plugin: MyPlugin; beforeEach(async () => { - plugin = new MyPlugin(app.done(), {} as PluginManifest); + plugin = new MyPlugin(new App(), {} as PluginManifest); }); it("Should register two open modal commands", async () => { @@ -35,7 +32,6 @@ describe("MyPlugin", () => { }); it("Should load data and save as settings", async () => { - const plugin = new MyPlugin(app.done(), {} as PluginManifest); const loadDataSpy = jest.spyOn(plugin, "loadData"); await plugin.onload(); expect(loadDataSpy).toHaveBeenCalled(); diff --git a/testHelpers/AppBuilder.ts b/testHelpers/AppBuilder.ts deleted file mode 100644 index 6ed417b..0000000 --- a/testHelpers/AppBuilder.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { - App, - CachedMetadata, - EventRef, - FileManager, - Keymap, - MetadataCache, - Scope, - TAbstractFile, - TFile, - TFolder, - UserEvent, - Workspace, -} from "obsidian"; -import { join } from "path"; -import { FileBuilder } from "./FileBuilder"; -import { MockVault } from "./MockVault"; - -export class MockCache implements MetadataCache { - private cache: Map; - - constructor(cache: Map) { - this.cache = cache; - } - - getCache(path: string): CachedMetadata | null { - return this.cache.get(join("/", path)) || null; - } - - getFileCache(file: TFile): CachedMetadata | null { - return this.getCache(file.path); - } - - // Below here is not implemented. - - getFirstLinkpathDest(linkpath: string, sourcePath: string): TFile | null { - throw new Error("Method not implemented."); - } - fileToLinktext( - file: TFile, - sourcePath: string, - omitMdExtension?: boolean | undefined - ): string { - throw new Error("Method not implemented."); - } - resolvedLinks: Record> = {}; - unresolvedLinks: Record> = {}; - on( - name: "changed", - callback: (file: TFile, data: string, cache: CachedMetadata) => unknown, - ctx?: unknown - ): EventRef; - on( - name: "deleted", - callback: (file: TFile, prevCache: CachedMetadata | null) => unknown, - ctx?: unknown - ): EventRef; - on( - name: "resolve", - callback: (file: TFile) => unknown, - ctx?: unknown - ): EventRef; - on(name: "resolved", callback: () => unknown, ctx?: unknown): EventRef; - on( - name: unknown, - callback: unknown, - ctx?: unknown - ): import("obsidian").EventRef { - throw new Error("Method not implemented."); - } - off(name: string, callback: (...data: unknown[]) => unknown): void { - throw new Error("Method not implemented."); - } - offref(ref: EventRef): void { - throw new Error("Method not implemented."); - } - trigger(name: string, ...data: unknown[]): void { - throw new Error("Method not implemented."); - } - tryTrigger(evt: EventRef, args: unknown[]): void { - throw new Error("Method not implemented."); - } -} - -export class MockApp implements App { - keymap: Keymap = {} as Keymap; - scope: Scope = {} as Scope; - workspace: Workspace = {} as Workspace; - lastEvent: UserEvent | null = null; - - fileManager: FileManager = {} as FileManager; - metadataCache: MetadataCache; - vault: MockVault; - - constructor(vault: MockVault, cache: MockCache) { - this.vault = vault; - this.metadataCache = cache; - } -} - -interface FileTree { - [key: string]: { t: "file"; v: T } | { t: "folder"; v: FileTree }; -} - -function toPathMap(tree: FileTree): Map { - const recurse = (t: FileTree, path: string): [string, T][] => - Object.entries(t).flatMap(([name, v]) => - v.t === "file" - ? [[join(path, name), v.v]] - : recurse(v.v, join(path, name)) - ); - return new Map(recurse(tree, "/")); -} - -export class MockAppBuilder { - children: TAbstractFile[]; - metadata: FileTree; - contents: FileTree; - path: string; - - static make() { - return new MockAppBuilder("/", [], {}, {}); - } - - constructor( - path: string, - children: TAbstractFile[] = [], - contents: FileTree = {}, - metadata: FileTree = {} - ) { - this.path = join("/", path); - this.children = children; - this.metadata = metadata; - this.contents = contents; - } - - file(filename: string, builder: FileBuilder): MockAppBuilder { - const file = new TFile(); - file.name = filename; - - const [contents, metadata] = builder.done(); - - return new MockAppBuilder( - this.path, - [...this.children, file], - { ...this.contents, [filename]: { t: "file", v: contents } }, - { ...this.metadata, [filename]: { t: "file", v: metadata } } - ); - } - - folder(f: MockAppBuilder) { - return new MockAppBuilder( - this.path, - [...this.children, f.makeFolder()], - { ...this.contents, [f.path]: { t: "folder", v: f.contents } }, - { ...this.metadata, [f.path]: { t: "folder", v: f.metadata } } - ); - } - - private makeFolder(): TFolder { - const folder = new TFolder(); - folder.name = this.path; - this.children.forEach((f) => (f.parent = folder)); - folder.children = [...this.children]; - return folder; - } - - done(): MockApp { - return new MockApp( - new MockVault(this.makeFolder(), toPathMap(this.contents)), - new MockCache(toPathMap(this.metadata)) - ); - } -} diff --git a/testHelpers/FileBuilder.ts b/testHelpers/FileBuilder.ts deleted file mode 100644 index 7ba29bb..0000000 --- a/testHelpers/FileBuilder.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { CachedMetadata, ListItemCache, Loc, Pos } from "obsidian"; - -type ListItem = { - type: "item"; - text: string; - checkbox: "x" | " " | undefined; -}; - -type ListBlock = { - type: "list"; - items: (ListItem | ListBlock)[]; -}; - -type ListBlockItem = { - type: "listitem"; - text: string; - checkbox: " " | "x" | undefined; - depth: number; - parent: number; // TODO: Higher-level helper to build full lists to properly set the parent. -}; - -const makeListItems = ( - list: ListBlock, - startingLine: number, - depth = 0 -): ListBlockItem[] => { - if (startingLine === 0) { - // Special case from Obsidian. My guess is that plugins can check for nested - // lists with `parent > 0` rather than `parent >= 0`. - startingLine = 1; - } - const lines: ListBlockItem[] = []; - for (const i of list.items) { - if (i.type === "item") { - lines.push({ - type: "listitem", - text: i.text, - checkbox: i.checkbox, - depth, - parent: depth === 0 ? -startingLine : startingLine, - }); - } else { - lines.push( - ...makeListItems(i, startingLine + lines.length - 1, depth + 1) - ); - } - } - return lines; -}; - -type FileBlock = - | { - type: "heading"; - text: string; - level: number; - } - | ListBlock - | { type: "frontmatter"; key: string; text: string } - | { type: "text"; text: string }; - -const makeFile = ( - lines: FileBlock[], - tabChars = " ".repeat(4) -): [string, CachedMetadata] => { - let lineNum = 0; - let content = ""; - - const appendLine = (line: string): Pos => { - const start: Loc = { line: lineNum, col: 0, offset: content.length }; - content += `${line}\n`; - const end: Loc = { - line: lineNum++, - col: line.length, // Columns are 0-indexed - offset: content.length - 1, // Account for the newline and 0-indexing. - }; - return { start, end }; - }; - - const meta: CachedMetadata = {}; - - const frontmatter = lines.flatMap((l) => - l.type === "frontmatter" ? l : [] - ); - - if (frontmatter.length > 0) { - const data: { [key: string]: unknown } = {}; - const { start } = appendLine("---"); - for (const elt of frontmatter) { - if (Array.isArray(elt.text)) { - appendLine(`${elt.key}: [${elt.text}]`); - } else { - appendLine(`${elt.key}: ${elt.text}`); - } - data[elt.key] = elt.text; - } - const { end } = appendLine("---"); - meta.frontmatter = { - position: { start, end }, - ...data, - }; - } - - const blocks = lines.flatMap((l) => (l.type !== "frontmatter" ? l : [])); - for (const block of blocks) { - switch (block.type) { - case "heading": { - if (!meta.headings) { - meta.headings = []; - } - const position = appendLine( - "#".repeat(block.level) + " " + block.text - ); - meta.headings.push({ - position, - heading: block.text, - level: block.level, - }); - continue; - } - - case "list": { - if (!meta.listItems) { - meta.listItems = []; - } - for (const item of makeListItems(block, lineNum)) { - const indent = tabChars.repeat(item.depth); - const position = appendLine( - indent + - "- " + - (item.checkbox ? `[${item.checkbox}] ` : "") + - item.text - ); - if (indent.length > 0) { - position.start.col += indent.length - 2; - position.start.offset += indent.length - 2; - } - - const listItem: ListItemCache = { - position, - parent: item.parent, - }; - if (item.checkbox) { - listItem["task"] = item.checkbox; - } - - meta.listItems.push(listItem); - } - - continue; - } - - case "text": - appendLine(block.text); - } - } - - return [content, meta]; -}; - -/** - * Build up lists that can be consumed by the FileBuilder. - */ -export class ListBuilder { - items: (ListItem | ListBlock)[] = []; - - constructor(items: (ListItem | ListBlock)[] = []) { - this.items = items; - } - - item(text: string, checkbox?: boolean | undefined) { - const i: ListItem = { - type: "item", - text, - checkbox: - checkbox === true ? "x" : checkbox === false ? " " : undefined, - }; - return new ListBuilder([...this.items, i]); - } - - list(lb: ListBuilder) { - return new ListBuilder([...this.items, lb.done()]); - } - - done(): ListBlock { - return { type: "list", items: this.items }; - } -} - -/** - * Build up file contents and metadata entries simultaneously. Basically a reverse-parser - * for metadata. Construct files line-by-line and metadata that *would* be parsed by - * Obsidian is generated at the same time! - */ -export class FileBuilder { - private lines: FileBlock[]; - - constructor(lines: FileBlock[] = []) { - this.lines = lines; - } - - /** - * Add frontmatter to the file. - * @param frontmatter Dictionary representing YAML frontmatter. - * @returns an updated FileBuilder - */ - frontmatter(frontmatter: Record): FileBuilder { - const frontmatterLines = Object.entries(frontmatter).map( - ([k, v]): FileBlock => ({ type: "frontmatter", key: k, text: v }) - ); - return new FileBuilder([...this.lines, ...frontmatterLines]); - } - - /** - * Add a heading to the file. - * @param level Heading level (h1, h2, etc.) - * @param text Text for heading - * @returns an updated FileBuilder. - */ - heading(level: number, text: string): FileBuilder { - return new FileBuilder([ - ...this.lines, - { type: "heading", level, text }, - ]); - } - - /** - * Add a plain text paragraph to the file. - * @param text Text body - * @returns an updated FileBuilder - */ - text(text: string): FileBuilder { - return new FileBuilder([...this.lines, { type: "text", text }]); - } - - /** - * Add a list to the file. - * @param list a ListBuilder - * @returns an updated FileBuilder - */ - list(list: ListBuilder): FileBuilder { - return new FileBuilder([...this.lines, list.done()]); - } - - done() { - return makeFile(this.lines); - } -} diff --git a/testHelpers/MockVault.ts b/testHelpers/MockVault.ts deleted file mode 100644 index 3bad52d..0000000 --- a/testHelpers/MockVault.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { - DataAdapter, - DataWriteOptions, - EventRef, - TAbstractFile, - TFile, - TFolder, - Vault, -} from "obsidian"; -import { basename, dirname, join, normalize } from "path"; - -/** - * Return all files that exist under a given folder. - * @param folder Folder to collect children under. - * @returns All files under this folder, recursively. - */ -const collectChildren = (folder: TFolder): TAbstractFile[] => { - return folder.children.flatMap((f) => { - if (f instanceof TFolder) { - return [f, ...collectChildren(f)]; - } else { - return f; - } - }); -}; - -export class MockVault implements Vault { - root: TFolder; - contents: Map; - - constructor(root: TFolder, contents: Map) { - this.root = root; - this.contents = contents; - } - - // These aren't implemented in the mock. - adapter: DataAdapter = {} as DataAdapter; - configDir = ""; - - getName(): string { - return "Mock Vault"; - } - - getAllLoadedFiles(): TAbstractFile[] { - return [this.root, ...collectChildren(this.root)]; - } - - getAbstractFileByPath(path: string): TAbstractFile | null { - const normalizedPath = join("/", normalize(path)); - return ( - this.getAllLoadedFiles().find( - (f) => join("/", normalize(f.path)) === normalizedPath - ) || null - ); - } - getRoot(): TFolder { - return this.root; - } - async read(file: TFile): Promise { - const p = join("/", file.path); - const contents = this.contents.get(p); - if (!contents) { - throw new Error(`File at path ${p} does not have contents`); - } - return contents; - } - cachedRead(file: TFile): Promise { - return this.read(file); - } - - getFiles(): TFile[] { - return this.getAllLoadedFiles().flatMap((f) => - f instanceof TFile ? f : [] - ); - } - - getMarkdownFiles(): TFile[] { - return this.getFiles().filter( - (f) => f.extension.toLowerCase() === "md" - ); - } - - private setParent(path: string, f: TAbstractFile) { - const parentPath = dirname(path); - const folder = this.getAbstractFileByPath(parentPath); - if (folder instanceof TFolder) { - f.parent = folder; - folder.children.push(f); - } - throw new Error("Parent path is not folder."); - } - - process( - file: TFile, - fn: (data: string) => string, - options?: DataWriteOptions | undefined - ): Promise { - throw new Error("Method not implemented."); - } - - async create( - path: string, - data: string, - options?: DataWriteOptions | undefined - ): Promise { - if (this.getAbstractFileByPath(path)) { - throw new Error("File already exists."); - } - const file = new TFile(); - file.name = basename(path); - this.setParent(path, file); - this.contents.set(path, data); - return file; - } - async createFolder(path: string): Promise { - const folder = new TFolder(); - folder.name = basename(path); - this.setParent(path, folder); - return folder; - } - async delete( - file: TAbstractFile, - force?: boolean | undefined - ): Promise { - file.parent?.children.remove(file); - } - trash(file: TAbstractFile, system: boolean): Promise { - return this.delete(file); - } - - async rename(file: TAbstractFile, newPath: string): Promise { - const newParentPath = dirname(newPath); - const newParent = this.getAbstractFileByPath(newParentPath); - if (!(newParent instanceof TFolder)) { - throw new Error(`No such folder: ${newParentPath}`); - } - - if (file instanceof TFile) { - // If we're renaming a file, just update the parent and name in the - // file, and the entry in the content map. - const contents = this.contents.get(file.path); - if (!contents) { - throw new Error(`File did not have contents: ${file.path}`); - } - this.contents.delete(file.path); - - // Update the parent and name and re-set contents with the new path. - // NOTE: This relies on using the included mock that derives the path - // from the parent and filename as a getter property. - file.parent = newParent; - file.name = basename(newPath); - this.contents.set(file.path, contents); - } else if (file instanceof TFolder) { - // If we're renaming a folder, we need to update the content map for - // every TFile under this folder. - - // Collect all files under this folder, get the string contents, delete - // the entry for the old path, and return the file and contents in a tuple. - const filesAndContents = collectChildren(file) - .flatMap((f) => (f instanceof TFile ? f : [])) - .map((f): [TFile, string] => { - const contents = this.contents.get(f.path); - if (!contents) { - throw new Error( - `File did not have contents: ${f.path}` - ); - } - this.contents.delete(f.path); - return [f, contents]; - }); - - // Update the parent and name for this folder. - file.parent = newParent; - file.name = basename(newPath); - - // Re-add all the paths to the content dir. - for (const [f, contents] of filesAndContents) { - this.contents.set(f.path, contents); - } - } else { - throw new Error(`File is not a file or folder: ${file.path}`); - } - } - - async modify( - file: TFile, - data: string, - options?: DataWriteOptions | undefined - ): Promise { - this.contents.set(file.path, data); - } - - async copy(file: TFile, newPath: string): Promise { - const data = await this.read(file); - return await this.create(newPath, data); - } - - // TODO: Implement callbacks. - on( - name: "create", - callback: (file: TAbstractFile) => unknown, - ctx?: unknown - ): EventRef; - on( - name: "modify", - callback: (file: TAbstractFile) => unknown, - ctx?: unknown - ): EventRef; - on( - name: "delete", - callback: (file: TAbstractFile) => unknown, - ctx?: unknown - ): EventRef; - on( - name: "rename", - callback: (file: TAbstractFile, oldPath: string) => unknown, - ctx?: unknown - ): EventRef; - on(name: "closed", callback: () => unknown, ctx?: unknown): EventRef; - on(name: unknown, callback: unknown, ctx?: unknown): EventRef { - throw new Error("Method not implemented."); - } - off(name: string, callback: (...data: unknown[]) => unknown): void { - throw new Error("Method not implemented."); - } - offref(ref: EventRef): void { - throw new Error("Method not implemented."); - } - trigger(name: string, ...data: unknown[]): void { - throw new Error("Method not implemented."); - } - tryTrigger(evt: EventRef, args: unknown[]): void { - throw new Error("Method not implemented."); - } - append( - file: TFile, - data: string, - options?: DataWriteOptions | undefined - ): Promise { - throw new Error("Method not implemented."); - } - - createBinary( - path: string, - data: ArrayBuffer, - options?: DataWriteOptions | undefined - ): Promise { - throw new Error("Method not implemented."); - } - readBinary(file: TFile): Promise { - throw new Error("Method not implemented."); - } - - modifyBinary( - file: TFile, - data: ArrayBuffer, - options?: DataWriteOptions | undefined - ): Promise { - throw new Error("Method not implemented."); - } - - getResourcePath(file: TFile): string { - throw new Error("Method not implemented."); - } -}