web based infinite canvas
at main 129 lines 4.6 kB view raw
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});