openpencil/tests/engine/scene-graph/basic/variables.test.ts
Danila Poyarkov e704343f13 fix(fig): honor node-scoped variable modes
- Import and round-trip per-node variable mode overrides

- Resolve fill and stroke aliases through the nearest mode scope

- Preserve mode maps across cloning and instance synchronization
2026-07-21 00:23:00 +03:00

321 lines
8.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
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('node variable modes inherit from the nearest ancestor', () => {
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
})
const page = graph.addPage('Page')
const frame = graph.createNode('FRAME', page.id, { variableModes: { col1: 'dark' } })
const child = graph.createNode('RECTANGLE', frame.id)
const nestedOverride = graph.createNode('RECTANGLE', frame.id, {
variableModes: { col1: 'light' }
})
expect(graph.resolveColorVariableForNode(child.id, 'v1')).toEqual({
r: 0,
g: 0,
b: 0,
a: 1
})
expect(graph.resolveColorVariableForNode(nestedOverride.id, 'v1')).toEqual({
r: 1,
g: 1,
b: 1,
a: 1
})
})
test('missing active mode falls back to default 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 } },
description: '',
hiddenFromPublishing: false
})
graph.setActiveMode('col1', 'dark')
expect(graph.resolveColorVariable('v1')).toEqual({ r: 1, g: 1, b: 1, a: 1 })
})
test('explicit alias resolution preserves source mode', () => {
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: 'base',
name: 'base',
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
})
graph.addVariable({
id: 'alias',
name: 'alias',
type: 'COLOR',
collectionId: 'col1',
valuesByMode: {
light: { aliasId: 'base' },
dark: { aliasId: 'base' }
},
description: '',
hiddenFromPublishing: false
})
expect(graph.resolveVariable('alias', 'dark')).toEqual({ r: 0, g: 0, b: 0, a: 1 })
})
test('bind and unbind variable to node', () => {
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: 1, g: 0, b: 0, a: 1 } },
description: '',
hiddenFromPublishing: false
})
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
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',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
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()
})
})