- Replace clipboard, variable, centerline, layout, menu, and text formatting assertions with explicit guards - Use expectDefined and getNodeOrThrow instead of postfix non-null assertions - Validate affected engine and E2E tests
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { computeLayout, computeAllLayouts, FigmaAPI, SceneGraph } from '@open-pencil/core'
|
|
|
|
import { getNodeOrThrow } from '#tests/helpers/assert'
|
|
import { autoFrame, fixed, fr, gridFrame, pageId, rect } from '#tests/helpers/layout'
|
|
|
|
describe('Grid Layout', () => {
|
|
describe('basic grid', () => {
|
|
test('places children in 2x2 equal columns', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr(), fr()])
|
|
for (let i = 0; i < 4; i++) rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
// Row 1: (0,0) and (200,0)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[0].y).toBe(0)
|
|
expect(children[1].x).toBe(200)
|
|
expect(children[1].y).toBe(0)
|
|
// Row 2: (0,150) and (200,150)
|
|
expect(children[2].x).toBe(0)
|
|
expect(children[2].y).toBe(150)
|
|
expect(children[3].x).toBe(200)
|
|
expect(children[3].y).toBe(150)
|
|
})
|
|
|
|
test('fixed column widths', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fixed(100), fixed(300)], [fr()])
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[0].width).toBe(50)
|
|
expect(children[1].x).toBe(100)
|
|
expect(children[1].width).toBe(50)
|
|
})
|
|
|
|
test('mixed fr and fixed columns', () => {
|
|
const graph = new SceneGraph()
|
|
// 400px wide: 100px fixed + 300px remaining as 1fr
|
|
const frame = gridFrame(graph, pageId(graph), [fixed(100), fr()], [fr()])
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(100)
|
|
})
|
|
|
|
test('unequal fr columns', () => {
|
|
const graph = new SceneGraph()
|
|
// 1fr + 2fr = 3fr total → 133.33px + 266.67px
|
|
const frame = gridFrame(graph, pageId(graph), [fr(1), fr(2)], [fr()], {
|
|
width: 300,
|
|
height: 100
|
|
})
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBeCloseTo(100, 0)
|
|
})
|
|
})
|
|
|
|
describe('gaps', () => {
|
|
test('column gap', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()], { gridColumnGap: 20 })
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
// (400 - 20) / 2 = 190 per column
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(210)
|
|
})
|
|
|
|
test('row gap', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr()], [fr(), fr()], {
|
|
gridRowGap: 20,
|
|
height: 200
|
|
})
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].y).toBe(0)
|
|
// (200 - 20) / 2 = 90 per row
|
|
expect(children[1].y).toBe(110)
|
|
})
|
|
|
|
test('both column and row gaps', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr(), fr()], {
|
|
gridColumnGap: 10,
|
|
gridRowGap: 10,
|
|
width: 210,
|
|
height: 210
|
|
})
|
|
for (let i = 0; i < 4; i++) rect(graph, frame.id, 30, 30)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
// (210 - 10) / 2 = 100 per col, (210 - 10) / 2 = 100 per row
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[0].y).toBe(0)
|
|
expect(children[1].x).toBe(110)
|
|
expect(children[1].y).toBe(0)
|
|
expect(children[2].x).toBe(0)
|
|
expect(children[2].y).toBe(110)
|
|
expect(children[3].x).toBe(110)
|
|
expect(children[3].y).toBe(110)
|
|
})
|
|
})
|
|
|
|
describe('padding', () => {
|
|
test('padding offsets grid content', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr()], [fr()], {
|
|
paddingTop: 10,
|
|
paddingLeft: 20,
|
|
paddingRight: 30,
|
|
paddingBottom: 40
|
|
})
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const child = graph.getChildren(frame.id)[0]
|
|
expect(child.x).toBe(20)
|
|
expect(child.y).toBe(10)
|
|
})
|
|
})
|
|
|
|
describe('explicit placement', () => {
|
|
test('gridPosition places child at specific cell', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr(), fr()], [fr(), fr()])
|
|
// Place in column 2, row 1 (0-indexed internally, but yoga uses 1-indexed)
|
|
rect(graph, frame.id, 50, 50, {
|
|
gridPosition: { column: 2, row: 1, columnSpan: 1, rowSpan: 1 }
|
|
})
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const child = graph.getChildren(frame.id)[0]
|
|
// Column 2 starts at 400/3 ≈ 133px
|
|
expect(child.x).toBeCloseTo(133, 0)
|
|
expect(child.y).toBe(0)
|
|
})
|
|
|
|
test('column span stretches across multiple columns', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr(), fr()], [fr()], {
|
|
width: 300,
|
|
height: 100
|
|
})
|
|
rect(graph, frame.id, 50, 50, {
|
|
gridPosition: { column: 1, row: 1, columnSpan: 2, rowSpan: 1 }
|
|
})
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const child = graph.getChildren(frame.id)[0]
|
|
expect(child.x).toBe(0)
|
|
// Child keeps its own 50px width since it's not set to fill
|
|
expect(child.width).toBe(50)
|
|
})
|
|
|
|
test('row span stretches across multiple rows', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr()], [fr(), fr(), fr()], {
|
|
width: 100,
|
|
height: 300
|
|
})
|
|
rect(graph, frame.id, 50, 50, {
|
|
gridPosition: { column: 1, row: 1, columnSpan: 1, rowSpan: 2 }
|
|
})
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const child = graph.getChildren(frame.id)[0]
|
|
expect(child.y).toBe(0)
|
|
expect(child.height).toBe(50)
|
|
})
|
|
})
|
|
|
|
describe('absolute children', () => {
|
|
test('absolute children are skipped in grid layout', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
|
|
rect(graph, frame.id, 50, 50)
|
|
rect(graph, frame.id, 50, 50, { layoutPositioning: 'ABSOLUTE', x: 300, y: 200 })
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(300)
|
|
expect(children[1].y).toBe(200)
|
|
expect(children[2].x).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('hidden children', () => {
|
|
test('hidden children preserve size in grid', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
|
|
rect(graph, frame.id, 50, 50, { visible: false })
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].width).toBe(50)
|
|
expect(children[0].height).toBe(50)
|
|
})
|
|
})
|
|
|
|
describe('flex-to-grid switch', () => {
|
|
test('HUG frame expands to fit children in grid', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
|
|
const frame = autoFrame(graph, page, {
|
|
layoutMode: 'VERTICAL',
|
|
primaryAxisSizing: 'HUG',
|
|
counterAxisSizing: 'HUG',
|
|
width: 100,
|
|
height: 100
|
|
})
|
|
rect(graph, frame.id, 80, 80)
|
|
rect(graph, frame.id, 80, 80)
|
|
rect(graph, frame.id, 80, 80)
|
|
rect(graph, frame.id, 80, 80)
|
|
|
|
computeLayout(graph, frame.id)
|
|
const hugNode = getNodeOrThrow(graph, frame.id)
|
|
expect(hugNode.width).toBe(80)
|
|
expect(hugNode.height).toBe(320)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
const cols = Math.max(2, Math.ceil(Math.sqrt(children.length)))
|
|
const rows = Math.max(1, Math.ceil(children.length / cols))
|
|
const maxChildW = Math.max(...children.map((c) => c.width))
|
|
const maxChildH = Math.max(...children.map((c) => c.height))
|
|
|
|
graph.updateNode(frame.id, {
|
|
layoutMode: 'GRID',
|
|
primaryAxisSizing: 'FIXED',
|
|
counterAxisSizing: 'FIXED',
|
|
width: maxChildW * cols,
|
|
height: maxChildH * rows,
|
|
gridTemplateColumns: Array.from({ length: cols }, () => ({
|
|
sizing: 'FR' as const,
|
|
value: 1
|
|
})),
|
|
gridTemplateRows: Array.from({ length: rows }, () => ({ sizing: 'FR' as const, value: 1 })),
|
|
gridColumnGap: 0,
|
|
gridRowGap: 0
|
|
})
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const gridNode = getNodeOrThrow(graph, frame.id)
|
|
expect(gridNode.width).toBe(160)
|
|
expect(gridNode.height).toBe(160)
|
|
|
|
const gridChildren = graph.getChildren(frame.id)
|
|
expect(gridChildren[0].x).toBe(0)
|
|
expect(gridChildren[0].y).toBe(0)
|
|
expect(gridChildren[1].x).toBe(80)
|
|
expect(gridChildren[1].y).toBe(0)
|
|
expect(gridChildren[2].x).toBe(0)
|
|
expect(gridChildren[2].y).toBe(80)
|
|
expect(gridChildren[3].x).toBe(80)
|
|
expect(gridChildren[3].y).toBe(80)
|
|
})
|
|
})
|
|
|
|
describe('computeAllLayouts with grid', () => {
|
|
test('grid frames are computed in bottom-up pass', () => {
|
|
const graph = new SceneGraph()
|
|
const page = pageId(graph)
|
|
const outer = autoFrame(graph, page, {
|
|
layoutMode: 'VERTICAL',
|
|
width: 400,
|
|
height: 600,
|
|
itemSpacing: 10
|
|
})
|
|
const inner = gridFrame(graph, outer.id, [fr(), fr()], [fr()], {
|
|
width: 380,
|
|
height: 100
|
|
})
|
|
rect(graph, inner.id, 50, 50)
|
|
rect(graph, inner.id, 50, 50)
|
|
rect(graph, outer.id, 100, 50)
|
|
|
|
computeAllLayouts(graph)
|
|
|
|
const innerChildren = graph.getChildren(inner.id)
|
|
expect(innerChildren[0].x).toBe(0)
|
|
expect(innerChildren[1].x).toBe(190)
|
|
|
|
const outerChildren = graph.getChildren(outer.id)
|
|
expect(outerChildren[0].y).toBe(0)
|
|
expect(outerChildren[1].y).toBe(110)
|
|
})
|
|
})
|
|
|
|
describe('grid stretch sizing', () => {
|
|
test('STRETCH child fills grid cell', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()], {
|
|
width: 300,
|
|
height: 100
|
|
})
|
|
rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'STRETCH' as const })
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].width).toBe(150)
|
|
expect(children[0].height).toBe(100)
|
|
})
|
|
|
|
test('layoutGrow child fills grid cell', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()], {
|
|
width: 400,
|
|
height: 200
|
|
})
|
|
rect(graph, frame.id, 50, 50, { layoutGrow: 1 })
|
|
rect(graph, frame.id, 50, 50)
|
|
|
|
computeLayout(graph, frame.id)
|
|
|
|
const children = graph.getChildren(frame.id)
|
|
expect(children[0].width).toBe(200)
|
|
expect(children[0].height).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('layoutSizingVertical with cross-axis children', () => {
|
|
test('HORIZONTAL children in VERTICAL parent respect FIXED height after layout', () => {
|
|
const graph = new SceneGraph()
|
|
const api = new FigmaAPI(graph)
|
|
|
|
const root = api.createFrame()
|
|
root.layoutMode = 'VERTICAL'
|
|
root.resize(375, 812)
|
|
api.currentPage.appendChild(root)
|
|
|
|
const statusBar = api.createFrame()
|
|
statusBar.layoutMode = 'HORIZONTAL'
|
|
root.appendChild(statusBar)
|
|
statusBar.layoutSizingHorizontal = 'FILL'
|
|
statusBar.layoutSizingVertical = 'FIXED'
|
|
statusBar.resize(375, 44)
|
|
|
|
const toolbar = api.createFrame()
|
|
toolbar.layoutMode = 'HORIZONTAL'
|
|
root.appendChild(toolbar)
|
|
toolbar.layoutSizingHorizontal = 'FILL'
|
|
toolbar.layoutSizingVertical = 'FIXED'
|
|
toolbar.resize(375, 52)
|
|
|
|
const canvas = api.createFrame()
|
|
root.appendChild(canvas)
|
|
canvas.layoutSizingHorizontal = 'FILL'
|
|
canvas.layoutSizingVertical = 'FILL'
|
|
|
|
const panel = api.createFrame()
|
|
panel.layoutMode = 'VERTICAL'
|
|
root.appendChild(panel)
|
|
panel.layoutSizingHorizontal = 'FILL'
|
|
panel.layoutSizingVertical = 'FIXED'
|
|
panel.resize(375, 220)
|
|
|
|
computeAllLayouts(graph)
|
|
|
|
const sb = getNodeOrThrow(graph, statusBar.id)
|
|
const tb = getNodeOrThrow(graph, toolbar.id)
|
|
const cv = getNodeOrThrow(graph, canvas.id)
|
|
const pn = getNodeOrThrow(graph, panel.id)
|
|
|
|
expect(sb.height).toBe(44)
|
|
expect(tb.height).toBe(52)
|
|
expect(pn.height).toBe(220)
|
|
expect(cv.height).toBe(812 - 44 - 52 - 220)
|
|
})
|
|
})
|
|
})
|