import { describe, test, expect } from 'bun:test' import { SceneGraph } from '@open-pencil/core' function pageId(graph: SceneGraph) { return graph.getPages()[0].id } function rect(graph: SceneGraph, name: string, x = 0, y = 0, w = 50, h = 50) { return graph.createNode('RECTANGLE', pageId(graph), { name, x, y, width: w, height: h }).id } 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.type).toBe('RECTANGLE') expect(node.x).toBe(100) expect(node.width).toBe(200) }) 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('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('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('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.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.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))! const instLabel = graph.getChildren(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))! const instLabel = graph.getChildren(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(graph.getChildren(instance.id)).toHaveLength(1) graph.createNode('TEXT', comp.id, { name: 'Label', text: 'New' }) graph.syncInstances(comp.id) const instChildren = graph.getChildren(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.type).toBe('INSTANCE') graph.detachInstance(instance.id) expect(instance.type).toBe('FRAME') expect(instance.componentId).toBeNull() expect(graph.getInstances(comp.id)).toHaveLength(0) }) }) describe('Variables', () => { function pageId(graph: SceneGraph): string { return graph.getPages()[0]!.id } test('add and resolve color variable', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Colors', modes: [{ modeId: 'm1', name: 'Light' }], defaultModeId: 'm1', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'Primary', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { r: 0, g: 0.5, b: 1, a: 1 } }, description: '', hiddenFromPublishing: false }) const color = graph.resolveColorVariable('v1') expect(color).toEqual({ r: 0, g: 0.5, b: 1, a: 1 }) }) test('resolve number variable', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Spacing', modes: [{ modeId: 'm1', name: 'Default' }], defaultModeId: 'm1', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'spacing/md', type: 'FLOAT', collectionId: 'col1', valuesByMode: { m1: 16 }, description: '', hiddenFromPublishing: false }) expect(graph.resolveNumberVariable('v1')).toBe(16) }) test('resolve alias variable', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Tokens', modes: [{ modeId: 'm1', name: 'Light' }], defaultModeId: 'm1', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'Blue/500', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { r: 0, g: 0, b: 1, a: 1 } }, description: '', hiddenFromPublishing: false }) graph.addVariable({ id: 'v2', name: 'Primary', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { aliasId: 'v1' } }, description: '', hiddenFromPublishing: false }) expect(graph.resolveColorVariable('v2')).toEqual({ r: 0, g: 0, b: 1, a: 1 }) }) test('mode switching changes resolved value', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Theme', modes: [ { modeId: 'light', name: 'Light' }, { modeId: 'dark', name: 'Dark' } ], defaultModeId: 'light', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'bg', type: 'COLOR', collectionId: 'col1', valuesByMode: { light: { r: 1, g: 1, b: 1, a: 1 }, dark: { r: 0, g: 0, b: 0, a: 1 } }, description: '', hiddenFromPublishing: false }) expect(graph.resolveColorVariable('v1')).toEqual({ r: 1, g: 1, b: 1, a: 1 }) graph.setActiveMode('col1', 'dark') expect(graph.resolveColorVariable('v1')).toEqual({ r: 0, g: 0, b: 0, a: 1 }) }) test('bind and unbind variable to node', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' }) graph.bindVariable(node.id, 'fills/0/color', 'v1') expect(node.boundVariables['fills/0/color']).toBe('v1') graph.unbindVariable(node.id, 'fills/0/color') expect(node.boundVariables['fills/0/color']).toBeUndefined() }) test('removing variable cleans up bindings', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Colors', modes: [{ modeId: 'm1', name: 'Default' }], defaultModeId: 'm1', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'Red', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { r: 1, g: 0, b: 0, a: 1 } }, description: '', hiddenFromPublishing: false }) const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' }) graph.bindVariable(node.id, 'fills/0/color', 'v1') expect(node.boundVariables['fills/0/color']).toBe('v1') graph.removeVariable('v1') expect(node.boundVariables['fills/0/color']).toBeUndefined() expect(graph.variables.size).toBe(0) }) test('circular alias does not infinite loop', () => { const graph = new SceneGraph() graph.addCollection({ id: 'col1', name: 'Test', modes: [{ modeId: 'm1', name: 'Default' }], defaultModeId: 'm1', variableIds: [] }) graph.addVariable({ id: 'v1', name: 'A', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { aliasId: 'v2' } }, description: '', hiddenFromPublishing: false }) graph.addVariable({ id: 'v2', name: 'B', type: 'COLOR', collectionId: 'col1', valuesByMode: { m1: { aliasId: 'v1' } }, description: '', hiddenFromPublishing: false }) expect(graph.resolveColorVariable('v1')).toBeUndefined() }) }) describe('hitTest', () => { test('hits a rectangle', () => { const graph = new SceneGraph() const id = rect(graph, 'R', 10, 10, 50, 50) expect(graph.hitTest(35, 35, pageId(graph))?.id).toBe(id) }) test('misses empty space', () => { const graph = new SceneGraph() rect(graph, 'R', 10, 10, 50, 50) expect(graph.hitTest(200, 200, pageId(graph))).toBeNull() }) test('frame without fills is click-through', () => { const graph = new SceneGraph() graph.createNode('FRAME', pageId(graph), { name: 'Empty Frame', x: 0, y: 0, width: 200, height: 200, fills: [] }) expect(graph.hitTest(100, 100, pageId(graph))).toBeNull() }) test('frame with visible fill is hittable', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Filled Frame', x: 0, y: 0, width: 200, height: 200, fills: [ { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' } ] }) expect(graph.hitTest(100, 100, pageId(graph))?.id).toBe(frame.id) }) test('group returns group on hit, not child (Figma-style)', () => { const graph = new SceneGraph() const groupId = graph.createNode('GROUP', pageId(graph), { name: 'Group', x: 0, y: 0, width: 200, height: 200 }).id graph.createNode('RECTANGLE', groupId, { name: 'Child', x: 10, y: 10, width: 30, height: 30 }) // Hit returns group (single click selects group, dblclick enters) expect(graph.hitTest(20, 20, pageId(graph))?.id).toBe(groupId) // Miss in group's empty area expect(graph.hitTest(201, 200, pageId(graph))).toBeNull() }) test('clipsContent prevents hits outside parent bounds', () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Clip Frame', x: 0, y: 0, width: 100, height: 100, clipsContent: true, fills: [] }) const childId = graph.createNode('RECTANGLE', frame.id, { name: 'Overflow Child', x: 50, y: 50, width: 200, height: 200 }).id // Inside both frame and child — hit expect(graph.hitTest(75, 75, pageId(graph))?.id).toBe(childId) // Inside child but outside clipping frame — miss expect(graph.hitTest(150, 150, pageId(graph))).toBeNull() }) test('instance without fills is click-through in empty area', () => { const graph = new SceneGraph() const compId = graph.createNode('COMPONENT', pageId(graph), { name: 'Comp', x: 0, y: 0, width: 200, height: 200, fills: [] }).id graph.createNode('RECTANGLE', compId, { name: 'Inner', x: 10, y: 10, width: 30, height: 30 }) const instId = graph.createInstance(compId, pageId(graph), { x: 300, y: 0 }).id // Hit on instance's child area — returns instance (opaque container) expect(graph.hitTest(320, 20, pageId(graph))?.id).toBe(instId) // Miss on instance's empty area (no fills) expect(graph.hitTest(450, 150, pageId(graph))).toBeNull() }) })