From bc768afb555acb1738f2738f73362c9c88f4015f Mon Sep 17 00:00:00 2001 From: hjonasson Date: Fri, 17 Nov 2023 10:51:58 +1300 Subject: [PATCH] Steal mocks from obsidian-full-calendar --- test_helpers/AppBuilder.ts | 171 +++++++++++++++++++++++ test_helpers/FileBuilder.ts | 247 +++++++++++++++++++++++++++++++++ test_helpers/MockVault.ts | 266 ++++++++++++++++++++++++++++++++++++ 3 files changed, 684 insertions(+) create mode 100644 test_helpers/AppBuilder.ts create mode 100644 test_helpers/FileBuilder.ts create mode 100644 test_helpers/MockVault.ts diff --git a/test_helpers/AppBuilder.ts b/test_helpers/AppBuilder.ts new file mode 100644 index 0000000..04616d4 --- /dev/null +++ b/test_helpers/AppBuilder.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +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) => any, + ctx?: any + ): EventRef; + on( + name: "deleted", + callback: (file: TFile, prevCache: CachedMetadata | null) => any, + ctx?: any + ): EventRef; + on(name: "resolve", callback: (file: TFile) => any, ctx?: any): EventRef; + on(name: "resolved", callback: () => any, ctx?: any): EventRef; + on( + name: unknown, + callback: unknown, + ctx?: unknown + ): import("obsidian").EventRef { + throw new Error("Method not implemented."); + } + off(name: string, callback: (...data: any) => any): void { + throw new Error("Method not implemented."); + } + offref(ref: EventRef): void { + throw new Error("Method not implemented."); + } + trigger(name: string, ...data: any[]): void { + throw new Error("Method not implemented."); + } + tryTrigger(evt: EventRef, args: any[]): 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/test_helpers/FileBuilder.ts b/test_helpers/FileBuilder.ts new file mode 100644 index 0000000..7ba29bb --- /dev/null +++ b/test_helpers/FileBuilder.ts @@ -0,0 +1,247 @@ +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/test_helpers/MockVault.ts b/test_helpers/MockVault.ts new file mode 100644 index 0000000..d87ea23 --- /dev/null +++ b/test_helpers/MockVault.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +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) => any, + ctx?: any + ): EventRef; + on( + name: "modify", + callback: (file: TAbstractFile) => any, + ctx?: any + ): EventRef; + on( + name: "delete", + callback: (file: TAbstractFile) => any, + ctx?: any + ): EventRef; + on( + name: "rename", + callback: (file: TAbstractFile, oldPath: string) => any, + ctx?: any + ): EventRef; + on(name: "closed", callback: () => any, ctx?: any): EventRef; + on(name: unknown, callback: unknown, ctx?: unknown): EventRef { + throw new Error("Method not implemented."); + } + off(name: string, callback: (...data: any) => any): void { + throw new Error("Method not implemented."); + } + offref(ref: EventRef): void { + throw new Error("Method not implemented."); + } + trigger(name: string, ...data: any[]): void { + throw new Error("Method not implemented."); + } + tryTrigger(evt: EventRef, args: any[]): 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."); + } +}