174 lines
5.6 KiB
TypeScript
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)
|
|
})
|
|
})
|