openpencil/tests/engine/editor/components/properties.test.ts
Danila Poyarkov 23645d5a2d feat(editor): add component property controls
- Model typed component definitions, references, assignments, and preferred swap values

- Add SDK-owned variant, text, boolean, and nested instance-swap controls with mixed-selection undo

- Preserve assignments across variant changes and component synchronization

- Round-trip component property metadata through .fig import and export
2026-07-17 21:59:49 +03:00

134 lines
6 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
function childByName(editor: ReturnType<typeof createEditor>, parentId: string, name: string) {
return editor.graph.getChildren(parentId).find((node) => node.name === name)
}
function setupComponentProperties() {
const editor = createEditor()
const pageId = editor.state.currentPageId
const iconA = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon A' })
editor.graph.createNode('RECTANGLE', iconA.id, { name: 'A shape' })
const iconB = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon B' })
editor.graph.createNode('ELLIPSE', iconB.id, { name: 'B shape' })
const component = editor.graph.createNode('COMPONENT', pageId, {
name: 'Card',
componentPropertyDefinitions: [
{ id: '10:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
{ id: '10:2', name: 'Show icon', type: 'BOOLEAN', defaultValue: 'true' },
{ id: '10:3', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: iconA.id }
]
})
editor.graph.createNode('TEXT', component.id, {
name: 'Label',
text: 'Default',
componentPropertyReferences: [{ propertyId: '10:1', field: 'TEXT' }]
})
editor.graph.createNode('FRAME', component.id, {
name: 'Badge',
componentPropertyReferences: [{ propertyId: '10:2', field: 'VISIBLE' }]
})
const sourceIcon = editor.graph.createInstance(iconA.id, component.id, { name: 'Icon' })
if (!sourceIcon) throw new Error('Expected nested source instance')
editor.graph.updateNode(sourceIcon.id, {
componentPropertyReferences: [{ propertyId: '10:3', field: 'INSTANCE_SWAP' }]
})
const instance = editor.graph.createInstance(component.id, pageId)
if (!instance) throw new Error('Expected card instance')
return { editor, component, instance, iconA, iconB }
}
describe('component property actions', () => {
test('applies and undoes text, boolean, and instance-swap properties', () => {
const { editor, instance, iconA, iconB } = setupComponentProperties()
editor.setInstanceComponentProperty(instance.id, '10:1', 'Changed')
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Changed')
expect(editor.graph.getNode(instance.id)?.componentPropertyAssignments['10:1']).toBe('Changed')
editor.undo.undo()
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Default')
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
editor.undo.undo()
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(true)
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
const swapped = childByName(editor, instance.id, 'Icon B')
expect(swapped?.componentId).toBe(iconB.id)
expect(swapped?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['B shape'])
editor.setInstanceComponentProperty(instance.id, '10:3', iconA.id)
editor.undo.undo()
expect(childByName(editor, instance.id, 'Icon B')?.componentId).toBe(iconB.id)
editor.undo.undo()
const restored = childByName(editor, instance.id, 'Icon A')
expect(restored?.componentId).toBe(iconA.id)
expect(restored?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['A shape'])
})
test('preserves assignments when the main component synchronizes', () => {
const { editor, component, instance, iconB } = setupComponentProperties()
editor.setInstanceComponentProperty(instance.id, '10:1', 'Custom')
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
const sourceLabel = childByName(editor, component.id, 'Label')
if (!sourceLabel) throw new Error('Expected source label')
editor.graph.updateNode(sourceLabel.id, { text: 'Updated default' })
editor.graph.syncInstances(component.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
const nestedInstances = editor.graph
.getChildren(instance.id)
.filter((node) => node.type === 'INSTANCE')
expect(nestedInstances).toHaveLength(1)
expect(nestedInstances[0].name).toBe('Icon B')
expect(nestedInstances[0].childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual([
'B shape'
])
})
test('reapplies non-variant assignments after variant swaps and undo', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const componentSet = editor.graph.createNode('COMPONENT_SET', pageId, {
componentPropertyDefinitions: [
{ id: '20:1', name: 'State', type: 'VARIANT', defaultValue: 'A' },
{ id: '20:2', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
]
})
const variantA = editor.graph.createNode('COMPONENT', componentSet.id, {
name: 'State=A',
componentPropertyValues: { State: 'A' }
})
editor.graph.createNode('TEXT', variantA.id, {
name: 'Label',
text: 'A default',
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
})
const variantB = editor.graph.createNode('COMPONENT', componentSet.id, {
name: 'State=B',
componentPropertyValues: { State: 'B' }
})
editor.graph.createNode('TEXT', variantB.id, {
name: 'Label',
text: 'B default',
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
})
const instance = editor.graph.createInstance(variantA.id, pageId)
if (!instance) throw new Error('Expected variant instance')
editor.setInstanceComponentProperty(instance.id, '20:2', 'Custom')
editor.setInstanceComponentProperty(instance.id, '20:1', 'B')
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantB.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
editor.undo.undo()
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantA.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
})
})