import { describe, test, expect, spyOn } from 'bun:test' import { create as createPRNG } from 'lib0/prng' import * as Y from 'yjs' import { TestConnector, type TestYInstance } from 'yjs/testHelper' import type { Fill, GeometryPath, SceneNode } from '@open-pencil/scene-graph' import { SceneGraph } from '@open-pencil/scene-graph' import { nodeVisualBounds } from '@open-pencil/scene-graph/geometry' import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults' import { decodeNodeFromYjs, syncEncodedNodeToYMap } from '@/app/collab/node-codec' import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert' import { withSyncedStores } from '#tests/helpers/collab/synced-stores' function firstPage(graph: SceneGraph): SceneNode { return expectDefined(graph.getPages()[0], 'first page') } describe('collab yjs-sync', () => { test('createNodeWithId forces the requested id even if synced props contain a stale id', () => { const graph = new SceneGraph() const page = firstPage(graph) const node = graph.createNodeWithId('remote-id', 'RECTANGLE', page.id, { id: 'stale-local-id', width: 50 }) expect(node.id).toBe('remote-id') expect(graph.getNode('remote-id')).toBe(node) expect(graph.getNode('stale-local-id')).toBeUndefined() expect(page.childIds).toContain('remote-id') }) test('excludes derived text pictures from collaboration payloads', () => { const graph = new SceneGraph() const page = firstPage(graph) const text = graph.createNode('TEXT', page.id, { text: 'Shared text', textPicture: new Uint8Array([4, 5, 6]) }) const doc = new Y.Doc() const ynode = new Y.Map() doc.getMap>('nodes').set(text.id, ynode) syncEncodedNodeToYMap(text, ynode) expect(ynode.has('textPicture')).toBe(false) ynode.set('textPicture', new Uint8Array([9])) expect(decodeNodeFromYjs(ynode).textPicture).toBeNull() }) test('normalizes malformed source metadata and geometry at the remote boundary', () => { const doc = new Y.Doc() const ynode = new Y.Map() doc.getMap>('nodes').set('remote', ynode) ynode.set('source', { format: 'fig', fig: { rawNodeFields: 'invalid' } }) ynode.set('fillGeometry', [ { windingRule: 'EVENODD', commandsBlob: new Uint8Array([0]), fills: [ null, 'invalid', { type: 'GRADIENT_LINEAR', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, gradientStops: 'invalid' }, { type: 'NOISE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, noiseSize: 'invalid' }, { type: 'GRADIENT_LINEAR', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 0.8, visible: true, gradientStops: [ { color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 }, { color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 } ], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }, { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } ] }, { windingRule: 'NONZERO', commandsBlob: 'invalid' } ]) ynode.set('strokeGeometry', 'invalid') const props = decodeNodeFromYjs(ynode) const source = props.source as SceneNode['source'] const fillGeometry = props.fillGeometry as GeometryPath[] expect(source).toEqual({ ...createDefaultSourceMetadata(), format: 'fig' }) expect(fillGeometry).toHaveLength(1) expect(fillGeometry[0]?.commandsBlob).toBeInstanceOf(Uint8Array) expect(fillGeometry[0]?.fills).toHaveLength(2) expect(fillGeometry[0]?.fills?.[0]).toMatchObject({ type: 'GRADIENT_LINEAR', opacity: 0.8, gradientStops: [ { color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 }, { color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 } ], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }) expect(fillGeometry[0]?.fills?.[1]?.type).toBe('SOLID') expect(props.strokeGeometry).toEqual([]) }) test('binary geometry fields round-trip as Uint8Array, not strings', () => { const host = new SceneGraph() const page = firstPage(host) const blob = new Uint8Array([1, 2, 3, 250]) const geometry: GeometryPath[] = [{ windingRule: 'NONZERO', commandsBlob: blob }] const ellipse = host.createNode('ELLIPSE', page.id, { width: 100, height: 100, fillGeometry: geometry }) const doc = new Y.Doc() const ynode = new Y.Map() doc.getMap>('nodes').set(ellipse.id, ynode) syncEncodedNodeToYMap(ellipse, ynode) blob[0] = 99 const props = decodeNodeFromYjs(ynode) expect(typeof ynode.get('fillGeometry')).not.toBe('string') const decoded = props.fillGeometry as GeometryPath[] expect(Array.isArray(decoded)).toBe(true) const commandsBlob = expectDefined(decoded[0], 'decoded geometry path').commandsBlob expect(commandsBlob).toBeInstanceOf(Uint8Array) expect(Array.from(commandsBlob)).toEqual([1, 2, 3, 250]) expect(Array.isArray(props.strokeGeometry)).toBe(true) }) test('a fresh peer reconstructs one ellipse with no duplicate child entries', async () => { await withSyncedStores( ({ hostStore, peerStore, hostSync, hostDoc, peerDoc }) => { const hostPage = firstPage(hostStore.graph) const ellipse = hostStore.graph.createNode('ELLIPSE', hostPage.id, { width: 80, height: 60 }) hostSync.syncAllNodesToYjs() Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(hostDoc)) const peer = peerStore.graph const peerEllipse = getNodeOrThrow(peer, ellipse.id) expect(peerEllipse.type).toBe('ELLIPSE') expect(peerEllipse.parentId).toBe(hostPage.id) const refs = getNodeOrThrow(peer, hostPage.id).childIds.filter((c) => c === ellipse.id) expect(refs).toHaveLength(1) expect(peer.getPages().map((page) => page.id)).toContain(hostPage.id) expect(() => nodeVisualBounds(peerEllipse, (id) => { const n = peer.getNode(id) return { x: n?.x ?? 0, y: n?.y ?? 0 } }) ).not.toThrow() }, { connectImmediately: false } ) }) test('deduplicates a pending page switch and handles its rejection', async () => { await withSyncedStores(async ({ hostStore, peerStore, hostSync }) => { const page = firstPage(hostStore.graph) const node = hostStore.graph.createNode('RECTANGLE', page.id) const { promise: pending, reject: rejectSwitch } = Promise.withResolvers() const switchPage = spyOn(peerStore, 'switchPage').mockReturnValue(pending) const log = spyOn(console, 'error').mockImplementation(() => undefined) try { hostSync.syncAllNodesToYjs() hostSync.syncNodeToYjs(node.id) expect(switchPage).toHaveBeenCalledTimes(1) const error = new Error('Page preparation failed') rejectSwitch(error) await pending.catch(() => undefined) await Promise.resolve() expect(log).toHaveBeenCalledWith('[Collab] Failed to switch to a synced page:', error) } finally { switchPage.mockRestore() log.mockRestore() } }) }) test('syncAllNodesToYjs populates peer graph and current page', async () => { await withSyncedStores(async ({ hostStore, peerStore, hostSync }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) const pageChanged = new Promise((resolve) => { const off = peerStore.onEditorEvent('page:changed', () => { off() resolve() }) }) hostSync.syncAllNodesToYjs() expect(peerStore.graph.rootId).toBe(hostStore.graph.rootId) await pageChanged expect(peerStore.state.currentPageId).toBe(hostPage.id) expect(peerStore.graph.getPages().map((page) => page.id)).toContain(hostPage.id) expect(getNodeOrThrow(peerStore.graph, rect.id).type).toBe('RECTANGLE') }) }) test('live-created and edited nodes sync in both directions', async () => { await withSyncedStores(({ hostStore, peerStore, hostSync, peerSync }) => { const hostPage = firstPage(hostStore.graph) hostSync.syncAllNodesToYjs() const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 50, height: 50 }) hostSync.syncNodeToYjs(rect.id) const peerRect = getNodeOrThrow(peerStore.graph, rect.id) expect(peerRect.parentId).toBe(hostPage.id) expect(getNodeOrThrow(peerStore.graph, hostPage.id).childIds).toContain(rect.id) peerStore.graph.updateNode(rect.id, { x: 42, y: 24 }) peerSync.syncNodeToYjs(rect.id) expect(getNodeOrThrow(hostStore.graph, rect.id).x).toBe(42) expect(getNodeOrThrow(hostStore.graph, rect.id).y).toBe(24) }) }) test('unchanged node synchronization emits no Yjs update', async () => { await withSyncedStores(({ hostStore, hostSync, hostDoc }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) hostSync.syncNodeToYjs(rect.id) let updateCount = 0 let updateBytes = 0 const trackUpdate = (update: Uint8Array) => { updateCount++ updateBytes += update.byteLength } hostDoc.on('update', trackUpdate) for (let index = 0; index < 100; index++) hostSync.syncNodeToYjs(rect.id) hostDoc.off('update', trackUpdate) expect(updateCount).toBe(0) expect(updateBytes).toBe(0) }) }) test('repeated drag-like updates stay field-sized and do not echo', async () => { await withSyncedStores(({ hostStore, peerStore, hostSync, hostDoc, peerDoc }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) hostSync.syncAllNodesToYjs() hostSync.syncNodeToYjs(rect.id) let hostUpdateCount = 0 let hostUpdateBytes = 0 let peerUpdateCount = 0 const trackHostUpdate = (update: Uint8Array) => { hostUpdateCount++ hostUpdateBytes += update.byteLength } const trackPeerUpdate = () => { peerUpdateCount++ } hostDoc.on('update', trackHostUpdate) peerDoc.on('update', trackPeerUpdate) for (let index = 1; index <= 100; index++) { hostStore.graph.updateNode(rect.id, { x: index }) hostSync.syncNodeToYjs(rect.id) } hostDoc.off('update', trackHostUpdate) peerDoc.off('update', trackPeerUpdate) expect(getNodeOrThrow(peerStore.graph, rect.id).x).toBe(100) expect(hostUpdateCount).toBe(100) expect(peerUpdateCount).toBe(100) expect(hostUpdateBytes).toBeLessThan(8_000) }) }) test('queued concurrent edits converge through the official Yjs test connector', async () => { const connector = new TestConnector(createPRNG(526)) const hostDoc: TestYInstance = connector.createY(1) const peerDoc: TestYInstance = connector.createY(2) connector.syncAll() await withSyncedStores( ({ hostStore, peerStore, hostSync, peerSync }) => { const hostPage = firstPage(hostStore.graph) const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { width: 80, height: 60 }) hostSync.syncAllNodesToYjs() hostSync.syncNodeToYjs(rect.id) connector.flushAllMessages() expect(getNodeOrThrow(peerStore.graph, rect.id).type).toBe('RECTANGLE') hostDoc.disconnect() hostStore.graph.updateNode(rect.id, { x: 42 }) hostSync.syncNodeToYjs(rect.id) peerStore.graph.updateNode(rect.id, { y: 24 }) peerSync.syncNodeToYjs(rect.id) hostDoc.connect() expect(connector.flushRandomMessage()).toBe(true) connector.flushAllMessages() expect(getNodeOrThrow(hostStore.graph, rect.id)).toMatchObject({ x: 42, y: 24 }) expect(getNodeOrThrow(peerStore.graph, rect.id)).toMatchObject({ x: 42, y: 24 }) expect(Y.encodeStateVector(hostDoc)).toEqual(Y.encodeStateVector(peerDoc)) }, { hostDoc, peerDoc, connectImmediately: false } ) }) test('image fills sync image bytes', async () => { await withSyncedStores(({ hostStore, peerStore, hostSync }) => { const hostPage = firstPage(hostStore.graph) hostSync.syncAllNodesToYjs() const imageHash = 'image-hash' const imageFill: Fill = { type: 'IMAGE', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, imageHash, imageScaleMode: 'FILL' } const rect = hostStore.graph.createNode('RECTANGLE', hostPage.id, { fills: [imageFill] }) hostStore.graph.images.set(imageHash, new Uint8Array([9, 8, 7])) hostSync.syncNodeToYjs(rect.id) expect( Array.from(expectDefined(peerStore.graph.images.get(imageHash), 'peer image')) ).toEqual([9, 8, 7]) }) }) })