mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
// The open folders with the disk faked out: what goes in the store when a root opens, what stops
|
|
// when it closes, and what a relaunch is able to put back.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { FileNode } from "../ipc";
|
|
|
|
const roots = vi.hoisted(() => ({
|
|
rootsList: vi.fn(),
|
|
rootOpen: vi.fn(),
|
|
rootClose: vi.fn(),
|
|
treeRead: vi.fn(),
|
|
sweepDocuments: vi.fn(),
|
|
revealInFinder: vi.fn(),
|
|
openExternal: vi.fn(),
|
|
}));
|
|
|
|
const files = vi.hoisted(() => ({
|
|
fileRead: vi.fn(),
|
|
fileWrite: vi.fn(),
|
|
fileCreate: vi.fn(),
|
|
fileFolderCreate: vi.fn(),
|
|
fileRename: vi.fn(),
|
|
fileMove: vi.fn(),
|
|
fileDuplicate: vi.fn(),
|
|
fileTrash: vi.fn(),
|
|
assetWrite: vi.fn(),
|
|
}));
|
|
|
|
const watch = vi.hoisted(() => ({ watchStart: vi.fn(), watchStop: vi.fn() }));
|
|
|
|
const index = vi.hoisted(() => ({
|
|
indexRebuild: vi.fn(),
|
|
indexStatus: vi.fn(),
|
|
searchQuickOpen: vi.fn(),
|
|
searchText: vi.fn(),
|
|
backlinksFor: vi.fn(),
|
|
}));
|
|
|
|
const bridge = vi.hoisted(() => ({
|
|
parseMarkdown: vi.fn(),
|
|
serializeMarkdown: vi.fn(),
|
|
parsePlainText: vi.fn(),
|
|
serializePlainText: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../api/roots", () => roots);
|
|
vi.mock("../api/files", () => files);
|
|
vi.mock("../api/watch", () => watch);
|
|
vi.mock("../api", () => index);
|
|
vi.mock("../markdown", () => bridge);
|
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}) }));
|
|
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
|
|
|
const { useWorkspace } = await import("./useWorkspace");
|
|
const { useDocument } = await import("./useDocument");
|
|
const { restoreSession } = await import("../workspace");
|
|
const { schema } = await import("../model/schema");
|
|
|
|
const HANDBOOK = "/Users/you/Documents/Handbook";
|
|
|
|
const dir = (path: string, children: FileNode[]): FileNode => ({
|
|
path,
|
|
name: path.slice(path.lastIndexOf("/") + 1),
|
|
kind: "dir",
|
|
editable: false,
|
|
modifiedMs: 1000,
|
|
children,
|
|
});
|
|
|
|
const file = (path: string, kind: FileNode["kind"] = "markdown"): FileNode => ({
|
|
path,
|
|
name: path.slice(path.lastIndexOf("/") + 1),
|
|
kind,
|
|
editable: kind === "markdown" || kind === "text",
|
|
modifiedMs: 1000,
|
|
children: [],
|
|
});
|
|
|
|
function fakeStorage(): Storage {
|
|
const map = new Map<string, string>();
|
|
return {
|
|
get length() {
|
|
return map.size;
|
|
},
|
|
clear: () => map.clear(),
|
|
getItem: (key: string) => map.get(key) ?? null,
|
|
key: (n: number) => [...map.keys()][n] ?? null,
|
|
removeItem: (key: string) => void map.delete(key),
|
|
setItem: (key: string, value: string) => void map.set(key, value),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("localStorage", fakeStorage());
|
|
for (const group of [roots, files, watch, index, bridge]) {
|
|
for (const mock of Object.values(group)) mock.mockReset();
|
|
}
|
|
|
|
roots.rootsList.mockResolvedValue([]);
|
|
roots.rootOpen.mockImplementation(async (path: string) => ({
|
|
id: `id-${path}`,
|
|
path,
|
|
name: path.slice(path.lastIndexOf("/") + 1),
|
|
openedMs: 1000,
|
|
}));
|
|
roots.treeRead.mockImplementation(async (rootId: string) => {
|
|
const path = rootId.slice("id-".length);
|
|
return dir(path, [
|
|
file(`${path}/README.md`),
|
|
dir(`${path}/guides`, [file(`${path}/guides/writing.md`)]),
|
|
file(`${path}/logo.png`, "other"),
|
|
]);
|
|
});
|
|
roots.sweepDocuments.mockImplementation(async (rootId: string) => {
|
|
const path = rootId.slice("id-".length);
|
|
return [`${path}/README.md`, `${path}/guides/writing.md`];
|
|
});
|
|
roots.rootClose.mockResolvedValue(undefined);
|
|
roots.revealInFinder.mockResolvedValue(undefined);
|
|
watch.watchStart.mockResolvedValue(undefined);
|
|
watch.watchStop.mockResolvedValue(undefined);
|
|
index.indexRebuild.mockResolvedValue({
|
|
phase: "idle",
|
|
indexed: 3,
|
|
total: 3,
|
|
lastIndexed: 1000,
|
|
error: null,
|
|
message: null,
|
|
});
|
|
files.fileRead.mockImplementation(async (path: string) => ({
|
|
path,
|
|
text: "hello",
|
|
modifiedMs: 1000,
|
|
}));
|
|
bridge.parseMarkdown.mockImplementation((source: string, path: string) => ({
|
|
frontmatter: null,
|
|
doc: schema.nodes.doc.create(null, schema.nodes.paragraph.create(null, schema.text(source))),
|
|
source,
|
|
path,
|
|
}));
|
|
|
|
useWorkspace.setState({
|
|
roots: [],
|
|
expanded: new Set(),
|
|
selectedPath: null,
|
|
showIgnored: false,
|
|
recentFolders: [],
|
|
scanPhase: "idle",
|
|
scanError: null,
|
|
});
|
|
useDocument.setState({
|
|
path: null,
|
|
document: null,
|
|
content: null,
|
|
modifiedMs: null,
|
|
dirty: false,
|
|
savePhase: "idle",
|
|
saveError: null,
|
|
frontmatter: null,
|
|
externalChange: "synced",
|
|
history: [],
|
|
historyIndex: -1,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("opening a folder", () => {
|
|
it("reads the tree once and watches it, and writes nothing into it", async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
|
|
const [root] = useWorkspace.getState().roots;
|
|
expect(root.id).toBe(`id-${HANDBOOK}`);
|
|
expect(root.name).toBe("Handbook");
|
|
expect(root.tree.map((n) => n.name)).toEqual(["README.md", "guides", "logo.png"]);
|
|
expect(watch.watchStart).toHaveBeenCalledWith(`id-${HANDBOOK}`);
|
|
expect(files.fileCreate).not.toHaveBeenCalled();
|
|
expect(files.fileWrite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("marks directories as directories and everything else by what opens it", async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
|
|
const [readme, guides, logo] = useWorkspace.getState().roots[0].tree;
|
|
expect(readme.isDir).toBe(false);
|
|
expect(readme.editable).toBe(true);
|
|
expect(guides.isDir).toBe(true);
|
|
expect(guides.children?.map((n) => n.name)).toEqual(["writing.md"]);
|
|
expect(logo.editable).toBe(false);
|
|
expect(logo.children).toBeUndefined();
|
|
});
|
|
|
|
it("remembers the folder so the next launch can put it back", async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
|
|
expect(useWorkspace.getState().recentFolders).toEqual([HANDBOOK]);
|
|
expect(JSON.parse(localStorage.getItem("margindocs-recents") ?? "[]")).toEqual([HANDBOOK]);
|
|
expect(JSON.parse(localStorage.getItem("margindocs-roots") ?? "[]")).toEqual([HANDBOOK]);
|
|
});
|
|
|
|
it("refreshes a root that is opened twice instead of listing it twice", async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
await addRoot(HANDBOOK);
|
|
|
|
expect(useWorkspace.getState().roots).toHaveLength(1);
|
|
expect(useWorkspace.getState().recentFolders).toEqual([HANDBOOK]);
|
|
});
|
|
|
|
it("reports a folder it could not read without leaving the phase stuck", async () => {
|
|
roots.treeRead.mockRejectedValueOnce(new Error("permission denied"));
|
|
const { addRoot } = await import("../workspace");
|
|
|
|
await expect(addRoot(HANDBOOK)).rejects.toThrow("permission denied");
|
|
expect(useWorkspace.getState().scanPhase).toBe("error");
|
|
expect(useWorkspace.getState().scanError).toContain("permission denied");
|
|
expect(useWorkspace.getState().roots).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("closing a folder", () => {
|
|
it("stops the watcher, closes the root and forgets it, touching nothing on disk", async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
useWorkspace.getState().select(`${HANDBOOK}/README.md`);
|
|
|
|
useWorkspace.getState().closeFolder(HANDBOOK);
|
|
await vi.waitFor(() => expect(roots.rootClose).toHaveBeenCalled());
|
|
|
|
expect(useWorkspace.getState().roots).toHaveLength(0);
|
|
expect(useWorkspace.getState().selectedPath).toBeNull();
|
|
expect(watch.watchStop).toHaveBeenCalledWith(`id-${HANDBOOK}`);
|
|
expect(roots.rootClose).toHaveBeenCalledWith(`id-${HANDBOOK}`);
|
|
expect(JSON.parse(localStorage.getItem("margindocs-roots") ?? "[]")).toEqual([]);
|
|
expect(files.fileTrash).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("restoring a session", () => {
|
|
it("opens what the backend already has and what was persisted, once each", async () => {
|
|
localStorage.setItem("margindocs-roots", JSON.stringify([HANDBOOK, "/Users/you/Scratch"]));
|
|
roots.rootsList.mockResolvedValue([
|
|
{ id: `id-${HANDBOOK}`, path: HANDBOOK, name: "Handbook", openedMs: 1 },
|
|
]);
|
|
|
|
await restoreSession();
|
|
|
|
expect(useWorkspace.getState().roots.map((r) => r.path)).toEqual([
|
|
HANDBOOK,
|
|
"/Users/you/Scratch",
|
|
]);
|
|
expect(roots.rootOpen).toHaveBeenCalledTimes(2);
|
|
expect(index.indexRebuild).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("carries on past a folder that is no longer there", async () => {
|
|
localStorage.setItem("margindocs-roots", JSON.stringify(["/gone", HANDBOOK]));
|
|
roots.rootOpen.mockRejectedValueOnce(new Error("no such directory"));
|
|
|
|
await restoreSession();
|
|
|
|
expect(useWorkspace.getState().roots.map((r) => r.path)).toEqual([HANDBOOK]);
|
|
});
|
|
});
|
|
|
|
describe("changing the tree", () => {
|
|
beforeEach(async () => {
|
|
const { addRoot } = await import("../workspace");
|
|
await addRoot(HANDBOOK);
|
|
roots.treeRead.mockClear();
|
|
});
|
|
|
|
it("creates a document and re-reads the root it landed in", async () => {
|
|
files.fileCreate.mockResolvedValue(file(`${HANDBOOK}/Untitled.md`));
|
|
|
|
const path = await useWorkspace.getState().newDocument(HANDBOOK);
|
|
|
|
expect(path).toBe(`${HANDBOOK}/Untitled.md`);
|
|
expect(files.fileCreate).toHaveBeenCalledWith(HANDBOOK, "Untitled.md");
|
|
expect(roots.treeRead).toHaveBeenCalledWith(`id-${HANDBOOK}`);
|
|
});
|
|
|
|
it("creates a folder with a name the user can see and rename", async () => {
|
|
files.fileFolderCreate.mockResolvedValue(dir(`${HANDBOOK}/Untitled Folder`, []));
|
|
|
|
await useWorkspace.getState().newFolder(HANDBOOK);
|
|
|
|
expect(files.fileFolderCreate).toHaveBeenCalledWith(HANDBOOK, "Untitled Folder");
|
|
});
|
|
|
|
it("sends a delete to the Trash and closes the document if that was the one", async () => {
|
|
await useDocument.getState().open(`${HANDBOOK}/README.md`);
|
|
useWorkspace.getState().select(`${HANDBOOK}/README.md`);
|
|
files.fileTrash.mockResolvedValue(undefined);
|
|
|
|
await useWorkspace.getState().deleteEntry(`${HANDBOOK}/README.md`);
|
|
|
|
expect(files.fileTrash).toHaveBeenCalledWith(`${HANDBOOK}/README.md`);
|
|
expect(useDocument.getState().path).toBeNull();
|
|
expect(useWorkspace.getState().selectedPath).toBeNull();
|
|
});
|
|
|
|
it("does not write a deleted document back out on its way to the Trash", async () => {
|
|
await useDocument.getState().open(`${HANDBOOK}/README.md`);
|
|
useDocument.getState().setContent(
|
|
schema.nodes.doc.create(null, schema.nodes.paragraph.create(null, schema.text("edited"))),
|
|
);
|
|
files.fileTrash.mockResolvedValue(undefined);
|
|
|
|
await useWorkspace.getState().deleteEntry(`${HANDBOOK}/README.md`);
|
|
await vi.waitFor(() => expect(useDocument.getState().path).toBeNull());
|
|
|
|
expect(files.fileWrite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("follows the open document to its new name after a rename", async () => {
|
|
await useDocument.getState().open(`${HANDBOOK}/README.md`);
|
|
files.fileRename.mockResolvedValue(file(`${HANDBOOK}/Overview.md`));
|
|
|
|
await useWorkspace.getState().renameEntry(`${HANDBOOK}/README.md`, "Overview.md");
|
|
|
|
expect(useDocument.getState().path).toBe(`${HANDBOOK}/Overview.md`);
|
|
expect(files.fileWrite).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves the open document alone when something else is renamed", async () => {
|
|
await useDocument.getState().open(`${HANDBOOK}/README.md`);
|
|
files.fileRename.mockResolvedValue(file(`${HANDBOOK}/guides/style.md`));
|
|
|
|
await useWorkspace.getState().renameEntry(`${HANDBOOK}/guides/writing.md`, "style.md");
|
|
|
|
expect(useDocument.getState().path).toBe(`${HANDBOOK}/README.md`);
|
|
});
|
|
});
|