openpencil/tests/engine/io/fig/roundtrip/variables.test.ts
Danila Poyarkov 584400e017 fix(fig): preserve variable references on export
- Reuse imported collection, mode, and variable GUIDs when safe

- Keep raw paint asset refs instead of promoting incomplete library bindings

- Retain GUID conversion only for effect aliases required by Kiwi
2026-07-21 01:16:37 +03:00

255 lines
8.5 KiB
TypeScript

import { describe, expect, setDefaultTimeout, test } from 'bun:test'
import {
exportFigFile,
FigmaAPI,
initCodec,
parseFigFile,
SceneGraph,
type Color
} from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { parseFixture } from '#tests/helpers/fig-fixtures'
import { runsHeavyTests } from '#tests/helpers/test-utils'
setDefaultTimeout(60_000)
describe('variable roundtrip', () => {
test('variables and collections survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Design Tokens')
graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 })
graph.createVariable('spacing/base', 'FLOAT', col.id, 8)
graph.createVariable('visible', 'BOOLEAN', col.id, true)
graph.createVariable('label', 'STRING', col.id, 'Hello')
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(4)
expect(reimported.variableCollections.size).toBe(1)
const reimportedCol = [...reimported.variableCollections.values()][0]
expect(reimportedCol.name).toBe('Design Tokens')
expect(reimportedCol.variableIds).toHaveLength(4)
const vars = [...reimported.variables.values()]
const colorVar = vars.find((v) => v.name === 'color/primary')
expect(colorVar).toBeDefined()
expect(expectDefined(colorVar, 'colorVar').type).toBe('COLOR')
const colorVal = Object.values(colorVar.valuesByMode)[0] as Color
expect(colorVal.r).toBeCloseTo(0.23, 1)
const floatVar = vars.find((v) => v.name === 'spacing/base')
expect(floatVar).toBeDefined()
expect(expectDefined(floatVar, 'floatVar').type).toBe('FLOAT')
expect(Object.values(floatVar.valuesByMode)[0]).toBe(8)
const boolVar = vars.find((v) => v.name === 'visible')
expect(boolVar).toBeDefined()
expect(expectDefined(boolVar, 'boolVar').type).toBe('BOOLEAN')
expect(Object.values(boolVar.valuesByMode)[0]).toBe(true)
const strVar = vars.find((v) => v.name === 'label')
expect(strVar).toBeDefined()
expect(expectDefined(strVar, 'strVar').type).toBe('STRING')
expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello')
})
test('variable bindings survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Tokens')
const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12)
const fillVar = graph.createVariable('surface', 'COLOR', col.id, { r: 1, g: 1, b: 1, a: 1 })
const strokeVar = graph.createVariable('border', 'COLOR', col.id, {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1
})
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, {
name: 'Bound Rect',
width: 100,
height: 100,
cornerRadius: 12,
fills: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
],
strokes: [
{
color: { r: 0.1, g: 0.2, b: 0.3, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE',
cap: 'NONE',
join: 'MITER',
dashPattern: []
}
]
})
graph.bindVariable(rect.id, 'cornerRadius', floatVar.id)
graph.bindVariable(rect.id, 'fills/0/color', fillVar.id)
graph.bindVariable(rect.id, 'strokes/0/color', strokeVar.id)
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect')
expect(reimportedRect).toBeDefined()
expect(Object.keys(expectDefined(reimportedRect, 'reimportedRect').boundVariables)).toContain(
'cornerRadius'
)
expect(Object.keys(reimportedRect.boundVariables)).toContain('fills/0/color')
expect(Object.keys(reimportedRect.boundVariables)).toContain('strokes/0/color')
})
test('node-scoped variable modes survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
graph.addCollection({
id: '4:55',
name: 'Theme',
modes: [
{ modeId: '4:1', name: 'Light' },
{ modeId: '4:2', name: 'Dark' }
],
defaultModeId: '4:1',
variableIds: []
})
graph.addVariable({
id: '5:1',
name: 'Background',
type: 'COLOR',
collectionId: '4:55',
valuesByMode: {
'4:1': { r: 1, g: 1, b: 1, a: 1 },
'4:2': { r: 0, g: 0, b: 0, a: 1 }
},
description: '',
hiddenFromPublishing: false
})
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Dark scope',
variableModes: { '4:55': '4:2' }
})
graph.createNode('RECTANGLE', frame.id, { name: 'Scoped child' })
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const importedFrame = expectDefined(
[...reimported.getAllNodes()].find((node) => node.name === 'Dark scope'),
'dark scope'
)
const importedChild = expectDefined(
[...reimported.getAllNodes()].find((node) => node.name === 'Scoped child'),
'scoped child'
)
const importedBackground = expectDefined(
[...reimported.variables.values()].find((variable) => variable.name === 'Background'),
'background variable'
)
const importedCollection = expectDefined(
reimported.variableCollections.get(importedBackground.collectionId),
'theme collection'
)
const importedDarkMode = expectDefined(
importedCollection.modes.find((mode) => mode.name === 'Dark'),
'dark mode'
)
expect(importedCollection.id).toBe('4:55')
expect(importedBackground.id).toBe('5:1')
expect(importedDarkMode.modeId).toBe('4:2')
expect(importedFrame.variableModes).toEqual({
[importedCollection.id]: importedDarkMode.modeId
})
expect(reimported.resolveColorVariableForNode(importedChild.id, importedBackground.id)).toEqual(
{
r: 0,
g: 0,
b: 0,
a: 1
}
)
})
test.if(runsHeavyTests)(
'material3.fig variables survive round-trip',
async () => {
const original = await parseFixture('material3.fig')
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(original.variables.size)
expect(reimported.variableCollections.size).toBeGreaterThanOrEqual(
[...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length
)
},
120_000
)
test('pluginID casing is consistent across full codec pipeline', async () => {
await initCodec()
// Create a graph with multiple nodes having pluginData entries
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
frame.name = 'PluginID Test'
frame.setPluginData('key1', 'value1')
frame.setPluginData('key2', 'value2')
const rect = api.createRectangle()
rect.name = 'Plugin Rect'
rect.setPluginData('testKey', 'testValue')
// Round-trip through the codec
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
// Find all nodes with pluginData
let nodesWithPluginData = 0
let totalEntries = 0
for (const node of reimported.getAllNodes()) {
if (node.pluginData && node.pluginData.length > 0) {
nodesWithPluginData++
for (const entry of node.pluginData) {
totalEntries++
// Every entry MUST use pluginId (lowercase d, matching SceneGraph type)
expect(entry).toHaveProperty('pluginId')
expect(typeof entry.pluginId).toBe('string')
expect(entry.pluginId.length).toBeGreaterThan(0)
}
}
// Also check pluginRelaunchData entries
if (node.pluginRelaunchData && node.pluginRelaunchData.length > 0) {
for (const entry of node.pluginRelaunchData) {
expect(entry).toHaveProperty('pluginId')
expect(typeof entry.pluginId).toBe('string')
}
}
}
// Should have at least 2 nodes with plugin data (frame + rect)
expect(nodesWithPluginData).toBeGreaterThanOrEqual(2)
expect(totalEntries).toBeGreaterThanOrEqual(3)
})
})