2026-09-13 16:27:06 +00:00
|
|
|
|
import { expect, expectTypeOf, test } from 'bun:test'
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Component,
|
|
|
|
|
|
ComponentSet,
|
|
|
|
|
|
type Frame,
|
|
|
|
|
|
Instance,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
node,
|
|
|
|
|
|
renderTree
|
|
|
|
|
|
} from '@open-pencil/core/design-jsx'
|
|
|
|
|
|
import type { ComponentPropertyDefinition, SceneNode } from '@open-pencil/scene-graph'
|
|
|
|
|
|
|
|
|
|
|
|
import { getNodeOrThrow } from '#tests/helpers/assert'
|
|
|
|
|
|
import { makeSceneGraph } from '#tests/helpers/scene'
|
|
|
|
|
|
|
|
|
|
|
|
test('property props are scoped to their authoring entities', () => {
|
|
|
|
|
|
expectTypeOf<Parameters<typeof Frame>[0]['properties']>().toEqualTypeOf<undefined>()
|
|
|
|
|
|
expectTypeOf<Parameters<typeof Text>[0]['properties']>().toEqualTypeOf<undefined>()
|
|
|
|
|
|
expectTypeOf<Parameters<typeof Component>[0]['properties']>().toEqualTypeOf<
|
|
|
|
|
|
SceneNode['componentPropertyDefinitions'] | undefined
|
|
|
|
|
|
>()
|
|
|
|
|
|
expectTypeOf<Parameters<typeof Instance>[0]['properties']>().toEqualTypeOf<
|
|
|
|
|
|
SceneNode['componentPropertyAssignments'] | undefined
|
|
|
|
|
|
>()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const MESSAGE: ComponentPropertyDefinition = {
|
|
|
|
|
|
id: 'message',
|
|
|
|
|
|
name: 'Message',
|
|
|
|
|
|
type: 'TEXT',
|
|
|
|
|
|
defaultValue: 'Default'
|
|
|
|
|
|
}
|
|
|
|
|
|
const VISIBLE: ComponentPropertyDefinition = {
|
|
|
|
|
|
id: 'visible',
|
|
|
|
|
|
name: 'Visible',
|
|
|
|
|
|
type: 'BOOLEAN',
|
|
|
|
|
|
defaultValue: 'true'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function setup() {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
const component = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Component({
|
|
|
|
|
|
name: 'Note',
|
|
|
|
|
|
flex: 'col',
|
|
|
|
|
|
w: 280,
|
|
|
|
|
|
h: 'hug',
|
|
|
|
|
|
properties: [MESSAGE, VISIBLE],
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text({
|
|
|
|
|
|
name: 'Duplicate name',
|
|
|
|
|
|
children: 'Default',
|
|
|
|
|
|
w: 'fill',
|
|
|
|
|
|
color: '#111111',
|
|
|
|
|
|
propertyRefs: [
|
|
|
|
|
|
{ propertyId: MESSAGE.id, field: 'TEXT' },
|
|
|
|
|
|
{ propertyId: VISIBLE.id, field: 'VISIBLE' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}),
|
|
|
|
|
|
Text({ name: 'Duplicate name', children: 'Unrelated', color: '#111111' })
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
return { graph, component }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test('assigns real text and visibility properties without resolving child names', async () => {
|
|
|
|
|
|
const { graph, component } = await setup()
|
|
|
|
|
|
const result = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Instance({ of: component.id, properties: { message: 'June’s review', visible: 'false' } })
|
|
|
|
|
|
)
|
|
|
|
|
|
const instance = getNodeOrThrow(graph, result.id)
|
|
|
|
|
|
const children = graph.getChildren(instance.id)
|
|
|
|
|
|
expect(children.map((child) => child.text)).toEqual(['June’s review', 'Unrelated'])
|
|
|
|
|
|
expect(children[0].visible).toBe(false)
|
|
|
|
|
|
expect(instance.componentPropertyAssignments).toEqual({
|
|
|
|
|
|
message: 'June’s review',
|
|
|
|
|
|
visible: 'false'
|
|
|
|
|
|
})
|
|
|
|
|
|
graph.syncInstances(component.id)
|
|
|
|
|
|
expect(graph.getChildren(instance.id)[0].text).toBe('June’s review')
|
|
|
|
|
|
expect(graph.getChildren(instance.id)[0].visible).toBe(false)
|
|
|
|
|
|
expect(graph.getChildren(component.id)[0].text).toBe('Default')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('variant selection ignores non-variant instance props', async () => {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
const set = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
ComponentSet({
|
|
|
|
|
|
name: 'Button',
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Component({ name: 'variant=Primary', w: 100, h: 40 }),
|
|
|
|
|
|
Component({
|
|
|
|
|
|
name: 'variant=Secondary',
|
|
|
|
|
|
w: 100,
|
|
|
|
|
|
h: 40,
|
|
|
|
|
|
properties: [MESSAGE],
|
|
|
|
|
|
children: Text({
|
|
|
|
|
|
children: 'Default',
|
|
|
|
|
|
propertyRefs: [{ propertyId: MESSAGE.id, field: 'TEXT' }]
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
const result = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Instance({ of: set.id, variant: 'Secondary', w: 120, properties: { message: 'Changed' } })
|
|
|
|
|
|
)
|
|
|
|
|
|
const instance = getNodeOrThrow(graph, result.id)
|
|
|
|
|
|
expect(instance.componentId).toBe(graph.getChildren(set.id)[1].id)
|
|
|
|
|
|
expect(graph.getChildren(instance.id)[0].text).toBe('Changed')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('invalid assignments remove the new instance and do not edit its source', async () => {
|
|
|
|
|
|
const { graph, component } = await setup()
|
|
|
|
|
|
const count = graph.nodes.size
|
|
|
|
|
|
for (const assignments of [
|
|
|
|
|
|
{ missing: 'value' },
|
|
|
|
|
|
{ visible: 'yes' },
|
|
|
|
|
|
{ message: 'Changed', missing: 'value' }
|
|
|
|
|
|
]) {
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
renderTree(graph, Instance({ of: component.id, properties: assignments }))
|
|
|
|
|
|
).rejects.toThrow()
|
|
|
|
|
|
expect(graph.nodes.size).toBe(count)
|
|
|
|
|
|
expect(graph.getChildren(component.id)[0].text).toBe('Default')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('assigns nested instance swaps through stable property references', async () => {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
const first = await renderTree(graph, Component({ name: 'First', w: 20, h: 20 }))
|
|
|
|
|
|
const second = await renderTree(graph, Component({ name: 'Second', w: 30, h: 30 }))
|
|
|
|
|
|
const component = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Component({
|
|
|
|
|
|
name: 'With slot',
|
|
|
|
|
|
properties: [{ id: 'slot', name: 'Slot', type: 'INSTANCE_SWAP', defaultValue: first.id }],
|
|
|
|
|
|
children: Instance({
|
|
|
|
|
|
of: first.id,
|
|
|
|
|
|
propertyRefs: [{ propertyId: 'slot', field: 'INSTANCE_SWAP' }]
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
const result = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Instance({ of: component.id, properties: { slot: second.id } })
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(graph.getChildren(result.id)[0].componentId).toBe(second.id)
|
|
|
|
|
|
graph.syncInstances(component.id)
|
|
|
|
|
|
expect(graph.getChildren(result.id)[0].componentId).toBe(second.id)
|
|
|
|
|
|
expect(graph.getChildren(component.id)[0].componentId).toBe(first.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('ordinary elements reject properties before rendering', async () => {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
for (const type of ['frame', 'text', 'rectangle', 'icon', 'svg']) {
|
|
|
|
|
|
await expect(renderTree(graph, node(type, { properties: [MESSAGE] }))).rejects.toThrow(
|
|
|
|
|
|
'Only components'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('validates metadata rather than accepting silent malformed property definitions', async () => {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
await expect(renderTree(graph, node('text', { properties: [MESSAGE] }))).rejects.toThrow(
|
|
|
|
|
|
'Only components'
|
|
|
|
|
|
)
|
|
|
|
|
|
await expect(renderTree(graph, Component({ properties: [MESSAGE, MESSAGE] }))).rejects.toThrow(
|
|
|
|
|
|
'Duplicate'
|
|
|
|
|
|
)
|
2026-09-13 21:14:09 +00:00
|
|
|
|
for (const element of [Component, ComponentSet]) {
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
element({
|
|
|
|
|
|
properties: [
|
|
|
|
|
|
{ id: 'kind-a', name: 'Kind', type: 'VARIANT', defaultValue: 'A' },
|
|
|
|
|
|
{ id: 'kind-b', name: 'Kind', type: 'VARIANT', defaultValue: 'B' }
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
).rejects.toThrow('Duplicate variant property names')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('preserves duplicate display names for non-variant properties with distinct IDs', async () => {
|
|
|
|
|
|
const graph = makeSceneGraph()
|
|
|
|
|
|
const result = await renderTree(
|
|
|
|
|
|
graph,
|
|
|
|
|
|
Component({ properties: [MESSAGE, { ...MESSAGE, id: 'other-message' }] })
|
|
|
|
|
|
)
|
|
|
|
|
|
expect(
|
|
|
|
|
|
getNodeOrThrow(graph, result.id).componentPropertyDefinitions.map((item) => item.id)
|
|
|
|
|
|
).toEqual(['message', 'other-message'])
|
2026-09-13 16:27:06 +00:00
|
|
|
|
})
|