openpencil/tests/engine/scene-graph/variable/binding/extended.test.ts

513 lines
18 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { nodeProxyToJSON } from '@open-pencil/core/figma-api/serialization'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
function setupColorVars(graph: SceneGraph, ...ids: string[]): void {
graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'm1', name: 'Light' }],
defaultModeId: 'm1',
variableIds: []
})
for (const id of ids) {
graph.addVariable({
id,
name: `Var ${id}`,
type: 'COLOR',
collectionId: 'col1',
valuesByMode: { m1: { r: 0.5, g: 0.5, b: 0.5, a: 1 } },
description: '',
hiddenFromPublishing: false
})
}
}
// ─── boundVariables sync from component to instance ──────────────────────
describe('INSTANCE_SYNC_PROPS includes boundVariables', () => {
function setupGraph(): SceneGraph {
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
})
graph.addVariable({
id: 'v2',
name: 'Secondary',
type: 'COLOR',
collectionId: 'col1',
valuesByMode: { m1: { r: 0, g: 0, b: 1, a: 1 } },
description: '',
hiddenFromPublishing: false
})
return graph
}
test('syncInstances propagates boundVariables from component to instance', () => {
const graph = setupGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
// Bind variable on component child
graph.bindVariable(child.id, 'fills/0/color', 'v1')
// Create instance
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
// Change component child's binding
graph.bindVariable(child.id, 'fills/0/color', 'v2')
// Sync
graph.syncInstances(component.id)
// Instance child should now have the updated binding
expect(instanceChild.boundVariables['fills/0/color']).toBe('v2')
})
test('synced boundVariables is an independent copy', () => {
const graph = setupGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(child.id, 'fills/0/color', 'v1')
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
const compChild = graph.getNode(child.id)
graph.syncInstances(component.id)
// After sync, boundVariables must still be independent objects
expect(instanceChild.boundVariables).not.toBe(compChild.boundVariables)
})
test('instance override blocks boundVariables sync', () => {
const graph = setupGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(child.id, 'fills/0/color', 'v1')
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
// Set an override to block boundVariables sync
instance.overrides[`${instanceChild.id}:boundVariables`] = true
// Change component child's binding
graph.bindVariable(child.id, 'fills/0/color', 'v2')
// Sync
graph.syncInstances(component.id)
// Instance child binding should NOT be overwritten (override takes precedence)
expect(instanceChild.boundVariables['fills/0/color']).toBe('v1')
})
})
// ─── bindVariable validation continued ────────────────────────────────────
describe('bindVariable validation continued', () => {
function setupGraph(): SceneGraph {
const graph = new SceneGraph()
graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'm1', name: 'Light' }],
defaultModeId: 'm1',
variableIds: []
})
graph.addVariable({
id: 'v-float',
name: 'Spacing',
type: 'FLOAT',
collectionId: 'col1',
valuesByMode: { m1: 16 },
description: '',
hiddenFromPublishing: false
})
return graph
}
test('bindVariable rejects unknown field names', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
expect(() => {
graph.bindVariable(node.id, 'customPluginField', 'v-float')
}).toThrow(/Unknown binding field/)
})
})
// ─── unbindVariable removes binding correctly ────────────────────────────
describe('unbindVariable', () => {
function setupGraph(): SceneGraph {
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.5, g: 0.5, b: 0.5, a: 1 } },
description: '',
hiddenFromPublishing: false
})
return graph
}
test('unbindVariable removes the binding from node', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBe('v1')
graph.unbindVariable(node.id, 'fills/0/color')
expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBeUndefined()
})
test('unbindVariable emits node:updated event', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const events: Array<{ nodeId: string; changes: Record<string, unknown> }> = []
graph.onNodeEvents({
updated: (nodeId, changes) => events.push({ nodeId, changes })
})
graph.unbindVariable(node.id, 'fills/0/color')
expect(events.length).toBe(1)
expect(events[0].nodeId).toBe(node.id)
expect('boundVariables' in events[0].changes).toBe(true)
})
test('unbindVariable on non-existent binding is a no-op', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
// Should not throw when unbinding a field that was never bound
expect(() => graph.unbindVariable(node.id, 'opacity')).not.toThrow()
})
test('unbindVariable does not emit event for non-existent binding', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
const events: Array<{ nodeId: string; changes: Record<string, unknown> }> = []
graph.onNodeEvents({
updated: (nodeId, changes) => events.push({ nodeId, changes })
})
graph.unbindVariable(node.id, 'opacity')
expect(events).toHaveLength(0)
})
})
// ─── nodeProxyToJSON includes boundVariables ─────────────────────────────
describe('nodeProxyToJSON boundVariables', () => {
test('nodeProxyToJSON includes boundVariables with resolved info', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const api = new FigmaAPI(graph)
const json = nodeProxyToJSON(graph, api, node.id)
expect(json.boundVariables).toBeDefined()
expect(json.boundVariables['fills/0/color']).toBeDefined()
expect(json.boundVariables['fills/0/color'].variableId).toBe('v1')
expect(json.boundVariables['fills/0/color'].variableName).toBe('Var v1')
})
test('nodeProxyToJSON omits boundVariables when none exist', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
const api = new FigmaAPI(graph)
const json = nodeProxyToJSON(graph, api, node.id)
expect(json.boundVariables).toBeUndefined()
})
})
// ─── Scope expansion: bindVariable range validation tests ────────────────
describe('bindVariable out-of-range index validation', () => {
function setupGraph(): SceneGraph {
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.5, g: 0.5, b: 0.5, a: 1 } },
description: '',
hiddenFromPublishing: false
})
return graph
}
test('bindVariable rejects index beyond current fills length', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [
{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 },
{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
// Node has 2 fills (indices 0, 1). Index 5 is out of range.
expect(() => {
graph.bindVariable(node.id, 'fills/5/color', 'v1')
}).toThrow(/out of range/)
})
test('bindVariable rejects next-index binding (index == currentLength)', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [
{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 },
{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
// Node has 2 fills (indices 0, 1). Index 2 is out of range (index >= length).
expect(() => {
graph.bindVariable(node.id, 'fills/2/color', 'v1')
}).toThrow(/out of range/)
})
test('bindVariable rejects binding to empty fills array (index 0, length 0)', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: []
})
// No fills exist. Index 0 is out of range (0 >= 0).
expect(() => {
graph.bindVariable(node.id, 'fills/0/color', 'v1')
}).toThrow(/out of range/)
})
test('bindVariable rejects out-of-range stroke index', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
strokes: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
// Node has 1 stroke. Index 3 is out of range.
expect(() => {
graph.bindVariable(node.id, 'strokes/3/color', 'v1')
}).toThrow(/out of range/)
})
test('bindVariable rejects unknown field names', () => {
const graph = setupGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
expect(() => {
graph.bindVariable(node.id, 'someRandomField', 'v1')
}).toThrow(/Unknown binding field/)
})
})
// ─── cleanupStaleBindings handles any indexed sub-path ──────────────────────
describe('cleanupStaleBindings handles any indexed sub-path', () => {
test('cleans up fills/N/something bindings when fills shrink past the index', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [
{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 },
{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
const n = graph.getNode(node.id)
// Bind a hypothetical non-color sub-path at index 1
n.boundVariables['fills/1/somethingElse'] = 'v1'
// Remove all fills — index 1 is now beyond the array (1 > 0)
graph.updateNode(node.id, { fills: [] })
// fills/1/somethingElse binding must be removed (index 1 > length 0)
expect(n.boundVariables['fills/1/somethingElse']).toBeUndefined()
})
test('fills/0/color binding is cleaned up when fills shrink to empty', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
// Shrink fills to empty — fills/0/color must be removed (0 >= 0 is true)
graph.updateNode(node.id, { fills: [] })
expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBeUndefined()
})
test('malformed non-numeric indexed keys are cleaned up on fills change', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
const n = graph.getNode(node.id)
// Simulate a malformed binding key from legacy data — non-numeric index portion
n.boundVariables['fills/blendMode'] = 'v1'
// Any fills change triggers cleanup — malformed key must be removed
graph.updateNode(node.id, {
fills: [{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }]
})
expect(n.boundVariables['fills/blendMode']).toBeUndefined()
})
test('negative indexed keys are cleaned up on fills change', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
const n = graph.getNode(node.id)
// Simulate a malformed binding key from legacy data — negative index
n.boundVariables['fills/-1/color'] = 'v1'
// Any fills change triggers cleanup — negative index must be removed
graph.updateNode(node.id, {
fills: [{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }]
})
expect(n.boundVariables['fills/-1/color']).toBeUndefined()
})
})
// ─── cleanupStaleBindings includes boundVariables in changes payload ──────
describe('cleanupStaleBindings includes boundVariables in changes payload', () => {
test('updateNode emits boundVariables in changes when stale bindings are cleaned', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [
{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 },
{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
graph.bindVariable(node.id, 'fills/1/color', 'v2')
const events: Array<{ nodeId: string; changes: Record<string, unknown> }> = []
graph.onNodeEvents({
updated: (nodeId, changes) => events.push({ nodeId, changes })
})
graph.updateNode(node.id, { fills: [] })
const updateEvent = events.find((e) => e.nodeId === node.id)
expect(updateEvent).toBeDefined()
if (updateEvent) {
expect('boundVariables' in updateEvent.changes).toBe(true)
}
})
})
// ─── bindVariable creates new reference (immutability) ──────────────────
describe('bindVariable creates new boundVariables reference', () => {
test('binding a variable replaces the boundVariables object reference', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5, a: 1 }, visible: true, opacity: 1 }]
})
const originalBV = graph.getNode(node.id).boundVariables
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const newBV = graph.getNode(node.id).boundVariables
expect(newBV).not.toBe(originalBV)
})
})