2026-07-17 23:41:35 +00:00
|
|
|
import { describe, expect, test } from 'bun:test'
|
|
|
|
|
|
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
2026-08-19 15:05:25 +00:00
|
|
|
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
2026-07-17 23:41:35 +00:00
|
|
|
|
|
|
|
|
import {
|
2026-07-18 00:37:58 +00:00
|
|
|
buildComponentPropIndex,
|
2026-07-17 23:41:35 +00:00
|
|
|
fractionalPosition,
|
|
|
|
|
mapToFigmaType,
|
|
|
|
|
sceneNodeToKiwi,
|
|
|
|
|
type FigNodeChangeExportRuntime
|
|
|
|
|
} from '../src/node-change'
|
|
|
|
|
|
|
|
|
|
describe('@open-pencil/fig SceneGraph export policy', () => {
|
|
|
|
|
test('maps node types and sibling positions deterministically', () => {
|
|
|
|
|
expect(mapToFigmaType('COMPONENT')).toBe('SYMBOL')
|
|
|
|
|
expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!'])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-18 00:37:58 +00:00
|
|
|
test('reuses an export-scoped component property definition index', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const component = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{ id: '1:100', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
const instance = graph.createNode('INSTANCE', page.id, {
|
|
|
|
|
componentId: component.id,
|
|
|
|
|
componentPropertyAssignments: { '1:100': 'Override' }
|
|
|
|
|
})
|
|
|
|
|
const serialize = (definitions?: ReturnType<typeof buildComponentPropIndex>) =>
|
|
|
|
|
sceneNodeToKiwi(
|
|
|
|
|
instance,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[],
|
|
|
|
|
new Map(),
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
new Set(),
|
|
|
|
|
undefined,
|
|
|
|
|
definitions
|
|
|
|
|
)[0].componentPropAssignments
|
|
|
|
|
|
|
|
|
|
const definitions = buildComponentPropIndex(graph)
|
|
|
|
|
expect(definitions.get('1:100')).toBe(component.componentPropertyDefinitions[0])
|
|
|
|
|
expect(serialize(definitions)).toEqual(serialize())
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 09:09:34 +00:00
|
|
|
test('merges edited text into an existing override path', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const component = graph.createNode('COMPONENT', page.id)
|
|
|
|
|
const sourceText = graph.createNode('TEXT', component.id, {
|
|
|
|
|
overrideKey: '2:20',
|
|
|
|
|
text: 'Default'
|
|
|
|
|
})
|
|
|
|
|
const instance = graph.createInstance(component.id, page.id)
|
|
|
|
|
expect(instance).toBeDefined()
|
|
|
|
|
const targetText = graph.getChildren(instance?.id ?? '')[0]
|
|
|
|
|
expect(targetText).toBeDefined()
|
|
|
|
|
const originalOverride = {
|
|
|
|
|
guidPath: { guids: [{ sessionID: 2, localID: 20 }] },
|
|
|
|
|
textData: { characters: 'Stale' },
|
|
|
|
|
opacity: 0.5
|
|
|
|
|
}
|
|
|
|
|
graph.updateNode(instance?.id ?? '', {
|
|
|
|
|
overrides: { [`${targetText?.id}:text`]: 'Edited' },
|
|
|
|
|
source: {
|
|
|
|
|
...instance?.source,
|
|
|
|
|
fig: {
|
|
|
|
|
...instance?.source.fig,
|
|
|
|
|
symbolOverrides: [originalOverride]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const [change] = sceneNodeToKiwi(
|
|
|
|
|
graph.getNode(instance?.id ?? '') ?? instance,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(sourceText.overrideKey).toBe('2:20')
|
|
|
|
|
expect(change.symbolData?.symbolOverrides).toEqual([
|
|
|
|
|
{
|
|
|
|
|
...originalOverride,
|
|
|
|
|
textData: { characters: 'Edited' }
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 23:41:35 +00:00
|
|
|
test('injects runtime glyph outlines into derived text data', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const text = graph.createNode('TEXT', graph.getPages()[0].id, {
|
|
|
|
|
text: 'A',
|
|
|
|
|
width: 20,
|
|
|
|
|
height: 20,
|
|
|
|
|
fontSize: 16
|
|
|
|
|
})
|
|
|
|
|
const blobs: Uint8Array[] = []
|
|
|
|
|
const runtime: FigNodeChangeExportRuntime = {
|
|
|
|
|
getGlyphOutlineMetrics: () => [
|
|
|
|
|
{
|
|
|
|
|
commands: [{ type: 'M', x: 0, y: 0 }, { type: 'L', x: 8, y: 16 }, { type: 'Z' }],
|
|
|
|
|
x: 0,
|
|
|
|
|
advance: 10
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [change] = sceneNodeToKiwi(
|
|
|
|
|
text,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
blobs,
|
|
|
|
|
undefined,
|
|
|
|
|
new Map([['Inter|Regular', new Uint8Array([1, 2, 3])]]),
|
|
|
|
|
undefined,
|
|
|
|
|
new Map(),
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
runtime
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(change.derivedTextData?.glyphs).toHaveLength(1)
|
|
|
|
|
expect(blobs).toHaveLength(1)
|
|
|
|
|
})
|
2026-08-17 22:10:11 +00:00
|
|
|
|
|
|
|
|
test('mints a synthetic GUID for app-created (non-Figma-shaped) component property IDs', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const componentSet = graph.createNode('COMPONENT_SET', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{
|
|
|
|
|
id: 'prop:abc12345',
|
|
|
|
|
name: 'Style',
|
|
|
|
|
type: 'VARIANT',
|
|
|
|
|
defaultValue: 'Primary',
|
|
|
|
|
variantOptions: ['Primary', 'Secondary']
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 15:05:25 +00:00
|
|
|
const [change] = sceneNodeToKiwi(
|
|
|
|
|
componentSet,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[]
|
|
|
|
|
)
|
2026-08-17 22:10:11 +00:00
|
|
|
|
|
|
|
|
expect(change.componentPropDefs).toHaveLength(1)
|
|
|
|
|
expect(change.componentPropDefs?.[0].id).toEqual(
|
|
|
|
|
expect.objectContaining({ sessionID: expect.any(Number), localID: expect.any(Number) })
|
|
|
|
|
)
|
|
|
|
|
expect(change.componentPropDefs?.[0].name).toBe('Style')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('reuses the same synthetic GUID for a def and the ref that points at it', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const component = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{ id: 'prop:icon1234', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: '' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
const slot = graph.createNode('INSTANCE', component.id, {
|
|
|
|
|
componentPropertyReferences: [{ propertyId: 'prop:icon1234', field: 'INSTANCE_SWAP' }]
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 15:05:25 +00:00
|
|
|
const nodeIdToGuid = new Map<string, GUID>()
|
|
|
|
|
const propertyIdToGuid = new Map<string, GUID>()
|
2026-08-17 22:10:11 +00:00
|
|
|
const localIdCounter = { value: 2 }
|
|
|
|
|
const [componentChange] = sceneNodeToKiwi(
|
|
|
|
|
component,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
localIdCounter,
|
|
|
|
|
graph,
|
|
|
|
|
[],
|
|
|
|
|
nodeIdToGuid,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
propertyIdToGuid
|
|
|
|
|
)
|
|
|
|
|
const slotChange = sceneNodeToKiwi(
|
|
|
|
|
slot,
|
|
|
|
|
componentChange.guid,
|
|
|
|
|
0,
|
|
|
|
|
localIdCounter,
|
|
|
|
|
graph,
|
|
|
|
|
[],
|
|
|
|
|
nodeIdToGuid,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
propertyIdToGuid
|
|
|
|
|
)[0]
|
|
|
|
|
|
2026-08-19 15:05:25 +00:00
|
|
|
expect(componentChange.componentPropDefs?.[0].id).toEqual(
|
|
|
|
|
slotChange.componentPropRefs?.[0].defID
|
|
|
|
|
)
|
2026-08-17 22:10:11 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('points an INSTANCE_SWAP default value at the same GUID the target component is exported with', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
2026-08-19 15:05:25 +00:00
|
|
|
const icon = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
name: 'Icon/Tune',
|
|
|
|
|
componentKey: 'icon-tune-key'
|
|
|
|
|
})
|
2026-08-17 22:10:11 +00:00
|
|
|
const button = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{ id: 'prop:iconswap1', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: icon.id }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-19 15:05:25 +00:00
|
|
|
const nodeIdToGuid = new Map<string, GUID>()
|
|
|
|
|
const propertyIdToGuid = new Map<string, GUID>()
|
2026-08-17 22:10:11 +00:00
|
|
|
const localIdCounter = { value: 2 }
|
|
|
|
|
const [iconChange] = sceneNodeToKiwi(
|
|
|
|
|
icon,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
localIdCounter,
|
|
|
|
|
graph,
|
|
|
|
|
[],
|
|
|
|
|
nodeIdToGuid,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
propertyIdToGuid
|
|
|
|
|
)
|
|
|
|
|
const [buttonChange] = sceneNodeToKiwi(
|
|
|
|
|
button,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
1,
|
|
|
|
|
localIdCounter,
|
|
|
|
|
graph,
|
|
|
|
|
[],
|
|
|
|
|
nodeIdToGuid,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
propertyIdToGuid
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(buttonChange.componentPropDefs?.[0].initialValue).toEqual({ guidValue: iconChange.guid })
|
2026-08-19 15:05:25 +00:00
|
|
|
expect(buttonChange.componentPropDefs?.[0].preferredValues).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('exports INSTANCE_SWAP preferred values as component keys', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const icon = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
name: 'Icon/Tune',
|
|
|
|
|
componentKey: 'icon-tune-key'
|
|
|
|
|
})
|
|
|
|
|
const button = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{
|
|
|
|
|
id: 'prop:iconswap2',
|
|
|
|
|
name: 'Icon',
|
|
|
|
|
type: 'INSTANCE_SWAP',
|
|
|
|
|
defaultValue: icon.id,
|
|
|
|
|
preferredValues: [icon.id, 'external-library-key']
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const [buttonChange] = sceneNodeToKiwi(
|
|
|
|
|
button,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(buttonChange.componentPropDefs?.[0].preferredValues?.instanceSwapValues).toEqual([
|
|
|
|
|
{ type: 'COMPONENT', key: 'icon-tune-key' },
|
|
|
|
|
{ type: 'COMPONENT', key: 'external-library-key' }
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('preserves unresolved GUID-shaped INSTANCE_SWAP values as GUIDs', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const component = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{ id: 'prop:iconswap3', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: '70:1' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const [change] = sceneNodeToKiwi(
|
|
|
|
|
component,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(change.componentPropDefs?.[0].initialValue).toEqual({
|
|
|
|
|
guidValue: { sessionID: 70, localID: 1 }
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('shares synthetic property GUIDs across recursive serialization without a supplied map', () => {
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const component = graph.createNode('COMPONENT', page.id, {
|
|
|
|
|
componentPropertyDefinitions: [
|
|
|
|
|
{ id: 'prop:recursive', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('TEXT', component.id, {
|
|
|
|
|
componentPropertyReferences: [{ propertyId: 'prop:recursive', field: 'TEXT' }]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const changes = sceneNodeToKiwi(
|
|
|
|
|
component,
|
|
|
|
|
{ sessionID: 1, localID: 1 },
|
|
|
|
|
0,
|
|
|
|
|
{ value: 2 },
|
|
|
|
|
graph,
|
|
|
|
|
[]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(changes[0].componentPropDefs?.[0].id).toEqual(changes[1].componentPropRefs?.[0].defID)
|
2026-08-17 22:10:11 +00:00
|
|
|
})
|
2026-07-17 23:41:35 +00:00
|
|
|
})
|