openpencil/tests/e2e/viewport/zoom-pan.spec.ts
2026-05-15 13:02:41 +03:00

303 lines
11 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
const NODE_COUNT = 200
test.describe('Zoom and pan', () => {
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')
await helper.waitForInit()
await page.evaluate((count: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const cols = Math.ceil(Math.sqrt(count))
for (let i = 0; i < count; i++) {
store.graph.createNode('RECTANGLE', store.state.currentPageId, {
x: (i % cols) * 60,
y: Math.floor(i / cols) * 60,
width: 50,
height: 50,
fills: [
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.8, a: 1 }, visible: true, opacity: 1 }
]
})
}
store.requestRender()
}, NODE_COUNT)
await helper.waitForRender()
})
test.afterAll(async () => {
await helper.page.close()
})
test('wheel zoom updates viewport correctly', async () => {
const before = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { panX: store.state.panX, panY: store.state.panY, zoom: store.state.zoom }
})
// Use Playwright's native mouse.wheel with Control key held for zoom
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
await helper.page.mouse.move(box.x + 400, box.y + 300)
await helper.page.keyboard.down('Control')
await helper.page.mouse.wheel(0, -100)
await helper.page.keyboard.up('Control')
await helper.waitForRender()
await helper.page.waitForTimeout(50)
const after = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { panX: store.state.panX, panY: store.state.panY, zoom: store.state.zoom }
})
expect(after.zoom).not.toBe(before.zoom)
helper.assertNoErrors()
})
test('wheel pan updates viewport correctly', async () => {
const before = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { panX: store.state.panX, panY: store.state.panY }
})
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
await helper.page.mouse.move(box.x + 400, box.y + 300)
// Playwright wheel without ctrl → pan
await helper.page.mouse.wheel(100, 50)
await helper.waitForRender()
await helper.page.waitForTimeout(50)
const after = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { panX: store.state.panX, panY: store.state.panY }
})
expect(after.panX).toBeLessThan(before.panX)
expect(after.panY).toBeLessThan(before.panY)
helper.assertNoErrors()
})
test('rapid wheel events are coalesced without errors', async () => {
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
await helper.page.mouse.move(box.x + 400, box.y + 300)
await helper.page.keyboard.down('Control')
for (let i = 0; i < 50; i++) {
await helper.page.mouse.wheel(0, -5)
}
await helper.page.keyboard.up('Control')
await helper.waitForRender()
await helper.page.waitForTimeout(50)
const state = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return { zoom: store.state.zoom }
})
expect(state.zoom).toBeGreaterThan(0)
expect(state.zoom).toBeLessThan(256)
helper.assertNoErrors()
})
test('shallowReactive: selection replace triggers UI update', async () => {
const result = await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const firstId = pageNode.childIds[0]
if (!firstId) throw new Error('First page child not found')
store.select([firstId])
const after = store.state.selectedIds.has(firstId)
const size = store.state.selectedIds.size
store.clearSelection()
const cleared = store.state.selectedIds.size
return { after, size, cleared }
})
expect(result.after).toBe(true)
expect(result.size).toBe(1)
expect(result.cleared).toBe(0)
})
test('useRafFn loop picks up renderVersion changes', async () => {
// Ensure clean state, wait for any pending renders
await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.state.panX = 0
store.state.panY = 0
store.state.zoom = 1
store.clearSelection()
store.requestRender()
})
await helper.waitForRender()
await helper.page.waitForTimeout(100)
const before = await helper.screenshotCanvas()
// Change fill color — always visible
await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const firstId = pageNode.childIds[0]
if (!firstId) throw new Error('First page child not found')
store.graph.updateNode(firstId, {
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
store.requestRender()
})
await helper.waitForRender()
await helper.page.waitForTimeout(100)
const after = await helper.screenshotCanvas()
expect(Buffer.from(before)).not.toEqual(Buffer.from(after))
// Restore
await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const firstId = pageNode.childIds[0]
if (!firstId) throw new Error('First page child not found')
store.graph.updateNode(firstId, {
fills: [
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.8, a: 1 }, visible: true, opacity: 1 }
]
})
store.requestRender()
})
await helper.waitForRender()
})
test('useRafFn loop picks up selection changes', async () => {
const before = await helper.screenshotCanvas()
await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageNode = store.graph.getNode(store.state.currentPageId)
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
const firstId = pageNode.childIds[0]
if (!firstId) throw new Error('First page child not found')
store.select([firstId])
})
await helper.waitForRender()
await helper.page.waitForTimeout(50)
const after = await helper.screenshotCanvas()
// Selection border should change the rendering
expect(Buffer.from(before)).not.toEqual(Buffer.from(after))
await helper.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
store.clearSelection()
})
await helper.waitForRender()
})
test('benchmark: zoom/pan pipeline throughput', async () => {
const ITERATIONS = 500
const results = await helper.page.evaluate((iterations: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
// Reset viewport
store.state.panX = 0
store.state.panY = 0
store.state.zoom = 1
store.requestRepaint()
// Benchmark: applyZoom calls (store → state mutation → renderVersion bump)
const zoomStart = performance.now()
for (let i = 0; i < iterations; i++) {
store.applyZoom(i % 2 === 0 ? -1 : 1, 400, 300)
}
const zoomMs = performance.now() - zoomStart
// Reset
store.state.zoom = 1
store.state.panX = 0
store.state.panY = 0
// Benchmark: pan calls
const panStart = performance.now()
for (let i = 0; i < iterations; i++) {
store.pan(1, 1)
}
const panMs = performance.now() - panStart
// Benchmark: simulated wheel coalescing — many wheel events in one frame
store.state.zoom = 1
store.state.panX = 0
store.state.panY = 0
const canvas = document.querySelector<HTMLCanvasElement>('[data-ready="1"]')
if (!canvas) throw new Error('Canvas element not found')
const wheelStart = performance.now()
for (let i = 0; i < iterations; i++) {
canvas.dispatchEvent(
new WheelEvent('wheel', {
deltaX: 0,
deltaY: -2,
ctrlKey: true,
clientX: 400,
clientY: 300,
bubbles: true,
cancelable: true
})
)
}
const wheelMs = performance.now() - wheelStart
return {
zoom: {
ms: Math.round(zoomMs * 100) / 100,
avg: Math.round((zoomMs / iterations) * 1000) / 1000
},
pan: {
ms: Math.round(panMs * 100) / 100,
avg: Math.round((panMs / iterations) * 1000) / 1000
},
wheel: {
ms: Math.round(wheelMs * 100) / 100,
avg: Math.round((wheelMs / iterations) * 1000) / 1000
}
}
}, ITERATIONS)
console.log(`\n═══ ZOOM/PAN PIPELINE BENCHMARK (${ITERATIONS} iterations) ═══`)
console.log(` applyZoom: ${results.zoom.avg}ms/call (${results.zoom.ms}ms total)`)
console.log(` pan: ${results.pan.avg}ms/call (${results.pan.ms}ms total)`)
console.log(` wheel events: ${results.wheel.avg}ms/event (${results.wheel.ms}ms total)`)
console.log(`═══════════════════════════════════════════════════════\n`)
// Each call should be sub-millisecond
expect(results.zoom.avg).toBeLessThan(1)
expect(results.pan.avg).toBeLessThan(1)
expect(results.wheel.avg).toBeLessThan(1)
helper.assertNoErrors()
})
})