openpencil/tests/engine/editor/structure/flatten.test.ts
Danila Poyarkov 5c09ca0299 test(core): stabilize engine unit tests
- Update EffectType schema assertion for modern Figma enum values
- Treat unsupported OpenType font buffers as unavailable
- Skip CJK outline fallback coverage when the LFS font fixture is not materialized
2026-05-25 01:41:59 +03:00

412 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, test } from 'bun:test'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import { BLACK, TRANSPARENT } from '#core/constants'
import { createEditor } from '#core/editor'
import { fontManager } from '#core/text/fonts'
import { textNodeToOutlineLayout } from '#core/text/outlines'
async function createEditorWithRenderer() {
const ck = await initCanvasKit()
const surface = ck.MakeSurface(200, 200)
if (!surface) throw new Error('Could not create CanvasKit surface')
const renderer = new SkiaRenderer(ck, surface)
const editor = createEditor()
editor.setCanvasKit(ck, renderer)
return { editor, surface }
}
async function loadInterRegular() {
const data = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', data)
}
async function loadInterBold() {
const data = await Bun.file('public/Inter-Bold.ttf').arrayBuffer()
fontManager.markLoaded('Inter', 'Bold', data)
}
async function loadNotoSansSC() {
const file = Bun.file('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const text = await file.slice(0, 32).text()
if (text.startsWith('version ')) return false
const data = await file.arrayBuffer()
fontManager.markLoaded('Noto Sans SC', 'Regular', data)
fontManager.setCJKFallbackFamily('Noto Sans SC')
return true
}
describe('flattenSelected', () => {
test('converts selected shapes into a vector path', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const first = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 50,
height: 40
})
const second = editor.graph.createNode('ELLIPSE', pageId, {
x: 40,
y: 30,
width: 50,
height: 40
})
editor.select([first.id, second.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.x).toBe(10)
expect(vector?.y).toBe(20)
expect(vector?.width).toBe(80)
expect(vector?.height).toBe(50)
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(first.id)).toBeUndefined()
expect(editor.graph.getNode(second.id)).toBeUndefined()
surface.delete()
})
test('outlines visible strokes into a vector path', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 30,
fills: [TRANSPARENT],
strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }]
})
editor.select([rect.id])
editor.outlineStrokeSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.name).toBe('Outline stroke')
expect(vector?.x).toBe(16)
expect(vector?.y).toBe(26)
expect(vector?.width).toBe(48)
expect(vector?.height).toBe(38)
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(rect.id)).toBeUndefined()
surface.delete()
})
test('outlines stroked descendants inside groups', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 30,
fills: [TRANSPARENT],
strokes: [{ type: 'SOLID', color: BLACK, weight: 8, opacity: 1, visible: true }]
})
editor.select([rect.id])
editor.groupSelected()
editor.outlineStrokeSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.name).toBe('Outline stroke')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
surface.delete()
})
test('does not outline fill-only shapes as strokes', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 20,
y: 30,
width: 40,
height: 30,
strokes: []
})
editor.select([rect.id])
const result = editor.outlineStrokeSelected()
expect(result).toBeNull()
expect(editor.graph.getNode(rect.id)?.type).toBe('RECTANGLE')
surface.delete()
})
test('outlines loaded text through the shared text outline path', async () => {
await loadInterRegular()
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Hi',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 80,
height: 40
})
editor.select([text.id])
editor.outlineTextSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.name).toBe('Outline text')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(text.id)).toBeUndefined()
surface.delete()
})
test('flattens loaded text as outlines', async () => {
await loadInterRegular()
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Hi',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 80,
height: 40
})
editor.select([text.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(text.id)).toBeUndefined()
surface.delete()
})
test('wraps loaded text style runs as outlines', async () => {
await loadInterRegular()
await loadInterBold()
const editor = createEditor()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Hello world',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 100,
height: 100,
styleRuns: [{ start: 6, length: 5, style: { fontWeight: 700 } }]
})
const layout = textNodeToOutlineLayout(text)
expect(layout).not.toBeNull()
expect(layout?.height).toBeGreaterThan(40)
})
test('flattens loaded text style runs as outlines', async () => {
await loadInterRegular()
await loadInterBold()
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Hi',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 80,
height: 40,
styleRuns: [{ start: 1, length: 1, style: { fontWeight: 700, fontSize: 40 } }]
})
editor.select([text.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
surface.delete()
})
test('flattens mixed Latin and CJK text with loaded fallback outlines', async () => {
await loadInterRegular()
const hasCjkFallback = await loadNotoSansSC()
if (!hasCjkFallback) return
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Hi你',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 120,
height: 40
})
editor.select([text.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
surface.delete()
})
test('does not flatten complex script text without shaping support', async () => {
await loadInterRegular()
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'مرحبا',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 32,
width: 120,
height: 40
})
editor.select([text.id])
const result = editor.flattenSelected()
expect(result).toBeNull()
expect(editor.graph.getNode(text.id)?.type).toBe('TEXT')
surface.delete()
})
test('does not flatten unsupported text nodes', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const text = editor.graph.createNode('TEXT', pageId, {
text: 'Nope',
fontFamily: 'Definitely Missing Font'
})
const rect = editor.graph.createNode('RECTANGLE', pageId)
editor.select([text.id, rect.id])
const result = editor.flattenSelected()
expect(result).toBeNull()
expect(editor.graph.getNode(pageId)?.childIds).toEqual([text.id, rect.id])
expect(editor.state.selectedIds).toEqual(new Set([text.id, rect.id]))
surface.delete()
})
test('does not flatten containers with unsupported descendants', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId)
const image = editor.graph.createNode('RECTANGLE', pageId, {
fills: [
{
type: 'IMAGE',
imageHash: 'image',
imageScaleMode: 'FILL',
color: TRANSPARENT,
opacity: 1,
visible: true
}
]
})
editor.select([rect.id, image.id])
editor.groupSelected()
const [groupId] = [...editor.state.selectedIds]
const result = editor.flattenSelected()
expect(result).toBeNull()
expect(editor.graph.getNode(groupId)?.type).toBe('GROUP')
surface.delete()
})
test('flattens visual descendants from groups', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 50,
height: 40
})
const ellipse = editor.graph.createNode('ELLIPSE', pageId, {
x: 70,
y: 20,
width: 30,
height: 30
})
editor.select([rect.id, ellipse.id])
editor.groupSelected()
const [groupId] = [...editor.state.selectedIds]
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(editor.graph.getNode(groupId)).toBeUndefined()
surface.delete()
})
test('includes stroke-only rectangles as flattenable outlines', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const rect = editor.graph.createNode('RECTANGLE', pageId, {
x: 10,
y: 20,
width: 50,
height: 40,
fills: [],
strokes: [
{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 8, align: 'CENTER' }
]
})
editor.select([rect.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
const vector = editor.graph.getNode(vectorId)
expect(vector?.type).toBe('VECTOR')
expect(vector?.width).toBeGreaterThan(50)
expect(vector?.height).toBeGreaterThan(40)
surface.delete()
})
test('undo and redo restore flattened children and vector', async () => {
const { editor, surface } = await createEditorWithRenderer()
const pageId = editor.state.currentPageId
const before = editor.graph.createNode('RECTANGLE', pageId, { name: 'Before' })
const first = editor.graph.createNode('RECTANGLE', pageId, { name: 'First' })
const second = editor.graph.createNode('ELLIPSE', pageId, { name: 'Second', x: 50 })
const after = editor.graph.createNode('RECTANGLE', pageId, { name: 'After' })
editor.select([first.id, second.id])
editor.flattenSelected()
const [vectorId] = [...editor.state.selectedIds]
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id])
editor.undo.undo()
expect(editor.graph.getNode(pageId)?.childIds).toEqual([
before.id,
first.id,
second.id,
after.id
])
expect(editor.state.selectedIds).toEqual(new Set([first.id, second.id]))
editor.undo.redo()
expect(editor.graph.getNode(pageId)?.childIds).toEqual([before.id, vectorId, after.id])
expect(editor.graph.getNode(vectorId)?.type).toBe('VECTOR')
surface.delete()
})
})