423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import {
|
|
populateAndApplyOverrides,
|
|
protectField,
|
|
syncNodeProps,
|
|
type ProtectionMap
|
|
} from '../src/instance-overrides'
|
|
|
|
describe('@open-pencil/fig instance interpretation', () => {
|
|
test('populates an empty instance from its component tree', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, { name: 'Button' })
|
|
graph.createNode('TEXT', component.id, { text: 'Label' })
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
componentId: component.id,
|
|
childIds: []
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const populated = graph.getNode(instance.id)
|
|
expect(populated?.childIds).toHaveLength(1)
|
|
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
|
|
})
|
|
|
|
test('repositions pinned children through nested resized instances', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, {
|
|
width: 442,
|
|
height: 32,
|
|
layoutMode: 'HORIZONTAL'
|
|
})
|
|
graph.createNode('TEXT', component.id, { x: 32, y: 6, width: 80, height: 20 })
|
|
graph.createNode('TEXT', component.id, { x: 120, y: 6, width: 80, height: 20 })
|
|
graph.createNode('INSTANCE', component.id, {
|
|
x: 420,
|
|
y: 9,
|
|
width: 14,
|
|
height: 14,
|
|
layoutPositioning: 'ABSOLUTE',
|
|
horizontalConstraint: 'MAX',
|
|
verticalConstraint: 'CENTER'
|
|
})
|
|
const source = graph.createNode('INSTANCE', pageId, {
|
|
width: 256,
|
|
height: 32,
|
|
layoutMode: 'HORIZONTAL',
|
|
componentId: component.id
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 256,
|
|
height: 32,
|
|
layoutMode: 'HORIZONTAL',
|
|
componentId: source.id
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const pinned = graph.getChildren(instance.id)[2]
|
|
expect(pinned).toMatchObject({ x: 234, y: 9, width: 14, height: 14 })
|
|
})
|
|
|
|
test('resizes stretched absolute children with resized instances', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, {
|
|
width: 100,
|
|
height: 80,
|
|
layoutMode: 'HORIZONTAL'
|
|
})
|
|
graph.createNode('RECTANGLE', component.id, {
|
|
x: 10,
|
|
y: 10,
|
|
width: 80,
|
|
height: 60,
|
|
layoutPositioning: 'ABSOLUTE',
|
|
horizontalConstraint: 'STRETCH',
|
|
verticalConstraint: 'STRETCH'
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 200,
|
|
height: 120,
|
|
layoutMode: 'HORIZONTAL',
|
|
componentId: component.id
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
expect(graph.getChildren(instance.id)[0]).toMatchObject({
|
|
x: 10,
|
|
y: 10,
|
|
width: 180,
|
|
height: 100
|
|
})
|
|
})
|
|
|
|
test('applies pinned constraints inside resized freeform instances', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, { width: 100, height: 80 })
|
|
graph.createNode('RECTANGLE', component.id, {
|
|
x: 80,
|
|
y: 10,
|
|
width: 10,
|
|
height: 60,
|
|
layoutPositioning: 'ABSOLUTE',
|
|
horizontalConstraint: 'MAX',
|
|
verticalConstraint: 'STRETCH'
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 200,
|
|
height: 120,
|
|
componentId: component.id
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
expect(graph.getChildren(instance.id)[0]).toMatchObject({
|
|
x: 180,
|
|
y: 10,
|
|
width: 10,
|
|
height: 100
|
|
})
|
|
})
|
|
|
|
test('preserves an inset child when a nested instance becomes narrower', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const field = graph.createNode('COMPONENT', pageId, { width: 280, height: 40 })
|
|
graph.createNode('TEXT', field.id, {
|
|
x: 16,
|
|
y: 10,
|
|
width: 248,
|
|
height: 20,
|
|
text: 'Placeholder'
|
|
})
|
|
const source = graph.createNode('INSTANCE', pageId, {
|
|
width: 240,
|
|
height: 40,
|
|
componentId: field.id
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 180,
|
|
height: 40,
|
|
componentId: source.id
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const placeholder = graph.getChildren(instance.id)[0]
|
|
expect(placeholder).toMatchObject({ x: 16, y: 10, text: 'Placeholder' })
|
|
})
|
|
|
|
test('scales target-aspect instance geometry through fixed wrappers', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, { width: 310, height: 62 })
|
|
const wrapper = graph.createNode('FRAME', component.id, { width: 310, height: 61.214 })
|
|
const inset = graph.createNode('RECTANGLE', wrapper.id, {
|
|
x: 250,
|
|
y: 10,
|
|
width: 20,
|
|
height: 20,
|
|
horizontalConstraint: 'MAX',
|
|
verticalConstraint: 'MIN'
|
|
})
|
|
const vector = graph.createNode('VECTOR', wrapper.id, {
|
|
width: 56.392,
|
|
height: 61.214,
|
|
horizontalConstraint: 'SCALE',
|
|
verticalConstraint: 'SCALE'
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 100,
|
|
height: 20,
|
|
componentId: component.id
|
|
})
|
|
instance.source.fig.rawNodeFields.targetAspectRatio = { value: { x: 310, y: 62 } }
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const scaledWrapper = graph.getChildren(instance.id)[0]
|
|
const scaledShape = graph.getChildren(scaledWrapper.id)[1]
|
|
const preservedInset = graph.getChildren(scaledWrapper.id)[0]
|
|
expect(scaledWrapper.width).toBeCloseTo(100)
|
|
expect(scaledWrapper.height).toBeCloseTo((61.214 * 20) / 62)
|
|
expect(preservedInset).toMatchObject({
|
|
x: inset.x,
|
|
y: inset.y,
|
|
width: inset.width,
|
|
height: inset.height
|
|
})
|
|
expect(scaledShape.width).toBeCloseTo((vector.width * 100) / 310)
|
|
expect(scaledShape.height).toBeCloseTo((vector.height * 20) / 62)
|
|
})
|
|
|
|
test('uses target-aspect metadata only to reach scale-constrained descendants', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const component = graph.createNode('COMPONENT', pageId, { width: 24, height: 24 })
|
|
const vector = graph.createNode('VECTOR', component.id, {
|
|
x: 9,
|
|
y: 3,
|
|
width: 6,
|
|
height: 6,
|
|
horizontalConstraint: 'SCALE',
|
|
verticalConstraint: 'SCALE'
|
|
})
|
|
const instance = graph.createNode('INSTANCE', pageId, {
|
|
width: 14,
|
|
height: 14,
|
|
componentId: component.id
|
|
})
|
|
instance.source.fig.rawNodeFields.targetAspectRatio = { value: { x: 32, y: 32 } }
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const scaledVector = graph.getChildren(instance.id)[0]
|
|
expect(scaledVector).toMatchObject({
|
|
x: (vector.x * 14) / 24,
|
|
y: (vector.y * 14) / 24,
|
|
width: (vector.width * 14) / 24,
|
|
height: (vector.height * 14) / 24
|
|
})
|
|
})
|
|
|
|
test('limits lazy population to required global propagation scans', () => {
|
|
const graph = new SceneGraph()
|
|
const activePage = graph.getPages()[0]
|
|
const unrelatedPage = graph.addPage('Unrelated')
|
|
const component = graph.createNode('COMPONENT', unrelatedPage.id, {
|
|
width: 100,
|
|
height: 40
|
|
})
|
|
graph.createNode('TEXT', component.id, { text: 'Label' })
|
|
const instance = graph.createNode('INSTANCE', activePage.id, {
|
|
width: 100,
|
|
height: 40,
|
|
componentId: component.id
|
|
})
|
|
for (let index = 0; index < 5_000; index++) {
|
|
graph.createNode('RECTANGLE', unrelatedPage.id)
|
|
}
|
|
|
|
let globalScans = 0
|
|
const getAllNodes = graph.getAllNodes.bind(graph)
|
|
graph.getAllNodes = () => {
|
|
globalScans++
|
|
return getAllNodes()
|
|
}
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map(), [], [activePage.id])
|
|
|
|
expect(graph.getNode(instance.id)?.childIds).toHaveLength(1)
|
|
expect(globalScans).toBe(2)
|
|
})
|
|
|
|
test('restores effective image and thin-clone geometry after final swaps', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const avatarComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
|
graph.createNode('ROUNDED_RECTANGLE', avatarComponent.id, {
|
|
width: 100,
|
|
height: 100,
|
|
horizontalConstraint: 'SCALE',
|
|
verticalConstraint: 'SCALE',
|
|
fills: [
|
|
{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }
|
|
]
|
|
})
|
|
const avatar = graph.createNode('INSTANCE', pageId, {
|
|
componentId: avatarComponent.id,
|
|
width: 14,
|
|
height: 14
|
|
})
|
|
|
|
const dividerComponent = graph.createNode('COMPONENT', pageId, {
|
|
width: 112,
|
|
height: 28,
|
|
layoutMode: 'HORIZONTAL',
|
|
counterAxisAlign: 'CENTER'
|
|
})
|
|
const dividerSource = graph.createNode('RECTANGLE', dividerComponent.id, {
|
|
x: 0,
|
|
y: 13.5,
|
|
width: 112,
|
|
height: 1,
|
|
figmaDerivedLayout: { x: 0, y: 13.5, width: 112, height: 1 }
|
|
})
|
|
const dividerInstance = graph.createNode('INSTANCE', pageId, {
|
|
componentId: dividerComponent.id,
|
|
width: 112,
|
|
height: 28,
|
|
layoutMode: 'HORIZONTAL',
|
|
counterAxisAlign: 'CENTER'
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
const avatarLeaf = graph.getChildren(avatar.id)[0]
|
|
expect(avatarLeaf).toMatchObject({ width: 14, height: 14 })
|
|
const divider = graph.getChildren(dividerInstance.id)[0]
|
|
expect(divider.componentId).toBe(dividerSource.id)
|
|
expect(divider.figmaDerivedLayout).toMatchObject({ x: 0, y: 13.5 })
|
|
})
|
|
|
|
test('leaves unrelated scaled vector and multi-child instance geometry unchanged', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const vectorComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
|
graph.createNode('VECTOR', vectorComponent.id, {
|
|
width: 20,
|
|
height: 10,
|
|
horizontalConstraint: 'SCALE',
|
|
verticalConstraint: 'SCALE'
|
|
})
|
|
const vectorInstance = graph.createNode('INSTANCE', pageId, {
|
|
componentId: vectorComponent.id,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
const multiComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
|
graph.createNode('ROUNDED_RECTANGLE', multiComponent.id, {
|
|
width: 100,
|
|
height: 100,
|
|
horizontalConstraint: 'SCALE',
|
|
verticalConstraint: 'SCALE',
|
|
fills: [
|
|
{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }
|
|
]
|
|
})
|
|
graph.createNode('RECTANGLE', multiComponent.id, { width: 10, height: 10 })
|
|
const multiInstance = graph.createNode('INSTANCE', pageId, {
|
|
componentId: multiComponent.id,
|
|
width: 50,
|
|
height: 50
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
expect(graph.getChildren(vectorInstance.id)[0]).toMatchObject({ width: 10, height: 5 })
|
|
expect(graph.getChildren(multiInstance.id)[0]).toMatchObject({ width: 50, height: 50 })
|
|
})
|
|
|
|
test('resolves text clone chains to their source values', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const source = graph.createNode('TEXT', pageId, {
|
|
text: 'Label',
|
|
width: 80,
|
|
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
})
|
|
const middle = graph.createNode('TEXT', pageId, {
|
|
componentId: source.id,
|
|
text: 'Label',
|
|
width: 120
|
|
})
|
|
const leaf = graph.createNode('TEXT', pageId, {
|
|
componentId: middle.id,
|
|
text: 'Label',
|
|
width: 160
|
|
})
|
|
|
|
populateAndApplyOverrides(graph, new Map(), new Map())
|
|
|
|
expect(graph.getNode(middle.id)?.width).toBe(80)
|
|
expect(graph.getNode(leaf.id)).toMatchObject({ width: 80, fills: source.fills })
|
|
})
|
|
|
|
test('synchronizes opacity bindings with their resolved value', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const source = graph.createNode('INSTANCE', pageId, {
|
|
opacity: 0.5,
|
|
boundVariables: { opacity: 'opacity-var' }
|
|
})
|
|
const target = graph.createNode('INSTANCE', pageId, {
|
|
opacity: 1,
|
|
componentId: source.id
|
|
})
|
|
|
|
syncNodeProps(graph, source, target)
|
|
|
|
expect(graph.getNode(target.id)).toMatchObject({
|
|
opacity: 0.5,
|
|
boundVariables: { opacity: 'opacity-var' }
|
|
})
|
|
})
|
|
|
|
test('clears opacity bindings removed from the source', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const source = graph.createNode('INSTANCE', pageId, { opacity: 1 })
|
|
const target = graph.createNode('INSTANCE', pageId, {
|
|
opacity: 0.5,
|
|
componentId: source.id,
|
|
boundVariables: { opacity: 'stale-opacity-var', width: 'width-var' }
|
|
})
|
|
|
|
syncNodeProps(graph, source, target)
|
|
|
|
expect(graph.getNode(target.id)?.boundVariables).toEqual({ width: 'width-var' })
|
|
})
|
|
|
|
test('preserves protected text while synchronizing other fields', () => {
|
|
const graph = new SceneGraph()
|
|
const pageId = graph.getPages()[0].id
|
|
const source = graph.createNode('TEXT', pageId, { text: 'Source', visible: false })
|
|
const target = graph.createNode('TEXT', pageId, { text: 'Override', visible: true })
|
|
const protections: ProtectionMap = new Map()
|
|
protectField(protections, target.id, 'text')
|
|
|
|
syncNodeProps(graph, source, target, protections)
|
|
|
|
expect(graph.getNode(target.id)).toMatchObject({ text: 'Override', visible: false })
|
|
})
|
|
})
|