import { describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/core' import { collectSubtrees } from '@open-pencil/core/editor/clipboard/subtree-history' function pageId(graph: SceneGraph): string { return graph.getPages()[0].id } function setupColorVars(graph: SceneGraph, ...ids: string[]): void { graph.addCollection({ id: 'col1', name: 'Colors', modes: [{ modeId: 'm1', name: 'Light' }], defaultModeId: 'm1', variableIds: [] }) for (const id of ids) { graph.addVariable({ id, name: `Var ${id}`, type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { r: 0.5, g: 0.5, b: 0.5, a: 1 } }, description: '', hiddenFromPublishing: false }) } } // ─── cloneTree deep-copies boundVariables, overrides, styleRuns ───────────── describe('cloneTree deep-copies boundVariables, overrides, and styleRuns', () => { test('clone.boundVariables is not the same reference as original', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(node.id, 'fills/0/color', 'v1') const original = graph.getNode(node.id) const clone = graph.cloneTree(node.id, pageId(graph)) ?? (() => { throw new Error('clone failed') })() expect(clone).not.toBeNull() expect(clone.boundVariables).not.toBe(original.boundVariables) }) test('binding on clone does not corrupt original node', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(node.id, 'fills/0/color', 'v1') const original = graph.getNode(node.id) const clone = graph.cloneTree(node.id, pageId(graph)) ?? (() => { throw new Error('clone failed') })() graph.bindVariable(clone.id, 'fills/0/color', 'v2') // Original must still have v1 expect(original.boundVariables['fills/0/color']).toBe('v1') // Clone must have v2 expect(clone.boundVariables['fills/0/color']).toBe('v2') }) test('clone.overrides is not the same reference as original', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', overrides: { someKey: 'someValue' } }) const original = graph.getNode(node.id) const clone = graph.cloneTree(node.id, pageId(graph)) ?? (() => { throw new Error('clone failed') })() expect(clone.overrides).not.toBe(original.overrides) }) test('clone.styleRuns is not the same reference as original', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'Original', text: 'Hello', fontSize: 14, fontFamily: 'Inter', styleRuns: [{ start: 0, length: 5, style: { fontWeight: 700 } }] }) const original = graph.getNode(node.id) const clone = graph.cloneTree(node.id, pageId(graph)) ?? (() => { throw new Error('clone failed') })() expect(clone.styleRuns).not.toBe(original.styleRuns) // Deep check: styleRuns elements must also be independent if (original.styleRuns.length > 0 && clone.styleRuns.length > 0) { expect(clone.styleRuns[0]).not.toBe(original.styleRuns[0]) } }) test('clone.fills is not the same reference as original', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }] }) const original = graph.getNode(node.id) const clone = graph.cloneTree(node.id, pageId(graph)) ?? (() => { throw new Error('clone failed') })() expect(clone.fills).not.toBe(original.fills) // Fill elements should also be independent if (original.fills.length > 0 && clone.fills.length > 0) { expect(clone.fills[0]).not.toBe(original.fills[0]) } }) }) // ─── instance child binding independence ────────────────────────────────── describe('instance child bindings are independent from component', () => { test('instance child boundVariables is not shared with component child', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const page = pageId(graph) // Create component with child const component = graph.createNode('COMPONENT', page, { name: 'Button', width: 100, height: 40 }) const child = graph.createNode('RECTANGLE', component.id, { name: 'Bg', width: 100, height: 40, fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(child.id, 'fills/0/color', 'v1') const instance = graph.createInstance(component.id, page) ?? (() => { throw new Error('instance failed') })() const instanceChild = graph.getChildren(instance.id)[0] // Instance child's boundVariables must be independent const componentChild = graph.getNode(child.id)?.boundVariables expect(componentChild).toBeDefined() expect(instanceChild.boundVariables).not.toBe(componentChild) }) test('binding on instance child does not corrupt component child', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const page = pageId(graph) const component = graph.createNode('COMPONENT', page, { name: 'Button', width: 100, height: 40 }) const child = graph.createNode('RECTANGLE', component.id, { name: 'Bg', width: 100, height: 40, fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(child.id, 'fills/0/color', 'v1') const compChild = graph.getNode(child.id) const instance = graph.createInstance(component.id, page) ?? (() => { throw new Error('instance failed') })() const instanceChild = graph.getChildren(instance.id)[0] // Bind different variable on instance child graph.bindVariable(instanceChild.id, 'fills/0/color', 'v2') // Component child must still have v1 expect(compChild.boundVariables['fills/0/color']).toBe('v1') // Instance child must have v2 expect(instanceChild.boundVariables['fills/0/color']).toBe('v2') }) test('instance child overrides is not shared with component child', () => { const graph = new SceneGraph() const page = pageId(graph) const component = graph.createNode('COMPONENT', page, { name: 'Button', width: 100, height: 40 }) const child = graph.createNode('RECTANGLE', component.id, { name: 'Bg', width: 100, height: 40 }) const compChild = graph.getNode(child.id) const instance = graph.createInstance(component.id, page) ?? (() => { throw new Error('instance failed') })() const instanceChild = graph.getChildren(instance.id)[0] expect(instanceChild.overrides).not.toBe(compChild.overrides) }) test('instance child source.fig is not shared with component child', () => { const graph = new SceneGraph() const page = pageId(graph) const component = graph.createNode('COMPONENT', page, { name: 'Button', width: 100, height: 40 }) const child = graph.createNode('RECTANGLE', component.id, { name: 'Bg', width: 100, height: 40 }) // Set source.fig on component child graph.updateNode(child.id, { source: { ...graph.getNode(child.id).source, format: 'fig', fig: { rawSize: { x: 100, y: 40 }, rawTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, rawNodeFields: { visible: true, opacity: 1 }, layout: null, symbolOverrides: [], componentPropAssignments: [], derivedSymbolData: [], derivedSymbolDataLayoutVersion: null, uniformScaleFactor: null } } }) const compChild = graph.getNode(child.id) const instance = graph.createInstance(component.id, page) ?? (() => { throw new Error('instance failed') })() const instanceChild = graph.getChildren(instance.id)[0] // source.fig must be deep-copied, not shared if (compChild.source.fig && instanceChild.source.fig) { expect(instanceChild.source.fig).not.toBe(compChild.source.fig) // Mutating instance source.fig must not affect component instanceChild.source.fig.rawSize = { x: 999, y: 999 } expect(compChild.source.fig.rawSize).toEqual({ x: 100, y: 40 }) } }) }) // ─── set_fill/set_stroke clean up stale bindings ───────────────────────── describe('set_fill/set_stroke clean up stale bindings', () => { test('set_fill removes fills/N/color bindings that are out of range', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect', fills: [ { type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }, { type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 } ] }) // Bind both fills graph.bindVariable(node.id, 'fills/0/color', 'v1') graph.bindVariable(node.id, 'fills/1/color', 'v2') const n = graph.getNode(node.id) expect(n.boundVariables['fills/0/color']).toBe('v1') expect(n.boundVariables['fills/1/color']).toBe('v2') // Replace fills with an empty array via updateNode (same path tools use) graph.updateNode(node.id, { fills: [] }) // fills/1/color binding must be removed (index 1 > length 0) expect(n.boundVariables['fills/1/color']).toBeUndefined() }) test('set_fill removes top-level fills binding (dead data)', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' }) const n = graph.getNode(node.id) // Set a top-level 'fills' binding (dead data — renderer never reads this) n.boundVariables['fills'] = 'v1' // Replace fills via updateNode (same path tools use) graph.updateNode(node.id, { fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }] }) // Top-level 'fills' binding should be cleaned up expect(n.boundVariables['fills']).toBeUndefined() }) }) // ─── collectSubtrees uses structuredClone ────────────────────────────────── describe('collectSubtrees deep-copies nodes', () => { test('collectSubtrees does not share boundVariables reference', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect', fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(node.id, 'fills/0/color', 'v1') const original = graph.getNode(node.id) const collected = collectSubtrees(graph, [node.id]) expect(collected.length).toBe(1) expect(collected[0].boundVariables).not.toBe(original.boundVariables) }) }) // ─── undo of binding on clone restores original correctly ────────────────── describe('undo of binding on clone restores original state', () => { test('bind on clone, then undo restores original binding state', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(node.id, 'fills/0/color', 'v1') const clone = graph.cloneTree(node.id, pageId(graph)) graph.bindVariable(clone.id, 'fills/0/color', 'v2') expect(graph.getNode(clone.id).boundVariables['fills/0/color']).toBe('v2') // Original should be unaffected expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBe('v1') }) }) // ─── variable deletion and re-clone safety ────────────────────────────── describe('variable deletion and re-clone safety', () => { test('deleting a variable removes bindings and re-cloning works safely', () => { const graph = new SceneGraph() setupColorVars(graph, 'v1', 'v2') const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Original', fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }] }) graph.bindVariable(node.id, 'fills/0/color', 'v1') const clone1 = graph.cloneTree(node.id, pageId(graph)) expect(graph.getNode(clone1.id).boundVariables['fills/0/color']).toBe('v1') // Delete the variable — this cleans up bindings on all nodes graph.removeVariable('v1') // Bindings are removed from both original and clone expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBeUndefined() expect(graph.getNode(clone1.id).boundVariables['fills/0/color']).toBeUndefined() // Clone again — should not crash, clone has no stale binding const clone2 = graph.cloneTree(node.id, pageId(graph)) expect(clone2).toBeDefined() expect(graph.getNode(clone2.id).boundVariables['fills/0/color']).toBeUndefined() }) })