test(layout): split auto and grid coverage
- Move layout.test.ts into auto-layout and grid-layout files - Add shared layout helpers for test graph setup and fixture loading - Preserve all 95 layout tests after the split
This commit is contained in:
parent
0532dfc697
commit
c47f8a0359
|
|
@ -1,58 +1,16 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditorStore } from '@/app/editor/session'
|
||||
import {
|
||||
SceneGraph,
|
||||
type SceneNode,
|
||||
type GridTrack,
|
||||
computeLayout,
|
||||
computeAllLayouts,
|
||||
createEditor,
|
||||
setTextMeasurer,
|
||||
FigmaAPI,
|
||||
readFigFile
|
||||
SceneGraph,
|
||||
setTextMeasurer
|
||||
} from '@open-pencil/core'
|
||||
|
||||
function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
function autoFrame(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('FRAME', parentId, {
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
width: 400,
|
||||
height: 200,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function rect(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
w = 50,
|
||||
h = 50,
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('RECTANGLE', parentId, {
|
||||
name: 'Rect',
|
||||
width: w,
|
||||
height: h,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
async function loadFixtureGraph(name: string) {
|
||||
const path = new URL(`../fixtures/${name}`, import.meta.url)
|
||||
const buffer = await Bun.file(path).arrayBuffer()
|
||||
const file = new File([buffer], name, { type: 'application/octet-stream' })
|
||||
return readFigFile(file)
|
||||
}
|
||||
import { autoFrame, loadFixtureGraph, pageId, rect } from '../../helpers/layout'
|
||||
|
||||
describe('Auto Layout', () => {
|
||||
describe('horizontal basic', () => {
|
||||
|
|
@ -1814,437 +1772,3 @@ describe('Auto Layout', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
function gridFrame(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
columns: GridTrack[],
|
||||
rows: GridTrack[],
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('FRAME', parentId, {
|
||||
layoutMode: 'GRID',
|
||||
width: 400,
|
||||
height: 300,
|
||||
gridTemplateColumns: columns,
|
||||
gridTemplateRows: rows,
|
||||
gridColumnGap: 0,
|
||||
gridRowGap: 0,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function fr(value = 1): GridTrack {
|
||||
return { sizing: 'FR', value }
|
||||
}
|
||||
|
||||
function fixed(value: number): GridTrack {
|
||||
return { sizing: 'FIXED', value }
|
||||
}
|
||||
|
||||
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 = graph.getNode(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 = graph.getNode(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 = graph.getNode(statusBar.id)!
|
||||
const tb = graph.getNode(toolbar.id)!
|
||||
const cv = graph.getNode(canvas.id)!
|
||||
const pn = graph.getNode(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)
|
||||
})
|
||||
})
|
||||
})
|
||||
412
tests/engine/layout/grid-layout.test.ts
Normal file
412
tests/engine/layout/grid-layout.test.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { computeLayout, computeAllLayouts, FigmaAPI, SceneGraph } from '@open-pencil/core'
|
||||
|
||||
import { autoFrame, fixed, fr, gridFrame, pageId, rect } from '../../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 = graph.getNode(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 = graph.getNode(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 = graph.getNode(statusBar.id)!
|
||||
const tb = graph.getNode(toolbar.id)!
|
||||
const cv = graph.getNode(canvas.id)!
|
||||
const pn = graph.getNode(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)
|
||||
})
|
||||
})
|
||||
})
|
||||
71
tests/helpers/layout.ts
Normal file
71
tests/helpers/layout.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { readFigFile, SceneGraph } from '@open-pencil/core'
|
||||
|
||||
import type { GridTrack, SceneNode } from '@open-pencil/core'
|
||||
|
||||
export function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
export function autoFrame(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('FRAME', parentId, {
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED',
|
||||
width: 400,
|
||||
height: 200,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
export function rect(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
w = 50,
|
||||
h = 50,
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('RECTANGLE', parentId, {
|
||||
name: 'Rect',
|
||||
width: w,
|
||||
height: h,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadFixtureGraph(name: string) {
|
||||
const path = new URL(`../fixtures/${name}`, import.meta.url)
|
||||
const buffer = await Bun.file(path).arrayBuffer()
|
||||
const file = new File([buffer], name, { type: 'application/octet-stream' })
|
||||
return readFigFile(file)
|
||||
}
|
||||
|
||||
export function gridFrame(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
columns: GridTrack[],
|
||||
rows: GridTrack[],
|
||||
overrides: Partial<SceneNode> = {}
|
||||
): SceneNode {
|
||||
return graph.createNode('FRAME', parentId, {
|
||||
layoutMode: 'GRID',
|
||||
width: 400,
|
||||
height: 300,
|
||||
gridTemplateColumns: columns,
|
||||
gridTemplateRows: rows,
|
||||
gridColumnGap: 0,
|
||||
gridRowGap: 0,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
export function fr(value = 1): GridTrack {
|
||||
return { sizing: 'FR', value }
|
||||
}
|
||||
|
||||
export function fixed(value: number): GridTrack {
|
||||
return { sizing: 'FIXED', value }
|
||||
}
|
||||
Loading…
Reference in a new issue