web based infinite canvas
1/* eslint-disable @typescript-eslint/no-explicit-any */
2import type { Observable, Observer, Subscription } from "dexie";
3import type { DocPatch, InkfiniteDB, PageRecord, PersistentDocRepo } from "inkfinite-core";
4import { describe, expect, it, vi } from "vitest";
5import { createPersistenceManager, type PersistenceManagerOptions } from "../status";
6
7function createMockRepo(): PersistentDocRepo {
8 return {
9 listBoards: vi.fn(async () => []),
10 createBoard: vi.fn(async () => "board:mock"),
11 openBoard: vi.fn(async () => {}),
12 renameBoard: vi.fn(async () => {}),
13 deleteBoard: vi.fn(async () => {}),
14 loadDoc: vi.fn(async () => ({ pages: {}, shapes: {}, bindings: {}, order: { pageIds: [], shapeOrder: {} } })),
15 applyDocPatch: vi.fn(async () => {}),
16 exportBoard: vi.fn(async () => ({
17 board: { id: "board:mock", name: "", createdAt: 0, updatedAt: 0 },
18 doc: { pages: {}, shapes: {}, bindings: {} },
19 order: { pageIds: [], shapeOrder: {} },
20 })),
21 importBoard: vi.fn(async () => "board:mock"),
22 };
23}
24
25type ObserverLike = { next: (value: any) => void; error?: (err: unknown) => void };
26
27function createMockLiveQuery() {
28 const observers = new Set<ObserverLike>();
29 const factory: PersistenceManagerOptions["liveQueryFn"] = () => {
30 const observable: Observable<any> = {
31 subscribe(observer?: Observer<any> | ((value: any) => void) | null) {
32 const normalized: ObserverLike = typeof observer === "function"
33 ? { next: observer }
34 : observer
35 ? { next: observer.next ?? (() => {}), error: observer.error }
36 : { next: () => {} };
37 observers.add(normalized);
38 const subscription = {
39 closed: false,
40 unsubscribe() {
41 if (subscription.closed) {
42 return;
43 }
44 subscription.closed = true;
45 observers.delete(normalized);
46 },
47 };
48 return subscription as Subscription;
49 },
50 [Symbol.observable]() {
51 return this;
52 },
53 };
54 return observable;
55 };
56
57 return {
58 factory,
59 emit(value: any) {
60 for (const observer of observers) {
61 observer.next(value);
62 }
63 },
64 error(err: unknown) {
65 for (const observer of observers) {
66 observer.error?.(err);
67 }
68 },
69 observerCount() {
70 return observers.size;
71 },
72 };
73}
74
75function createStatusTracker(
76 overrides?: { repo?: PersistentDocRepo; options?: PersistenceManagerOptions; db?: Partial<InkfiniteDB> },
77) {
78 const repo = overrides?.repo ?? createMockRepo();
79 const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery();
80 const options: PersistenceManagerOptions = overrides?.options ?? { liveQueryFn: live?.factory };
81 const db = (overrides?.db ?? { boards: { get: vi.fn(async () => undefined) } }) as InkfiniteDB;
82 const manager = createPersistenceManager(db, repo, options);
83 const mock = { repo, live, manager };
84 return mock;
85}
86
87function buildPatch(): DocPatch {
88 return { upserts: { pages: [{ id: "page:1", name: "Page 1", shapeIds: [] } as PageRecord] } };
89}
90
91describe("createPersistenceManager", () => {
92 it("tracks pending writes and resets when liveQuery emits", () => {
93 const { live, manager } = createStatusTracker();
94 expect(manager.status.get().pendingWrites).toBe(0);
95 manager.setActiveBoard("board:1");
96
97 manager.sink.enqueueDocPatch("board:1", buildPatch());
98 let status = manager.status.get();
99 expect(status.state).toBe("saving");
100 expect(status.pendingWrites).toBe(1);
101
102 live?.emit({ updatedAt: 123 });
103 status = manager.status.get();
104 expect(status.pendingWrites).toBe(0);
105 expect(status.state).toBe("saved");
106 expect(status.lastSavedAt).toBe(123);
107 });
108
109 it("records errors from flush", async () => {
110 const repo = createMockRepo();
111 (repo.applyDocPatch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("failed"));
112 const { manager } = createStatusTracker({ repo });
113 manager.setActiveBoard("board:1");
114 manager.sink.enqueueDocPatch("board:1", buildPatch());
115
116 await expect(manager.sink.flush()).rejects.toThrow("failed");
117 expect(manager.status.get().state).toBe("error");
118 expect(manager.status.get().errorMsg).toBe("failed");
119 });
120
121 it("stops liveQuery when disposed", () => {
122 const live = createMockLiveQuery();
123 const { manager } = createStatusTracker({ options: { liveQueryFn: live.factory } });
124 manager.setActiveBoard("board:1");
125 expect(live.observerCount()).toBe(1);
126 manager.dispose();
127 expect(live.observerCount()).toBe(0);
128 });
129});