* fix(fig): keep Figma's order keys on instances and untouched siblings
Materialized instance occurrences were built without the record's
parentIndex position, so every imported instance lost its key and was
renumbered on save. They now carry it like other imported nodes.
Sibling keys anchored greedily on the first imported key, so a layer moved
up past its siblings kept its key and pushed every sibling after it onto a
new one. Keep the longest increasing run of imported keys instead, so only
the moved and inserted layers are re-keyed.
* fix(fig): don't anchor sibling keys on one nothing can precede
A moved layer in front of the lowest key ('!') left no room below it, so
the anchor was dropped and every sibling up to the next anchor re-keyed.
Such a key no longer starts the kept run when a sibling precedes it.
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
import { expect, test } from 'bun:test'
|
|
|
|
import { guid } from '#fig-tests/helpers/guid'
|
|
import { interpretComponent, interpretInstance } from '#fig/instance-overrides/interpret'
|
|
import { materializeInstance } from '#fig/instance-overrides/materialize-instance'
|
|
import type { MissingComponentDiagnostic } from '#fig/instance-overrides/occurrence/types'
|
|
|
|
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import fixture from './fixtures/accordion-source-closure.json'
|
|
|
|
function setup() {
|
|
const occurrence = interpretInstance(fixture as NodeChange[], '7:283', {
|
|
onUnresolvedProperty: (diagnostic) => {
|
|
expect(diagnostic.reason).toBe('missing-target')
|
|
}
|
|
})
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const components = new Map<string, string>()
|
|
function visit(node: typeof occurrence): void {
|
|
if (node.mainComponentId && !components.has(node.mainComponentId)) {
|
|
components.set(node.mainComponentId, graph.createNode('COMPONENT', page.id).id)
|
|
}
|
|
node.children.forEach(visit)
|
|
}
|
|
visit(occurrence)
|
|
return { occurrence, graph, page, components }
|
|
}
|
|
|
|
test('materializes separate occurrence nodes with direct component identities and resolved text', () => {
|
|
const { occurrence, graph, page, components } = setup()
|
|
const result = materializeInstance(graph, page.id, occurrence, components)
|
|
expect(result.root.type).toBe('INSTANCE')
|
|
expect(result.root.componentId).toBe(components.get('7:186'))
|
|
const labels = [...result.nodes].filter(([source]) => source.sourceId === '4:483')
|
|
expect(labels).toHaveLength(2)
|
|
expect(labels.map(([, node]) => node.text)).toEqual(['Is it styled?', 'Is it animated?'])
|
|
expect(labels[0][1].id).not.toBe(labels[1][1].id)
|
|
expect(graph.getChildren(result.root.id).map((node) => node.componentId)).toEqual([
|
|
components.get('7:251'),
|
|
components.get('7:156'),
|
|
components.get('7:156')
|
|
])
|
|
for (const node of result.nodes.values()) expect(node.source.id).toBeNull()
|
|
})
|
|
|
|
test('materialized occurrences keep the order keys Figma saved', () => {
|
|
const { occurrence, graph, page, components } = setup()
|
|
const result = materializeInstance(graph, page.id, occurrence, components)
|
|
for (const [source, node] of result.nodes) {
|
|
expect(node.source.orderKey).toBe(source.properties.parentIndex?.position ?? null)
|
|
}
|
|
expect(result.root.source.orderKey).not.toBeNull()
|
|
})
|
|
|
|
test('materialization does not share editable paint payloads with interpreted occurrences', () => {
|
|
const { occurrence, graph, page, components } = setup()
|
|
occurrence.properties.fillPaints = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true
|
|
}
|
|
]
|
|
const before = structuredClone(occurrence)
|
|
const first = materializeInstance(graph, page.id, occurrence, components)
|
|
const second = materializeInstance(graph, page.id, occurrence, components)
|
|
const fill = first.root.fills[0]
|
|
if (!fill?.color) throw new Error('Missing materialized fill')
|
|
fill.color.r = 0
|
|
expect(second.root.fills[0].color?.r).toBe(1)
|
|
expect(occurrence).toEqual(before)
|
|
})
|
|
|
|
test('rejects an invalid late descendant without creating a partial tree', () => {
|
|
const { occurrence, graph, page, components } = setup()
|
|
occurrence.children[2].properties.type = 'DOCUMENT'
|
|
const count = graph.nodes.size
|
|
expect(() => materializeInstance(graph, page.id, occurrence, components)).toThrow(
|
|
'Cannot materialize DOCUMENT'
|
|
)
|
|
expect(graph.nodes.size).toBe(count)
|
|
})
|
|
|
|
test('rejects cyclic occurrence input before mutating the destination', () => {
|
|
const { occurrence, graph, page, components } = setup()
|
|
occurrence.children.push(occurrence)
|
|
const count = graph.nodes.size
|
|
expect(() => materializeInstance(graph, page.id, occurrence, components)).toThrow(
|
|
'Repeated or cyclic'
|
|
)
|
|
expect(graph.nodes.size).toBe(count)
|
|
})
|
|
|
|
test('rejects missing components before creating occurrence nodes', () => {
|
|
const { occurrence, graph, page } = setup()
|
|
const count = graph.nodes.size
|
|
expect(() => materializeInstance(graph, page.id, occurrence, new Map())).toThrow(
|
|
'Missing materialized component'
|
|
)
|
|
expect(graph.nodes.size).toBe(count)
|
|
})
|
|
|
|
// Figma keeps an instance whose main component was deleted, with its saved reference.
|
|
test('an instance of a deleted component stays a childless instance when acknowledged', () => {
|
|
const changes = [
|
|
{ guid: guid(1, 4), type: 'SYMBOL' },
|
|
{
|
|
guid: guid(2, 4),
|
|
type: 'INSTANCE',
|
|
parentIndex: { guid: guid(1, 4), position: '!' },
|
|
name: 'Orphan',
|
|
size: { x: 10, y: 10 },
|
|
symbolData: {
|
|
symbolID: guid(9, 4),
|
|
symbolOverrides: [{ guidPath: { guids: [guid(8, 4)] }, opacity: 0.5 }]
|
|
}
|
|
}
|
|
] as NodeChange[]
|
|
expect(() => interpretComponent(changes, '4:1')).toThrow('Missing source node 4:9')
|
|
const missing: MissingComponentDiagnostic[] = []
|
|
const component = interpretComponent(changes, '4:1', {
|
|
onMissingComponent: (diagnostic) => missing.push(diagnostic)
|
|
})
|
|
expect(missing).toEqual([{ ownerId: '4:2', componentId: '4:9' }])
|
|
const orphan = component.children[0]
|
|
expect(orphan.mainComponentId).toBeNull()
|
|
expect(orphan.children).toEqual([])
|
|
expect(orphan.properties.symbolData?.symbolID).toEqual(guid(9, 4))
|
|
const graph = new SceneGraph()
|
|
const page = graph.getPages()[0]
|
|
const materialized = materializeInstance(graph, page.id, component, new Map())
|
|
const node = graph.getChildren(materialized.root.id)[0]
|
|
expect(node.type).toBe('INSTANCE')
|
|
expect(node.componentId).toBeNull()
|
|
expect(node.childIds).toEqual([])
|
|
})
|