openpencil/tests/engine/io/fig/export/guid-collision.test.ts
2026-07-04 12:42:06 +03:00

174 lines
5.6 KiB
TypeScript

import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
/**
* Regression test: two distinct nodes sharing the same source.id
* (as happens with component-instance children) must receive
* different GUIDs on export, preventing silent data loss on reimport.
*/
describe('export: GUID collision prevention', () => {
beforeAll(async () => {
await initCodec()
})
test('two nodes with same source.id get different GUIDs on export', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
// Create two nodes that share the same Figma source.id
// (simulates component master + instance child)
const rect1 = graph.createNode('RECTANGLE', page.id, {
name: 'Master Child',
width: 100,
height: 50
})
graph.updateNode(rect1.id, {
source: { ...rect1.source, id: '1:94', format: 'fig' }
})
const rect2 = graph.createNode('RECTANGLE', page.id, {
name: 'Instance Child',
width: 100,
height: 50
})
graph.updateNode(rect2.id, {
source: { ...rect2.source, id: '1:94', format: 'fig' }
})
const figBytes = await exportFigFile(graph)
const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer)
// Both nodes must survive reimport — no silent last-write-wins
const allNodes = [...reimported.getAllNodes()]
const rects = allNodes.filter(
(n) => n.type === 'RECTANGLE' && (n.name === 'Master Child' || n.name === 'Instance Child')
)
expect(rects.length).toBe(2)
})
test('cloned node does not collide with original GUID', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, {
name: 'Original',
width: 100,
height: 50
})
graph.updateNode(rect.id, {
source: { ...rect.source, id: '1:200', format: 'fig' }
})
// Clone the node — cloneTree should clear source.id
const clone = graph.cloneTree(rect.id, page.id)
expect(clone).not.toBeNull()
if (!clone) throw new Error('Expected clone to exist')
expect(clone.source.id).toBeNull()
const figBytes = await exportFigFile(graph)
const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer)
const allNodes = [...reimported.getAllNodes()]
const rects = allNodes.filter((n) => n.type === 'RECTANGLE')
// Both original and clone must survive
expect(rects.length).toBe(2)
})
test('cross-page cloned frames survive export roundtrip', async () => {
const graph = new SceneGraph()
const page1 = graph.getPages()[0]
page1.name = 'Page 1'
const frame = graph.createNode('FRAME', page1.id, {
name: 'A',
width: 200,
height: 100
})
graph.createNode('TEXT', frame.id, {
name: 'Label',
text: 'Hello',
width: 100,
height: 20
})
const clone = graph.cloneTree(frame.id, page1.id)
expect(clone).not.toBeNull()
if (!clone) throw new Error('Expected clone to exist')
const page2 = graph.addPage('Page 2')
graph.reparentNode(clone.id, page2.id)
const figBytes = await exportFigFile(graph)
const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer)
const pages = reimported.getPages()
expect(pages.map((page) => page.name)).toEqual(['Page 1', 'Page 2'])
for (const page of pages) {
const [child] = reimported.getChildren(page.id)
expect(child?.name).toBe('A')
expect(child?.type).toBe('FRAME')
expect(child ? reimported.getChildren(child.id).map((node) => node.name) : []).toEqual([
'Label'
])
}
})
test('export roundtrip preserves three nodes with identical source.id', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
for (let i = 0; i < 3; i++) {
const rect = graph.createNode('RECTANGLE', page.id, {
name: `Rect ${i}`,
width: 50,
height: 50
})
graph.updateNode(rect.id, {
source: { ...rect.source, id: '1:500', format: 'fig' }
})
}
const figBytes = await exportFigFile(graph)
const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer)
const allNodes = [...reimported.getAllNodes()]
const rects = allNodes.filter((n) => n.type === 'RECTANGLE')
expect(rects.length).toBe(3)
})
test('nodes with session-0 source.id do not collide with document GUID', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
// The document GUID is always {sessionID:0, localID:0}.
// Imported nodes with source.id in the 0:* namespace must not reuse
// that slot — the export must reserve the document GUID first.
const rect1 = graph.createNode('RECTANGLE', page.id, {
name: 'S0 Node A',
width: 100,
height: 50
})
graph.updateNode(rect1.id, {
source: { ...rect1.source, id: '0:94', format: 'fig' }
})
const rect2 = graph.createNode('RECTANGLE', page.id, {
name: 'S0 Node B',
width: 100,
height: 50
})
graph.updateNode(rect2.id, {
source: { ...rect2.source, id: '0:94', format: 'fig' }
})
const figBytes = await exportFigFile(graph)
const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer)
const allNodes = [...reimported.getAllNodes()]
const rects = allNodes.filter(
(n) => n.type === 'RECTANGLE' && (n.name === 'S0 Node A' || n.name === 'S0 Node B')
)
// Both nodes must survive — the document GUID (0:0) must not swallow them
expect(rects.length).toBe(2)
})
})