113 lines
3.7 KiB
TypeScript
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();
|
|
});
|
|
});
|