import { describe, expect, test } from 'bun:test' import { buildComponentPropIndex, fractionalPosition, mapToFigmaType, sceneNodeToKiwi, type FigNodeChangeExportRuntime } from '#fig/node-change/index' import { SceneGraph } from '@open-pencil/scene-graph' import type { GUID } from '@open-pencil/scene-graph/primitives' 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(['!', '~', '~!', '~~!']) }) test('exports one ordering scheme when preserved and generated siblings are mixed', () => { const graph = new SceneGraph() const page = graph.getPages()[0] const node = graph.createNode('RECTANGLE', page.id) node.source.orderKey = 'z' const change = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, []) expect(change[0]?.parentIndex?.position).toBe(fractionalPosition(0)) }) 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) => sceneNodeToKiwi(instance, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [], { nodeIdToGuid: new Map(), assignedGuidValues: new Set(), componentPropertyDefinitionsById: definitions })[0].componentPropAssignments const definitions = buildComponentPropIndex(graph) expect(definitions.get('1:100')).toBe(component.componentPropertyDefinitions[0]) expect(serialize(definitions)).toEqual(serialize()) }) 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 ?? '', { instanceOverrides: { self: new Map(), descendants: new Map([[targetText?.id ?? '', new Map([['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' } } ]) }) 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, { fontDigestMap: new Map([['Inter|Regular', new Uint8Array([1, 2, 3])]]), glyphBlobMap: new Map(), runtime } ) expect(change.derivedTextData?.glyphs).toHaveLength(1) expect(blobs).toHaveLength(1) }) 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'] } ] }) const [change] = sceneNodeToKiwi( componentSet, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [] ) 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' }] }) const nodeIdToGuid = new Map() const propertyIdToGuid = new Map() const localIdCounter = { value: 2 } const [componentChange] = sceneNodeToKiwi( component, { sessionID: 1, localID: 1 }, 0, localIdCounter, graph, [], { nodeIdToGuid, propertyIdToGuid } ) const slotChange = sceneNodeToKiwi(slot, componentChange.guid, 0, localIdCounter, graph, [], { nodeIdToGuid, propertyIdToGuid })[0] expect(componentChange.componentPropDefs?.[0].id).toEqual( slotChange.componentPropRefs?.[0].defID ) }) 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] const icon = graph.createNode('COMPONENT', page.id, { name: 'Icon/Tune', componentKey: 'icon-tune-key' }) const button = graph.createNode('COMPONENT', page.id, { componentPropertyDefinitions: [ { id: 'prop:iconswap1', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: icon.id } ] }) const nodeIdToGuid = new Map() const propertyIdToGuid = new Map() const localIdCounter = { value: 2 } const [iconChange] = sceneNodeToKiwi( icon, { sessionID: 1, localID: 1 }, 0, localIdCounter, graph, [], { nodeIdToGuid, propertyIdToGuid } ) const [buttonChange] = sceneNodeToKiwi( button, { sessionID: 1, localID: 1 }, 1, localIdCounter, graph, [], { nodeIdToGuid, propertyIdToGuid } ) expect(buttonChange.componentPropDefs?.[0].initialValue).toEqual({ guidValue: iconChange.guid }) 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) }) test('keeps colorVar bindings on imported nodes with stale raw paints', () => { const graph = new SceneGraph() const page = graph.getPages()[0] const collection = graph.createCollection('Tokens') const brand = graph.createVariable('brand', 'COLOR', collection.id, { r: 0.2, g: 0.4, b: 0.9, a: 1 }) const color = { r: 0.2, g: 0.4, b: 0.9, a: 1 } const node = graph.createNode('RECTANGLE', page.id, { name: 'ImportedBound', width: 40, height: 40, fills: [{ type: 'SOLID', color, opacity: 1, visible: true }] }) const current = graph.getNode(node.id) if (!current) throw new Error('Expected rectangle node') // Simulate an imported node whose raw paints predate the binding. graph.updateNode(node.id, { boundVariables: { 'fills/0/color': brand.id }, source: { ...current.source, id: '1:2', fig: { ...current.source.fig, rawNodeFields: { ...current.source.fig.rawNodeFields, fillPaints: [ { type: 'SOLID', color, opacity: 1, visible: true, blendMode: 'NORMAL' } ] } } } }) const updated = graph.getNode(node.id) if (!updated) throw new Error('Expected updated node') const [change] = sceneNodeToKiwi( updated, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [] ) expect(change.fillPaints?.[0]?.colorVar?.resolvedDataType).toBe('COLOR') }) test('drops a raw colorVar when the imported node is no longer bound', () => { const graph = new SceneGraph() const page = graph.getPages()[0] const color = { r: 0.2, g: 0.4, b: 0.9, a: 1 } const node = graph.createNode('RECTANGLE', page.id, { name: 'ImportedUnbound', width: 40, height: 40, fills: [{ type: 'SOLID', color, opacity: 1, visible: true }] }) const current = graph.getNode(node.id) if (!current) throw new Error('Expected rectangle node') // Simulate an imported node that Figma saved with a binding the user has since removed. graph.updateNode(node.id, { boundVariables: {}, source: { ...current.source, id: '1:2', fig: { ...current.source.fig, rawNodeFields: { ...current.source.fig.rawNodeFields, fillPaints: [ { type: 'SOLID', color, opacity: 1, visible: true, blendMode: 'NORMAL', colorVar: { value: { alias: { guid: { sessionID: 0, localID: 12 } } }, dataType: 'ALIAS', resolvedDataType: 'COLOR' } } ] } } } }) const updated = graph.getNode(node.id) if (!updated) throw new Error('Expected updated node') const [change] = sceneNodeToKiwi( updated, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [] ) expect(change.fillPaints?.[0]).toMatchObject({ type: 'SOLID', blendMode: 'NORMAL' }) expect(change.fillPaints?.[0]?.colorVar).toBeUndefined() }) test('drops the OpenPencil bindings entry when an imported node is no longer bound', () => { const graph = new SceneGraph() const page = graph.getPages()[0] const node = graph.createNode('RECTANGLE', page.id, { name: 'ImportedPluginBinding', width: 40, height: 40, boundVariables: {}, pluginData: [ { pluginId: 'open-pencil', key: 'boundVariables', value: '{"fills/0/color":"0:12"}' }, { pluginId: 'other-plugin', key: 'boundVariables', value: 'kept' } ] }) const [change] = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, []) const bindingEntries = (change.pluginData ?? []).filter( (entry) => entry.key === 'boundVariables' ) expect(bindingEntries).toEqual([ { pluginID: 'other-plugin', key: 'boundVariables', value: 'kept' } ]) }) })