import { describe, test, expect, beforeAll } from 'bun:test' import { SceneGraph, sceneNodeToKiwi, exportFigFile, parseFigFile, initCodec, } from '@open-pencil/core' beforeAll(async () => { await initCodec() }) function pageId(graph: SceneGraph) { return graph.getPages()[0].id } const ROOT_GUID = { sessionID: 1, localID: 0 } function toKiwi(node: ReturnType, graph: SceneGraph) { const blobs: Uint8Array[] = [] return sceneNodeToKiwi(node, ROOT_GUID, 0, { value: 100 }, graph, blobs) } // --------------------------------------------------------------------------- // Fix 1 — Auto-layout children get (0,0) transforms // --------------------------------------------------------------------------- describe('Fix 1: auto-layout child transforms', () => { test('auto-layout child gets zero transform regardless of its x/y', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'AutoLayout', x: 0, y: 0, width: 300, height: 400, layoutMode: 'VERTICAL', itemSpacing: 8, paddingTop: 16, paddingLeft: 16, paddingBottom: 16, paddingRight: 16, }) const child = graph.createNode('FRAME', parent.id, { name: 'Child', x: 50, y: 100, width: 200, height: 60, }) const blobs: Uint8Array[] = [] // Serialize the parent — children are serialized recursively const changes = sceneNodeToKiwi( graph.getNode(parent.id)!, ROOT_GUID, 0, { value: 100 }, graph, blobs ) as Record[] // changes[0] = parent, changes[1] = child const childNc = changes.find((nc) => nc.name === 'Child')! expect(childNc).toBeDefined() expect(childNc.transform.m02).toBe(0) expect(childNc.transform.m12).toBe(0) }) test('absolute-positioned child inside auto-layout keeps its real x/y', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'AutoLayout', x: 0, y: 0, width: 300, height: 400, layoutMode: 'VERTICAL', itemSpacing: 8, }) const absChild = graph.createNode('FRAME', parent.id, { name: 'AbsChild', x: 75, y: 120, width: 50, height: 50, layoutPositioning: 'ABSOLUTE', }) const blobs: Uint8Array[] = [] const changes = sceneNodeToKiwi( graph.getNode(parent.id)!, ROOT_GUID, 0, { value: 100 }, graph, blobs ) as Record[] const absNc = changes.find((nc) => nc.name === 'AbsChild')! expect(absNc).toBeDefined() expect(absNc.transform.m02).toBe(75) expect(absNc.transform.m12).toBe(120) }) test('child in non-auto-layout parent keeps its real x/y', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'PlainFrame', x: 0, y: 0, width: 300, height: 400, // layoutMode defaults to 'NONE' }) const child = graph.createNode('FRAME', parent.id, { name: 'Child', x: 30, y: 45, width: 100, height: 80, }) const blobs: Uint8Array[] = [] const changes = sceneNodeToKiwi( graph.getNode(parent.id)!, ROOT_GUID, 0, { value: 100 }, graph, blobs ) as Record[] const childNc = changes.find((nc) => nc.name === 'Child')! expect(childNc).toBeDefined() expect(childNc.transform.m02).toBe(30) expect(childNc.transform.m12).toBe(45) }) test('horizontal auto-layout child also gets zero transform', () => { const graph = new SceneGraph() const parent = graph.createNode('FRAME', pageId(graph), { name: 'HorizontalLayout', x: 0, y: 0, width: 600, height: 100, layoutMode: 'HORIZONTAL', itemSpacing: 12, }) graph.createNode('RECTANGLE', parent.id, { name: 'Item', x: 200, y: 50, width: 80, height: 80, }) const blobs: Uint8Array[] = [] const changes = sceneNodeToKiwi( graph.getNode(parent.id)!, ROOT_GUID, 0, { value: 100 }, graph, blobs ) as Record[] const itemNc = changes.find((nc) => nc.name === 'Item')! expect(itemNc.transform.m02).toBe(0) expect(itemNc.transform.m12).toBe(0) }) }) // --------------------------------------------------------------------------- // Fix 2 — frameMaskDisabled always true // --------------------------------------------------------------------------- describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { test('FRAME without clipsContent gets frameMaskDisabled=true', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'Frame', x: 0, y: 0, width: 100, height: 100, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(true) }) test('FRAME with clipsContent=true gets frameMaskDisabled=false', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'Clipping', x: 0, y: 0, width: 100, height: 100, clipsContent: true, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(false) }) test('FRAME with clipsContent=false gets frameMaskDisabled=true', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'NoClip', x: 0, y: 0, width: 100, height: 100, clipsContent: false, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(true) }) test('clipsContent roundtrips through export/parse', async () => { const graph = new SceneGraph() graph.createNode('FRAME', pageId(graph), { name: 'ClipFrame', x: 0, y: 0, width: 100, height: 100, clipsContent: true, }) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const frame = [...reimported.nodes.values()].find((n) => n.name === 'ClipFrame')! expect(frame.clipsContent).toBe(true) }) }) // --------------------------------------------------------------------------- // Fix 3 — bordersTakeSpace writes strokesIncludedInLayout // --------------------------------------------------------------------------- describe('Fix 3: bordersTakeSpace serialization', () => { test('auto-layout frame with strokesIncludedInLayout=true gets bordersTakeSpace=true', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'LayoutWithBorders', x: 0, y: 0, width: 300, height: 200, layoutMode: 'VERTICAL', itemSpacing: 8, strokesIncludedInLayout: true, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBe(true) }) test('auto-layout frame with strokesIncludedInLayout=false gets bordersTakeSpace=false', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'LayoutNoBorders', x: 0, y: 0, width: 300, height: 200, layoutMode: 'VERTICAL', itemSpacing: 8, strokesIncludedInLayout: false, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBe(false) }) test('non-auto-layout frame does not get bordersTakeSpace', () => { const graph = new SceneGraph() const node = graph.createNode('FRAME', pageId(graph), { name: 'PlainFrame', x: 0, y: 0, width: 300, height: 200, strokesIncludedInLayout: true, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBeUndefined() }) }) // --------------------------------------------------------------------------- // Fix 4 — lineHeight always written on text nodes // --------------------------------------------------------------------------- describe('Fix 4: text lineHeight serialization', () => { test('text node with explicit lineHeight uses that value', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'ExplicitLH', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Inter', fontWeight: 400, fontSize: 16, lineHeight: 24, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].lineHeight).toEqual({ value: 24, units: 'PIXELS' }) }) test('text node without lineHeight defaults to ceil(fontSize * 1.2)', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'DefaultLH', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Inter', fontWeight: 400, fontSize: 16, // lineHeight not set — defaults to null }) const changes = toKiwi(node, graph) as Record[] // ceil(16 * 1.2) = ceil(19.2) = 20 expect(changes[0].lineHeight).toEqual({ value: 20, units: 'PIXELS' }) }) test('lineHeight default for odd fontSize', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'OddSize', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Inter', fontWeight: 400, fontSize: 14, }) const changes = toKiwi(node, graph) as Record[] // ceil(14 * 1.2) = ceil(16.8) = 17 expect(changes[0].lineHeight).toEqual({ value: 17, units: 'PIXELS' }) }) test('lineHeight survives roundtrip through export/parse', async () => { const graph = new SceneGraph() graph.createNode('TEXT', pageId(graph), { name: 'Roundtrip', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Inter', fontWeight: 400, fontSize: 16, lineHeight: 28, }) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')! expect(textNode.lineHeight).toBe(28) }) }) // --------------------------------------------------------------------------- // Fix 5 — Font family normalization in derivedTextData // --------------------------------------------------------------------------- describe('Fix 5: font family normalization in derivedTextData', () => { test('optical size suffix stripped in roundtrip', async () => { const graph = new SceneGraph() graph.createNode('TEXT', pageId(graph), { name: 'OpticalSize', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'DM Sans 9pt', fontWeight: 400, fontSize: 14, }) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')! expect(textNode.fontFamily).toBe('DM Sans') }) test('Variable suffix stripped in roundtrip', async () => { const graph = new SceneGraph() graph.createNode('TEXT', pageId(graph), { name: 'Variable', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Roboto Variable', fontWeight: 700, fontSize: 14, }) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')! expect(textNode.fontFamily).toBe('Roboto') }) test('normal font family preserved unchanged', async () => { const graph = new SceneGraph() graph.createNode('TEXT', pageId(graph), { name: 'Normal', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'Inter', fontWeight: 400, fontSize: 14, }) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')! expect(textNode.fontFamily).toBe('Inter') }) test('fontName in kiwi output uses normalized family', () => { const graph = new SceneGraph() const node = graph.createNode('TEXT', pageId(graph), { name: 'KiwiFontName', x: 0, y: 0, width: 100, height: 20, text: 'Hello', fontFamily: 'DM Sans 18px', fontWeight: 400, fontSize: 14, }) const changes = toKiwi(node, graph) as Record[] expect(changes[0].fontName.family).toBe('DM Sans') }) }) // --------------------------------------------------------------------------- // Integration: all fixes together in a realistic auto-layout component // --------------------------------------------------------------------------- describe('Integration: auto-layout component with all fixes', () => { test('full component roundtrip preserves layout structure', async () => { const graph = new SceneGraph() // Parent auto-layout frame (like a card component) const card = graph.createNode('FRAME', pageId(graph), { name: 'StatCard', x: 100, y: 100, width: 328, height: 200, layoutMode: 'VERTICAL', itemSpacing: 8, paddingTop: 16, paddingLeft: 16, paddingBottom: 16, paddingRight: 16, primaryAxisSizing: 'FIXED', counterAxisSizing: 'FIXED', clipsContent: true, strokesIncludedInLayout: true, fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], }) // Title text graph.createNode('TEXT', card.id, { name: 'Title', x: 16, y: 16, width: 296, height: 20, text: 'Total Active Users', fontFamily: 'DM Sans 9pt', fontWeight: 600, fontSize: 14, lineHeight: 20, }) // Value text graph.createNode('TEXT', card.id, { name: 'Value', x: 16, y: 44, width: 296, height: 40, text: '12,345', fontFamily: 'Inter', fontWeight: 700, fontSize: 32, lineHeight: 40, }) // Verify kiwi output directly const blobs: Uint8Array[] = [] const changes = sceneNodeToKiwi( graph.getNode(card.id)!, ROOT_GUID, 0, { value: 100 }, graph, blobs ) as Record[] const cardNc = changes[0] const titleNc = changes.find((nc) => nc.name === 'Title')! const valueNc = changes.find((nc) => nc.name === 'Value')! // Fix 1: children have zero transforms expect(titleNc.transform.m02).toBe(0) expect(titleNc.transform.m12).toBe(0) expect(valueNc.transform.m02).toBe(0) expect(valueNc.transform.m12).toBe(0) // Fix 2: frameMaskDisabled is inverse of clipsContent expect(cardNc.frameMaskDisabled).toBe(false) // clipsContent=true → frameMaskDisabled=false expect(titleNc.frameMaskDisabled).toBe(true) // text nodes don't clip // Fix 3: bordersTakeSpace expect(cardNc.bordersTakeSpace).toBe(true) // Fix 4: lineHeight on text nodes expect(titleNc.lineHeight).toEqual({ value: 20, units: 'PIXELS' }) expect(valueNc.lineHeight).toEqual({ value: 40, units: 'PIXELS' }) // Fix 5: font family normalized expect(titleNc.fontName.family).toBe('DM Sans') expect(valueNc.fontName.family).toBe('Inter') // Roundtrip through export/parse const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const nodes = [...reimported.nodes.values()] const cardNode = nodes.find((n) => n.name === 'StatCard')! const titleNode = nodes.find((n) => n.name === 'Title')! expect(cardNode.layoutMode).toBe('VERTICAL') expect(cardNode.clipsContent).toBe(true) expect(titleNode.fontFamily).toBe('DM Sans') expect(titleNode.lineHeight).toBe(20) }) }) describe('Directionality plugin fallback', () => { test('text and layout direction roundtrip through export/parse', async () => { const graph = new SceneGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'RTL Row', layoutMode: 'HORIZONTAL', layoutDirection: 'RTL', width: 240, height: 80 }) const text = graph.createNode('TEXT', frame.id, { text: 'مرحبا', textDirection: 'RTL', width: 120, height: 24 }) const bytes = await exportFigFile(graph) const parsed = await parseFigFile(bytes.buffer as ArrayBuffer) const parsedFrame = [...parsed.getAllNodes()].find((node) => node.name === 'RTL Row') const parsedText = [...parsed.getAllNodes()].find((node) => node.type === 'TEXT') expect(parsedFrame?.layoutDirection).toBe('RTL') expect(parsedText?.textDirection).toBe('RTL') }) })