openpencil/tests/engine/render/jsx/text-props.test.ts
Danila Poyarkov 8404cee664
refactor(design-jsx): extract design JSX into its own package (#793)
* refactor(design-jsx): extract design JSX into its own package

Design JSX elements, helpers, schema, reference, and JSX export only need
the scene graph, yet lived in Core, so every consumer of the authoring API
pulled in the renderer, layout, and file formats.

@open-pencil/design-jsx now owns them and depends only on scene-graph. The
renderer takes icon lookup, SVG conversion, vector creation, and layout as
DesignJSXServices; Core binds its own and exports the bound renderJSX and
renderTree from @open-pencil/core/design-jsx.

* feat(design-jsx): export the JSX runtime for TSX authoring

The package already had a JSX runtime, but nothing exported it, so design
trees could only be written as function calls or JSX strings. Export
`./jsx-runtime` and `./jsx-dev-runtime` so `jsxImportSource` works, and make
`Fragment` produce the same empty-type node as `<>` in `renderJSX` strings.

* fix(design-jsx): render fragments nested in other elements

A fragment builds a node with an empty type, which only renderJSX expanded, and only at the root. Nested fragments and fragments passed to renderTree failed with 'Unknown element: <>'. Inline fragment children when trees are built, and share root expansion between renderTree and renderJSX.
2026-09-30 20:55:27 +04:00

168 lines
6.1 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import { renderJSX } from '@open-pencil/core'
import { sceneNodeToJSX } from '@open-pencil/design-jsx'
import { getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
import { makeSceneGraph } from '#tests/helpers/scene'
describe('text props round-trip', () => {
it('lineHeight renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.lineHeight).toBe(24)
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('lineHeight={24}')
})
it('letterSpacing renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.letterSpacing).toBe(2)
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('letterSpacing={2}')
})
it('textDecoration renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.textDecoration).toBe('UNDERLINE')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('textDecoration="underline"')
})
it('textCase renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.textCase).toBe('UPPER')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('textCase="upper"')
})
it('maxLines renders with truncation', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.maxLines).toBe(2)
expect(n.textTruncation).toBe('ENDING')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('maxLines={2}')
})
it('truncate without maxLines', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.textTruncation).toBe('ENDING')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('truncate')
})
it('accepts Figma-style text aliases', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
'<Text text="Alias text" fontSize={24} fontFamily="Inter" fontWeight={700} fill="#123456" textHorizontalAlignment="CENTER" textVerticalAlignment="CENTER" />'
)
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Alias text')
expect(n.fontSize).toBe(24)
expect(n.fontFamily).toBe('Inter')
expect(n.fontWeight).toBe(700)
expect(n.textAlignHorizontal).toBe('CENTER')
expect(n.textAlignVertical).toBe('CENTER')
expect(n.fills[0]?.type).toBe('SOLID')
})
it('accepts characters as a text content alias', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text characters="Characters text" color="#000" />')
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Characters text')
})
it('prefers text children over text prop alias', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text text="Prop text" color="#000">Child text</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.text).toBe('Child text')
})
it('defaults omit text props', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000">Plain</Text>')
const n = getNodeOrThrow(g, result.id)
expect(n.lineHeight).toBeNull()
expect(n.letterSpacing).toBe(0)
expect(n.textDecoration).toBe('NONE')
expect(n.textCase).toBe('ORIGINAL')
expect(n.maxLines).toBeNull()
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).not.toContain('lineHeight')
expect(jsx).not.toContain('letterSpacing')
expect(jsx).not.toContain('textDecoration')
expect(jsx).not.toContain('textCase')
expect(jsx).not.toContain('maxLines')
expect(jsx).not.toContain('truncate')
})
it('text w="fill" in flex="col" exports as w="fill" not w={computed}', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`
<Frame name="Card" flex="col" w={300} p={20}>
<Text name="Title" size={22} weight="bold" color="#111" w="fill">Hello World</Text>
</Frame>
`
)
const card = getNodeOrThrow(g, result.id)
const title = getNodeOrThrow(g, childIdAt(card, 0))
expect(title.layoutAlignSelf).toBe('STRETCH')
expect(title.textAutoResize).toBe('HEIGHT')
const jsx = sceneNodeToJSX(title.id, g)
expect(jsx).toContain('w="fill"')
expect(jsx).not.toMatch(/w=\{/)
})
it('text grow={1} in flex="row" exports as grow not w={computed}', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`
<Frame name="Row" flex="row" w={300}>
<Text name="Label" color="#999" w={60}>Label</Text>
<Text name="Value" color="#111" w="fill">Some value text</Text>
</Frame>
`
)
const row = getNodeOrThrow(g, result.id)
const value = getNodeOrThrow(g, childIdAt(row, 1))
expect(value.layoutGrow).toBe(1)
const jsx = sceneNodeToJSX(value.id, g)
expect(jsx).toContain('grow={1}')
expect(jsx).not.toMatch(/\bw=\{\d+\}/)
})
})
describe('attribute string round-trip', () => {
it.each([
// Unescaped, this name would inject `w={999}` into the exported frame.
'a" w={999} x="',
'Fish &amp; chips',
'Back\\slash',
'Two\nlines',
'Plain name'
])('keeps the layer name %p', async (name) => {
const g = makeSceneGraph()
const [source] = await renderJSX(g, '<Frame w={10} h={10} />')
getNodeOrThrow(g, source.id).name = name
const [result] = await renderJSX(g, sceneNodeToJSX(source.id, g))
expect(getNodeOrThrow(g, result.id)).toMatchObject({ name, width: 10 })
})
})