import { describe, expect, test } from 'bun:test' import { generateId, SceneGraph } from '@open-pencil/core' import { expectDefined } from '#tests/helpers/assert' import { pageId, rect } from './helpers' describe('SceneGraph', () => { test('create rectangle', () => { const graph = new SceneGraph() const id = rect(graph, 'Rect', 100, 100, 200, 150) const node = graph.getNode(id) expect(node).toBeDefined() expect(expectDefined(node, 'node').type).toBe('RECTANGLE') expect(node.x).toBe(100) expect(node.width).toBe(200) }) test('create node skips imported ids that collide with the local generator', () => { const graph = new SceneGraph() const page = pageId(graph) const probe = generateId() const nextLocalId = `0:${Number(probe.split(':')[1]) + 1}` const imported = graph.createNode('RECTANGLE', page, { id: nextLocalId, name: 'Imported node' }) const countBefore = graph.nodes.size const created = graph.createNode('RECTANGLE', page, { name: 'Fresh node' }) expect(created.id).not.toBe(imported.id) expect(graph.nodes.size).toBe(countBefore + 1) expect(graph.getNode(imported.id)?.name).toBe('Imported node') expect(graph.getNode(created.id)?.name).toBe('Fresh node') }) test('create and delete', () => { const graph = new SceneGraph() const id = rect(graph, 'R') expect(graph.getNode(id)).toBeTruthy() graph.deleteNode(id) expect(graph.getNode(id)).toBeFalsy() }) test('delete frame recursively deletes children', () => { const graph = new SceneGraph() const page = pageId(graph) const frame = graph.createNode('FRAME', page, { name: 'F', x: 0, y: 0, width: 200, height: 200 }).id const child1 = graph.createNode('RECTANGLE', frame, { name: 'C1', x: 0, y: 0, width: 10, height: 10 }).id const child2 = graph.createNode('RECTANGLE', frame, { name: 'C2', x: 20, y: 20, width: 10, height: 10 }).id expect(graph.getNode(frame)).toBeTruthy() expect(graph.getNode(child1)).toBeTruthy() expect(graph.getNode(child2)).toBeTruthy() graph.deleteNode(frame) // Frame and all children should be deleted expect(graph.getNode(frame)).toBeFalsy() expect(graph.getNode(child1)).toBeFalsy() expect(graph.getNode(child2)).toBeFalsy() // Page should have no children expect(graph.getChildren(page)).toHaveLength(0) }) test('delete nested frame deletes all descendants', () => { const graph = new SceneGraph() const page = pageId(graph) const outer = graph.createNode('FRAME', page, { name: 'Outer', x: 0, y: 0, width: 300, height: 300 }).id const inner = graph.createNode('FRAME', outer, { name: 'Inner', x: 10, y: 10, width: 100, height: 100 }).id const leaf = graph.createNode('RECTANGLE', inner, { name: 'Leaf', x: 5, y: 5, width: 20, height: 20 }).id // Another top-level node to verify it survives const survivor = rect(graph, 'Survivor') graph.deleteNode(outer) expect(graph.getNode(outer)).toBeFalsy() expect(graph.getNode(inner)).toBeFalsy() expect(graph.getNode(leaf)).toBeFalsy() expect(graph.getNode(survivor)).toBeTruthy() expect(graph.getChildren(page)).toHaveLength(1) }) test('delete instance cleans up instance index', () => { const graph = new SceneGraph() const page = pageId(graph) const comp = graph.createNode('COMPONENT', page, { name: 'Comp', width: 100, height: 40 }) graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 }) const inst = graph.createInstance(comp.id, page) expect(inst).toBeDefined() expect(graph.getInstances(comp.id)).toHaveLength(1) graph.deleteNode(inst.id) expect(graph.getInstances(comp.id)).toHaveLength(0) }) test('delete root is no-op', () => { const graph = new SceneGraph() const rootId = graph.rootId const before = graph.nodes.size graph.deleteNode(rootId) expect(graph.nodes.size).toBe(before) }) test('reparent into frame', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 50, y: 50, width: 400, height: 400 }).id const r = rect(graph, 'R', 100, 100) graph.reparentNode(r, frame) const children = graph.getChildren(frame) expect(children.map((c) => c.id)).toContain(r) }) test('reparent to same parent is no-op', () => { const graph = new SceneGraph() const page = pageId(graph) const r = rect(graph, 'R', 100, 200) const node = graph.getNode(r) expect(node).toBeDefined() expect(expectDefined(node, 'node').parentId).toBe(page) graph.reparentNode(r, page) // Position should not change const after = graph.getNode(r) expect(after).toBeDefined() expect(expectDefined(after, 'updated node').x).toBe(100) expect(expectDefined(after, 'updated node').y).toBe(200) expect(after.parentId).toBe(page) expect(graph.getChildren(page)).toHaveLength(1) }) test('reparent preserves absolute position', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 200, y: 100, width: 400, height: 400 }).id const r = rect(graph, 'R', 300, 150) // Node is at (300, 150) absolute on page const absBefore = graph.getAbsolutePosition(r) expect(absBefore.x).toBe(300) expect(absBefore.y).toBe(150) graph.reparentNode(r, frame) // Now node is inside frame at (200,100), so local coords should be (100, 50) const after = graph.getNode(r) expect(after).toBeDefined() expect(expectDefined(after, 'updated node').x).toBe(100) expect(after.y).toBe(50) // Absolute position preserved const absAfter = graph.getAbsolutePosition(r) expect(absAfter.x).toBe(300) expect(absAfter.y).toBe(150) }) test('reparent removes from old parent childIds', () => { const graph = new SceneGraph() const page = pageId(graph) const frame1 = graph.createNode('FRAME', page, { name: 'F1', x: 0, y: 0, width: 200, height: 200 }).id const frame2 = graph.createNode('FRAME', page, { name: 'F2', x: 300, y: 0, width: 200, height: 200 }).id const r = rect(graph, 'R', 10, 10) // Move R into F1 first graph.reparentNode(r, frame1) expect(graph.getChildren(frame1).map((c) => c.id)).toContain(r) // Now reparent to F2 graph.reparentNode(r, frame2) expect(graph.getChildren(frame1).map((c) => c.id)).not.toContain(r) expect(graph.getChildren(frame2).map((c) => c.id)).toContain(r) }) test('reparent deep hierarchy preserves child positions', () => { const graph = new SceneGraph() const page = pageId(graph) const frame = graph.createNode('FRAME', page, { name: 'F', x: 50, y: 50, width: 200, height: 200 }).id const inner = graph.createNode('FRAME', page, { name: 'Inner', x: 100, y: 100, width: 80, height: 80 }).id const child = graph.createNode('RECTANGLE', inner, { name: 'Child', x: 10, y: 10, width: 20, height: 20 }).id // Child is at (110, 110) absolute expect(graph.getAbsolutePosition(child)).toEqual({ x: 110, y: 110 }) // Reparent inner frame into frame F graph.reparentNode(inner, frame) // Inner was at (100,100), frame is at (50,50), so inner's local = (50,50) const innerNode = graph.getNode(inner) expect(innerNode).toBeDefined() expect(expectDefined(innerNode, 'inner node').x).toBe(50) expect(innerNode.y).toBe(50) // Child still at (10,10) relative to inner, absolute = (110,110) expect(graph.getAbsolutePosition(child)).toEqual({ x: 110, y: 110 }) }) test('children order', () => { const graph = new SceneGraph() rect(graph, 'A') rect(graph, 'B') rect(graph, 'C') const names = graph.getChildren(pageId(graph)).map((n) => n.name) expect(names).toEqual(['A', 'B', 'C']) }) test('pages', () => { const graph = new SceneGraph() expect(graph.getPages()).toHaveLength(1) expect(graph.getPages()[0].name).toBe('Page 1') const page2 = graph.addPage('Page 2') expect(graph.getPages()).toHaveLength(2) expect(page2.name).toBe('Page 2') rect(graph, 'Shape', 0, 0, 50, 50) expect(graph.getChildren(pageId(graph))).toHaveLength(1) expect(graph.getChildren(page2.id)).toHaveLength(0) }) test('countDescendants returns 0 for empty page', () => { const graph = new SceneGraph() expect(graph.countDescendants(pageId(graph))).toBe(0) }) test('countDescendants counts direct children', () => { const graph = new SceneGraph() rect(graph, 'A') rect(graph, 'B') rect(graph, 'C') expect(graph.countDescendants(pageId(graph))).toBe(3) }) test('countDescendants counts nested descendants recursively', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'F', x: 0, y: 0, width: 200, height: 200 }) rect(graph, 'Top', 0, 0) // direct child of page graph.createNode('RECTANGLE', frame.id, { name: 'Inner1', x: 0, y: 0, width: 10, height: 10 }) graph.createNode('RECTANGLE', frame.id, { name: 'Inner2', x: 0, y: 0, width: 10, height: 10 }) // Page has 2 direct children (frame + Top) + frame has 2 children = 4 total expect(graph.countDescendants(pageId(graph))).toBe(4) }) test('countDescendants is per-page (multi-page document)', () => { const graph = new SceneGraph() const page1 = pageId(graph) const page2 = graph.addPage('Page 2').id // Add 3 nodes to page 1 rect(graph, 'A') rect(graph, 'B') rect(graph, 'C') // Add 1 node to page 2 graph.createNode('RECTANGLE', page2, { name: 'D', x: 0, y: 0, width: 10, height: 10 }) expect(graph.countDescendants(page1)).toBe(3) expect(graph.countDescendants(page2)).toBe(1) // Total nodes across all pages is 4 + 2 pages + 1 root = 7 expect(graph.nodes.size).toBe(7) }) test('update node', () => { const graph = new SceneGraph() const id = rect(graph, 'R') graph.updateNode(id, { x: 200, name: 'Updated' }) const node = graph.getNode(id) expect(node).toBeDefined() expect(expectDefined(node, 'node').x).toBe(200) expect(node.name).toBe('Updated') }) test('create instance clones children with componentId mapping', () => { const graph = new SceneGraph() const comp = graph.createNode('COMPONENT', pageId(graph), { name: 'Btn', width: 100, height: 40 }) const child = graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 }) const instance = graph.createInstance(comp.id, pageId(graph)) expect(instance).toBeDefined() expect(expectDefined(instance, 'instance').type).toBe('INSTANCE') expect(instance.componentId).toBe(comp.id) const instChildren = graph.getChildren(instance.id) expect(instChildren).toHaveLength(1) expect(instChildren[0].componentId).toBe(child.id) expect(instChildren[0].name).toBe('BG') }) test('syncInstances propagates changes from component to instance', () => { const graph = new SceneGraph() const comp = graph.createNode('COMPONENT', pageId(graph), { name: 'Card', width: 200, height: 100 }) const label = graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Hello', fontSize: 14 }) const instance = graph.createInstance(comp.id, pageId(graph)) expect(instance).toBeDefined() const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0] expect(instLabel.text).toBe('Hello') graph.updateNode(label.id, { text: 'Updated', fontSize: 18 }) graph.syncInstances(comp.id) expect(instLabel.text).toBe('Updated') expect(instLabel.fontSize).toBe(18) }) test('syncInstances preserves overrides', () => { const graph = new SceneGraph() const comp = graph.createNode('COMPONENT', pageId(graph), { name: 'Card', width: 200, height: 100 }) graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Default', fontSize: 14 }) const instance = graph.createInstance(comp.id, pageId(graph)) expect(instance).toBeDefined() const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0] // Override the text on the instance child graph.updateNode(instLabel.id, { text: 'Custom' }) instance.overrides[`${instLabel.id}:text`] = 'Custom' // Change component graph.updateNode(graph.getChildren(comp.id)[0].id, { text: 'New Default', fontSize: 20 }) graph.syncInstances(comp.id) // Text preserved (overridden), fontSize synced (not overridden) expect(instLabel.text).toBe('Custom') expect(instLabel.fontSize).toBe(20) }) test('syncInstances adds new children from component', () => { const graph = new SceneGraph() const comp = graph.createNode('COMPONENT', pageId(graph), { name: 'Card', width: 200, height: 100 }) graph.createNode('RECTANGLE', comp.id, { name: 'BG' }) const instance = graph.createInstance(comp.id, pageId(graph)) expect(instance).toBeDefined() expect(graph.getChildren(expectDefined(instance, 'instance').id)).toHaveLength(1) graph.createNode('TEXT', comp.id, { name: 'Label', text: 'New' }) graph.syncInstances(comp.id) const instChildren = graph.getChildren(expectDefined(instance, 'instance').id) expect(instChildren).toHaveLength(2) expect(instChildren[1].name).toBe('Label') expect(instChildren[1].text).toBe('New') }) test('detachInstance breaks link', () => { const graph = new SceneGraph() const comp = graph.createNode('COMPONENT', pageId(graph), { name: 'Btn', width: 100, height: 40 }) graph.createNode('RECTANGLE', comp.id, { name: 'BG' }) const instance = graph.createInstance(comp.id, pageId(graph)) expect(instance).toBeDefined() expect(expectDefined(instance, 'instance').type).toBe('INSTANCE') graph.detachInstance(expectDefined(instance, 'instance').id) expect(expectDefined(instance, 'instance').type).toBe('FRAME') expect(instance.componentId).toBeNull() expect(graph.getInstances(comp.id)).toHaveLength(0) }) })