import { describe, expect, test } from 'bun:test' import { SceneGraph, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core' function makeGraph() { const graph = new SceneGraph() graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' }) return graph } function pageId(graph: SceneGraph) { return graph.getPages()[0].id } describe('sceneNodeToJSX', () => { test('basic rectangle', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Box', width: 100, height: 50 }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toBe('') }) test('rectangle with fill and rounded corners', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Card', width: 320, height: 200, fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], cornerRadius: 16 }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('w={320}') expect(jsx).toContain('h={200}') expect(jsx).toContain('bg="#FFFFFF"') expect(jsx).toContain('rounded={16}') expect(jsx).toContain('') }) test('text node', () => { const graph = makeGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'Title', width: 200, height: 24, text: 'Hello World', fontSize: 18, fontWeight: 700, fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('Hello World') }) test('rtl text node', () => { const graph = makeGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'Arabic Title', width: 200, height: 24, text: 'مرحبا', textDirection: 'RTL' }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('dir="rtl"') }) test('frame with auto-layout', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Row', width: 400, height: 100, layoutMode: 'HORIZONTAL', itemSpacing: 16, paddingTop: 12, paddingRight: 12, paddingBottom: 12, paddingLeft: 12, primaryAxisSizing: 'FIXED', counterAxisSizing: 'HUG' }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('flex="row"') expect(jsx).toContain('gap={16}') expect(jsx).toContain('p={12}') expect(jsx).toContain('w={400}') }) test('frame with rtl auto-layout', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Row RTL', width: 400, height: 100, layoutMode: 'HORIZONTAL', layoutDirection: 'RTL' }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('dir="rtl"') }) test('frame with children', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Card', width: 320, height: 200, layoutMode: 'VERTICAL', itemSpacing: 8, primaryAxisSizing: 'HUG', counterAxisSizing: 'FIXED' }) graph.createNode('RECTANGLE', frame.id, { name: 'Image', width: 320, height: 120 }) graph.createNode('TEXT', frame.id, { name: 'Title', width: 200, height: 20, text: 'Card Title', fontSize: 16, fontWeight: 700, fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('Card Title') expect(jsx).toContain('') }) test('asymmetric padding', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Box', width: 100, height: 100, layoutMode: 'HORIZONTAL', paddingTop: 8, paddingRight: 16, paddingBottom: 8, paddingLeft: 16, primaryAxisSizing: 'FIXED', counterAxisSizing: 'FIXED' }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('py={8}') expect(jsx).toContain('px={16}') expect(jsx).not.toContain('pt=') expect(jsx).not.toContain('pb=') }) test('independent corners', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Pill', width: 100, height: 40, cornerRadius: 20, independentCorners: true, topLeftRadius: 20, topRightRadius: 0, bottomRightRadius: 0, bottomLeftRadius: 20 }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('roundedTL={20}') expect(jsx).toContain('roundedBL={20}') expect(jsx).not.toContain('roundedTR=') expect(jsx).not.toContain('roundedBR=') }) test('opacity and rotation', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { width: 50, height: 50, opacity: 0.5, rotation: 45 }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('opacity={0.5}') expect(jsx).toContain('rotate={45}') }) test('stroke', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { width: 100, height: 100, strokes: [ { color: { r: 1, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'INSIDE' as const } ] }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('stroke="#FF0000"') expect(jsx).toContain('strokeWidth={2}') }) test('hidden children are excluded', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Parent', width: 100, height: 100 }) graph.createNode('RECTANGLE', frame.id, { name: 'Visible', width: 50, height: 50 }) graph.createNode('RECTANGLE', frame.id, { name: 'Hidden', width: 50, height: 50, visible: false }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('Visible') expect(jsx).not.toContain('Hidden') }) test('empty text node renders self-closing', () => { const graph = makeGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'Empty', width: 100, height: 20, text: '' }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('/>') expect(jsx).not.toContain('') }) test('omits default values', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { width: 100, height: 100, opacity: 1, rotation: 0, cornerRadius: 0 }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).not.toContain('opacity') expect(jsx).not.toContain('rotate') expect(jsx).not.toContain('rounded') }) test('effects: shadow and blur', () => { const graph = makeGraph() const node = graph.createNode('RECTANGLE', pageId(graph), { width: 100, height: 100, effects: [ { type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.25 }, offset: { x: 0, y: 4 }, radius: 8, spread: 0, visible: true }, { type: 'LAYER_BLUR', color: { r: 0, g: 0, b: 0, a: 0 }, offset: { x: 0, y: 0 }, radius: 4, spread: 0, visible: true } ] }) const jsx = sceneNodeToJSX(node.id, graph) expect(jsx).toContain('shadow="0 4 8') expect(jsx).toContain('blur={4}') }) test('grid layout frame', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Grid', width: 400, height: 300, layoutMode: 'GRID', gridTemplateColumns: [ { sizing: 'FR', value: 1 }, { sizing: 'FR', value: 1 }, { sizing: 'FR', value: 1 } ], gridTemplateRows: [ { sizing: 'FR', value: 1 }, { sizing: 'FR', value: 1 } ], gridColumnGap: 16, gridRowGap: 8, paddingTop: 12, paddingRight: 12, paddingBottom: 12, paddingLeft: 12 }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('grid') expect(jsx).toContain('columns="1fr 1fr 1fr"') expect(jsx).toContain('rows="1fr 1fr"') expect(jsx).toContain('columnGap={16}') expect(jsx).toContain('rowGap={8}') expect(jsx).toContain('p={12}') expect(jsx).toContain('w={400}') expect(jsx).not.toContain('flex') }) test('grid with mixed track sizes', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { width: 600, height: 400, layoutMode: 'GRID', gridTemplateColumns: [ { sizing: 'FIXED', value: 200 }, { sizing: 'FR', value: 1 }, { sizing: 'AUTO', value: 0 } ], gridTemplateRows: [], gridColumnGap: 0, gridRowGap: 0 }) const jsx = sceneNodeToJSX(frame.id, graph) expect(jsx).toContain('columns="200px 1fr auto"') expect(jsx).not.toContain('rows=') expect(jsx).not.toContain('columnGap') }) test('child grid position', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { width: 400, height: 300, layoutMode: 'GRID', gridTemplateColumns: [ { sizing: 'FR', value: 1 }, { sizing: 'FR', value: 1 } ], gridTemplateRows: [ { sizing: 'FR', value: 1 }, { sizing: 'FR', value: 1 } ], gridColumnGap: 0, gridRowGap: 0 }) const child = graph.createNode('RECTANGLE', frame.id, { name: 'Span', width: 100, height: 50, gridPosition: { column: 1, row: 2, columnSpan: 2, rowSpan: 1 } }) const jsx = sceneNodeToJSX(child.id, graph) expect(jsx).toContain('colStart={1}') expect(jsx).toContain('rowStart={2}') expect(jsx).toContain('colSpan={2}') expect(jsx).not.toContain('rowSpan') }) }) describe('selectionToJSX', () => { test('multiple nodes separated by blank lines', () => { const graph = makeGraph() const a = graph.createNode('RECTANGLE', pageId(graph), { name: 'A', width: 10, height: 10 }) const b = graph.createNode('ELLIPSE', pageId(graph), { name: 'B', width: 20, height: 20 }) const jsx = selectionToJSX([a.id, b.id], graph) expect(jsx).toContain('