openpencil/tests/engine/layout/auto-layout/imported-derived-layout.test.ts
Danila Poyarkov 75dd86d0ad fix(fig): improve imported component layout parity
- Preserve Figma min/max constraints, scalar variable bindings, and direct text bounds
- Reflow only authoritative generated instance geometry and pinned descendants
- Avoid double-applying auto spacing and unsafe lone-child normalization
2026-08-03 18:00:15 +03:00

500 lines
14 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { computeAllLayouts, SceneGraph } from '@open-pencil/core'
import { getAbsolutePositionFull } from '@open-pencil/scene-graph'
describe('imported auto-layout bounds', () => {
test('preserves visible hug container bounds when hidden children would collapse layout', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
width: 280,
height: 44,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
paddingTop: 2,
paddingRight: 2,
paddingBottom: 2,
paddingLeft: 2,
fills: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true
}
],
strokes: [
{
color: { r: 0.58, g: 0.64, b: 0.72, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'OUTSIDE'
}
],
figmaDerivedLayout: { x: 0, y: 0, width: 280, height: 44 }
})
const wrapper = graph.createNode('FRAME', frame.id, {
x: 2,
y: 2,
width: 276,
height: 40,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const field = graph.createNode('FRAME', wrapper.id, {
width: 276,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
layoutAlignSelf: 'STRETCH',
paddingTop: 8,
paddingRight: 56,
paddingBottom: 8,
paddingLeft: 12,
fills: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true
}
],
strokes: [
{
color: { r: 0.8, g: 0.84, b: 0.88, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'OUTSIDE'
}
],
figmaDerivedLayout: { x: 0, y: 0, width: 276, height: 40 }
})
graph.createNode('TEXT', field.id, {
x: 12,
y: 8,
width: 132,
height: 24,
text: 'typing something',
visible: false
})
graph.createNode('LINE', field.id, {
x: 12,
y: 8,
width: 24,
height: 0,
rotation: 90,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
]
})
computeAllLayouts(graph)
expect(graph.getNode(field.id)).toMatchObject({ x: 0, y: 0, width: 276, height: 40 })
})
test('uses imported HUG dimensions when positioning following siblings', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 200,
height: 100,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
graph.createNode('FRAME', column.id, {
width: 200,
height: 1,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 4,
paddingBottom: 4,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
],
figmaDerivedLayout: { x: 0, y: 0, width: 200, height: 1 }
})
const following = graph.createNode('RECTANGLE', column.id, { width: 200, height: 20 })
computeAllLayouts(graph)
expect(graph.getNode(following.id)?.y).toBe(1)
})
test('positions generated fill children from exact imported dimensions and gaps', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 288,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 14,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 14 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
graph.createNode('RECTANGLE', first.id, { width: 200, height: 14 })
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 152, width: 136 })
})
test('uses exact imported dimensions with space-between despite stored spacing', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 288,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'SPACE_BETWEEN',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 152,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 152, height: 40 }
})
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 136, width: 152 })
})
test('keeps normal flex sizing when imported dimensions do not fit the parent', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 320,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 14,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 14 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 168, width: 136 })
})
test('stretches generated children inside authoritative imported bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('INSTANCE', page.id, {
width: 302,
height: 60,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
figmaDerivedLayout: { width: 302, height: 60 }
})
const label = graph.createNode('INSTANCE', column.id, {
width: 280,
height: 14,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
layoutAlignSelf: 'STRETCH'
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)).toMatchObject({ x: 0, width: 302 })
})
test('does not infer authoritative stretch without generated parent bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 624,
height: 290,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const label = graph.createNode('INSTANCE', column.id, {
width: 44,
height: 14,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
layoutAlignSelf: 'STRETCH'
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)?.width).toBe(44)
})
test('preserves hidden child geometry while excluding it from parent flow', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 280,
height: 40,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const label = graph.createNode('INSTANCE', column.id, {
width: 280,
height: 14,
visible: false,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG'
})
graph.createNode('TEXT', label.id, {
width: 37,
height: 14,
text: 'Label',
textAutoResize: 'WIDTH_AND_HEIGHT',
figmaDerivedLayout: { x: 0, y: 0, width: 37, height: 14 }
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)).toMatchObject({ width: 280, height: 14, visible: false })
})
test('uses exact derived width for a growing text leaf when siblings fill the parent', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('INSTANCE', page.id, {
width: 224,
height: 32,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8
})
graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
const text = graph.createNode('TEXT', row.id, {
width: 196,
height: 20,
text: 'Models',
textAutoResize: 'HEIGHT',
layoutGrow: 1,
figmaDerivedLayout: { width: 160, height: 20 }
})
const chevron = graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
computeAllLayouts(graph)
expect(graph.getNode(text.id)).toMatchObject({ x: 32, width: 160 })
expect(graph.getNode(chevron.id)?.x).toBe(200)
})
test('lets live Yoga resize imported text when stored and derived sizes agree', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
width: 200,
height: 100,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20
})
const text = graph.createNode('TEXT', frame.id, {
width: 424,
height: 40,
layoutAlignSelf: 'STRETCH',
figmaDerivedLayout: { width: 424, height: 40 }
})
graph.updateNode(text.id, {
source: { ...text.source, format: 'fig', id: '1:3' }
})
computeAllLayouts(graph)
expect(graph.getNode(text.id)).toMatchObject({ width: 160, height: 40 })
})
test('preserves imported HUG cross size backed by stretched derived children', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
width: 381,
height: 102,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 24,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24
})
graph.updateNode(frame.id, { source: { ...frame.source, format: 'fig' } })
graph.createNode('TEXT', frame.id, {
width: 333,
height: 30,
text: 'Bar Chart',
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
figmaDerivedLayout: { width: 333, height: 30 }
})
computeAllLayouts(graph)
expect(graph.getNode(frame.id)?.width).toBe(381)
})
test('preserves direct imported frame geometry inside imported parents', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const parent = graph.createNode('FRAME', page.id, {
width: 768,
height: 454,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
counterAxisAlign: 'CENTER'
})
const child = graph.createNode('FRAME', parent.id, {
x: 192,
y: 40,
width: 384,
height: 414,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutAlignSelf: 'STRETCH'
})
graph.updateNode(parent.id, { source: { ...parent.source, format: 'fig' } })
graph.updateNode(child.id, { source: { ...child.source, format: 'fig' } })
computeAllLayouts(graph)
expect(graph.getNode(child.id)).toMatchObject({ x: 192, y: 40, width: 384, height: 414 })
})
test('preserves transformed bounds for rotated imported flow children', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const parent = graph.createNode('FRAME', page.id, {
width: 17,
height: 20,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 16
})
const separator = graph.createNode('INSTANCE', parent.id, {
x: 6.5,
y: 9.5,
width: 20,
height: 1,
rotation: -90
})
graph.updateNode(separator.id, {
source: { ...separator.source, format: 'fig' }
})
const before = getAbsolutePositionFull(separator, graph)
computeAllLayouts(graph)
const afterNode = graph.getNode(separator.id)
expect(afterNode).toBeDefined()
const after = getAbsolutePositionFull(afterNode ?? separator, graph)
expect(after).toMatchObject({
boundX: before.boundX,
boundY: before.boundY,
width: before.width,
height: before.height
})
})
test('uses Yoga positions for imported instances while preserving imported size', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 200,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'MAX',
counterAxisAlign: 'CENTER'
})
const instance = graph.createNode('INSTANCE', row.id, {
x: 0,
y: 0,
width: 40,
height: 20,
figmaDerivedLayout: { x: 0, y: 0, width: 40, height: 20 }
})
computeAllLayouts(graph)
expect(graph.getNode(instance.id)).toMatchObject({ x: 160, y: 10, width: 40, height: 20 })
})
})