openpencil/tests/engine/render/jsx/render-tree.test.ts
mrhard9090 4faf20bab8
fix(design-jsx): warn about unsupported paint and effect helper options (#762)
* fix(design-jsx): accept blur in effect helpers and warn about unknown options

dropShadow({ blur: 12 }) silently used the default radius, although the shadow shorthand and the blur prop both call that value the blur, and any misspelled option was dropped without a word. The shadow and blur helpers now take blur as the radius, and renderJSX reports any other option they ignore, next to the existing unsupported-prop warnings.

Fixes #736

* refactor(design-jsx): check options for every paint and effect helper

The option check covered only effect helpers, and its key lists repeated
the option types by hand, so a new option could turn into a false
warning. One wrapper in `design-jsx/helpers.ts` now checks every paint
and effect helper that evaluated JSX can call, with key lists typed
against their option interfaces. Only plain objects are checked, and
repeated warnings are collapsed once. The authoring reference documents
the `blur` alias and the warnings.

* docs(design-jsx): scope helper option warnings to rendered JSX

* fix(design-jsx): name effect radius as Figma does and hint at it for blur

Accepting both `radius` and `blur` gave effect helpers two names for one
value, and when both were set `blur` was dropped without a warning.
Figma's effects only have `radius`, so the helpers take `radius` alone
and `blur` now warns with a pointer to it. The default radius is named
once instead of repeated.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 09:59:45 +04:00

633 lines
20 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import {
renderTree,
renderJSX,
renderTreeNode,
Frame,
Text,
Rectangle,
Ellipse,
Line,
Star,
Group,
Section,
Component,
ComponentSet,
Instance,
defineVars,
designVar,
dropShadow,
innerShadow,
layerBlur,
linearGradient,
solid
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
import { addTestColorVariable, makeSceneGraph } from '#tests/helpers/scene'
describe('renderTree', () => {
it('renders a simple frame', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'MyFrame', w: 200, h: 100, bg: '#FF0000' })
const result = await renderTree(g, tree)
expect(result.name).toBe('MyFrame')
expect(result.type).toBe('FRAME')
const node = getNodeOrThrow(g, result.id)
expect(node.width).toBe(200)
expect(node.height).toBe(100)
expect(node.fills.length).toBe(1)
expect(expectDefined(node.fills[0], 'first fill').type).toBe('SOLID')
})
it('renders structured fill helpers', async () => {
const g = makeSceneGraph()
const result = await renderTree(
g,
Frame({
name: 'Paints',
w: 200,
h: 100,
fills: [
solid('#112233'),
linearGradient([
['#ffffff', 0],
['rgba(0, 0, 0, 0)', 1]
])
]
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.fills).toHaveLength(2)
expect(node.fills[0]?.type).toBe('SOLID')
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
expect(node.fills[1]?.gradientStops).toHaveLength(2)
})
it('renders structured fill helpers from JSX strings', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame name="Paints" w={200} h={100} fills={[solid('#112233'), linearGradient([['#fff', 0], ['#0000', 1]])]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.fills).toHaveLength(2)
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
})
it('renders structured effect helpers', async () => {
const g = makeSceneGraph()
const result = await renderTree(
g,
Frame({
name: 'Effects',
w: 200,
h: 100,
effects: [
dropShadow({ x: 0, y: 8, radius: 16 }),
innerShadow({ color: '#ff000080' }),
layerBlur(4)
]
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects).toHaveLength(3)
expect(node.effects[0]?.type).toBe('DROP_SHADOW')
expect(node.effects[0]?.offset.y).toBe(8)
expect(node.effects[1]?.type).toBe('INNER_SHADOW')
expect(node.effects[2]?.type).toBe('LAYER_BLUR')
})
it('renders structured effect helpers from JSX strings', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame name="Effects" w={200} h={100} effects={[dropShadow({ x: 0, y: 8, radius: 16 }), backgroundBlur(12)]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects).toHaveLength(2)
expect(node.effects[0]?.type).toBe('DROP_SHADOW')
expect(node.effects[1]?.type).toBe('BACKGROUND_BLUR')
})
it('renders text node with content', async () => {
const g = makeSceneGraph()
const tree = Text({
name: 'Heading',
size: 24,
weight: 'bold',
color: '#111',
children: 'Hello'
})
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.type).toBe('TEXT')
expect(node.text).toBe('Hello')
expect(node.fontSize).toBe(24)
expect(node.fontWeight).toBe(700)
expect(node.fills.length).toBe(1)
})
it('renders common React aliases that models often produce', async () => {
const g = makeSceneGraph()
const result = await renderJSX(
g,
`<div name="Card" style={{ width: 320, height: 120, backgroundColor: '#2563EB', borderRadius: 16 }}>
<Text name="Heading" content="Visible content" style={{ color: '#FFFFFF', fontSize: 20, fontWeight: 700 }} />
</div>`
)
const card = getNodeOrThrow(g, result[0].id)
expect(card.type).toBe('FRAME')
expect(card.width).toBe(320)
expect(card.height).toBe(120)
expect(card.cornerRadius).toBe(16)
expect(card.fills.length).toBe(1)
const heading = getNodeOrThrow(g, childIdAt(card, 0))
expect(heading.type).toBe('TEXT')
expect(heading.text).toBe('Visible content')
expect(heading.fontSize).toBe(20)
expect(heading.fontWeight).toBe(700)
expect(heading.fills.length).toBe(1)
})
it('binds variable refs used as style values', async () => {
const g = makeSceneGraph()
g.addCollection({
id: 'colors',
name: 'Colors',
modes: [{ modeId: 'light', name: 'Light' }],
defaultModeId: 'light',
variableIds: []
})
g.addVariable({
id: 'var-bg',
name: 'Background',
type: 'COLOR',
collectionId: 'colors',
valuesByMode: { light: { r: 1, g: 0, b: 0, a: 1 } },
description: '',
hiddenFromPublishing: false
})
const vars = defineVars({ bg: { id: 'var-bg', name: 'Background' } })
const result = await renderTree(g, Frame({ name: 'Bound', w: 100, h: 100, fill: vars.bg }))
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
expect(node.fills[0]?.type).toBe('SOLID')
})
it('supports explicit variable bindings for supported paths', async () => {
const g = makeSceneGraph()
addTestColorVariable(g, 'var-shadow', 'Shadow', { r: 0, g: 0, b: 0, a: 1 })
addTestColorVariable(g, 'var-bg', 'Background')
const variable = designVar('var-shadow', '#000000')
const result = await renderTree(
g,
Frame({
name: 'Bound explicit',
w: 100,
h: 100,
fill: '#ffffff',
stroke: '#000000',
bind: { 'strokes/0/color': variable, 'fills/0/color': 'var-bg' }
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['strokes/0/color']).toBe('var-shadow')
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
})
it('binds variables from JSX strings', async () => {
const g = makeSceneGraph()
addTestColorVariable(g, 'var-bg', 'Background')
const [result] = await renderJSX(
g,
`<Frame name="Bound JSX" w={100} h={100} fill={designVar('var-bg', '#ffffff')} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
expect(node.fills[0]?.type).toBe('SOLID')
})
it('renders components and instances', async () => {
const g = makeSceneGraph()
const component = await renderTree(
g,
Component({
name: 'Badge',
w: 64,
h: 24,
children: [Text({ name: 'Label', color: '#000', children: 'Live' })]
})
)
const instance = await renderTree(
g,
Instance({ component: component.id, name: 'Badge Instance' })
)
const node = getNodeOrThrow(g, instance.id)
expect(component.type).toBe('COMPONENT')
expect(node.type).toBe('INSTANCE')
expect(node.componentId).toBe(component.id)
expect(node.childIds.length).toBe(1)
})
it('renders component sets and variant instances', async () => {
const g = makeSceneGraph()
const set = await renderTree(
g,
ComponentSet({
name: 'Button',
children: [
Component({ name: 'variant=Primary', w: 120, h: 40, bg: '#2563EB' }),
Component({ name: 'variant=Secondary', w: 120, h: 40, bg: '#FFFFFF' })
]
})
)
const setNode = getNodeOrThrow(g, set.id)
expect(setNode.type).toBe('COMPONENT_SET')
expect(setNode.componentPropertyDefinitions[0]?.name).toBe('variant')
const instance = await renderTree(g, Instance({ of: set.id, variant: 'Secondary' }))
const node = getNodeOrThrow(g, instance.id)
expect(node.type).toBe('INSTANCE')
expect(getNodeOrThrow(g, node.componentId ?? '').name).toBe('variant=Secondary')
})
it('renders nested structure', async () => {
const g = makeSceneGraph()
const tree = Frame({
name: 'Card',
w: 320,
flex: 'col',
gap: 16,
p: 24,
bg: '#FFF',
children: [
Rectangle({ name: 'Image', w: 272, h: 200, bg: '#E5E7EB' }),
Text({ name: 'Title', size: 18, weight: 'bold', color: '#111', children: 'Card Title' }),
Text({ name: 'Description', size: 14, color: '#6B7280', children: 'Lorem ipsum' })
]
})
const result = await renderTree(g, tree)
const card = getNodeOrThrow(g, result.id)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.itemSpacing).toBe(16)
expect(card.paddingTop).toBe(24)
expect(card.paddingRight).toBe(24)
expect(card.childIds.length).toBe(3)
const title = getNodeOrThrow(g, childIdAt(card, 1))
expect(title.text).toBe('Card Title')
expect(title.fontWeight).toBe(700)
})
it('renders with position override', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Positioned', w: 100, h: 100 })
const result = await renderTree(g, tree, { x: 50, y: 75 })
const node = getNodeOrThrow(g, result.id)
expect(node.x).toBe(50)
expect(node.y).toBe(75)
})
it('renders into a specific parent', async () => {
const g = makeSceneGraph()
const page = expectDefined(g.getPages()[0], 'first page')
const container = g.createNode('FRAME', page.id, { name: 'Container' })
const tree = Frame({ name: 'Child', w: 50, h: 50 })
await renderTree(g, tree, { parentId: container.id })
expect(container.childIds.length).toBe(1)
})
it('handles auto-layout properties', async () => {
const g = makeSceneGraph()
const tree = Frame({
name: 'Flex',
flex: 'row',
gap: 8,
justify: 'between',
items: 'center',
wrap: true
})
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.layoutMode).toBe('HORIZONTAL')
expect(node.itemSpacing).toBe(8)
expect(node.primaryAxisAlign).toBe('SPACE_BETWEEN')
expect(node.counterAxisAlign).toBe('CENTER')
expect(node.layoutWrap).toBe('WRAP')
})
it('justify/items without flex auto-enables auto-layout', async () => {
const g = makeSceneGraph()
const tree = Frame(
{ name: 'IconBtn', w: 36, h: 36, justify: 'center', items: 'center' },
Text({ size: 16, color: '#FFFFFF', children: '★' })
)
const result = await renderTree(g, tree)
const n = getNodeOrThrow(g, result.id)
expect(n.layoutMode).toBe('VERTICAL')
expect(n.primaryAxisAlign).toBe('CENTER')
expect(n.counterAxisAlign).toBe('CENTER')
})
it('handles padding shorthands', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Padded', px: 16, py: 8, pt: 4 })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.paddingLeft).toBe(16)
expect(node.paddingRight).toBe(16)
expect(node.paddingBottom).toBe(8)
expect(node.paddingTop).toBe(4) // pt overrides py
})
it('handles corner radius', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Rounded', rounded: 12 })
const result = await renderTree(g, tree)
expect(getNodeOrThrow(g, result.id).cornerRadius).toBe(12)
})
it('handles independent corners', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Corners', roundedTL: 8, roundedBR: 16 })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.independentCorners).toBe(true)
expect(node.topLeftRadius).toBe(8)
expect(node.bottomRightRadius).toBe(16)
})
it('handles stroke', async () => {
const g = makeSceneGraph()
const tree = Rectangle({ name: 'Bordered', stroke: '#000', strokeWidth: 2 })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.strokes.length).toBe(1)
expect(expectDefined(node.strokes[0], 'first stroke').weight).toBe(2)
})
it('handles opacity and rotation', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Transformed', opacity: 0.5, rotate: 45 })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.opacity).toBe(0.5)
expect(node.rotation).toBe(45)
})
it('handles overflow hidden (clip)', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Clipped', overflow: 'hidden' })
const result = await renderTree(g, tree)
expect(getNodeOrThrow(g, result.id).clipsContent).toBe(true)
})
it('handles hug sizing', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Hug', w: 'hug', h: 'hug', flex: 'col' })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.primaryAxisSizing).toBe('HUG')
expect(node.counterAxisSizing).toBe('HUG')
})
it('handles fill sizing', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Fill', w: 'fill' })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.layoutGrow).toBe(1)
})
it('handles shadow effect', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Shadow', shadow: '0 4 12 rgba(0,0,0,0.1)' })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.effects.length).toBe(1)
expect(expectDefined(node.effects[0], 'first effect').type).toBe('DROP_SHADOW')
expect(expectDefined(node.effects[0], 'first effect').radius).toBe(12)
})
it('handles blur effect', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Blurred', blur: 8 })
const result = await renderTree(g, tree)
const node = getNodeOrThrow(g, result.id)
expect(node.effects.length).toBe(1)
expect(expectDefined(node.effects[0], 'first effect').type).toBe('LAYER_BLUR')
expect(expectDefined(node.effects[0], 'first effect').radius).toBe(8)
})
it('renders all primitive types', async () => {
const g = makeSceneGraph()
const types = [
{ fn: Rectangle, expected: 'RECTANGLE' },
{ fn: Ellipse, expected: 'ELLIPSE' },
{ fn: Line, expected: 'LINE' },
{ fn: Star, expected: 'STAR' },
{ fn: Group, expected: 'GROUP' },
{ fn: Section, expected: 'SECTION' }
] as const
for (const { fn, expected } of types) {
const tree = fn({ name: expected })
const result = await renderTree(g, tree)
expect(getNodeOrThrow(g, result.id).type).toBe(expected)
}
})
it('throws on unknown element type', () => {
const g = makeSceneGraph()
const tree = { type: 'foobar', props: {}, children: [] }
expect(() => renderTree(g, tree)).toThrow('Unknown element: <foobar>')
})
})
describe('renderTreeNode', () => {
it('renders pre-built tree (browser path)', async () => {
const g = makeSceneGraph()
const tree = Frame({
name: 'FromAI',
w: 200,
h: 100,
bg: '#3B82F6',
children: [Text({ name: 'Label', size: 16, color: '#FFF', children: 'Button' })]
})
const result = await renderTreeNode(g, tree)
expect(result.name).toBe('FromAI')
const node = getNodeOrThrow(g, result.id)
expect(node.childIds.length).toBe(1)
const label = getNodeOrThrow(g, childIdAt(node, 0))
expect(label.text).toBe('Button')
})
})
describe('renderJSX (string → scene graph)', () => {
it('renders JSX string', async () => {
const g = makeSceneGraph()
const jsx = `
<Frame name="Test" w={200} h={100} bg="#FF0000">
<Text name="Hello" size={16} color="#000">World</Text>
</Frame>
`
const [result] = await renderJSX(g, jsx)
expect(result.name).toBe('Test')
const node = getNodeOrThrow(g, result.id)
expect(node.type).toBe('FRAME')
expect(node.childIds.length).toBe(1)
const text = getNodeOrThrow(g, childIdAt(node, 0))
expect(text.text).toBe('World')
})
it('renders nested JSX', async () => {
const g = makeSceneGraph()
const jsx = `
<Frame name="Card" w={320} flex="col" gap={16} p={24} bg="#FFF" rounded={16}>
<Rectangle name="Image" w={272} h={200} bg="#E5E7EB" rounded={12} />
<Text name="Title" size={18} weight="bold" color="#111">Card Title</Text>
<Text name="Description" size={14} color="#6B7280">Lorem ipsum</Text>
</Frame>
`
const [result] = await renderJSX(g, jsx)
const card = getNodeOrThrow(g, result.id)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.childIds.length).toBe(3)
})
it('renders with position', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
const node = getNodeOrThrow(g, result.id)
expect(node.x).toBe(100)
expect(node.y).toBe(200)
})
it('accepts rotation as an alias for rotate', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Rectangle name="Rotated" w={50} h={50} rotation={15} />')
const node = getNodeOrThrow(g, result.id)
expect(node.rotation).toBe(15)
})
it('strips HTML comments before JSX parsing', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Frame name="Comments" w={50} h={50}><!-- generated note --><Text color="#000">Ok</Text></Frame>'
)
const node = getNodeOrThrow(g, result.id)
expect(node.name).toBe('Comments')
expect(node.childIds.length).toBe(1)
})
it('warns about unsupported props', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Frame name="Warn" w={50} h={50} mt={8} />')
expect(result.warnings).toEqual(['Unsupported prop "mt" on <frame> is ignored.'])
})
it('points blur in effect helpers at radius, the name Figma uses', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[dropShadow({ x: 4, y: 4, blur: 12 }), layerBlur({ blur: 3 })]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects.map((effect) => effect.radius)).toEqual([8, 8])
expect(result.warnings).toEqual([
'Unsupported option "blur" in dropShadow() is ignored. Use "radius", the name Figma uses.',
'Unsupported option "blur" in layerBlur() is ignored. Use "radius", the name Figma uses.'
])
})
it('warns about effect helper options it ignores', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[dropShadow({ colour: '#FF0000' }), dropShadow({ colour: '#00FF00' }), backgroundBlur({ amount: 4 })]} />`
)
expect(result.warnings).toEqual([
'Unsupported option "colour" in dropShadow() is ignored. Supported options: color, x, y, offset, radius, spread, visible, blendMode, showShadowBehindNode.',
'Unsupported option "amount" in backgroundBlur() is ignored. Supported options: radius, visible.'
])
})
it('warns about paint helper options it ignores', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} fills={[solid('#FF0000', { opactiy: 0.5 }), linearGradient([['#000', 0], ['#FFF', 1]], { angle: 90 })]} />`
)
expect(result.warnings).toEqual([
'Unsupported option "opactiy" in solid() is ignored. Supported options: opacity, visible, blendMode.',
'Unsupported option "angle" in linearGradient() is ignored. Supported options: opacity, visible, blendMode, transform.'
])
})
it('only checks options objects passed to helpers', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame w={100} h={60} effects={[layerBlur(4)]} fills={[solid('#FF0000')]} />`
)
expect(result.warnings).toBeUndefined()
})
it('accepts CSS-style layout aliases', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Frame name="Aliases" w={200} h={100} flex="row" justifyContent="center" alignItems="center"><Rectangle w={20} h={20} /></Frame>'
)
const node = getNodeOrThrow(g, result.id)
expect(node.primaryAxisAlign).toBe('CENTER')
expect(node.counterAxisAlign).toBe('CENTER')
})
})