import { beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test' import { exportFigFile, initCodec, parseFigFile, SceneGraph, type SceneNode } from '@open-pencil/core' import { expectDefined } from '#tests/helpers/assert' import { collectAllNodes, countByType } from '#tests/helpers/fig-traversal' setDefaultTimeout(60_000) describe('roundtrip: export → re-import', () => { let reImported: SceneGraph let reImportedNodes: SceneNode[] beforeAll(async () => { await initCodec() const graph = new SceneGraph() const page1 = graph.getPages()[0] const page2 = graph.addPage('Second Page') const internalPage = graph.addPage('Internal Only Canvas') internalPage.internalOnly = true graph.createNode('RECTANGLE', internalPage.id, { name: 'Internal Rect', width: 50, height: 50 }) graph.createNode('FRAME', page1.id, { name: 'Container', x: 0, y: 0, width: 400, height: 300, layoutMode: 'VERTICAL', itemSpacing: 16, paddingTop: 24, paddingRight: 24, paddingBottom: 24, paddingLeft: 24, cornerRadius: 12, fills: [ { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' } ] }) const container = graph.getChildren(page1.id)[0] graph.createNode('RECTANGLE', container.id, { name: 'Header BG', x: 0, y: 0, width: 400, height: 80, cornerRadius: 8, topLeftRadius: 8, topRightRadius: 8, bottomRightRadius: 0, bottomLeftRadius: 0, independentCorners: true, fills: [ { type: 'SOLID', color: { r: 0.2, g: 0.4, b: 0.8, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }, { type: 'GRADIENT_LINEAR', color: { r: 0, g: 0, b: 0, a: 0 }, opacity: 0.5, visible: true, blendMode: 'NORMAL', gradientStops: [ { color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 }, { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 } ], gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } } ], 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, blendMode: 'NORMAL' } ] }) graph.createNode('TEXT', container.id, { name: 'Title', x: 24, y: 100, width: 352, height: 24, text: 'Hello World', fontSize: 18, fontFamily: 'Inter', fontWeight: 700, textAlignHorizontal: 'CENTER' }) graph.createNode('ELLIPSE', container.id, { name: 'Circle', x: 176, y: 140, width: 48, height: 48, fills: [ { type: 'SOLID', color: { r: 0.9, g: 0.1, b: 0.3, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' } ] }) graph.createNode('RECTANGLE', page2.id, { name: 'Page2 Rect', x: 50, y: 50, width: 100, height: 100 }) const figBytes = await exportFigFile(graph) reImported = await parseFigFile(figBytes.buffer as ArrayBuffer) reImportedNodes = collectAllNodes(reImported) }) test('preserves page count', () => { expect(reImported.getPages().length).toBe(2) }) test('preserves page names', () => { const names = reImported.getPages().map((p) => p.name) expect(names).toContain('Page 1') expect(names).toContain('Second Page') }) test('preserves internal pages', () => { const allPages = reImported.getPages(true) const publicPages = reImported.getPages(false) expect(allPages.length).toBe(3) expect(publicPages.length).toBe(2) const internal = allPages.find((p) => p.internalOnly) expect(internal).toBeDefined() expect(internal?.name).toBe('Internal Only Canvas') expect(reImported.getChildren(internal?.id ?? '').length).toBe(1) }) test('preserves node count', () => { // 1 frame + 1 rect + 1 text + 1 ellipse + 1 rect on page 2 = 5 expect(reImportedNodes.length).toBe(5) }) test('preserves node types', () => { const types = countByType(reImportedNodes) expect(types.get('FRAME')).toBe(1) expect(types.get('RECTANGLE')).toBe(2) expect(types.get('TEXT')).toBe(1) expect(types.get('ELLIPSE')).toBe(1) }) test('preserves component sets', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const componentSet = graph.createNode('COMPONENT_SET', page.id, { name: 'Button', width: 240, height: 80 }) graph.createNode('COMPONENT', componentSet.id, { name: 'Primary', x: 16, y: 16, width: 96, height: 40 }) const figBytes = await exportFigFile(graph) const parsed = await parseFigFile(figBytes.buffer as ArrayBuffer) const parsedSet = parsed.getAllNodes().find((node) => node.name === 'Button') expect(parsedSet).toBeDefined() expect(expectDefined(parsedSet, 'parsedSet').type).toBe('COMPONENT_SET') }) test('preserves node names', () => { const names = new Set(reImportedNodes.map((n) => n.name)) expect(names.has('Container')).toBe(true) expect(names.has('Header BG')).toBe(true) expect(names.has('Title')).toBe(true) expect(names.has('Circle')).toBe(true) expect(names.has('Page2 Rect')).toBe(true) }) test('preserves fills', () => { const headerBg = reImportedNodes.find((n) => n.name === 'Header BG') expect(headerBg).toBeDefined() expect(expectDefined(headerBg, 'headerBg').fills).toHaveLength(2) expect(headerBg.fills[0].type).toBe('SOLID') expect(headerBg.fills[0].color.r).toBeCloseTo(0.2, 1) expect(headerBg.fills[1].type).toBe('GRADIENT_LINEAR') expect(headerBg.fills[1].opacity).toBeCloseTo(0.5, 1) expect(headerBg.fills[1].gradientStops).toHaveLength(2) }) test('preserves text content', () => { const title = reImportedNodes.find((n) => n.name === 'Title') expect(title).toBeDefined() expect(expectDefined(title, 'title').text).toBe('Hello World') }) test('preserves text properties', () => { const title = reImportedNodes.find((n) => n.name === 'Title') expect(title).toBeDefined() expect(expectDefined(title, 'title').fontSize).toBe(18) expect(title.fontFamily).toBe('Inter') expect(title.fontWeight).toBe(700) expect(title.textAlignHorizontal).toBe('CENTER') }) test('preserves layout mode', () => { const container = reImportedNodes.find((n) => n.name === 'Container') expect(container).toBeDefined() expect(expectDefined(container, 'container').layoutMode).toBe('VERTICAL') }) test('preserves layout spacing', () => { const container = reImportedNodes.find((n) => n.name === 'Container') expect(container).toBeDefined() expect(expectDefined(container, 'container').itemSpacing).toBe(16) expect(container.paddingTop).toBe(24) expect(container.paddingRight).toBe(24) expect(container.paddingBottom).toBe(24) expect(container.paddingLeft).toBe(24) }) test('preserves corner radius', () => { const container = reImportedNodes.find((n) => n.name === 'Container') expect(container).toBeDefined() expect(expectDefined(container, 'container').cornerRadius).toBe(12) }) test('preserves independent corner radii', () => { const headerBg = reImportedNodes.find((n) => n.name === 'Header BG') expect(headerBg).toBeDefined() expect(expectDefined(headerBg, 'headerBg').independentCorners).toBe(true) expect(headerBg.topLeftRadius).toBe(8) expect(headerBg.topRightRadius).toBe(8) expect(headerBg.bottomRightRadius).toBe(0) expect(headerBg.bottomLeftRadius).toBe(0) }) test('preserves effects', () => { const headerBg = reImportedNodes.find((n) => n.name === 'Header BG') expect(headerBg).toBeDefined() expect(expectDefined(headerBg, 'headerBg').effects).toHaveLength(1) expect(headerBg.effects[0].type).toBe('DROP_SHADOW') expect(headerBg.effects[0].radius).toBe(8) expect(headerBg.effects[0].offset.y).toBe(4) }) test('preserves dimensions', () => { const container = reImportedNodes.find((n) => n.name === 'Container') expect(container).toBeDefined() expect(expectDefined(container, 'container').width).toBe(400) expect(container.height).toBe(300) }) })