openpencil/tests/engine/scene-graph/clone/node-props.test.ts
2026-06-30 11:18:19 +03:00

213 lines
7.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph, type SceneNode } from '@open-pencil/core'
import { cloneNodeProps } from '@open-pencil/scene-graph/copy'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
describe('cloneNodeProps deep-copies vectorNetwork', () => {
test('cloned vectorNetwork is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Vector',
vectorNetwork: {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1]] }]
}
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
expect(clonedNode.vectorNetwork).not.toBe(original.vectorNetwork)
if (original.vectorNetwork && clonedNode.vectorNetwork) {
expect(clonedNode.vectorNetwork.vertices).not.toBe(original.vectorNetwork.vertices)
expect(clonedNode.vectorNetwork.segments).not.toBe(original.vectorNetwork.segments)
expect(clonedNode.vectorNetwork.regions).not.toBe(original.vectorNetwork.regions)
}
})
test('mutating clone vectorNetwork does not affect original', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Vector',
vectorNetwork: {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 50 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
}
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
if (clonedNode.vectorNetwork) clonedNode.vectorNetwork.vertices[0].x = 999
expect(original.vectorNetwork?.vertices[0].x).toBe(0)
})
})
describe('cloneNodeProps deep-copies textPicture and figmaDerivedTextGlyphs', () => {
test('cloned textPicture is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Text',
text: 'Hello',
fontSize: 14,
fontFamily: 'Inter',
textPicture: new Uint8Array([1, 2, 3, 4])
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
expect(clonedNode.textPicture).not.toBe(original.textPicture)
if (original.textPicture && clonedNode.textPicture) {
clonedNode.textPicture[0] = 99
expect(original.textPicture[0]).toBe(1)
}
})
test('cloned figmaDerivedTextGlyphs is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Text',
text: 'Hi',
fontSize: 14,
fontFamily: 'Inter',
figmaDerivedTextGlyphs: [
{ commandsBlob: new Uint8Array([10, 20]), x: 0, y: 0, fontSize: 14 },
{ commandsBlob: new Uint8Array([30, 40]), x: 10, y: 0, fontSize: 14 }
]
})
const original = graph.getNode(node.id)
const clone = graph.cloneTree(node.id, pageId(graph))
const clonedNode = graph.getNode(clone.id)
expect(clonedNode.figmaDerivedTextGlyphs).not.toBe(original.figmaDerivedTextGlyphs)
if (original.figmaDerivedTextGlyphs && clonedNode.figmaDerivedTextGlyphs) {
expect(clonedNode.figmaDerivedTextGlyphs[0]).not.toBe(original.figmaDerivedTextGlyphs[0])
expect(clonedNode.figmaDerivedTextGlyphs[0].commandsBlob).not.toBe(
original.figmaDerivedTextGlyphs[0].commandsBlob
)
clonedNode.figmaDerivedTextGlyphs[0].commandsBlob[0] = 99
expect(original.figmaDerivedTextGlyphs[0].commandsBlob[0]).toBe(10)
}
})
})
describe('cloneNodeProps deep-copies overrides values', () => {
test('mutating clone overrides does not affect original', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, { name: 'Comp', width: 100, height: 40 })
graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
const instance = graph.createInstance(component.id, page)
if (!instance) throw new Error('instance failed')
const instanceChild = graph.getChildren(instance.id)[0]
instance.overrides[`${instanceChild.id}:fills`] = [
{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, visible: true, opacity: 1 }
]
const clone = graph.cloneTree(instance.id, page)
if (!clone) throw new Error('clone failed')
const clonedInstance = graph.getNode(clone.id)
const overrideKey = `${instanceChild.id}:fills`
const cloneOverrideVal = clonedInstance.overrides[overrideKey] as Array<{
color: { r: number }
}>
if (cloneOverrideVal) cloneOverrideVal[0].color.r = 0.5
const origOverrideVal = instance.overrides[overrideKey] as Array<{ color: { r: number } }>
if (origOverrideVal) expect(origOverrideVal[0].color.r).toBe(0)
})
})
describe('cloneNodeProps coverage guard', () => {
test('cloneNodeProps does not crash when array fields are undefined', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const node = graph.createNode('RECTANGLE', page, { name: 'Rect' })
const raw = graph.getNode(node.id)
const patch: Partial<
Pick<
SceneNode,
| 'componentPropertyDefinitions'
| 'symbolLinks'
| 'exportSettings'
| 'pluginData'
| 'pluginRelaunchData'
>
> = {
componentPropertyDefinitions: undefined,
symbolLinks: undefined,
exportSettings: undefined,
pluginData: undefined,
pluginRelaunchData: undefined
}
Object.assign(raw, patch)
expect(() => graph.cloneTree(node.id, page)).not.toThrow()
})
test('cloneNodeProps does not share mutable object fields by default', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const node = graph.createNode('RECTANGLE', page, {
name: 'Rich node',
boundVariables: { 'fills/0/color': 'v1' },
overrides: { nested: { value: true } },
vectorNetwork: {
vertices: [{ x: 0, y: 0 }],
segments: [{ start: 0, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: [{ windingRule: 'NONZERO', loops: [[0]] }]
},
source: {
format: 'fig',
id: 'source-node',
orderKey: 'a',
fig: {
rawSize: { x: 1, y: 2 },
rawTransform: null,
rawNodeFields: { nested: { survives: true } },
layout: null,
symbolOverrides: [],
componentPropAssignments: [],
derivedSymbolData: [],
derivedSymbolDataLayoutVersion: 1,
uniformScaleFactor: 1
}
},
textPicture: new Uint8Array([1, 2, 3]),
figmaDerivedTextGlyphs: [{ commandsBlob: new Uint8Array([4, 5]), x: 0, y: 0, fontSize: 12 }],
gridPosition: { column: 1, row: 1, columnSpan: 1, rowSpan: 1 }
})
const cloneProps = cloneNodeProps(node, null)
for (const [key, value] of Object.entries(node) as Array<[keyof SceneNode, unknown]>) {
if (key === 'id' || key === 'parentId' || key === 'childIds') continue
if (value && typeof value === 'object') {
expect(cloneProps[key], `field ${String(key)} should be copied`).not.toBe(value)
}
}
const clonedNestedRaw = cloneProps.source?.fig.rawNodeFields.nested as { survives: boolean }
clonedNestedRaw.survives = false
const originalNestedRaw = node.source.fig.rawNodeFields.nested as { survives: boolean }
expect(originalNestedRaw.survives).toBe(true)
})
})