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&no-rulers') 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('[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() }) })