openpencil/tests/engine/io/fig/roundtrip/basic.test.ts
2026-05-19 00:23:23 +03:00

290 lines
8.5 KiB
TypeScript

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)
})
})