openpencil/packages/scene-graph/tests/clone/node-props.test.ts
Danila Poyarkov 7140328b1d
test(core): move the fig round-trip suite home, and stop test imports drilling
* test(core): move the fig round-trip suite into the package test home

The reader change added `verifier-contracts.test.ts` under
`tests/engine` and grew the migration baseline to admit it, which the
testing architecture forbids: new tests go to the canonical home and
the baseline only shrinks. It also started
`packages/core/tests/io/formats/fig/roundtrip/`, leaving two homes for
one domain.

These tests drive Core's exporter, so Core's home is the canonical one.
The directory moves whole — eleven files, its helpers and raw verifiers
with it — and picks up the local `assert`, `traversal`, `test-utils`
and fixture helpers already there. The baseline loses nine entries.

* refactor(core): address test helpers by alias instead of drilling

`open-pencil/no-deep-parent-relative-imports` already forbids `../../`
drilling, but `lint:structure` names the directories it checks and
`packages/{core,scene-graph,vue}/tests` were never added — so the 72
drilled imports in Core's tests, including the ones the round-trip move
just wrote, were never seen.

Core gains `#core-tests/*` beside fig's `#fig-tests/*`, registered with
the architecture rules, and its tests reach their helpers through it
and their source through `#core/*`. The three test directories join
`lint:structure`, which also surfaced six errors they had never been
checked for: four duplicated imports, an empty `noop`, and a
self-assignment the test meant, now written through a named binding.

Both guides state the rule, since a lint nobody can find in prose is
how this went unnoticed.

* fix(tools): point the heavy unit patterns at the relocated round-trip tests

`HEAVY_UNIT_TEST_PATTERNS` still listed the three heavy round-trip
tests under `tests/engine`. `test:unit` discovers them by directory so
the move looked clean, but `--heavy-only` selects by path and silently
stopped choosing them: 8 files, 52 tests, where it now runs 11 and 80.
2026-10-01 14:11:53 +04:00

261 lines
9.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph, getInstanceOverride, setInstanceOverride, type SceneNode } from '@open-pencil/scene-graph'
import { cloneNodeProps } from '@open-pencil/scene-graph/copy'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
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 = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, 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 = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, 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 derivedTextGlyphs', () => {
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 = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, 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 derivedTextGlyphs 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',
derivedTextGlyphs: [
{ commandsBlob: new Uint8Array([10, 20]), x: 0, y: 0, fontSize: 14 },
{ commandsBlob: new Uint8Array([30, 40]), x: 10, y: 0, fontSize: 14 }
]
})
const original = getNodeOrThrow(graph, node.id)
const clone = expectDefined(graph.cloneTree(node.id, pageId(graph)), 'clone')
const clonedNode = getNodeOrThrow(graph, clone.id)
expect(clonedNode.derivedTextGlyphs).not.toBe(original.derivedTextGlyphs)
if (original.derivedTextGlyphs && clonedNode.derivedTextGlyphs) {
expect(clonedNode.derivedTextGlyphs[0]).not.toBe(original.derivedTextGlyphs[0])
expect(clonedNode.derivedTextGlyphs[0].commandsBlob).not.toBe(
original.derivedTextGlyphs[0].commandsBlob
)
clonedNode.derivedTextGlyphs[0].commandsBlob[0] = 99
expect(original.derivedTextGlyphs[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 = expectDefined(graph.createInstance(component.id, page), 'instance')
if (!instance) throw new Error('instance failed')
const instanceChild = graph.getChildren(instance.id)[0]
setInstanceOverride(instance.instanceOverrides, instance.id, instanceChild.id, 'fills', [
{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, visible: true, opacity: 1 }
])
const clone = expectDefined(graph.cloneTree(instance.id, page), 'clone')
if (!clone) throw new Error('clone failed')
const clonedInstance = getNodeOrThrow(graph, clone.id)
const cloneOverrideVal = getInstanceOverride(
clonedInstance.instanceOverrides,
clonedInstance.id,
instanceChild.id,
'fills'
) as Array<{
color: { r: number }
}>
if (cloneOverrideVal) cloneOverrideVal[0].color.r = 0.5
const origOverrideVal = getInstanceOverride(
instance.instanceOverrides,
instance.id,
instanceChild.id,
'fills'
) as Array<{ color: { r: number } }>
if (origOverrideVal) expect(origOverrideVal[0].color.r).toBe(0)
})
})
describe('cloneNodeProps fig import mode', () => {
test('shares immutable payloads while resetting provenance and mutable maps', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Imported component child',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }],
boundVariables: { 'fills/0/color': 'v1' },
source: {
format: 'fig',
id: 'fig-source',
orderKey: 'a',
editedFields: [],
fig: {
rawSize: { x: 100, y: 40 },
rawTransform: null,
rawNodeFields: { derivedTextData: { large: true } },
layout: null,
symbolOverrides: [],
componentPropAssignments: [],
derivedSymbolData: [],
derivedSymbolDataLayoutVersion: null,
uniformScaleFactor: null
}
}
})
const cloneProps = cloneNodeProps(node, node.id, 'fig-import')
expect(cloneProps.fills).toBe(node.fills)
expect(cloneProps.boundVariables).not.toBe(node.boundVariables)
expect(cloneProps.source?.format).toBeNull()
expect(cloneProps.source?.fig.rawNodeFields).toEqual({})
})
})
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 = getNodeOrThrow(graph, 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' },
instanceOverrides: { self: new Map(), descendants: new Map() },
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',
editedFields: [],
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]),
derivedTextGlyphs: [{ 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)
})
})