openpencil/tests/engine/layout/auto-layout/text/measurement.test.ts
Joseph Cumines fa5c072cad test: extend timeout for slow gold-preview fixture tests
Port the timeout fixes from PR #351 that resolved the unit-test CI
timeouts. Add HEAVY_TEST_TIMEOUT_MS (30s) in tests/helpers/test-utils.ts
and apply it to the slow gold-preview.fig fixture tests/hooks:
- clipboard roundtrip
- group reclassification
- glyph blob preservation
- auto-layout text measurement
- render/canvas/cache
2026-06-19 11:05:25 +10:00

532 lines
15 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core'
import { createEditorStore } from '@/app/editor/session'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout'
import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils'
describe('text measurement', () => {
test('derived text layout preserves imported auto-layout text bounds during measurement', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const tabs = autoFrame(graph, page, {
width: 180,
height: 42,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5
})
const tab = autoFrame(graph, tabs.id, {
width: 80,
height: 32,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 6,
paddingBottom: 6,
paddingLeft: 12,
paddingRight: 12
})
graph.createNode('TEXT', tab.id, {
text: 'Account',
width: 56,
height: 20,
textAutoResize: 'WIDTH_AND_HEIGHT',
figmaDerivedLayout: { width: 56, height: 20 }
})
setTextMeasurer(() => ({ width: 56, height: 40 }))
computeAllLayouts(graph, page)
setTextMeasurer(null)
expect(graph.getNode(tab.id)?.height).toBe(32)
expect(graph.getNode(tabs.id)?.height).toBe(42)
})
test('live text without derived glyphs still uses CanvasKit measurement', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const tabs = autoFrame(graph, page, {
width: 180,
height: 42,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5
})
const tab = autoFrame(graph, tabs.id, {
width: 80,
height: 32,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 6,
paddingBottom: 6,
paddingLeft: 12,
paddingRight: 12
})
graph.createNode('TEXT', tab.id, {
text: 'Account',
width: 56,
height: 20,
textAutoResize: 'WIDTH_AND_HEIGHT'
})
setTextMeasurer(() => ({ width: 56, height: 40 }))
computeAllLayouts(graph, page)
setTextMeasurer(null)
expect(graph.getNode(tab.id)?.height).toBe(52)
expect(graph.getNode(tabs.id)?.height).toBe(62)
})
test(
'opening imported fig keeps stored text bounds before CanvasKit measurement',
{ timeout: HEAVY_TEST_TIMEOUT_MS },
async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const store = createEditorStore(graph)
const title = [...store.graph.getAllNodes()].find(
(node) => node.type === 'TEXT' && node.text === "World's largest"
)
const subtitle = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' &&
node.text === 'Preline UI Figma - crafted with Tailwind CSS styles'
)
const description = [...store.graph.getAllNodes()].find(
(node) =>
node.type === 'TEXT' &&
node.text.startsWith('Preline UI Figma is the largest free design system for Figma')
)
if (!title || !subtitle || !description) {
throw new Error('Expected imported text nodes in gold-preview.fig')
}
expect(title.width).toBe(444)
expect(title.height).toBe(73)
expect(subtitle.width).toBe(439)
expect(subtitle.height).toBe(22)
expect(description.width).toBe(878)
expect(description.height).toBe(60)
await store.switchPage(store.graph.getPages()[0].id)
expect(store.graph.getNode(title.id)?.width).toBe(444)
expect(store.graph.getNode(title.id)?.height).toBe(73)
expect(store.graph.getNode(subtitle.id)?.width).toBe(439)
expect(store.graph.getNode(subtitle.id)?.height).toBe(22)
expect(store.graph.getNode(description.id)?.width).toBe(878)
expect(store.graph.getNode(description.id)?.height).toBe(60)
}
)
test(
'imported nested instance layout keeps hidden sibling offsets stable',
{ timeout: HEAVY_TEST_TIMEOUT_MS },
async () => {
const graph = await loadFixtureGraph('gold-preview.fig')
const previewRoot = graph.getChildren(graph.getPages()[0].id)[0]
const wysiwygEditor = graph
.getChildren(previewRoot.id)
.find((node) => node.name === '_WYSIWYG-editor')
const toolbarVariant = wysiwygEditor
? graph
.getChildren(wysiwygEditor.id)
.find((node) => node.name === '_on-text-WYSIWYG-toolbar')
: undefined
const toolbarRow = toolbarVariant
? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar')
: undefined
const hiddenInput = toolbarRow
? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input')
: undefined
const visibleToolbar = toolbarRow
? graph
.getChildren(toolbarRow.id)
.find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id)
: undefined
if (!toolbarRow || !hiddenInput || !visibleToolbar) {
throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig')
}
expect(hiddenInput.visible).toBe(false)
// x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03).
// The old assertion of x=298 was incorrect — it was a post-layout computed value from
// an older layout implementation. Post-layout, left-aligned HUG content retains x=8.
expect(visibleToolbar.x).toBe(8)
setTextMeasurer(null)
computeAllLayouts(graph, graph.getPages()[0].id)
expect(graph.getNode(visibleToolbar.id)?.x).toBe(8)
expect(graph.getNode(visibleToolbar.id)?.y).toBe(8)
}
)
test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER',
paddingLeft: 10,
paddingRight: 10,
itemSpacing: 10
})
const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
const text = graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const
})
const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
setTextMeasurer((node) => {
if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') {
return { width: 60, height: 20 }
}
return null
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = getNodeOrThrow(graph, text.id)
const updatedArrow1 = getNodeOrThrow(graph, arrow1.id)
const updatedArrow2 = getNodeOrThrow(graph, arrow2.id)
expect(updatedText.width).toBe(60)
// Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140
// Free space: 300 - 140 = 160, centered offset = 80
expect(updatedArrow1.x).toBe(90)
expect(updatedText.x).toBe(120)
expect(updatedArrow2.x).toBe(190)
})
test('without measurer, text preserves stored dimensions from .fig import', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER'
})
graph.createNode('TEXT', frame.id, {
width: 200,
height: 20,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
expect(updatedText.width).toBe(200)
expect(updatedText.height).toBe(20)
})
test('without measurer, text with 100x100 default falls back to estimate', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER'
})
graph.createNode('TEXT', frame.id, {
width: 100,
height: 100,
text: 'Test',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
expect(updatedText.width).toBeLessThan(100)
expect(updatedText.width).toBeGreaterThan(0)
expect(updatedText.height).toBeLessThan(100)
expect(updatedText.height).toBeGreaterThan(0)
})
test('without measurer, HEIGHT text preserves stored height from .fig import', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 269,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
graph.createNode('TEXT', frame.id, {
width: 269,
height: 66,
text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
fontSize: 15,
lineHeight: 22,
textAutoResize: 'HEIGHT' as const
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
expect(updatedText.height).toBe(66)
expect(updatedText.width).toBe(269)
})
test('without measurer, HEIGHT text with 100x100 default falls back to estimate', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 269,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
graph.createNode('TEXT', frame.id, {
width: 100,
height: 100,
text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
fontSize: 15,
lineHeight: 22,
textAutoResize: 'HEIGHT' as const
})
setTextMeasurer(null)
computeAllLayouts(graph)
const children = graph.getChildren(frame.id)
const updatedText = children[0]
expect(updatedText.height).toBeGreaterThan(22)
})
test('resizing vertical typography frames reflows fill-width auto-height text', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 200,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20
})
const text = graph.createNode('TEXT', frame.id, {
width: 260,
height: 20,
text: 'Body text — The quick brown fox jumps and wraps.',
fontSize: 14,
textAutoResize: 'HEIGHT' as const,
layoutAlignSelf: 'STRETCH' as const
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 260
return { width: w, height: w <= 160 ? 60 : 20 }
})
computeAllLayouts(graph)
expect(getNodeOrThrow(graph, text.id).width).toBe(260)
expect(getNodeOrThrow(graph, text.id).height).toBe(20)
graph.updateNode(frame.id, { width: 200 })
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(160)
expect(updatedText.height).toBe(60)
})
test('text with w="fill" in flex="col" stretches to parent width', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 400,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20
})
const text = graph.createNode('TEXT', frame.id, {
width: 100,
height: 20,
text: 'This text should fill the parent width',
fontSize: 14,
textAutoResize: 'HEIGHT' as const,
layoutAlignSelf: 'STRETCH' as const
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 260
return { width: w, height: 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = getNodeOrThrow(graph, text.id)
// Should stretch to 300 - 20 - 20 = 260, NOT stay at 100
expect(updatedText.width).toBe(260)
})
test('HEIGHT auto-resize text wraps via MeasureFunc when filling parent', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 200,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const text = graph.createNode('TEXT', frame.id, {
width: 300,
height: 20,
text: 'Long text that should wrap within the available width',
fontSize: 14,
textAutoResize: 'HEIGHT' as const
})
setTextMeasurer((_node, maxWidth) => {
const w = maxWidth ?? 1e6
if (w >= 300) return { width: 300, height: 20 }
return { width: w, height: 60 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(300)
expect(updatedText.height).toBe(20)
})
test('MeasureFunc receives constraint width from flex layout', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 400,
height: 200,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 10
})
rect(graph, frame.id, 100, 50)
const text = graph.createNode('TEXT', frame.id, {
width: 500,
height: 20,
text: 'Wide text',
fontSize: 14,
textAutoResize: 'WIDTH_AND_HEIGHT' as const,
layoutGrow: 1
})
const receivedWidths: number[] = []
setTextMeasurer((_node, maxWidth) => {
if (maxWidth !== undefined) receivedWidths.push(Math.round(maxWidth))
const w = maxWidth ?? 500
return { width: Math.min(200, w), height: w < 200 ? 40 : 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
// 400 - 100 - 10 = 290 available for the fill text
expect(receivedWidths.length).toBeGreaterThan(0)
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(290)
})
test('textAutoResize NONE skips MeasureFunc', () => {
const graph = new SceneGraph()
const pid = pageId(graph)
const frame = autoFrame(graph, pid, {
width: 300,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const text = graph.createNode('TEXT', frame.id, {
width: 150,
height: 40,
text: 'Fixed text',
fontSize: 14,
textAutoResize: 'NONE' as const
})
let measureCalled = false
setTextMeasurer(() => {
measureCalled = true
return { width: 80, height: 20 }
})
computeAllLayouts(graph)
setTextMeasurer(null)
expect(measureCalled).toBe(false)
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(150)
expect(updatedText.height).toBe(40)
})
})