openpencil/tests/engine/io/fig/import/edge-cases.test.ts
Danila Poyarkov 8dfed26d16 fix(fig): preserve paint variable overrides
- Carry paint bindings through symbol overrides and clone synchronization

- Keep explicit instance strokes and resolved variable opacity intact
2026-07-22 15:51:46 +03:00

540 lines
19 KiB
TypeScript

import { describe, expect, setDefaultTimeout, test } from 'bun:test'
import { importNodeChanges, parseFigFile } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
import { parseFixture } from '#tests/helpers/fig-fixtures'
setDefaultTimeout(60_000)
describe('edge cases', () => {
test('non-fig-kiwi bytes throw meaningful error', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
await expect(parseFigFile(garbage.buffer as ArrayBuffer)).rejects.toThrow()
})
test('invalid zip data throws', async () => {
const garbage = new Uint8Array(50).fill(0xff)
await expect(parseFigFile(garbage.buffer as ArrayBuffer)).rejects.toThrow()
})
test('REMOVED nodes are skipped', async () => {
const { importNodeChanges } = await import('#core/kiwi/fig/import')
const graph = importNodeChanges([
{
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 }
},
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
},
{
guid: { sessionID: 1, localID: 10 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'RECTANGLE',
name: 'Visible',
visible: true,
opacity: 1,
phase: 'CREATED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
},
{
guid: { sessionID: 1, localID: 11 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'RECTANGLE',
name: 'Deleted',
visible: true,
opacity: 1,
phase: 'REMOVED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
])
const children = graph.getChildren(graph.getPages()[0].id)
expect(children).toHaveLength(1)
expect(children[0].name).toBe('Visible')
})
test('symbolOverrides propagate through nested instances', () => {
// Structure:
// DOCUMENT (0:0)
// └─ CANVAS "Page" (0:1)
// ├─ COMPONENT "Inner" (1:1) ← inner component
// │ └─ TEXT "Label" (1:2) ← default text "Default"
// ├─ COMPONENT "Outer" (1:3) ← outer component
// │ └─ INSTANCE "InnerUse" (1:4) symId=1:1, override: Label→"Changed"
// └─ INSTANCE "OuterUse" (1:5) ← instance of Outer
//
// After import, OuterUse should contain a clone of InnerUse,
// which should contain a Label text with "Changed" (not "Default")
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED'
} as NodeChange,
// Inner component
{
guid: { sessionID: 1, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'Inner',
phase: 'CREATED',
size: { x: 100, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
// TEXT child of Inner
{
guid: { sessionID: 1, localID: 2 },
overrideKey: { sessionID: 99, localID: 2 },
parentIndex: { guid: { sessionID: 1, localID: 1 }, position: '!' },
type: 'TEXT',
name: 'Label',
textData: { characters: 'Default' },
phase: 'CREATED',
size: { x: 80, y: 20 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
// Outer component
{
guid: { sessionID: 1, localID: 3 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'SYMBOL',
name: 'Outer',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
// INSTANCE of Inner inside Outer, with symbolOverride on Label
{
guid: { sessionID: 1, localID: 4 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '!' },
type: 'INSTANCE',
name: 'InnerUse',
phase: 'CREATED',
size: { x: 100, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 1 },
symbolOverrides: [
{
guidPath: { guids: [{ sessionID: 99, localID: 2 }] },
textData: { characters: 'Changed' }
}
]
}
} as NodeChange,
// Top-level INSTANCE of Outer on the page
{
guid: { sessionID: 1, localID: 5 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' },
type: 'INSTANCE',
name: 'OuterUse',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 3 }
}
} as NodeChange
])
const page = graph.getPages()[0]
const outerUse = graph.getChildren(page.id).find((n) => n.name === 'OuterUse')
expect(outerUse).toBeDefined()
expect(expectDefined(outerUse, 'outerUse').type).toBe('INSTANCE')
// Walk down: OuterUse > InnerUse clone > Label clone
const innerClone = graph.getChildren(outerUse?.id ?? '')[0]
expect(innerClone).toBeDefined()
expect(innerClone.name).toBe('InnerUse')
const labelClone = graph.getChildren(innerClone.id)[0]
expect(labelClone).toBeDefined()
expect(labelClone.name).toBe('Label')
expect(labelClone.text).toBe('Changed')
})
test('overriddenSymbolID swaps instance component through nested levels', () => {
// Structure:
// COMPONENT "IconA" (1:1) → VECTOR "PathA"
// COMPONENT "IconB" (1:3) → VECTOR "PathB1", VECTOR "PathB2"
// COMPONENT "Button" (1:5) → INSTANCE "icon" of IconA
// COMPONENT "Toolbar" (1:7) → INSTANCE "btn" of Button, with override swapping icon to IconB
// INSTANCE "ToolbarUse" (1:9) of Toolbar on the page
//
// After import, ToolbarUse > btn clone > icon clone should have IconB's children
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED'
} as NodeChange,
// IconA component with 1 child
{
guid: { sessionID: 1, localID: 1 },
overrideKey: { sessionID: 90, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'IconA',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 2 },
overrideKey: { sessionID: 90, localID: 2 },
parentIndex: { guid: { sessionID: 1, localID: 1 }, position: '!' },
type: 'VECTOR',
name: 'PathA',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
// IconB component with 2 children
{
guid: { sessionID: 1, localID: 3 },
overrideKey: { sessionID: 90, localID: 3 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'SYMBOL',
name: 'IconB',
phase: 'CREATED',
size: { x: 40, y: 40 },
cornerRadius: 12,
frameMaskDisabled: false,
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 31 },
overrideKey: { sessionID: 90, localID: 31 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '!' },
type: 'VECTOR',
name: 'PathB1',
phase: 'CREATED',
size: { x: 24, y: 12 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 32 },
overrideKey: { sessionID: 90, localID: 32 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '"' },
type: 'VECTOR',
name: 'PathB2',
phase: 'CREATED',
size: { x: 24, y: 12 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 33 },
parentIndex: { guid: { sessionID: 1, localID: 3 }, position: '#' },
type: 'TEXT',
name: 'Icon label',
textData: { characters: 'Default' },
phase: 'CREATED',
size: { x: 24, y: 12 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
// Button component with instance of IconA
{
guid: { sessionID: 1, localID: 5 },
overrideKey: { sessionID: 90, localID: 5 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' },
type: 'SYMBOL',
name: 'Button',
phase: 'CREATED',
size: { x: 40, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 62 },
parentIndex: { guid: { sessionID: 1, localID: 5 }, position: '!' },
type: 'FRAME',
name: 'first icon wrapper',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 63 },
parentIndex: { guid: { sessionID: 1, localID: 5 }, position: '"' },
type: 'FRAME',
name: 'second icon wrapper',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 24, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 6 },
overrideKey: { sessionID: 90, localID: 6 },
parentIndex: { guid: { sessionID: 1, localID: 62 }, position: '!' },
type: 'INSTANCE',
name: 'icon',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: { symbolID: { sessionID: 1, localID: 1 } }
} as NodeChange,
{
guid: { sessionID: 1, localID: 61 },
overrideKey: { sessionID: 90, localID: 61 },
parentIndex: { guid: { sessionID: 1, localID: 63 }, position: '!' },
type: 'INSTANCE',
name: 'icon',
phase: 'CREATED',
size: { x: 24, y: 24 },
transform: { m00: 1, m01: 0, m02: 24, m10: 0, m11: 1, m12: 0 },
symbolData: { symbolID: { sessionID: 1, localID: 1 } }
} as NodeChange,
// Toolbar component with instance of Button, swapping icon to IconB
{
guid: { sessionID: 1, localID: 7 },
overrideKey: { sessionID: 90, localID: 7 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '$' },
type: 'SYMBOL',
name: 'Toolbar',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange,
{
guid: { sessionID: 1, localID: 8 },
overrideKey: { sessionID: 90, localID: 8 },
parentIndex: { guid: { sessionID: 1, localID: 7 }, position: '!' },
type: 'INSTANCE',
name: 'btn',
phase: 'CREATED',
size: { x: 40, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 5 },
symbolOverrides: [
{
guidPath: { guids: [{ sessionID: 90, localID: 61 }] },
overriddenSymbolID: { sessionID: 1, localID: 3 }
},
{
guidPath: {
guids: [
{ sessionID: 90, localID: 61 },
{ sessionID: 90, localID: 31 }
]
},
fillPaints: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
colorVar: {
value: { alias: { guid: { sessionID: 2, localID: 5 } } }
}
}
]
},
{
guidPath: {
guids: [
{ sessionID: 90, localID: 61 },
{ sessionID: 1, localID: 33 }
]
},
textData: { characters: 'Changed after swap' }
}
]
}
} as NodeChange,
// Page-level instance of Toolbar
{
guid: { sessionID: 1, localID: 9 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '%' },
type: 'INSTANCE',
name: 'ToolbarUse',
phase: 'CREATED',
size: { x: 200, y: 40 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
symbolData: {
symbolID: { sessionID: 1, localID: 7 }
}
} as NodeChange
])
const page = graph.getPages()[0]
const toolbarUse = graph.getChildren(page.id).find((n) => n.name === 'ToolbarUse')
expect(toolbarUse).toBeDefined()
// ToolbarUse > btn clone > second wrapped repeated icon clone
const btnClone = graph.getChildren(toolbarUse?.id ?? '')[0]
expect(btnClone).toBeDefined()
expect(btnClone.name).toBe('btn')
const buttonChildren = graph.getChildren(btnClone.id)
expect(buttonChildren.map((child) => child.name)).toEqual([
'first icon wrapper',
'second icon wrapper'
])
const iconClone = graph.getChildren(buttonChildren[1].id)[0]
expect(iconClone).toBeDefined()
expect(iconClone.name).toBe('icon')
expect(iconClone.width).toBe(24)
expect(iconClone.height).toBe(24)
expect(iconClone.cornerRadius).toBe(12)
expect(iconClone.clipsContent).toBe(true)
// Icon should have IconB's children, not IconA's child. The later text
// override must resolve through the same path prefix after the swap.
const iconChildren = graph.getChildren(iconClone.id)
expect(iconChildren).toHaveLength(3)
expect(iconChildren.map((c) => c.name).sort()).toEqual(['Icon label', 'PathB1', 'PathB2'])
expect(iconChildren.find((child) => child.name === 'PathB1')?.boundVariables).toMatchObject({
'fills/0/color': '2:5'
})
expect(iconChildren.find((child) => child.name === 'Icon label')?.text).toBe(
'Changed after swap'
)
})
test('component swaps use the component set name for variants', () => {
const graph = importNodeChanges([
{
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 1, localID: 1 },
overrideKey: { sessionID: 90, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
type: 'SYMBOL',
name: 'Search',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 1, localID: 2 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'FRAME',
name: 'Avatar',
phase: 'CREATED',
componentPropDefs: [
{
id: { sessionID: 2, localID: 1 },
name: 'Type',
type: 'VARIANT',
initialValue: { textValue: 'Picture' }
}
]
} as NodeChange,
{
guid: { sessionID: 1, localID: 3 },
parentIndex: { guid: { sessionID: 1, localID: 2 }, position: '!' },
type: 'SYMBOL',
name: 'Type=Picture',
phase: 'CREATED'
} as NodeChange,
{
guid: { sessionID: 1, localID: 4 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' },
type: 'INSTANCE',
name: 'Search',
phase: 'CREATED',
size: { x: 50, y: 60 },
symbolData: {
symbolID: { sessionID: 1, localID: 1 },
symbolOverrides: [
{
guidPath: { guids: [{ sessionID: 90, localID: 1 }] },
overriddenSymbolID: { sessionID: 1, localID: 3 },
size: { x: 200, y: 200 }
}
]
}
} as NodeChange
])
const instance = graph
.getChildren(graph.getPages()[0].id)
.find((node) => node.type === 'INSTANCE')
expect(instance?.name).toBe('Avatar')
expect(instance?.width).toBe(50)
expect(instance?.height).toBe(60)
})
test('DSD propagates through intermediate clones that are also DSD-targeted', async () => {
const graph = await parseFixture('gold-preview.fig')
const thumb = [...graph.getAllNodes()].find((n) => n.name === 'Preview Thumbnail')
expect(thumb).toBeDefined()
let overflows = 0
function walk(id: string) {
const node = graph.getNode(id)
if (!node) return
if (node.type === 'VECTOR') {
const parent = graph.getNode(node.parentId)
if (parent?.type === 'INSTANCE' && parent.width > 0 && parent.height > 0) {
// Check visibility
let vis = true
let cur: typeof node | null = node
while (cur) {
if (!cur.visible) {
vis = false
break
}
cur = cur.parentId ? (graph.getNode(cur.parentId) ?? null) : null
}
// Check clipping
let clipped = false
cur = graph.getNode(parent.parentId)
while (cur) {
if (cur.clipsContent) {
clipped = true
break
}
cur = cur.parentId ? (graph.getNode(cur.parentId) ?? null) : null
}
if (vis && !clipped && node.width > parent.width * 1.2) {
overflows++
}
}
}
for (const cid of node.childIds) walk(cid)
}
walk(thumb?.id ?? '')
expect(overflows).toBe(0)
})
})