openpencil/tests/engine/kiwi-serialize-fixes.test.ts
2026-03-29 11:42:30 +02:00

610 lines
17 KiB
TypeScript

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<SceneGraph['createNode']>, 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<string, any>[]
// 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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
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<string, any>[]
// 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<string, any>[]
// 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<string, any>[]
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<string, any>[]
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')
})
})