202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
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'
|
||
)
|
||
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'])
|
||
})
|