610 lines
17 KiB
TypeScript
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')
|
|
})
|
|
})
|