openpencil/tests/engine/fig-import.test.ts
Danila Poyarkov aa07e833cb chore(lint): ban broad double casts project-wide
- Extend the as-unknown-as structural rule beyond app and Vue code
- Remove existing broad double casts from core and tests
- Replace the vector base64 helper and XPath facade casts with precise code paths
2026-05-06 00:29:23 +03:00

642 lines
19 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { importNodeChanges, type NodeChange } from '@open-pencil/core'
function doc(): NodeChange {
return {
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function canvas(localID = 1): NodeChange {
return {
guid: { sessionID: 0, localID },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page 1',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function node(
type: string,
localID: number,
parentLocalID: number,
overrides: Partial<NodeChange> = {}
): NodeChange {
return {
guid: { sessionID: 1, localID },
parentIndex: {
guid: { sessionID: 0, localID: parentLocalID },
position: String.fromCharCode(33 + localID)
},
type,
name: `${type}_${localID}`,
visible: true,
opacity: 1,
phase: 'CREATED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
...overrides
} as NodeChange
}
describe('fig-import: node types', () => {
test('ROUNDED_RECTANGLE imported as its own type', () => {
const graph = importNodeChanges([doc(), canvas(), node('ROUNDED_RECTANGLE', 10, 1)])
const nodes = graph.getChildren(graph.getPages()[0].id)
expect(nodes[0].type).toBe('ROUNDED_RECTANGLE')
})
test('COMPONENT maps to COMPONENT', () => {
const graph = importNodeChanges([doc(), canvas(), node('COMPONENT', 10, 1)])
const nodes = graph.getChildren(graph.getPages()[0].id)
expect(nodes[0].type).toBe('COMPONENT')
})
test('INSTANCE maps to INSTANCE', () => {
const graph = importNodeChanges([doc(), canvas(), node('INSTANCE', 10, 1)])
const nodes = graph.getChildren(graph.getPages()[0].id)
expect(nodes[0].type).toBe('INSTANCE')
})
test('SYMBOL maps to COMPONENT', () => {
const graph = importNodeChanges([doc(), canvas(), node('SYMBOL', 10, 1)])
const nodes = graph.getChildren(graph.getPages()[0].id)
expect(nodes[0].type).toBe('COMPONENT')
})
test('REGULAR_POLYGON maps to POLYGON', () => {
const graph = importNodeChanges([doc(), canvas(), node('REGULAR_POLYGON', 10, 1)])
const nodes = graph.getChildren(graph.getPages()[0].id)
expect(nodes[0].type).toBe('POLYGON')
})
})
describe('fig-import: transforms', () => {
test('flipped transforms use their visual bounds', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('VECTOR', 10, 1, {
size: { x: 17, y: 36 },
transform: { m00: -1, m01: 0, m02: 70, m10: 0, m11: 1, m12: 7 }
})
])
const imported = graph.getChildren(graph.getPages()[0].id)[0]
expect(imported.x).toBe(53)
expect(imported.y).toBe(7)
expect(imported.flipX).toBe(true)
})
})
describe('fig-import: gradient fills', () => {
test('linear gradient', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
fillPaints: [
{
type: 'GRADIENT_LINEAR',
opacity: 1,
visible: true,
blendMode: 'NORMAL',
stops: [
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }
],
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
] as NodeChange['fillPaints']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.fills).toHaveLength(1)
expect(n.fills[0].type).toBe('GRADIENT_LINEAR')
expect(n.fills[0].gradientStops).toHaveLength(2)
expect(n.fills[0].gradientStops![0].color.r).toBe(1)
expect(n.fills[0].gradientStops![1].color.b).toBe(1)
expect(n.fills[0].gradientTransform).toBeDefined()
})
test('radial gradient', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('ELLIPSE', 10, 1, {
fillPaints: [
{
type: 'GRADIENT_RADIAL',
opacity: 0.8,
visible: true,
blendMode: 'NORMAL',
stops: [
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 0, a: 1 }, position: 1 }
],
transform: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 }
}
] as NodeChange['fillPaints']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.fills[0].type).toBe('GRADIENT_RADIAL')
expect(n.fills[0].opacity).toBe(0.8)
expect(n.fills[0].gradientStops).toHaveLength(2)
})
})
describe('fig-import: image fills', () => {
test('image fill with hash', () => {
const hash: Record<string, number> = {}
for (let i = 0; i < 20; i++) hash[String(i)] = i + 10
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
fillPaints: [
{
type: 'IMAGE',
opacity: 1,
visible: true,
blendMode: 'NORMAL',
image: { hash, name: 'test-image' },
imageScaleMode: 'FILL',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
] as NodeChange['fillPaints']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.fills[0].type).toBe('IMAGE')
expect(n.fills[0].imageHash).toBeDefined()
expect(n.fills[0].imageHash!.length).toBe(40)
expect(n.fills[0].imageScaleMode).toBe('FILL')
})
test('images stored on graph', () => {
const images = new Map<string, Uint8Array>()
images.set('abc123', new Uint8Array([1, 2, 3]))
const graph = importNodeChanges([doc(), canvas()], [], images)
expect(graph.images.get('abc123')).toEqual(new Uint8Array([1, 2, 3]))
})
})
describe('fig-import: effects', () => {
test('drop shadow', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 4, y: 4 },
radius: 8,
spread: 0,
visible: true
}
] as NodeChange['effects']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.effects).toHaveLength(1)
expect(n.effects[0].type).toBe('DROP_SHADOW')
expect(n.effects[0].radius).toBe(8)
expect(n.effects[0].offset.x).toBe(4)
})
test('inner shadow', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
effects: [
{
type: 'INNER_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 0, y: 2 },
radius: 4,
spread: 0,
visible: true
}
] as NodeChange['effects']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.effects[0].type).toBe('INNER_SHADOW')
})
})
describe('fig-import: stroke options', () => {
test('stroke cap and join', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('VECTOR', 10, 1, {
strokePaints: [
{
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
],
strokeWeight: 3,
strokeAlign: 'CENTER',
strokeCap: 'ROUND',
strokeJoin: 'BEVEL'
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.strokes[0].cap).toBe('ROUND')
expect(n.strokes[0].join).toBe('BEVEL')
})
test('dash pattern', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
strokePaints: [
{
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
],
strokeWeight: 2,
strokeAlign: 'CENTER',
dashPattern: [10, 5]
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.strokes[0].dashPattern).toEqual([10, 5])
})
})
describe('fig-import: text properties', () => {
test('text auto resize', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('TEXT', 10, 1, {
textData: { characters: 'Hello' },
fontSize: 16,
textAlignHorizontal: 'CENTER',
textAutoResize: 'WIDTH_AND_HEIGHT'
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(n.textAlignHorizontal).toBe('CENTER')
})
test('font weight mapping', () => {
const cases = [
['Bold', 700],
['Semi Bold', 600],
['Light', 300],
['ExtraBold', 800],
['Black', 900],
['Thin', 100],
['Medium', 500],
['Regular', 400]
] as const
for (const [style, expected] of cases) {
const graph = importNodeChanges([
doc(),
canvas(),
node('TEXT', 10, 1, {
textData: { characters: 'X' },
fontName: { family: 'Inter', style }
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.fontWeight).toBe(expected)
}
})
})
describe('fig-import: arc data', () => {
test('partial ellipse', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('ELLIPSE', 10, 1, {
arcData: {
startingAngle: 0,
endingAngle: Math.PI,
innerRadius: 0
}
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.arcData).toBeDefined()
expect(n.arcData!.startingAngle).toBe(0)
expect(n.arcData!.endingAngle).toBeCloseTo(Math.PI)
expect(n.arcData!.innerRadius).toBe(0)
})
test('donut (inner radius)', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('ELLIPSE', 10, 1, {
arcData: {
startingAngle: 0,
endingAngle: Math.PI * 2,
innerRadius: 0.5
}
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.arcData!.innerRadius).toBe(0.5)
})
})
describe('fig-import: constraints', () => {
test('horizontal and vertical constraints', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
horizontalConstraint: 'STRETCH',
verticalConstraint: 'CENTER'
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.horizontalConstraint).toBe('STRETCH')
expect(n.verticalConstraint).toBe('CENTER')
})
test('defaults to MIN', () => {
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.horizontalConstraint).toBe('MIN')
expect(n.verticalConstraint).toBe('MIN')
})
})
describe('fig-import: blend mode', () => {
test('node blend mode', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
blendMode: 'MULTIPLY'
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.blendMode).toBe('MULTIPLY')
})
test('defaults to PASS_THROUGH', () => {
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.blendMode).toBe('PASS_THROUGH')
})
})
describe('fig-import: independent stroke weights', () => {
test('border weights imported', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, {
borderTopWeight: 2,
borderRightWeight: 4,
borderBottomWeight: 2,
borderLeftWeight: 4,
borderStrokeWeightsIndependent: true
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.borderTopWeight).toBe(2)
expect(n.borderRightWeight).toBe(4)
expect(n.independentStrokeWeights).toBe(true)
})
})
describe('fig-import: multiple fills', () => {
test('solid + gradient stacked', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('RECTANGLE', 10, 1, {
fillPaints: [
{
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
},
{
type: 'GRADIENT_LINEAR',
opacity: 0.5,
visible: true,
blendMode: 'NORMAL',
stops: [
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 }
],
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
] as NodeChange['fillPaints']
})
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.fills).toHaveLength(2)
expect(n.fills[0].type).toBe('SOLID')
expect(n.fills[1].type).toBe('GRADIENT_LINEAR')
expect(n.fills[1].opacity).toBe(0.5)
})
})
describe('fig-import: auto-layout alignment', () => {
test('maps SPACE_EVENLY kiwi primary alignment to Figma space-between', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, {
stackMode: 'HORIZONTAL',
stackPrimaryAlignItems: 'SPACE_EVENLY'
} as Partial<NodeChange>)
])
const n = graph.getChildren(graph.getPages()[0].id)[0]
expect(n.primaryAxisAlign).toBe('SPACE_BETWEEN')
})
})
describe('fig-import: variable asset refs', () => {
test('resolves color variables and aliases by assetRef', () => {
const graph = importNodeChanges([
doc(),
canvas(),
{
...node('VARIABLE_SET', 20, 1),
name: 'Tokens',
key: 'collection-key',
version: '1:1',
variableSetModes: [{ id: { sessionID: 10, localID: 1 }, name: 'Default' }]
} as NodeChange,
{
...node('VARIABLE', 21, 1),
name: 'Blue',
key: 'blue-key',
version: '1:2',
variableSetID: { assetRef: { key: 'collection-key', version: '1:1' } },
variableResolvedType: 'COLOR',
variableDataValues: {
entries: [
{
modeID: { sessionID: 10, localID: 1 },
variableData: {
dataType: 'COLOR',
resolvedDataType: 'COLOR',
value: { colorValue: { r: 0.14, g: 0.39, b: 0.92, a: 1 } }
}
}
]
}
} as NodeChange,
{
...node('VARIABLE', 22, 1),
name: 'Primary',
key: 'primary-key',
version: '1:3',
variableSetID: { assetRef: { key: 'collection-key', version: '1:1' } },
variableResolvedType: 'COLOR',
variableDataValues: {
entries: [
{
modeID: { sessionID: 10, localID: 1 },
variableData: {
dataType: 'ALIAS',
resolvedDataType: 'COLOR',
value: { alias: { assetRef: { key: 'blue-key', version: '1:2' } } }
}
}
]
}
} as NodeChange,
node('TEXT', 30, 1, {
textData: { characters: 'browse' },
fillPaints: [
{
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
colorVar: {
value: { alias: { assetRef: { key: 'primary-key', version: '1:3' } } },
dataType: 'ALIAS',
resolvedDataType: 'COLOR'
}
}
] as NodeChange['fillPaints']
})
])
const primary = [...graph.variables.values()].find((v) => v.name === 'Primary')!
const resolved = graph.resolveColorVariable(primary.id)!
expect(resolved.r).toBeCloseTo(0.14)
expect(resolved.g).toBeCloseTo(0.39)
expect(resolved.b).toBeCloseTo(0.92)
const text = [...graph.getAllNodes()].find((n) => n.type === 'TEXT' && n.text === 'browse')!
expect(text.fills[0].color.r).toBeCloseTo(0.14)
expect(text.fills[0].color.g).toBeCloseTo(0.39)
expect(text.fills[0].color.b).toBeCloseTo(0.92)
})
})
describe('fig-import: component set detection', () => {
test('FRAME with VARIANT componentPropDefs becomes COMPONENT_SET', () => {
const changes: NodeChange[] = [
doc(),
canvas(),
{
...node('FRAME', 10, 1),
name: 'Button',
componentPropDefs: [{ id: { sessionID: 0, localID: 1 }, name: 'State', type: 'VARIANT' }]
} as NodeChange,
{
...node('SYMBOL', 11, 1),
parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' },
name: 'State=Default'
} as NodeChange,
{
...node('SYMBOL', 12, 1),
parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '"' },
name: 'State=Hover'
} as NodeChange
]
const graph = importNodeChanges(changes, [])
const page = graph.getPages()[0]
const set = graph.getChildren(page.id)[0]
expect(set.type).toBe('COMPONENT_SET')
expect(set.name).toBe('Button')
const children = graph.getChildren(set.id)
expect(children).toHaveLength(2)
expect(children[0].type).toBe('COMPONENT')
expect(children[1].type).toBe('COMPONENT')
})
test('FRAME without componentPropDefs stays FRAME', () => {
const changes: NodeChange[] = [doc(), canvas(), node('FRAME', 10, 1, { name: 'Regular Frame' })]
const graph = importNodeChanges(changes, [])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.type).toBe('FRAME')
})
})
describe('fig-import: clipsContent with resizeToFit', () => {
test('regular FRAME with frameMaskDisabled=false clips content', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { frameMaskDisabled: false })
])
const frame = graph.getChildren(graph.getPages()[0].id)[0]
expect(frame.clipsContent).toBe(true)
})
test('resizeToFit FRAME does not clip even with frameMaskDisabled=false', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { frameMaskDisabled: false, resizeToFit: true })
])
const frame = graph.getChildren(graph.getPages()[0].id)[0]
expect(frame.clipsContent).toBe(false)
})
test('FRAME with frameMaskDisabled=true does not clip', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { frameMaskDisabled: true })
])
const frame = graph.getChildren(graph.getPages()[0].id)[0]
expect(frame.clipsContent).toBe(false)
})
})