openpencil/tests/e2e/perf/basic.spec.ts

264 lines
9.5 KiB
TypeScript
Raw Normal View History

2026-03-01 19:32:35 +00:00
import { test, expect } from '@playwright/test'
2026-05-03 18:03:40 +00:00
import { CanvasHelper } from '#tests/helpers/canvas'
2026-03-01 19:32:35 +00:00
const NODE_COUNT = 500
const ITERATIONS = 200
test.describe('Render performance', () => {
let helper: CanvasHelper
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
helper = new CanvasHelper(page)
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
2026-03-01 19:32:35 +00:00
await helper.waitForInit()
await page.evaluate((count: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
2026-03-01 19:32:35 +00:00
const arr = new Uint8Array(count * 3)
crypto.getRandomValues(arr)
const cols = Math.ceil(Math.sqrt(count))
for (let i = 0; i < count; i++) {
const mod = i % 10
const isVector = mod === 0
const isEllipse = mod === 5
let type: 'VECTOR' | 'ELLIPSE' | 'RECTANGLE' = 'RECTANGLE'
if (isVector) type = 'VECTOR'
else if (isEllipse) type = 'ELLIPSE'
2026-03-01 19:32:35 +00:00
const props: Record<string, unknown> = {
x: (i % cols) * 60,
y: Math.floor(i / cols) * 60,
width: 50,
height: 50,
cornerRadius: type === 'RECTANGLE' ? 8 : 0,
fills: [
{
type: 'SOLID',
color: {
r: (arr[i * 3] ?? 0) / 255,
g: (arr[i * 3 + 1] ?? 0) / 255,
b: (arr[i * 3 + 2] ?? 0) / 255,
2026-03-01 19:32:35 +00:00
a: 1
},
visible: true,
opacity: 1
}
],
strokes: [
2026-05-03 18:03:40 +00:00
{
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
visible: true,
opacity: 1,
weight: 1
}
2026-03-01 19:32:35 +00:00
]
}
if (isVector) {
props.vectorNetwork = {
vertices: [
{ x: 0, y: 25 },
{ x: 25, y: 0 },
{ x: 50, y: 25 },
{ x: 25, y: 50 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: -15 }, tangentEnd: { x: -15, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 15, y: 0 }, tangentEnd: { x: 0, y: -15 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 15 }, tangentEnd: { x: 15, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: -15, y: 0 }, tangentEnd: { x: 0, y: 15 } }
],
regions: [{ loops: [[0, 1, 2, 3]], windingRule: 'NONZERO' as const }]
}
}
store.graph.createNode(type, store.state.currentPageId, props)
}
store.requestRender()
}, NODE_COUNT)
await helper.waitForRender()
})
test.afterAll(async () => {
await helper.page.close()
})
test('benchmark: synchronous render throughput', async () => {
const results = await helper.page.evaluate((iterations: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
2026-03-01 19:32:35 +00:00
function setupRenderer() {
renderer.dpr = window.devicePixelRatio || 1
renderer.panX = store.state.panX
renderer.panY = store.state.panY
renderer.zoom = store.state.zoom
renderer.viewportWidth = 1280
renderer.viewportHeight = 800
renderer.showRulers = false
renderer.pageColor = store.state.pageColor
renderer.pageId = store.state.currentPageId
}
// Warm up
setupRenderer()
renderer.render(store.graph, store.state.selectedIds, {}, store.state.sceneVersion)
renderer.render(store.graph, store.state.selectedIds, {}, store.state.sceneVersion)
// Benchmark 1: Pan (repaint — picture cache hit)
setupRenderer()
const panStart = performance.now()
for (let i = 0; i < iterations; i++) {
renderer.panX += 2
renderer.panY += 1
renderer.render(store.graph, store.state.selectedIds, {}, store.state.sceneVersion)
}
const panMs = performance.now() - panStart
// Benchmark 2: Scene change (cache miss every frame)
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const nodes = [...pageNode.childIds]
2026-03-01 19:32:35 +00:00
setupRenderer()
const sceneStart = performance.now()
for (let i = 0; i < iterations; i++) {
const nodeId = nodes[i % nodes.length]
if (!nodeId) throw new Error('Benchmark node id not found')
const node = store.graph.getNode(nodeId)
2026-03-01 19:32:35 +00:00
if (node) store.graph.updateNode(node.id, { x: node.x + 0.1 })
store.state.sceneVersion++
renderer.render(store.graph, store.state.selectedIds, {}, store.state.sceneVersion)
}
const sceneMs = performance.now() - sceneStart
// Benchmark 3: Hover (volatile overlay, no caching)
setupRenderer()
const hoverStart = performance.now()
for (let i = 0; i < iterations; i++) {
renderer.render(
store.graph,
store.state.selectedIds,
{ hoveredNodeId: nodes[i % nodes.length] },
store.state.sceneVersion
)
}
const hoverMs = performance.now() - hoverStart
return {
2026-05-03 18:03:40 +00:00
pan: {
ms: Math.round(panMs * 100) / 100,
avg: Math.round((panMs / iterations) * 100) / 100
},
scene: {
ms: Math.round(sceneMs * 100) / 100,
avg: Math.round((sceneMs / iterations) * 100) / 100
},
hover: {
ms: Math.round(hoverMs * 100) / 100,
avg: Math.round((hoverMs / iterations) * 100) / 100
}
2026-03-01 19:32:35 +00:00
}
}, ITERATIONS)
console.log(`\n═══ RENDER BENCHMARK (${NODE_COUNT} nodes, ${ITERATIONS} iterations) ═══`)
console.log(` Pan (cache hit): ${results.pan.avg}ms/frame (${results.pan.ms}ms total)`)
console.log(` Scene change: ${results.scene.avg}ms/frame (${results.scene.ms}ms total)`)
console.log(` Hover (no cache): ${results.hover.avg}ms/frame (${results.hover.ms}ms total)`)
console.log(` Speedup (pan vs scene): ${(results.scene.avg / results.pan.avg).toFixed(1)}x`)
console.log(`═══════════════════════════════════════════════════════\n`)
expect(results.pan.avg).toBeLessThan(50)
})
test('benchmark: shadow rendering throughput', async () => {
const SHADOW_NODES = 50
const SHADOW_ITERS = 100
const results = await helper.page.evaluate(
({ count, iterations }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
const graph = store.graph
const pageId = store.state.currentPageId
const shadowIds: string[] = []
const cols = Math.ceil(Math.sqrt(count))
for (let i = 0; i < count; i++) {
const id = graph.createNode('RECTANGLE', pageId, {
x: (i % cols) * 80,
y: Math.floor(i / cols) * 80 + 2000,
width: 60,
height: 60,
cornerRadius: 8,
2026-05-03 18:03:40 +00:00
fills: [
{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }
],
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.2 },
offset: { x: 0, y: 4 },
radius: 12,
spread: 0,
visible: true
}
]
})
shadowIds.push(id)
}
function setupRenderer() {
renderer.dpr = window.devicePixelRatio || 1
renderer.panX = 0
renderer.panY = -2000
renderer.zoom = 1
renderer.viewportWidth = 1280
renderer.viewportHeight = 800
renderer.showRulers = false
renderer.pageColor = store.state.pageColor
renderer.pageId = pageId
}
setupRenderer()
renderer.render(graph, store.state.selectedIds, {}, store.state.sceneVersion)
setupRenderer()
const sceneStart = performance.now()
for (let i = 0; i < iterations; i++) {
const nodeId = shadowIds[i % shadowIds.length]
if (!nodeId) throw new Error('Shadow benchmark node id not found')
const node = graph.getNode(nodeId)
if (node) graph.updateNode(node.id, { x: node.x + 0.1 })
store.state.sceneVersion++
renderer.render(graph, store.state.selectedIds, {}, store.state.sceneVersion)
}
const sceneMs = performance.now() - sceneStart
for (const id of shadowIds) graph.deleteNode(id)
return {
scene: {
ms: Math.round(sceneMs * 100) / 100,
avg: Math.round((sceneMs / iterations) * 100) / 100
}
}
},
{ count: SHADOW_NODES, iterations: SHADOW_ITERS }
)
console.log(
`\n═══ SHADOW BENCHMARK (${SHADOW_NODES} shadow nodes, ${SHADOW_ITERS} iterations) ═══`
)
2026-05-03 18:03:40 +00:00
console.log(` Scene change: ${results.scene.avg}ms/frame (${results.scene.ms}ms total)`)
console.log(`═══════════════════════════════════════════════════════\n`)
expect(results.scene.avg).toBeLessThan(50)
})
2026-03-01 19:32:35 +00:00
})