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) }) }) })