web based infinite canvas
1import {
2 type BoardMeta,
3 createFileData,
4 type DesktopFileOps,
5 type FileHandle,
6 PageRecord,
7 serializeDesktopFile,
8} from "inkfinite-core";
9import { beforeEach, describe, expect, it } from "vitest";
10import { createDesktopDocRepo } from "../persistence/desktop";
11
12function createFakeFileOps() {
13 const files = new Map<string, string>();
14 const recent: FileHandle[] = [];
15 let nextOpen: string | null = null;
16 let nextSave: string | null = null;
17 let workspaceDir: string | null = null;
18
19 const ops: DesktopFileOps = {
20 async showOpenDialog() {
21 const value = nextOpen;
22 nextOpen = null;
23 return value;
24 },
25 async showSaveDialog(defaultName) {
26 const value = nextSave ?? `/tmp/${defaultName ?? "untitled"}`;
27 nextSave = null;
28 return value;
29 },
30 async readFile(path) {
31 const content = files.get(path);
32 if (content === undefined) {
33 throw new Error(`Missing file: ${path}`);
34 }
35 return content;
36 },
37 async writeFile(path, content) {
38 files.set(path, content);
39 },
40 async getRecentFiles() {
41 return [...recent];
42 },
43 async addRecentFile(handle) {
44 const filtered = recent.filter((entry) => entry.path !== handle.path);
45 recent.splice(0, recent.length, handle, ...filtered);
46 },
47 async removeRecentFile(path) {
48 const index = recent.findIndex((entry) => entry.path === path);
49 if (index >= 0) {
50 recent.splice(index, 1);
51 }
52 },
53 async clearRecentFiles() {
54 recent.splice(0, recent.length);
55 },
56 async getWorkspaceDir() {
57 return workspaceDir;
58 },
59 async setWorkspaceDir(path) {
60 workspaceDir = path;
61 },
62 async pickWorkspaceDir() {
63 workspaceDir = "/tmp/workspace";
64 return workspaceDir;
65 },
66 async readDirectory(_directory, _pattern) {
67 return [];
68 },
69 async renameFile(oldPath, newPath) {
70 const content = files.get(oldPath);
71 if (content === undefined) {
72 throw new Error(`Missing file: ${oldPath}`);
73 }
74 files.set(newPath, content);
75 files.delete(oldPath);
76 },
77 async deleteFile(path) {
78 if (!files.has(path)) {
79 throw new Error(`Missing file: ${path}`);
80 }
81 files.delete(path);
82 },
83 };
84
85 return {
86 ops,
87 files,
88 recent,
89 setNextOpen(path: string | null) {
90 nextOpen = path;
91 },
92 setNextSave(path: string | null) {
93 nextSave = path;
94 },
95 };
96}
97
98describe("createDesktopDocRepo", () => {
99 const fake = createFakeFileOps();
100
101 beforeEach(() => {
102 fake.files.clear();
103 fake.recent.splice(0, fake.recent.length);
104 fake.setNextOpen(null);
105 fake.setNextSave(null);
106 });
107
108 it("creates a board and lists it via recent files", async () => {
109 const repo = createDesktopDocRepo(fake.ops);
110 fake.setNextSave("/tmp/board-one.inkfinite.json");
111 const boardId = await repo.createBoard("Board One");
112
113 const boards = await repo.listBoards();
114 expect(boards).toHaveLength(1);
115 expect(boards[0].id).toBe(boardId);
116 expect(boards[0].name).toBe("Board One");
117
118 const loaded = await repo.loadDoc(boardId);
119 expect(Object.keys(loaded.pages)).toHaveLength(1);
120 });
121
122 it("opens an existing file via dialog", async () => {
123 const repo = createDesktopDocRepo(fake.ops);
124 const page = PageRecord.create("Dialog Page");
125 const board = { id: "board-dialog", name: "Dialog Board", createdAt: Date.now(), updatedAt: Date.now() };
126 const fileData = createFileData(board, { [page.id]: page }, {}, {}, {
127 pageIds: [page.id],
128 shapeOrder: { [page.id]: [] },
129 });
130 const path = "/tmp/dialog-board.inkfinite.json";
131 fake.files.set(path, serializeDesktopFile(fileData));
132 fake.setNextOpen(path);
133
134 const opened = await repo.openFromDialog();
135 expect(opened.boardId).toBe("board-dialog");
136 expect(Object.keys(opened.doc.pages)).toEqual([page.id]);
137
138 const boards = await repo.listBoards();
139 expect(boards.some((entry: BoardMeta) => entry.id === "board-dialog")).toBe(true);
140 });
141
142 it("renames the current board and updates the file", async () => {
143 const repo = createDesktopDocRepo(fake.ops);
144 fake.setNextSave("/tmp/rename-board.inkfinite.json");
145 const boardId = await repo.createBoard("Old Name");
146 await repo.renameBoard(boardId, "New Name");
147
148 const stored = fake.files.get("/tmp/rename-board.inkfinite.json");
149 expect(stored).toBeTruthy();
150 const parsed = JSON.parse(String(stored));
151 expect(parsed.board.name).toBe("New Name");
152 });
153
154 it("prunes missing recents when listing boards", async () => {
155 const repo = createDesktopDocRepo(fake.ops);
156 fake.recent.push({ path: "/tmp/missing.inkfinite.json", name: "Missing" });
157 const boards = await repo.listBoards();
158 expect(boards).toHaveLength(0);
159 expect(fake.recent).toHaveLength(0);
160 });
161});