Files

113 lines
3.7 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { TFile } from "obsidian";
import type { App } from "obsidian";
import { depth, forget, record, redo, transaction, undo } from "../src/vault/history";
/** Obsidian hands out TFile instances; history checks for them by identity. */
function handle(path: string): TFile {
return Object.assign(Object.create(TFile.prototype) as TFile, { path });
}
function vault(seed: Record<string, string>) {
const files = new Map(Object.entries(seed));
const handles = new Map([...files.keys()].map((path) => [path, handle(path)]));
const app = {
vault: {
getAbstractFileByPath: (path: string) => handles.get(path) ?? null,
read: async (file: TFile) => files.get(file.path) ?? "",
process: async (file: TFile, fn: (data: string) => string) => {
const next = fn(files.get(file.path) ?? "");
files.set(file.path, next);
return next;
},
},
} as unknown as App;
return { app, files };
}
/** Mirrors what mutate.edit does: write the file, then log both sides. */
async function write(
{ app, files }: ReturnType<typeof vault>,
path: string,
next: string,
): Promise<void> {
const before = files.get(path) ?? "";
await app.vault.process(
app.vault.getAbstractFileByPath(path) as TFile,
() => next,
);
record(path, before, next);
}
describe("history", () => {
beforeEach(() => forget());
it("puts a single change back", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
expect(store.files.get("a.md")).toBe("one");
});
it("replays what it undid", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
await redo(store.app);
expect(store.files.get("a.md")).toBe("two");
});
it("walks back through several changes one at a time", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await write(store, "a.md", "three");
await undo(store.app);
expect(store.files.get("a.md")).toBe("two");
await undo(store.app);
expect(store.files.get("a.md")).toBe("one");
});
it("takes both files of a move back together", async () => {
const store = vault({ "a.md": "task", "b.md": "" });
await transaction("the move", async () => {
await write(store, "b.md", "task");
await write(store, "a.md", "");
});
expect(depth().past).toBe(1);
await undo(store.app);
expect(store.files.get("a.md")).toBe("task");
expect(store.files.get("b.md")).toBe("");
});
it("ignores a write that changed nothing", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "one");
expect(depth().past).toBe(0);
});
it("refuses to touch a file that moved on underneath it", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
store.files.set("a.md", "edited elsewhere");
await undo(store.app);
expect(store.files.get("a.md")).toBe("edited elsewhere");
// The step is kept, so a later undo can still work if the file comes back.
expect(depth().past).toBe(1);
});
it("drops the redo trail once something new is written", async () => {
const store = vault({ "a.md": "one" });
await write(store, "a.md", "two");
await undo(store.app);
expect(depth().future).toBe(1);
await write(store, "a.md", "three");
expect(depth().future).toBe(0);
});
it("says so instead of throwing when there is nothing left", async () => {
const store = vault({ "a.md": "one" });
await expect(undo(store.app)).resolves.toBeUndefined();
await expect(redo(store.app)).resolves.toBeUndefined();
});
});