diff --git a/oxlint.json b/oxlint.json index 9d2c07cc3..0b9dcaf57 100644 --- a/oxlint.json +++ b/oxlint.json @@ -212,14 +212,6 @@ "complexity": "off" } }, - { - "files": [ - "tests/engine/layout/auto-layout.test.ts" - ], - "rules": { - "max-lines": "off" - } - }, { "files": ["scripts/**/*.ts"], "rules": { diff --git a/tests/engine/layout/auto-layout.test.ts b/tests/engine/layout/auto-layout.test.ts deleted file mode 100644 index 00836f47f..000000000 --- a/tests/engine/layout/auto-layout.test.ts +++ /dev/null @@ -1,1775 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import { - computeAllLayouts, - computeLayout, - createEditor, - SceneGraph, - setTextMeasurer -} from '@open-pencil/core' - -import { createEditorStore } from '@/app/editor/session' - -import { getNodeOrThrow } from '#tests/helpers/assert' -import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout' - -describe('Auto Layout', () => { - describe('horizontal basic', () => { - test('positions children left-to-right', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph)) - rect(graph, frame.id, 80, 40) - rect(graph, frame.id, 60, 40) - rect(graph, frame.id, 100, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(80) - expect(children[2].x).toBe(140) - }) - - test('applies item spacing', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { itemSpacing: 10 }) - rect(graph, frame.id, 50, 50) - 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(60) - expect(children[2].x).toBe(120) - }) - - test('applies padding', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - paddingTop: 10, - paddingRight: 20, - paddingBottom: 30, - paddingLeft: 40 - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.x).toBe(40) - expect(child.y).toBe(10) - }) - - test('applies padding and spacing together', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - paddingLeft: 20, - paddingTop: 15, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 30) - rect(graph, frame.id, 50, 30) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(20) - expect(children[0].y).toBe(15) - expect(children[1].x).toBe(80) - expect(children[1].y).toBe(15) - }) - - test('positions children right-to-left when layoutDirection is RTL', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 80, - layoutDirection: 'RTL', - paddingLeft: 20, - paddingRight: 30, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 30) - rect(graph, frame.id, 60, 30) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(220) - expect(children[1].x).toBe(150) - }) - - test('inherits RTL flow when layoutDirection is AUTO inside an RTL parent', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 320, - height: 120, - layoutDirection: 'RTL' - }) - const inner = autoFrame(graph, outer.id, { - width: 240, - height: 80, - layoutDirection: 'AUTO' - }) - rect(graph, inner.id, 50, 30) - rect(graph, inner.id, 60, 30) - - computeAllLayouts(graph, outer.id) - - const children = graph.getChildren(inner.id) - expect(children[0].x).toBe(190) - expect(children[1].x).toBe(130) - }) - - test('allows nested frame to override parent flow direction', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 320, - height: 120, - layoutDirection: 'LTR' - }) - const inner = autoFrame(graph, outer.id, { - width: 240, - height: 80, - layoutDirection: 'RTL' - }) - rect(graph, inner.id, 50, 30) - rect(graph, inner.id, 60, 30) - - computeAllLayouts(graph, outer.id) - - const children = graph.getChildren(inner.id) - expect(children[0].x).toBe(190) - expect(children[1].x).toBe(130) - }) - - test('recomputes deep auto descendants when parent flow changes', () => { - const graph = new SceneGraph() - const outer = graph.createNode('FRAME', pageId(graph), { - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 320, - height: 200, - layoutDirection: 'LTR' - }) - const wrapper = graph.createNode('FRAME', outer.id, { - layoutMode: 'NONE', - width: 280, - height: 120 - }) - const inner = autoFrame(graph, wrapper.id, { - width: 240, - height: 80, - layoutDirection: 'AUTO' - }) - rect(graph, inner.id, 50, 30) - rect(graph, inner.id, 60, 30) - - const editor = createEditor({ graph }) - editor.updateNodeWithUndo(outer.id, { layoutDirection: 'RTL' }, 'Change layout direction') - - const children = graph.getChildren(inner.id) - expect(children[0].x).toBe(190) - expect(children[1].x).toBe(130) - }) - }) - - describe('vertical basic', () => { - test('positions children top-to-bottom', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400 - }) - rect(graph, frame.id, 50, 80) - rect(graph, frame.id, 50, 60) - rect(graph, frame.id, 50, 100) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(0) - expect(children[1].y).toBe(80) - expect(children[2].y).toBe(140) - }) - - test('applies item spacing vertically', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400, - itemSpacing: 16 - }) - rect(graph, frame.id, 50, 40) - rect(graph, frame.id, 50, 40) - rect(graph, frame.id, 50, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(0) - expect(children[1].y).toBe(56) - expect(children[2].y).toBe(112) - }) - }) - - describe('alignment - primary axis', () => { - test('center alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100, - primaryAxisAlign: 'CENTER' - }) - rect(graph, frame.id, 100, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.x).toBe(150) - }) - - test('max alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100, - primaryAxisAlign: 'MAX' - }) - rect(graph, frame.id, 100, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.x).toBe(300) - }) - - test('space-between alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100, - primaryAxisAlign: 'SPACE_BETWEEN' - }) - rect(graph, frame.id, 50, 50) - 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[2].x).toBe(350) - expect(children[1].x).toBeCloseTo(175, 0) - }) - - test('center alignment (vertical)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400, - primaryAxisAlign: 'CENTER' - }) - rect(graph, frame.id, 50, 100) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.y).toBe(150) - }) - - test('max alignment (vertical)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400, - primaryAxisAlign: 'MAX' - }) - rect(graph, frame.id, 50, 100) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.y).toBe(300) - }) - }) - - describe('alignment - counter axis', () => { - test('center cross-axis alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'CENTER' - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.y).toBe(75) - }) - - test('max cross-axis alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'MAX' - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.y).toBe(150) - }) - - test('center cross-axis alignment (vertical)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400, - counterAxisAlign: 'CENTER' - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.x).toBe(75) - }) - - test('stretch cross-axis alignment (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'STRETCH' - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.height).toBe(200) - }) - - test('stretch cross-axis alignment (vertical)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400, - counterAxisAlign: 'STRETCH' - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.width).toBe(200) - }) - }) - - describe('sizing modes', () => { - test('hug contents horizontally', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'FIXED', - width: 999, - height: 100, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 50) - rect(graph, frame.id, 70, 50) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(130) - }) - - test('hug contents vertically', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - primaryAxisSizing: 'HUG', - counterAxisSizing: 'FIXED', - width: 200, - height: 999, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 40) - rect(graph, frame.id, 50, 60) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.height).toBe(110) - }) - - test('hug includes padding', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG', - width: 999, - height: 999, - paddingTop: 10, - paddingRight: 20, - paddingBottom: 30, - paddingLeft: 40 - }) - rect(graph, frame.id, 100, 50) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(160) - expect(f.height).toBe(90) - }) - - test('child fill in horizontal layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100 - }) - rect(graph, frame.id, 100, 50) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(100) - expect(children[1].width).toBe(300) - }) - - test('child fill in vertical layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400 - }) - rect(graph, frame.id, 50, 100) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].height).toBe(100) - expect(children[1].height).toBe(300) - }) - - test('multiple fill children share space equally', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 100 - }) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(100) - expect(children[1].width).toBe(100) - expect(children[2].width).toBe(100) - }) - - test('fill with spacing and padding', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100, - paddingLeft: 20, - paddingRight: 20, - itemSpacing: 10 - }) - rect(graph, frame.id, 100, 50) - rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(100) - // 400 - 20 - 20 (padding) - 100 (fixed child) - 10 (spacing) = 250 - expect(children[1].width).toBe(250) - }) - }) - - describe('absolute positioning', () => { - test('absolute children are skipped in layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 50) - rect(graph, frame.id, 50, 50, { - layoutPositioning: 'ABSOLUTE', - x: 200, - y: 100 - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // First auto child at 0 - expect(children[0].x).toBe(0) - // Absolute child should keep its position - expect(children[1].x).toBe(200) - expect(children[1].y).toBe(100) - // Third child should be right after first (no gap for absolute) - expect(children[2].x).toBe(60) - }) - }) - - describe('hidden children', () => { - test('hidden children preserve original size but collapse in layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 100, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 50, { visible: false }) - rect(graph, frame.id, 80, 40) - rect(graph, frame.id, 50, 50, { visible: false }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(50) - expect(children[0].height).toBe(50) - expect(children[1].x).toBe(0) - expect(children[1].width).toBe(80) - expect(children[2].width).toBe(50) - expect(children[2].height).toBe(50) - }) - - test('hidden children do not consume spacing', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100, - itemSpacing: 20 - }) - rect(graph, frame.id, 50, 50, { visible: false }) - rect(graph, frame.id, 100, 50) - rect(graph, frame.id, 100, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[1].x).toBe(0) - expect(children[2].x).toBe(120) - }) - - test('hug frame ignores hidden children for sizing', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG' - }) - rect(graph, frame.id, 200, 200, { visible: false }) - rect(graph, frame.id, 50, 30) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(50) - expect(f.height).toBe(30) - }) - - test('hidden nested auto-layout children preserve size but collapse in layout', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 300, - height: 100, - itemSpacing: 16 - }) - const inner = autoFrame(graph, outer.id, { - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 50, - height: 50, - visible: false - }) - rect(graph, inner.id, 30, 30) - rect(graph, outer.id, 80, 40) - - computeLayout(graph, outer.id) - - const children = graph.getChildren(outer.id) - const innerNode = getNodeOrThrow(graph, inner.id) - expect(innerNode.width).toBe(50) - expect(innerNode.height).toBe(50) - expect(children[1].x).toBe(0) - }) - }) - - describe('self-alignment', () => { - test('layoutAlignSelf STRETCH overrides counter axis', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'MIN' - }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'STRETCH' }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].height).toBe(200) - expect(children[1].height).toBe(50) - }) - }) - - describe('nested auto layout', () => { - test('nested horizontal frames', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 500, - height: 200, - itemSpacing: 10 - }) - const inner = autoFrame(graph, outer.id, { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'FIXED', - width: 999, - height: 100, - itemSpacing: 5 - }) - rect(graph, inner.id, 40, 40) - rect(graph, inner.id, 60, 40) - rect(graph, outer.id, 80, 80) - - computeLayout(graph, outer.id) - - const innerNode = getNodeOrThrow(graph, inner.id) - expect(innerNode.width).toBe(105) - expect(innerNode.x).toBe(0) - - const outerChildren = graph.getChildren(outer.id) - expect(outerChildren[1].x).toBe(115) - }) - - test('nested vertical inside horizontal', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 500, - height: 300, - itemSpacing: 20 - }) - const inner = autoFrame(graph, outer.id, { - layoutMode: 'VERTICAL', - primaryAxisSizing: 'HUG', - counterAxisSizing: 'FIXED', - width: 100, - height: 999, - itemSpacing: 10 - }) - rect(graph, inner.id, 80, 50) - rect(graph, inner.id, 80, 70) - rect(graph, outer.id, 60, 60) - - computeLayout(graph, outer.id) - - const innerNode = getNodeOrThrow(graph, inner.id) - expect(innerNode.height).toBe(130) - - const outerChildren = graph.getChildren(outer.id) - expect(outerChildren[0].x).toBe(0) - expect(outerChildren[1].x).toBe(120) - }) - - test('computeAllLayouts handles deeply nested frames', () => { - const graph = new SceneGraph() - const page = pageId(graph) - const outer = autoFrame(graph, page, { - layoutMode: 'VERTICAL', - width: 300, - height: 500, - itemSpacing: 10 - }) - const middle = autoFrame(graph, outer.id, { - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG', - width: 999, - height: 999, - itemSpacing: 5 - }) - rect(graph, middle.id, 40, 30) - rect(graph, middle.id, 60, 30) - rect(graph, outer.id, 100, 50) - - computeAllLayouts(graph) - - const middleNode = getNodeOrThrow(graph, middle.id) - expect(middleNode.width).toBe(105) - expect(middleNode.height).toBe(30) - - const outerChildren = graph.getChildren(outer.id) - expect(outerChildren[0].y).toBe(0) - expect(outerChildren[1].y).toBe(40) - }) - }) - - describe('wrap layout', () => { - test('wraps children in horizontal layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP' - }) - rect(graph, frame.id, 80, 40) - rect(graph, frame.id, 80, 40) - rect(graph, frame.id, 80, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // First two fit on first row (80 + 80 = 160 <= 200) - expect(children[0].x).toBe(0) - expect(children[0].y).toBe(0) - expect(children[1].x).toBe(80) - expect(children[1].y).toBe(0) - // Third wraps to second row - expect(children[2].x).toBe(0) - expect(children[2].y).toBe(40) - }) - - test('counterAxisSpacing adds gap between wrapped rows', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP', - counterAxisSpacing: 10 - }) - rect(graph, frame.id, 120, 40) - rect(graph, frame.id, 120, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(0) - expect(children[1].y).toBe(50) - }) - - test('itemSpacing with wrap', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP', - itemSpacing: 10 - }) - // 90 + 10 + 90 = 190, fits in 200 - rect(graph, frame.id, 90, 40) - rect(graph, frame.id, 90, 40) - // 90 wraps to next row - rect(graph, frame.id, 90, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(100) - expect(children[2].x).toBe(0) - expect(children[2].y).toBe(40) - }) - }) - - describe('counter axis spacing (no wrap)', () => { - test('counterAxisSpacing is ignored without wrap in horizontal', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 100, - counterAxisSpacing: 50, - layoutWrap: 'NO_WRAP' - }) - rect(graph, frame.id, 50, 50) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // Without wrap, counterAxisSpacing has no effect - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(50) - }) - }) - - describe('layout mode NONE', () => { - test('computeLayout does nothing for NONE layout', () => { - const graph = new SceneGraph() - const frame = graph.createNode('FRAME', pageId(graph), { - name: 'Plain', - layoutMode: 'NONE', - width: 400, - height: 200 - }) - const child = rect(graph, frame.id, 50, 50, { x: 100, y: 100 }) - - computeLayout(graph, frame.id) - - const c = getNodeOrThrow(graph, child.id) - expect(c.x).toBe(100) - expect(c.y).toBe(100) - }) - }) - - describe('edge cases', () => { - test('empty auto layout frame', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG', - paddingTop: 10, - paddingRight: 20, - paddingBottom: 30, - paddingLeft: 40 - }) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(60) - expect(f.height).toBe(40) - }) - - test('single child', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 100 - }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.x).toBe(0) - expect(child.y).toBe(0) - expect(child.width).toBe(50) - expect(child.height).toBe(50) - }) - - test('all children absolute → frame hugs to padding only', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG', - paddingTop: 5, - paddingRight: 5, - paddingBottom: 5, - paddingLeft: 5 - }) - rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE' }) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(10) - expect(f.height).toBe(10) - }) - - test('zero-size children', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG' - }) - rect(graph, frame.id, 0, 0) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(50) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(0) - }) - }) - - describe('mixed sizing', () => { - test('fixed primary, hug counter (horizontal)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 999, - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'HUG' - }) - rect(graph, frame.id, 50, 80) - rect(graph, frame.id, 50, 120) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(400) - expect(f.height).toBe(120) - }) - - test('hug primary, fixed counter (vertical)', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 999, - primaryAxisSizing: 'HUG', - counterAxisSizing: 'FIXED', - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 40) - rect(graph, frame.id, 80, 60) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.height).toBe(110) - expect(f.width).toBe(200) - }) - }) - - describe('nested auto layout with fill children', () => { - test('child frame with FILL sizing expands in parent', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - itemSpacing: 10 - }) - rect(graph, outer.id, 100, 50) - const inner = autoFrame(graph, outer.id, { - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 50, - height: 50, - layoutGrow: 1 - }) - rect(graph, inner.id, 30, 30) - - computeLayout(graph, outer.id) - - const innerNode = getNodeOrThrow(graph, inner.id) - // 400 - 100 - 10 = 290 - expect(innerNode.width).toBe(290) - }) - }) - - describe('gap mapping correctness', () => { - test('horizontal: itemSpacing is column gap, counterAxisSpacing is row gap', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP', - itemSpacing: 10, - counterAxisSpacing: 20 - }) - // Each row: 90 + 10 + 90 = 190 <= 200 - rect(graph, frame.id, 90, 40) - rect(graph, frame.id, 90, 40) - // These wrap - rect(graph, frame.id, 90, 40) - rect(graph, frame.id, 90, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // Row 1 - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(100) - // Row 2: y = 40 (row height) + 20 (counterAxisSpacing) = 60 - expect(children[2].y).toBe(60) - expect(children[3].x).toBe(100) - }) - - test('vertical: itemSpacing is row gap, counterAxisSpacing is column gap', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 300, - height: 200, - layoutWrap: 'WRAP', - itemSpacing: 10, - counterAxisSpacing: 20 - }) - // Each column: 80 + 10 + 80 = 170 <= 200 - rect(graph, frame.id, 40, 80) - rect(graph, frame.id, 40, 80) - // These wrap to second column - rect(graph, frame.id, 40, 80) - rect(graph, frame.id, 40, 80) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // Column 1 - expect(children[0].y).toBe(0) - expect(children[1].y).toBe(90) - // Column 2: x = 40 (col width) + 20 (counterAxisSpacing) = 60 - expect(children[2].x).toBe(60) - expect(children[2].y).toBe(0) - expect(children[3].x).toBe(60) - expect(children[3].y).toBe(90) - }) - }) - - describe('text measurement', () => { - test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => { - const graph = await loadFixtureGraph('gold-preview.fig') - const store = createEditorStore(graph) - const title = [...store.graph.getAllNodes()].find( - (node) => node.type === 'TEXT' && node.text === "World's largest" - ) - const subtitle = [...store.graph.getAllNodes()].find( - (node) => - node.type === 'TEXT' && - node.text === 'Preline UI Figma - crafted with Tailwind CSS styles' - ) - const description = [...store.graph.getAllNodes()].find( - (node) => - node.type === 'TEXT' && - node.text.startsWith('Preline UI Figma is the largest free design system for Figma') - ) - - if (!title || !subtitle || !description) { - throw new Error('Expected imported text nodes in gold-preview.fig') - } - - expect(title.width).toBe(444) - expect(title.height).toBe(73) - expect(subtitle.width).toBe(439) - expect(subtitle.height).toBe(22) - expect(description.width).toBe(878) - expect(description.height).toBe(60) - - await store.switchPage(store.graph.getPages()[0].id) - - expect(store.graph.getNode(title.id)?.width).toBe(444) - expect(store.graph.getNode(title.id)?.height).toBe(73) - expect(store.graph.getNode(subtitle.id)?.width).toBe(439) - expect(store.graph.getNode(subtitle.id)?.height).toBe(22) - expect(store.graph.getNode(description.id)?.width).toBe(878) - expect(store.graph.getNode(description.id)?.height).toBe(60) - }) - - test('imported nested instance layout keeps hidden sibling offsets stable', async () => { - const graph = await loadFixtureGraph('gold-preview.fig') - const previewRoot = graph.getChildren(graph.getPages()[0].id)[0] - const wysiwygEditor = graph - .getChildren(previewRoot.id) - .find((node) => node.name === '_WYSIWYG-editor') - const toolbarVariant = wysiwygEditor - ? graph - .getChildren(wysiwygEditor.id) - .find((node) => node.name === '_on-text-WYSIWYG-toolbar') - : undefined - const toolbarRow = toolbarVariant - ? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar') - : undefined - const hiddenInput = toolbarRow - ? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input') - : undefined - const visibleToolbar = toolbarRow - ? graph - .getChildren(toolbarRow.id) - .find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id) - : undefined - - if (!toolbarRow || !hiddenInput || !visibleToolbar) { - throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig') - } - - expect(hiddenInput.visible).toBe(false) - // x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03). - // The old assertion of x=298 was incorrect — it was a post-layout computed value from - // an older layout implementation. Post-layout, left-aligned HUG content retains x=8. - expect(visibleToolbar.x).toBe(8) - - computeAllLayouts(graph, graph.getPages()[0].id) - - expect(graph.getNode(visibleToolbar.id)?.x).toBe(8) - expect(graph.getNode(visibleToolbar.id)?.y).toBe(8) - }) - - test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 40, - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - primaryAxisAlign: 'CENTER', - paddingLeft: 10, - paddingRight: 10, - itemSpacing: 10 - }) - - const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 }) - const text = graph.createNode('TEXT', frame.id, { - width: 200, - height: 20, - text: 'Test', - fontSize: 14, - textAutoResize: 'WIDTH_AND_HEIGHT' as const - }) - const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 }) - - setTextMeasurer((node) => { - if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') { - return { width: 60, height: 20 } - } - return null - }) - - computeAllLayouts(graph) - - setTextMeasurer(null) - - const updatedText = getNodeOrThrow(graph, text.id) - const updatedArrow1 = getNodeOrThrow(graph, arrow1.id) - const updatedArrow2 = getNodeOrThrow(graph, arrow2.id) - - expect(updatedText.width).toBe(60) - - // Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140 - // Free space: 300 - 140 = 160, centered offset = 80 - expect(updatedArrow1.x).toBe(90) - expect(updatedText.x).toBe(120) - expect(updatedArrow2.x).toBe(190) - }) - - test('without measurer, text preserves stored dimensions from .fig import', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 40, - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - primaryAxisAlign: 'CENTER' - }) - - graph.createNode('TEXT', frame.id, { - width: 200, - height: 20, - text: 'Test', - fontSize: 14, - textAutoResize: 'WIDTH_AND_HEIGHT' as const - }) - - setTextMeasurer(null) - computeAllLayouts(graph) - - const children = graph.getChildren(frame.id) - const updatedText = children[0] - expect(updatedText.width).toBe(200) - expect(updatedText.height).toBe(20) - }) - - test('without measurer, text with 100x100 default falls back to estimate', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 40, - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - primaryAxisAlign: 'CENTER' - }) - - graph.createNode('TEXT', frame.id, { - width: 100, - height: 100, - text: 'Test', - fontSize: 14, - textAutoResize: 'WIDTH_AND_HEIGHT' as const - }) - - setTextMeasurer(null) - computeAllLayouts(graph) - - const children = graph.getChildren(frame.id) - const updatedText = children[0] - expect(updatedText.width).toBeLessThan(100) - expect(updatedText.width).toBeGreaterThan(0) - expect(updatedText.height).toBeLessThan(100) - expect(updatedText.height).toBeGreaterThan(0) - }) - - test('without measurer, HEIGHT text preserves stored height from .fig import', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 269, - height: 400, - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED' - }) - - graph.createNode('TEXT', frame.id, { - width: 269, - height: 66, - text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected', - fontSize: 15, - lineHeight: 22, - textAutoResize: 'HEIGHT' as const - }) - - setTextMeasurer(null) - computeAllLayouts(graph) - - const children = graph.getChildren(frame.id) - const updatedText = children[0] - expect(updatedText.height).toBe(66) - expect(updatedText.width).toBe(269) - }) - - test('without measurer, HEIGHT text with 100x100 default falls back to estimate', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 269, - height: 400, - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED' - }) - - graph.createNode('TEXT', frame.id, { - width: 100, - height: 100, - text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected', - fontSize: 15, - lineHeight: 22, - textAutoResize: 'HEIGHT' as const - }) - - setTextMeasurer(null) - computeAllLayouts(graph) - - const children = graph.getChildren(frame.id) - const updatedText = children[0] - expect(updatedText.height).toBeGreaterThan(22) - }) - - test('text with w="fill" in flex="col" stretches to parent width', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 400, - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - paddingLeft: 20, - paddingRight: 20 - }) - - const text = graph.createNode('TEXT', frame.id, { - width: 100, - height: 20, - text: 'This text should fill the parent width', - fontSize: 14, - textAutoResize: 'HEIGHT' as const, - layoutAlignSelf: 'STRETCH' as const - }) - - setTextMeasurer((_node, maxWidth) => { - const w = maxWidth ?? 260 - return { width: w, height: 20 } - }) - - computeAllLayouts(graph) - setTextMeasurer(null) - - const updatedText = getNodeOrThrow(graph, text.id) - // Should stretch to 300 - 20 - 20 = 260, NOT stay at 100 - expect(updatedText.width).toBe(260) - }) - - test('HEIGHT auto-resize text wraps via MeasureFunc when filling parent', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 200, - layoutMode: 'VERTICAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED' - }) - - const text = graph.createNode('TEXT', frame.id, { - width: 300, - height: 20, - text: 'Long text that should wrap within the available width', - fontSize: 14, - textAutoResize: 'HEIGHT' as const - }) - - setTextMeasurer((_node, maxWidth) => { - const w = maxWidth ?? 1e6 - if (w >= 300) return { width: 300, height: 20 } - return { width: w, height: 60 } - }) - - computeAllLayouts(graph) - setTextMeasurer(null) - - const updatedText = getNodeOrThrow(graph, text.id) - expect(updatedText.width).toBe(300) - expect(updatedText.height).toBe(20) - }) - - test('MeasureFunc receives constraint width from flex layout', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 400, - height: 200, - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - itemSpacing: 10 - }) - - rect(graph, frame.id, 100, 50) - - const text = graph.createNode('TEXT', frame.id, { - width: 500, - height: 20, - text: 'Wide text', - fontSize: 14, - textAutoResize: 'WIDTH_AND_HEIGHT' as const, - layoutGrow: 1 - }) - - const receivedWidths: number[] = [] - setTextMeasurer((_node, maxWidth) => { - if (maxWidth !== undefined) receivedWidths.push(Math.round(maxWidth)) - const w = maxWidth ?? 500 - return { width: Math.min(200, w), height: w < 200 ? 40 : 20 } - }) - - computeAllLayouts(graph) - setTextMeasurer(null) - - // 400 - 100 - 10 = 290 available for the fill text - expect(receivedWidths.length).toBeGreaterThan(0) - const updatedText = getNodeOrThrow(graph, text.id) - expect(updatedText.width).toBe(290) - }) - - test('textAutoResize NONE skips MeasureFunc', () => { - const graph = new SceneGraph() - const pid = pageId(graph) - - const frame = autoFrame(graph, pid, { - width: 300, - height: 100, - layoutMode: 'HORIZONTAL', - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED' - }) - - const text = graph.createNode('TEXT', frame.id, { - width: 150, - height: 40, - text: 'Fixed text', - fontSize: 14, - textAutoResize: 'NONE' as const - }) - - let measureCalled = false - setTextMeasurer(() => { - measureCalled = true - return { width: 80, height: 20 } - }) - - computeAllLayouts(graph) - setTextMeasurer(null) - - expect(measureCalled).toBe(false) - const updatedText = getNodeOrThrow(graph, text.id) - expect(updatedText.width).toBe(150) - expect(updatedText.height).toBe(40) - }) - }) - - describe('min/max constraints', () => { - test('maxWidth clamps child in horizontal layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100 - }) - rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxWidth: 200 }) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.width).toBe(200) - }) - - test('minWidth prevents shrinking below minimum', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 100, - height: 100 - }) - rect(graph, frame.id, 200, 50, { minWidth: 150 }) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.width).toBeGreaterThanOrEqual(150) - }) - - test('maxHeight clamps child in vertical layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400 - }) - rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxHeight: 150 }) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.height).toBe(150) - }) - - test('minHeight enforces minimum in vertical layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 200, - height: 400 - }) - rect(graph, frame.id, 50, 30, { minHeight: 80 }) - - computeLayout(graph, frame.id) - - const child = graph.getChildren(frame.id)[0] - expect(child.height).toBe(80) - }) - - test('min/max on nested auto-layout frame', () => { - const graph = new SceneGraph() - const outer = autoFrame(graph, pageId(graph), { - width: 500, - height: 200 - }) - const inner = autoFrame(graph, outer.id, { - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 50, - height: 50, - layoutGrow: 1, - maxWidth: 250 - }) - rect(graph, inner.id, 30, 30) - - computeLayout(graph, outer.id) - - const innerNode = getNodeOrThrow(graph, inner.id) - expect(innerNode.width).toBe(250) - }) - }) - - describe('counterAxisAlignContent', () => { - test('SPACE_BETWEEN distributes wrapped rows evenly', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP', - counterAxisAlignContent: 'SPACE_BETWEEN' as const - }) - rect(graph, frame.id, 120, 40) - rect(graph, frame.id, 120, 40) - rect(graph, frame.id, 120, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - // 3 rows of 40px each = 120px total, 300 - 120 = 180px free space - // SPACE_BETWEEN: first row at 0, last row at 260 - expect(children[0].y).toBe(0) - expect(children[2].y).toBe(260) - // Middle row centered: (0 + 260) / 2 = 130 - expect(children[1].y).toBe(130) - }) - - test('AUTO (default) packs wrapped rows at start', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 200, - height: 300, - layoutWrap: 'WRAP' - }) - rect(graph, frame.id, 120, 40) - rect(graph, frame.id, 120, 40) - rect(graph, frame.id, 120, 40) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(0) - expect(children[1].y).toBe(40) - expect(children[2].y).toBe(80) - }) - }) - - describe('layoutAlignSelf extended values', () => { - test('layoutAlignSelf CENTER positions child at cross-axis center', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'MIN' - }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(75) - expect(children[1].y).toBe(0) - }) - - test('layoutAlignSelf MAX positions child at cross-axis end', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'MIN' - }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(150) - expect(children[1].y).toBe(0) - }) - - test('layoutAlignSelf MIN overrides parent STRETCH', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - counterAxisAlign: 'STRETCH' - }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MIN' as const }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].y).toBe(0) - expect(children[0].height).toBe(50) - expect(children[1].height).toBe(200) - }) - - test('layoutAlignSelf in vertical layout', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - layoutMode: 'VERTICAL', - width: 300, - height: 400, - counterAxisAlign: 'MIN' - }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const }) - rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(125) - expect(children[1].x).toBe(250) - }) - }) - - describe('FILL flexBasis', () => { - test('FILL children with different content sizes get equal width', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 100 - }) - - const inner1 = autoFrame(graph, frame.id, { - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 100, - height: 50, - layoutGrow: 1 - }) - rect(graph, inner1.id, 100, 50) - - const inner2 = autoFrame(graph, frame.id, { - primaryAxisSizing: 'FIXED', - counterAxisSizing: 'FIXED', - width: 200, - height: 50, - layoutGrow: 1 - }) - rect(graph, inner2.id, 200, 50) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(200) - expect(children[1].width).toBe(200) - }) - - test('nested FILL children distribute from zero basis', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 100, - itemSpacing: 0 - }) - autoFrame(graph, frame.id, { - primaryAxisSizing: 'FILL' as const, - counterAxisSizing: 'FIXED', - width: 50, - height: 100 - }) - autoFrame(graph, frame.id, { - primaryAxisSizing: 'FILL' as const, - counterAxisSizing: 'FIXED', - width: 50, - height: 100 - }) - autoFrame(graph, frame.id, { - primaryAxisSizing: 'FILL' as const, - counterAxisSizing: 'FIXED', - width: 50, - height: 100 - }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].width).toBe(100) - expect(children[1].width).toBe(100) - expect(children[2].width).toBe(100) - }) - }) - - describe('hidden children preserve dimensions', () => { - test('re-showing a hidden child restores original size', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 300, - height: 100 - }) - const child = rect(graph, frame.id, 80, 60) - - computeLayout(graph, frame.id) - expect(getNodeOrThrow(graph, child.id).width).toBe(80) - - graph.updateNode(child.id, { visible: false }) - computeLayout(graph, frame.id) - expect(getNodeOrThrow(graph, child.id).width).toBe(80) - expect(getNodeOrThrow(graph, child.id).height).toBe(60) - - graph.updateNode(child.id, { visible: true }) - computeLayout(graph, frame.id) - expect(getNodeOrThrow(graph, child.id).width).toBe(80) - expect(getNodeOrThrow(graph, child.id).height).toBe(60) - }) - }) - - describe('absolute children in Yoga tree', () => { - test('absolute children do not affect auto-layout flow', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - primaryAxisSizing: 'HUG', - counterAxisSizing: 'HUG' - }) - rect(graph, frame.id, 50, 50) - rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE', x: 300, y: 150 }) - rect(graph, frame.id, 50, 50) - - computeLayout(graph, frame.id) - - const f = getNodeOrThrow(graph, frame.id) - expect(f.width).toBe(100) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(0) - expect(children[2].x).toBe(50) - - expect(children[1].x).toBe(300) - expect(children[1].y).toBe(150) - }) - - test('absolute children preserve position when parent resizes', () => { - const graph = new SceneGraph() - const frame = autoFrame(graph, pageId(graph), { - width: 400, - height: 200, - itemSpacing: 10 - }) - rect(graph, frame.id, 50, 50) - rect(graph, frame.id, 80, 40, { layoutPositioning: 'ABSOLUTE', x: 200, y: 100 }) - - computeLayout(graph, frame.id) - - const children = graph.getChildren(frame.id) - expect(children[0].x).toBe(0) - expect(children[1].x).toBe(200) - expect(children[1].y).toBe(100) - expect(children[1].width).toBe(80) - expect(children[1].height).toBe(40) - }) - }) -}) diff --git a/tests/engine/layout/auto-layout/absolute-children-in-yoga-tree.test.ts b/tests/engine/layout/auto-layout/absolute-children-in-yoga-tree.test.ts new file mode 100644 index 000000000..736fd669e --- /dev/null +++ b/tests/engine/layout/auto-layout/absolute-children-in-yoga-tree.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('absolute children in Yoga tree', () => { + test('absolute children do not affect auto-layout flow', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG' + }) + rect(graph, frame.id, 50, 50) + rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE', x: 300, y: 150 }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(100) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(0) + expect(children[2].x).toBe(50) + + expect(children[1].x).toBe(300) + expect(children[1].y).toBe(150) + }) + + test('absolute children preserve position when parent resizes', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 50) + rect(graph, frame.id, 80, 40, { layoutPositioning: 'ABSOLUTE', x: 200, y: 100 }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(200) + expect(children[1].y).toBe(100) + expect(children[1].width).toBe(80) + expect(children[1].height).toBe(40) + }) +}) diff --git a/tests/engine/layout/auto-layout/absolute-positioning.test.ts b/tests/engine/layout/auto-layout/absolute-positioning.test.ts new file mode 100644 index 000000000..e5aa18cdb --- /dev/null +++ b/tests/engine/layout/auto-layout/absolute-positioning.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('absolute positioning', () => { + test('absolute children are skipped in layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 50) + rect(graph, frame.id, 50, 50, { + layoutPositioning: 'ABSOLUTE', + x: 200, + y: 100 + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // First auto child at 0 + expect(children[0].x).toBe(0) + // Absolute child should keep its position + expect(children[1].x).toBe(200) + expect(children[1].y).toBe(100) + // Third child should be right after first (no gap for absolute) + expect(children[2].x).toBe(60) + }) +}) diff --git a/tests/engine/layout/auto-layout/alignment-counter-axis.test.ts b/tests/engine/layout/auto-layout/alignment-counter-axis.test.ts new file mode 100644 index 000000000..614625f15 --- /dev/null +++ b/tests/engine/layout/auto-layout/alignment-counter-axis.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('alignment - counter axis', () => { + test('center cross-axis alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'CENTER' + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.y).toBe(75) + }) + + test('max cross-axis alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'MAX' + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.y).toBe(150) + }) + + test('center cross-axis alignment (vertical)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400, + counterAxisAlign: 'CENTER' + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.x).toBe(75) + }) + + test('stretch cross-axis alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'STRETCH' + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.height).toBe(200) + }) + + test('stretch cross-axis alignment (vertical)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400, + counterAxisAlign: 'STRETCH' + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.width).toBe(200) + }) +}) diff --git a/tests/engine/layout/auto-layout/alignment-primary-axis.test.ts b/tests/engine/layout/auto-layout/alignment-primary-axis.test.ts new file mode 100644 index 000000000..de0c7e15a --- /dev/null +++ b/tests/engine/layout/auto-layout/alignment-primary-axis.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('alignment - primary axis', () => { + test('center alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100, + primaryAxisAlign: 'CENTER' + }) + rect(graph, frame.id, 100, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.x).toBe(150) + }) + + test('max alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100, + primaryAxisAlign: 'MAX' + }) + rect(graph, frame.id, 100, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.x).toBe(300) + }) + + test('space-between alignment (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100, + primaryAxisAlign: 'SPACE_BETWEEN' + }) + rect(graph, frame.id, 50, 50) + 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[2].x).toBe(350) + expect(children[1].x).toBeCloseTo(175, 0) + }) + + test('center alignment (vertical)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400, + primaryAxisAlign: 'CENTER' + }) + rect(graph, frame.id, 50, 100) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.y).toBe(150) + }) + + test('max alignment (vertical)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400, + primaryAxisAlign: 'MAX' + }) + rect(graph, frame.id, 50, 100) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.y).toBe(300) + }) +}) diff --git a/tests/engine/layout/auto-layout/counter-axis-align-content.test.ts b/tests/engine/layout/auto-layout/counter-axis-align-content.test.ts new file mode 100644 index 000000000..36fa7c4c4 --- /dev/null +++ b/tests/engine/layout/auto-layout/counter-axis-align-content.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('counterAxisAlignContent', () => { + test('SPACE_BETWEEN distributes wrapped rows evenly', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP', + counterAxisAlignContent: 'SPACE_BETWEEN' as const + }) + rect(graph, frame.id, 120, 40) + rect(graph, frame.id, 120, 40) + rect(graph, frame.id, 120, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // 3 rows of 40px each = 120px total, 300 - 120 = 180px free space + // SPACE_BETWEEN: first row at 0, last row at 260 + expect(children[0].y).toBe(0) + expect(children[2].y).toBe(260) + // Middle row centered: (0 + 260) / 2 = 130 + expect(children[1].y).toBe(130) + }) + + test('AUTO (default) packs wrapped rows at start', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP' + }) + rect(graph, frame.id, 120, 40) + rect(graph, frame.id, 120, 40) + rect(graph, frame.id, 120, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(0) + expect(children[1].y).toBe(40) + expect(children[2].y).toBe(80) + }) +}) diff --git a/tests/engine/layout/auto-layout/counter-axis-spacing-no-wrap.test.ts b/tests/engine/layout/auto-layout/counter-axis-spacing-no-wrap.test.ts new file mode 100644 index 000000000..6fcf0d972 --- /dev/null +++ b/tests/engine/layout/auto-layout/counter-axis-spacing-no-wrap.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('counter axis spacing (no wrap)', () => { + test('counterAxisSpacing is ignored without wrap in horizontal', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 100, + counterAxisSpacing: 50, + layoutWrap: 'NO_WRAP' + }) + rect(graph, frame.id, 50, 50) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // Without wrap, counterAxisSpacing has no effect + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(50) + }) +}) diff --git a/tests/engine/layout/auto-layout/edge-cases.test.ts b/tests/engine/layout/auto-layout/edge-cases.test.ts new file mode 100644 index 000000000..c13ae37a7 --- /dev/null +++ b/tests/engine/layout/auto-layout/edge-cases.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('edge cases', () => { + test('empty auto layout frame', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + paddingTop: 10, + paddingRight: 20, + paddingBottom: 30, + paddingLeft: 40 + }) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(60) + expect(f.height).toBe(40) + }) + + test('single child', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 100 + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.x).toBe(0) + expect(child.y).toBe(0) + expect(child.width).toBe(50) + expect(child.height).toBe(50) + }) + + test('all children absolute → frame hugs to padding only', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + paddingTop: 5, + paddingRight: 5, + paddingBottom: 5, + paddingLeft: 5 + }) + rect(graph, frame.id, 100, 100, { layoutPositioning: 'ABSOLUTE' }) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(10) + expect(f.height).toBe(10) + }) + + test('zero-size children', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG' + }) + rect(graph, frame.id, 0, 0) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(50) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(0) + }) +}) diff --git a/tests/engine/layout/auto-layout/fill-flex-basis.test.ts b/tests/engine/layout/auto-layout/fill-flex-basis.test.ts new file mode 100644 index 000000000..4427a3872 --- /dev/null +++ b/tests/engine/layout/auto-layout/fill-flex-basis.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('FILL flexBasis', () => { + test('FILL children with different content sizes get equal width', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100 + }) + + const inner1 = autoFrame(graph, frame.id, { + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 100, + height: 50, + layoutGrow: 1 + }) + rect(graph, inner1.id, 100, 50) + + const inner2 = autoFrame(graph, frame.id, { + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 200, + height: 50, + layoutGrow: 1 + }) + rect(graph, inner2.id, 200, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(200) + expect(children[1].width).toBe(200) + }) + + test('nested FILL children distribute from zero basis', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 100, + itemSpacing: 0 + }) + autoFrame(graph, frame.id, { + primaryAxisSizing: 'FILL' as const, + counterAxisSizing: 'FIXED', + width: 50, + height: 100 + }) + autoFrame(graph, frame.id, { + primaryAxisSizing: 'FILL' as const, + counterAxisSizing: 'FIXED', + width: 50, + height: 100 + }) + autoFrame(graph, frame.id, { + primaryAxisSizing: 'FILL' as const, + counterAxisSizing: 'FIXED', + width: 50, + height: 100 + }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(100) + expect(children[1].width).toBe(100) + expect(children[2].width).toBe(100) + }) +}) diff --git a/tests/engine/layout/auto-layout/gap-mapping-correctness.test.ts b/tests/engine/layout/auto-layout/gap-mapping-correctness.test.ts new file mode 100644 index 000000000..2be2b4b7f --- /dev/null +++ b/tests/engine/layout/auto-layout/gap-mapping-correctness.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('gap mapping correctness', () => { + test('horizontal: itemSpacing is column gap, counterAxisSpacing is row gap', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP', + itemSpacing: 10, + counterAxisSpacing: 20 + }) + // Each row: 90 + 10 + 90 = 190 <= 200 + rect(graph, frame.id, 90, 40) + rect(graph, frame.id, 90, 40) + // These wrap + rect(graph, frame.id, 90, 40) + rect(graph, frame.id, 90, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // Row 1 + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(100) + // Row 2: y = 40 (row height) + 20 (counterAxisSpacing) = 60 + expect(children[2].y).toBe(60) + expect(children[3].x).toBe(100) + }) + + test('vertical: itemSpacing is row gap, counterAxisSpacing is column gap', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 300, + height: 200, + layoutWrap: 'WRAP', + itemSpacing: 10, + counterAxisSpacing: 20 + }) + // Each column: 80 + 10 + 80 = 170 <= 200 + rect(graph, frame.id, 40, 80) + rect(graph, frame.id, 40, 80) + // These wrap to second column + rect(graph, frame.id, 40, 80) + rect(graph, frame.id, 40, 80) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // Column 1 + expect(children[0].y).toBe(0) + expect(children[1].y).toBe(90) + // Column 2: x = 40 (col width) + 20 (counterAxisSpacing) = 60 + expect(children[2].x).toBe(60) + expect(children[2].y).toBe(0) + expect(children[3].x).toBe(60) + expect(children[3].y).toBe(90) + }) +}) diff --git a/tests/engine/layout/auto-layout/hidden-children-preserve-dimensions.test.ts b/tests/engine/layout/auto-layout/hidden-children-preserve-dimensions.test.ts new file mode 100644 index 000000000..45a3224d3 --- /dev/null +++ b/tests/engine/layout/auto-layout/hidden-children-preserve-dimensions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('hidden children preserve dimensions', () => { + test('re-showing a hidden child restores original size', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 100 + }) + const child = rect(graph, frame.id, 80, 60) + + computeLayout(graph, frame.id) + expect(getNodeOrThrow(graph, child.id).width).toBe(80) + + graph.updateNode(child.id, { visible: false }) + computeLayout(graph, frame.id) + expect(getNodeOrThrow(graph, child.id).width).toBe(80) + expect(getNodeOrThrow(graph, child.id).height).toBe(60) + + graph.updateNode(child.id, { visible: true }) + computeLayout(graph, frame.id) + expect(getNodeOrThrow(graph, child.id).width).toBe(80) + expect(getNodeOrThrow(graph, child.id).height).toBe(60) + }) +}) diff --git a/tests/engine/layout/auto-layout/hidden-children.test.ts b/tests/engine/layout/auto-layout/hidden-children.test.ts new file mode 100644 index 000000000..2091b96df --- /dev/null +++ b/tests/engine/layout/auto-layout/hidden-children.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('hidden children', () => { + test('hidden children preserve original size but collapse in layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 100, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 50, { visible: false }) + rect(graph, frame.id, 80, 40) + rect(graph, frame.id, 50, 50, { visible: false }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(50) + expect(children[0].height).toBe(50) + expect(children[1].x).toBe(0) + expect(children[1].width).toBe(80) + expect(children[2].width).toBe(50) + expect(children[2].height).toBe(50) + }) + + test('hidden children do not consume spacing', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100, + itemSpacing: 20 + }) + rect(graph, frame.id, 50, 50, { visible: false }) + rect(graph, frame.id, 100, 50) + rect(graph, frame.id, 100, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[1].x).toBe(0) + expect(children[2].x).toBe(120) + }) + + test('hug frame ignores hidden children for sizing', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG' + }) + rect(graph, frame.id, 200, 200, { visible: false }) + rect(graph, frame.id, 50, 30) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(50) + expect(f.height).toBe(30) + }) + + test('hidden nested auto-layout children preserve size but collapse in layout', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 300, + height: 100, + itemSpacing: 16 + }) + const inner = autoFrame(graph, outer.id, { + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 50, + height: 50, + visible: false + }) + rect(graph, inner.id, 30, 30) + rect(graph, outer.id, 80, 40) + + computeLayout(graph, outer.id) + + const children = graph.getChildren(outer.id) + const innerNode = getNodeOrThrow(graph, inner.id) + expect(innerNode.width).toBe(50) + expect(innerNode.height).toBe(50) + expect(children[1].x).toBe(0) + }) +}) diff --git a/tests/engine/layout/auto-layout/horizontal-basic.test.ts b/tests/engine/layout/auto-layout/horizontal-basic.test.ts new file mode 100644 index 000000000..043cd7ef7 --- /dev/null +++ b/tests/engine/layout/auto-layout/horizontal-basic.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from 'bun:test' + +import { computeAllLayouts, computeLayout, createEditor, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('horizontal basic', () => { + test('positions children left-to-right', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph)) + rect(graph, frame.id, 80, 40) + rect(graph, frame.id, 60, 40) + rect(graph, frame.id, 100, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(80) + expect(children[2].x).toBe(140) + }) + + test('applies item spacing', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { itemSpacing: 10 }) + rect(graph, frame.id, 50, 50) + 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(60) + expect(children[2].x).toBe(120) + }) + + test('applies padding', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + paddingTop: 10, + paddingRight: 20, + paddingBottom: 30, + paddingLeft: 40 + }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.x).toBe(40) + expect(child.y).toBe(10) + }) + + test('applies padding and spacing together', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + paddingLeft: 20, + paddingTop: 15, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 30) + rect(graph, frame.id, 50, 30) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(20) + expect(children[0].y).toBe(15) + expect(children[1].x).toBe(80) + expect(children[1].y).toBe(15) + }) + + test('positions children right-to-left when layoutDirection is RTL', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 80, + layoutDirection: 'RTL', + paddingLeft: 20, + paddingRight: 30, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 30) + rect(graph, frame.id, 60, 30) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(220) + expect(children[1].x).toBe(150) + }) + + test('inherits RTL flow when layoutDirection is AUTO inside an RTL parent', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 320, + height: 120, + layoutDirection: 'RTL' + }) + const inner = autoFrame(graph, outer.id, { + width: 240, + height: 80, + layoutDirection: 'AUTO' + }) + rect(graph, inner.id, 50, 30) + rect(graph, inner.id, 60, 30) + + computeAllLayouts(graph, outer.id) + + const children = graph.getChildren(inner.id) + expect(children[0].x).toBe(190) + expect(children[1].x).toBe(130) + }) + + test('allows nested frame to override parent flow direction', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 320, + height: 120, + layoutDirection: 'LTR' + }) + const inner = autoFrame(graph, outer.id, { + width: 240, + height: 80, + layoutDirection: 'RTL' + }) + rect(graph, inner.id, 50, 30) + rect(graph, inner.id, 60, 30) + + computeAllLayouts(graph, outer.id) + + const children = graph.getChildren(inner.id) + expect(children[0].x).toBe(190) + expect(children[1].x).toBe(130) + }) + + test('recomputes deep auto descendants when parent flow changes', () => { + const graph = new SceneGraph() + const outer = graph.createNode('FRAME', pageId(graph), { + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 320, + height: 200, + layoutDirection: 'LTR' + }) + const wrapper = graph.createNode('FRAME', outer.id, { + layoutMode: 'NONE', + width: 280, + height: 120 + }) + const inner = autoFrame(graph, wrapper.id, { + width: 240, + height: 80, + layoutDirection: 'AUTO' + }) + rect(graph, inner.id, 50, 30) + rect(graph, inner.id, 60, 30) + + const editor = createEditor({ graph }) + editor.updateNodeWithUndo(outer.id, { layoutDirection: 'RTL' }, 'Change layout direction') + + const children = graph.getChildren(inner.id) + expect(children[0].x).toBe(190) + expect(children[1].x).toBe(130) + }) +}) diff --git a/tests/engine/layout/auto-layout/layout-align-self-extended-values.test.ts b/tests/engine/layout/auto-layout/layout-align-self-extended-values.test.ts new file mode 100644 index 000000000..c34b99a5b --- /dev/null +++ b/tests/engine/layout/auto-layout/layout-align-self-extended-values.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('layoutAlignSelf extended values', () => { + test('layoutAlignSelf CENTER positions child at cross-axis center', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'MIN' + }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(75) + expect(children[1].y).toBe(0) + }) + + test('layoutAlignSelf MAX positions child at cross-axis end', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'MIN' + }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(150) + expect(children[1].y).toBe(0) + }) + + test('layoutAlignSelf MIN overrides parent STRETCH', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'STRETCH' + }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MIN' as const }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(0) + expect(children[0].height).toBe(50) + expect(children[1].height).toBe(200) + }) + + test('layoutAlignSelf in vertical layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 300, + height: 400, + counterAxisAlign: 'MIN' + }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'CENTER' as const }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'MAX' as const }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(125) + expect(children[1].x).toBe(250) + }) +}) diff --git a/tests/engine/layout/auto-layout/layout-mode-none.test.ts b/tests/engine/layout/auto-layout/layout-mode-none.test.ts new file mode 100644 index 000000000..3a9fbccab --- /dev/null +++ b/tests/engine/layout/auto-layout/layout-mode-none.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { pageId, rect } from '#tests/helpers/layout' + +describe('layout mode NONE', () => { + test('computeLayout does nothing for NONE layout', () => { + const graph = new SceneGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Plain', + layoutMode: 'NONE', + width: 400, + height: 200 + }) + const child = rect(graph, frame.id, 50, 50, { x: 100, y: 100 }) + + computeLayout(graph, frame.id) + + const c = getNodeOrThrow(graph, child.id) + expect(c.x).toBe(100) + expect(c.y).toBe(100) + }) +}) diff --git a/tests/engine/layout/auto-layout/min-max-constraints.test.ts b/tests/engine/layout/auto-layout/min-max-constraints.test.ts new file mode 100644 index 000000000..a00749c72 --- /dev/null +++ b/tests/engine/layout/auto-layout/min-max-constraints.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('min/max constraints', () => { + test('maxWidth clamps child in horizontal layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100 + }) + rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxWidth: 200 }) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.width).toBe(200) + }) + + test('minWidth prevents shrinking below minimum', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 100, + height: 100 + }) + rect(graph, frame.id, 200, 50, { minWidth: 150 }) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.width).toBeGreaterThanOrEqual(150) + }) + + test('maxHeight clamps child in vertical layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400 + }) + rect(graph, frame.id, 50, 50, { layoutGrow: 1, maxHeight: 150 }) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.height).toBe(150) + }) + + test('minHeight enforces minimum in vertical layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400 + }) + rect(graph, frame.id, 50, 30, { minHeight: 80 }) + + computeLayout(graph, frame.id) + + const child = graph.getChildren(frame.id)[0] + expect(child.height).toBe(80) + }) + + test('min/max on nested auto-layout frame', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 500, + height: 200 + }) + const inner = autoFrame(graph, outer.id, { + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 50, + height: 50, + layoutGrow: 1, + maxWidth: 250 + }) + rect(graph, inner.id, 30, 30) + + computeLayout(graph, outer.id) + + const innerNode = getNodeOrThrow(graph, inner.id) + expect(innerNode.width).toBe(250) + }) +}) diff --git a/tests/engine/layout/auto-layout/mixed-sizing.test.ts b/tests/engine/layout/auto-layout/mixed-sizing.test.ts new file mode 100644 index 000000000..11972a2bb --- /dev/null +++ b/tests/engine/layout/auto-layout/mixed-sizing.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('mixed sizing', () => { + test('fixed primary, hug counter (horizontal)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 999, + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG' + }) + rect(graph, frame.id, 50, 80) + rect(graph, frame.id, 50, 120) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(400) + expect(f.height).toBe(120) + }) + + test('hug primary, fixed counter (vertical)', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 999, + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED', + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 40) + rect(graph, frame.id, 80, 60) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.height).toBe(110) + expect(f.width).toBe(200) + }) +}) diff --git a/tests/engine/layout/auto-layout/nested-auto-layout-with-fill-children.test.ts b/tests/engine/layout/auto-layout/nested-auto-layout-with-fill-children.test.ts new file mode 100644 index 000000000..5ef6ce45e --- /dev/null +++ b/tests/engine/layout/auto-layout/nested-auto-layout-with-fill-children.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('nested auto layout with fill children', () => { + test('child frame with FILL sizing expands in parent', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + itemSpacing: 10 + }) + rect(graph, outer.id, 100, 50) + const inner = autoFrame(graph, outer.id, { + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + width: 50, + height: 50, + layoutGrow: 1 + }) + rect(graph, inner.id, 30, 30) + + computeLayout(graph, outer.id) + + const innerNode = getNodeOrThrow(graph, inner.id) + // 400 - 100 - 10 = 290 + expect(innerNode.width).toBe(290) + }) +}) diff --git a/tests/engine/layout/auto-layout/nested-auto-layout.test.ts b/tests/engine/layout/auto-layout/nested-auto-layout.test.ts new file mode 100644 index 000000000..702f8004b --- /dev/null +++ b/tests/engine/layout/auto-layout/nested-auto-layout.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test' + +import { computeAllLayouts, computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('nested auto layout', () => { + test('nested horizontal frames', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 500, + height: 200, + itemSpacing: 10 + }) + const inner = autoFrame(graph, outer.id, { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED', + width: 999, + height: 100, + itemSpacing: 5 + }) + rect(graph, inner.id, 40, 40) + rect(graph, inner.id, 60, 40) + rect(graph, outer.id, 80, 80) + + computeLayout(graph, outer.id) + + const innerNode = getNodeOrThrow(graph, inner.id) + expect(innerNode.width).toBe(105) + expect(innerNode.x).toBe(0) + + const outerChildren = graph.getChildren(outer.id) + expect(outerChildren[1].x).toBe(115) + }) + + test('nested vertical inside horizontal', () => { + const graph = new SceneGraph() + const outer = autoFrame(graph, pageId(graph), { + width: 500, + height: 300, + itemSpacing: 20 + }) + const inner = autoFrame(graph, outer.id, { + layoutMode: 'VERTICAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED', + width: 100, + height: 999, + itemSpacing: 10 + }) + rect(graph, inner.id, 80, 50) + rect(graph, inner.id, 80, 70) + rect(graph, outer.id, 60, 60) + + computeLayout(graph, outer.id) + + const innerNode = getNodeOrThrow(graph, inner.id) + expect(innerNode.height).toBe(130) + + const outerChildren = graph.getChildren(outer.id) + expect(outerChildren[0].x).toBe(0) + expect(outerChildren[1].x).toBe(120) + }) + + test('computeAllLayouts handles deeply nested frames', () => { + const graph = new SceneGraph() + const page = pageId(graph) + const outer = autoFrame(graph, page, { + layoutMode: 'VERTICAL', + width: 300, + height: 500, + itemSpacing: 10 + }) + const middle = autoFrame(graph, outer.id, { + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + width: 999, + height: 999, + itemSpacing: 5 + }) + rect(graph, middle.id, 40, 30) + rect(graph, middle.id, 60, 30) + rect(graph, outer.id, 100, 50) + + computeAllLayouts(graph) + + const middleNode = getNodeOrThrow(graph, middle.id) + expect(middleNode.width).toBe(105) + expect(middleNode.height).toBe(30) + + const outerChildren = graph.getChildren(outer.id) + expect(outerChildren[0].y).toBe(0) + expect(outerChildren[1].y).toBe(40) + }) +}) diff --git a/tests/engine/layout/auto-layout/self-alignment.test.ts b/tests/engine/layout/auto-layout/self-alignment.test.ts new file mode 100644 index 000000000..bccb2a11f --- /dev/null +++ b/tests/engine/layout/auto-layout/self-alignment.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('self-alignment', () => { + test('layoutAlignSelf STRETCH overrides counter axis', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 200, + counterAxisAlign: 'MIN' + }) + rect(graph, frame.id, 50, 50, { layoutAlignSelf: 'STRETCH' }) + rect(graph, frame.id, 50, 50) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].height).toBe(200) + expect(children[1].height).toBe(50) + }) +}) diff --git a/tests/engine/layout/auto-layout/sizing-modes.test.ts b/tests/engine/layout/auto-layout/sizing-modes.test.ts new file mode 100644 index 000000000..91020e726 --- /dev/null +++ b/tests/engine/layout/auto-layout/sizing-modes.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('sizing modes', () => { + test('hug contents horizontally', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED', + width: 999, + height: 100, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 50) + rect(graph, frame.id, 70, 50) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(130) + }) + + test('hug contents vertically', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED', + width: 200, + height: 999, + itemSpacing: 10 + }) + rect(graph, frame.id, 50, 40) + rect(graph, frame.id, 50, 60) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.height).toBe(110) + }) + + test('hug includes padding', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + width: 999, + height: 999, + paddingTop: 10, + paddingRight: 20, + paddingBottom: 30, + paddingLeft: 40 + }) + rect(graph, frame.id, 100, 50) + + computeLayout(graph, frame.id) + + const f = getNodeOrThrow(graph, frame.id) + expect(f.width).toBe(160) + expect(f.height).toBe(90) + }) + + test('child fill in horizontal layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100 + }) + rect(graph, frame.id, 100, 50) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(100) + expect(children[1].width).toBe(300) + }) + + test('child fill in vertical layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400 + }) + rect(graph, frame.id, 50, 100) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].height).toBe(100) + expect(children[1].height).toBe(300) + }) + + test('multiple fill children share space equally', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 300, + height: 100 + }) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(100) + expect(children[1].width).toBe(100) + expect(children[2].width).toBe(100) + }) + + test('fill with spacing and padding', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 400, + height: 100, + paddingLeft: 20, + paddingRight: 20, + itemSpacing: 10 + }) + rect(graph, frame.id, 100, 50) + rect(graph, frame.id, 50, 50, { layoutGrow: 1 }) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].width).toBe(100) + // 400 - 20 - 20 (padding) - 100 (fixed child) - 10 (spacing) = 250 + expect(children[1].width).toBe(250) + }) +}) diff --git a/tests/engine/layout/auto-layout/text-measurement.test.ts b/tests/engine/layout/auto-layout/text-measurement.test.ts new file mode 100644 index 000000000..abd05651c --- /dev/null +++ b/tests/engine/layout/auto-layout/text-measurement.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, test } from 'bun:test' + +import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core' + +import { createEditorStore } from '@/app/editor/session' + +import { getNodeOrThrow } from '#tests/helpers/assert' +import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout' + +describe('text measurement', () => { + test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => { + const graph = await loadFixtureGraph('gold-preview.fig') + const store = createEditorStore(graph) + const title = [...store.graph.getAllNodes()].find( + (node) => node.type === 'TEXT' && node.text === "World's largest" + ) + const subtitle = [...store.graph.getAllNodes()].find( + (node) => + node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles' + ) + const description = [...store.graph.getAllNodes()].find( + (node) => + node.type === 'TEXT' && + node.text.startsWith('Preline UI Figma is the largest free design system for Figma') + ) + + if (!title || !subtitle || !description) { + throw new Error('Expected imported text nodes in gold-preview.fig') + } + + expect(title.width).toBe(444) + expect(title.height).toBe(73) + expect(subtitle.width).toBe(439) + expect(subtitle.height).toBe(22) + expect(description.width).toBe(878) + expect(description.height).toBe(60) + + await store.switchPage(store.graph.getPages()[0].id) + + expect(store.graph.getNode(title.id)?.width).toBe(444) + expect(store.graph.getNode(title.id)?.height).toBe(73) + expect(store.graph.getNode(subtitle.id)?.width).toBe(439) + expect(store.graph.getNode(subtitle.id)?.height).toBe(22) + expect(store.graph.getNode(description.id)?.width).toBe(878) + expect(store.graph.getNode(description.id)?.height).toBe(60) + }) + + test('imported nested instance layout keeps hidden sibling offsets stable', async () => { + const graph = await loadFixtureGraph('gold-preview.fig') + const previewRoot = graph.getChildren(graph.getPages()[0].id)[0] + const wysiwygEditor = graph + .getChildren(previewRoot.id) + .find((node) => node.name === '_WYSIWYG-editor') + const toolbarVariant = wysiwygEditor + ? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar') + : undefined + const toolbarRow = toolbarVariant + ? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar') + : undefined + const hiddenInput = toolbarRow + ? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input') + : undefined + const visibleToolbar = toolbarRow + ? graph + .getChildren(toolbarRow.id) + .find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id) + : undefined + + if (!toolbarRow || !hiddenInput || !visibleToolbar) { + throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig') + } + + expect(hiddenInput.visible).toBe(false) + // x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03). + // The old assertion of x=298 was incorrect — it was a post-layout computed value from + // an older layout implementation. Post-layout, left-aligned HUG content retains x=8. + expect(visibleToolbar.x).toBe(8) + + computeAllLayouts(graph, graph.getPages()[0].id) + + expect(graph.getNode(visibleToolbar.id)?.x).toBe(8) + expect(graph.getNode(visibleToolbar.id)?.y).toBe(8) + }) + + test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + primaryAxisAlign: 'CENTER', + paddingLeft: 10, + paddingRight: 10, + itemSpacing: 10 + }) + + const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 }) + const text = graph.createNode('TEXT', frame.id, { + width: 200, + height: 20, + text: 'Test', + fontSize: 14, + textAutoResize: 'WIDTH_AND_HEIGHT' as const + }) + const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 }) + + setTextMeasurer((node) => { + if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') { + return { width: 60, height: 20 } + } + return null + }) + + computeAllLayouts(graph) + + setTextMeasurer(null) + + const updatedText = getNodeOrThrow(graph, text.id) + const updatedArrow1 = getNodeOrThrow(graph, arrow1.id) + const updatedArrow2 = getNodeOrThrow(graph, arrow2.id) + + expect(updatedText.width).toBe(60) + + // Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140 + // Free space: 300 - 140 = 160, centered offset = 80 + expect(updatedArrow1.x).toBe(90) + expect(updatedText.x).toBe(120) + expect(updatedArrow2.x).toBe(190) + }) + + test('without measurer, text preserves stored dimensions from .fig import', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + primaryAxisAlign: 'CENTER' + }) + + graph.createNode('TEXT', frame.id, { + width: 200, + height: 20, + text: 'Test', + fontSize: 14, + textAutoResize: 'WIDTH_AND_HEIGHT' as const + }) + + setTextMeasurer(null) + computeAllLayouts(graph) + + const children = graph.getChildren(frame.id) + const updatedText = children[0] + expect(updatedText.width).toBe(200) + expect(updatedText.height).toBe(20) + }) + + test('without measurer, text with 100x100 default falls back to estimate', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + primaryAxisAlign: 'CENTER' + }) + + graph.createNode('TEXT', frame.id, { + width: 100, + height: 100, + text: 'Test', + fontSize: 14, + textAutoResize: 'WIDTH_AND_HEIGHT' as const + }) + + setTextMeasurer(null) + computeAllLayouts(graph) + + const children = graph.getChildren(frame.id) + const updatedText = children[0] + expect(updatedText.width).toBeLessThan(100) + expect(updatedText.width).toBeGreaterThan(0) + expect(updatedText.height).toBeLessThan(100) + expect(updatedText.height).toBeGreaterThan(0) + }) + + test('without measurer, HEIGHT text preserves stored height from .fig import', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 269, + height: 400, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + + graph.createNode('TEXT', frame.id, { + width: 269, + height: 66, + text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected', + fontSize: 15, + lineHeight: 22, + textAutoResize: 'HEIGHT' as const + }) + + setTextMeasurer(null) + computeAllLayouts(graph) + + const children = graph.getChildren(frame.id) + const updatedText = children[0] + expect(updatedText.height).toBe(66) + expect(updatedText.width).toBe(269) + }) + + test('without measurer, HEIGHT text with 100x100 default falls back to estimate', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 269, + height: 400, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + + graph.createNode('TEXT', frame.id, { + width: 100, + height: 100, + text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected', + fontSize: 15, + lineHeight: 22, + textAutoResize: 'HEIGHT' as const + }) + + setTextMeasurer(null) + computeAllLayouts(graph) + + const children = graph.getChildren(frame.id) + const updatedText = children[0] + expect(updatedText.height).toBeGreaterThan(22) + }) + + test('text with w="fill" in flex="col" stretches to parent width', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 400, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + paddingLeft: 20, + paddingRight: 20 + }) + + const text = graph.createNode('TEXT', frame.id, { + width: 100, + height: 20, + text: 'This text should fill the parent width', + fontSize: 14, + textAutoResize: 'HEIGHT' as const, + layoutAlignSelf: 'STRETCH' as const + }) + + setTextMeasurer((_node, maxWidth) => { + const w = maxWidth ?? 260 + return { width: w, height: 20 } + }) + + computeAllLayouts(graph) + setTextMeasurer(null) + + const updatedText = getNodeOrThrow(graph, text.id) + // Should stretch to 300 - 20 - 20 = 260, NOT stay at 100 + expect(updatedText.width).toBe(260) + }) + + test('HEIGHT auto-resize text wraps via MeasureFunc when filling parent', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 200, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + + const text = graph.createNode('TEXT', frame.id, { + width: 300, + height: 20, + text: 'Long text that should wrap within the available width', + fontSize: 14, + textAutoResize: 'HEIGHT' as const + }) + + setTextMeasurer((_node, maxWidth) => { + const w = maxWidth ?? 1e6 + if (w >= 300) return { width: 300, height: 20 } + return { width: w, height: 60 } + }) + + computeAllLayouts(graph) + setTextMeasurer(null) + + const updatedText = getNodeOrThrow(graph, text.id) + expect(updatedText.width).toBe(300) + expect(updatedText.height).toBe(20) + }) + + test('MeasureFunc receives constraint width from flex layout', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 400, + height: 200, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED', + itemSpacing: 10 + }) + + rect(graph, frame.id, 100, 50) + + const text = graph.createNode('TEXT', frame.id, { + width: 500, + height: 20, + text: 'Wide text', + fontSize: 14, + textAutoResize: 'WIDTH_AND_HEIGHT' as const, + layoutGrow: 1 + }) + + const receivedWidths: number[] = [] + setTextMeasurer((_node, maxWidth) => { + if (maxWidth !== undefined) receivedWidths.push(Math.round(maxWidth)) + const w = maxWidth ?? 500 + return { width: Math.min(200, w), height: w < 200 ? 40 : 20 } + }) + + computeAllLayouts(graph) + setTextMeasurer(null) + + // 400 - 100 - 10 = 290 available for the fill text + expect(receivedWidths.length).toBeGreaterThan(0) + const updatedText = getNodeOrThrow(graph, text.id) + expect(updatedText.width).toBe(290) + }) + + test('textAutoResize NONE skips MeasureFunc', () => { + const graph = new SceneGraph() + const pid = pageId(graph) + + const frame = autoFrame(graph, pid, { + width: 300, + height: 100, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + + const text = graph.createNode('TEXT', frame.id, { + width: 150, + height: 40, + text: 'Fixed text', + fontSize: 14, + textAutoResize: 'NONE' as const + }) + + let measureCalled = false + setTextMeasurer(() => { + measureCalled = true + return { width: 80, height: 20 } + }) + + computeAllLayouts(graph) + setTextMeasurer(null) + + expect(measureCalled).toBe(false) + const updatedText = getNodeOrThrow(graph, text.id) + expect(updatedText.width).toBe(150) + expect(updatedText.height).toBe(40) + }) +}) diff --git a/tests/engine/layout/auto-layout/vertical-basic.test.ts b/tests/engine/layout/auto-layout/vertical-basic.test.ts new file mode 100644 index 000000000..60a86daaa --- /dev/null +++ b/tests/engine/layout/auto-layout/vertical-basic.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('vertical basic', () => { + test('positions children top-to-bottom', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400 + }) + rect(graph, frame.id, 50, 80) + rect(graph, frame.id, 50, 60) + rect(graph, frame.id, 50, 100) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(0) + expect(children[1].y).toBe(80) + expect(children[2].y).toBe(140) + }) + + test('applies item spacing vertically', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + layoutMode: 'VERTICAL', + width: 200, + height: 400, + itemSpacing: 16 + }) + rect(graph, frame.id, 50, 40) + rect(graph, frame.id, 50, 40) + rect(graph, frame.id, 50, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(0) + expect(children[1].y).toBe(56) + expect(children[2].y).toBe(112) + }) +}) diff --git a/tests/engine/layout/auto-layout/wrap-layout.test.ts b/tests/engine/layout/auto-layout/wrap-layout.test.ts new file mode 100644 index 000000000..82831c70d --- /dev/null +++ b/tests/engine/layout/auto-layout/wrap-layout.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' + +import { computeLayout, SceneGraph } from '@open-pencil/core' + +import { autoFrame, pageId, rect } from '#tests/helpers/layout' + +describe('wrap layout', () => { + test('wraps children in horizontal layout', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP' + }) + rect(graph, frame.id, 80, 40) + rect(graph, frame.id, 80, 40) + rect(graph, frame.id, 80, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + // First two fit on first row (80 + 80 = 160 <= 200) + expect(children[0].x).toBe(0) + expect(children[0].y).toBe(0) + expect(children[1].x).toBe(80) + expect(children[1].y).toBe(0) + // Third wraps to second row + expect(children[2].x).toBe(0) + expect(children[2].y).toBe(40) + }) + + test('counterAxisSpacing adds gap between wrapped rows', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP', + counterAxisSpacing: 10 + }) + rect(graph, frame.id, 120, 40) + rect(graph, frame.id, 120, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].y).toBe(0) + expect(children[1].y).toBe(50) + }) + + test('itemSpacing with wrap', () => { + const graph = new SceneGraph() + const frame = autoFrame(graph, pageId(graph), { + width: 200, + height: 300, + layoutWrap: 'WRAP', + itemSpacing: 10 + }) + // 90 + 10 + 90 = 190, fits in 200 + rect(graph, frame.id, 90, 40) + rect(graph, frame.id, 90, 40) + // 90 wraps to next row + rect(graph, frame.id, 90, 40) + + computeLayout(graph, frame.id) + + const children = graph.getChildren(frame.id) + expect(children[0].x).toBe(0) + expect(children[1].x).toBe(100) + expect(children[2].x).toBe(0) + expect(children[2].y).toBe(40) + }) +})