* feat(scene-graph): let a SceneGraph take its ID generator The constructor accepts an ID generator, used for the root node and for nodes, variables, and collections created later; generated IDs skip any node, variable, collection, or mode ID already in the graph. Refs #770 Signed-off-by: Marc Went <marc@went.io> * perf(scene-graph): check mode IDs without allocating per created entity Generating an ID spread every collection into a new array and scanned it for each candidate, which runs on every createNode. Check the node, variable, and collection maps first and walk the modes with an early exit. An index of mode IDs is not kept because history snapshots, transfer, and undo replace collection maps and edit modes in place, which would leave it stale. * test(scene-graph): cover injected ID generators and collision skipping * docs: note the SceneGraph ID generator in the changelog * test(core): keep reopened .fig GUIDs when a sibling is inserted before them Refs #770 * test(collab): cover concurrent additions, same-property writes, and delete versus edit Move the synced-store harness to tests/helpers/collab so the new cases live in tests/app/collab, and let it bind graph events as a collab session does. The cases edit both peers while disconnected and check the converged graphs. Refs #770 * fix(scene-graph): keep a collection's IDs apart and stop on an exhausted generator createCollection asks for the collection and default mode IDs before registering either, so a generator repeating a candidate gave both the same ID. A generator that only returned taken IDs looped forever, even in the constructor; it now throws after a bounded number of attempts. * test: require the inserted GUID and a real disconnect in new tests The GUID test passed even if export dropped the inserted layer, and the concurrent-edit helper would have tested sequential sync had its peers not been disconnectable. * feat(scene-graph): create variable modes through the graph's ID generator The editor minted added and duplicated modes as mode:<random hex>, so an injected generator governed every graph entity except modes added after a collection's first. SceneGraph.createMode mints the ID and adds the mode; undo and redo replay it with addMode. * test(core): type the reopened .fig buffer in the GUID test * refactor(scene-graph)!: share random helpers and one component property ID randomHex, randomInt, and randomIndex move from @open-pencil/core/random to @open-pencil/scene-graph/random, so format packages can use them: design-jsx and the MCP test server dropped private copies. Component property IDs, written as prop:<random hex> in six places across Core and design-jsx, come from createComponentPropertyId. * docs: point the randomness rule at the shared ID and random helpers --------- Signed-off-by: Marc Went <marc@went.io> Co-authored-by: Marc Went <marc@went.io>
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
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'
|
|
|
|
// Test copy of the private apply path.
|
|
function applyYnodeToGraph(peer: SceneGraph, nodeId: string, ynode: Y.Map<unknown>) {
|
|
const props = decodeNodeFromYjs(ynode)
|
|
if (peer.getNode(nodeId)) {
|
|
peer.updateNode(nodeId, props as Partial<SceneNode>)
|
|
return
|
|
}
|
|
const type = props.type as SceneNode['type'] | undefined
|
|
if (!type) return
|
|
const parentId = typeof props.parentId === 'string' ? props.parentId : null
|
|
peer.createNodeWithId(nodeId, type, parentId, props as Partial<SceneNode>)
|
|
if (parentId === null) peer.rootId = nodeId
|
|
}
|
|
|
|
function seedHostIntoYjs(host: SceneGraph): Y.Map<Y.Map<unknown>> {
|
|
const doc = new Y.Doc()
|
|
const ynodes = doc.getMap<Y.Map<unknown>>('nodes')
|
|
doc.transact(() => {
|
|
for (const node of host.getAllNodes()) {
|
|
const ynode = new Y.Map<unknown>()
|
|
ynodes.set(node.id, ynode)
|
|
syncEncodedNodeToYMap(node, ynode)
|
|
}
|
|
})
|
|
return ynodes
|
|
}
|
|
|
|
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<unknown>()
|
|
doc.getMap<Y.Map<unknown>>('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<unknown>()
|
|
doc.getMap<Y.Map<unknown>>('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<unknown>()
|
|
doc.getMap<Y.Map<unknown>>('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, no duplicate childIds, order-independent', () => {
|
|
const host = new SceneGraph()
|
|
const hostPage = firstPage(host)
|
|
const ellipse = host.createNode('ELLIPSE', hostPage.id, { width: 80, height: 60 })
|
|
|
|
const ynodes = seedHostIntoYjs(host)
|
|
|
|
const peer = new SceneGraph()
|
|
const ids = [...ynodes.keys()].reverse()
|
|
for (const id of ids) applyYnodeToGraph(peer, id, expectDefined(ynodes.get(id), `ynode ${id}`))
|
|
|
|
const peerEllipse = getNodeOrThrow(peer, ellipse.id)
|
|
expect(peerEllipse.type).toBe('ELLIPSE')
|
|
expect(peerEllipse.parentId).toBe(hostPage.id)
|
|
|
|
const peerPage = getNodeOrThrow(peer, hostPage.id)
|
|
const refs = peerPage.childIds.filter((c) => c === ellipse.id)
|
|
expect(refs).toHaveLength(1)
|
|
expect(peer.getPages().map((page) => page.id)).toContain(hostPage.id)
|
|
})
|
|
|
|
test('a live-created node links into its parent even when the parent childIds was not re-synced', () => {
|
|
const host = new SceneGraph()
|
|
const hostPage = firstPage(host)
|
|
const rect = host.createNode('RECTANGLE', hostPage.id, { width: 50, height: 50 })
|
|
|
|
const doc = new Y.Doc()
|
|
const ynodes = doc.getMap<Y.Map<unknown>>('nodes')
|
|
doc.transact(() => {
|
|
const pageYnode = new Y.Map<unknown>()
|
|
ynodes.set(hostPage.id, pageYnode)
|
|
syncEncodedNodeToYMap({ ...hostPage, childIds: [] } as SceneNode, pageYnode)
|
|
|
|
const rectYnode = new Y.Map<unknown>()
|
|
ynodes.set(rect.id, rectYnode)
|
|
syncEncodedNodeToYMap(rect, rectYnode)
|
|
})
|
|
|
|
const peer = new SceneGraph()
|
|
applyYnodeToGraph(peer, hostPage.id, expectDefined(ynodes.get(hostPage.id), 'page ynode'))
|
|
applyYnodeToGraph(peer, rect.id, expectDefined(ynodes.get(rect.id), 'rect ynode'))
|
|
|
|
const peerPage = getNodeOrThrow(peer, hostPage.id)
|
|
expect(peerPage.childIds).toEqual([rect.id])
|
|
expect(getNodeOrThrow(peer, rect.id).type).toBe('RECTANGLE')
|
|
})
|
|
|
|
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<undefined>()
|
|
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<void>((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)
|
|
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])
|
|
})
|
|
})
|
|
|
|
test('synced node does not crash the visual-bounds helper', () => {
|
|
const host = new SceneGraph()
|
|
const hostPage = firstPage(host)
|
|
const ellipse = host.createNode('ELLIPSE', hostPage.id, { width: 120, height: 90 })
|
|
|
|
const ynodes = seedHostIntoYjs(host)
|
|
const peer = new SceneGraph()
|
|
for (const id of ynodes.keys()) {
|
|
applyYnodeToGraph(peer, id, expectDefined(ynodes.get(id), `ynode ${id}`))
|
|
}
|
|
|
|
const peerEllipse = getNodeOrThrow(peer, ellipse.id)
|
|
expect(() =>
|
|
nodeVisualBounds(peerEllipse, (id) => {
|
|
const n = peer.getNode(id)
|
|
return { x: n?.x ?? 0, y: n?.y ?? 0 }
|
|
})
|
|
).not.toThrow()
|
|
})
|
|
})
|