2026-03-02 18:36:18 +00:00
|
|
|
import { test, expect } from '@playwright/test'
|
2026-05-03 18:03:40 +00:00
|
|
|
|
2026-05-06 07:16:24 +00:00
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
2026-05-05 23:22:08 +00:00
|
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
|
|
|
|
2026-03-02 18:36:18 +00:00
|
|
|
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)
|
2026-05-16 12:36:37 +00:00
|
|
|
await page.goto('http://localhost:1420/?test&no-chrome&no-rulers')
|
2026-03-02 18:36:18 +00:00
|
|
|
await helper.waitForInit()
|
|
|
|
|
|
|
|
|
|
await page.evaluate((count: number) => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
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,
|
2026-05-03 18:03:40 +00:00
|
|
|
fills: [
|
|
|
|
|
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.8, a: 1 }, visible: true, opacity: 1 }
|
|
|
|
|
]
|
2026-03-02 18:36:18 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
return { panX: store.state.panX, panY: store.state.panY, zoom: store.state.zoom }
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 11:58:02 +00:00
|
|
|
// Use Playwright's native mouse.wheel with Control key held for zoom
|
2026-05-06 07:16:24 +00:00
|
|
|
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
|
|
|
|
|
await helper.page.mouse.move(box.x + 400, box.y + 300)
|
2026-05-05 11:58:02 +00:00
|
|
|
await helper.page.keyboard.down('Control')
|
|
|
|
|
await helper.page.mouse.wheel(0, -100)
|
|
|
|
|
await helper.page.keyboard.up('Control')
|
2026-03-02 18:36:18 +00:00
|
|
|
await helper.waitForRender()
|
|
|
|
|
await helper.page.waitForTimeout(50)
|
|
|
|
|
|
|
|
|
|
const after = await helper.page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
return { panX: store.state.panX, panY: store.state.panY }
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-06 07:16:24 +00:00
|
|
|
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
|
|
|
|
|
await helper.page.mouse.move(box.x + 400, box.y + 300)
|
2026-03-02 18:36:18 +00:00
|
|
|
|
|
|
|
|
// Playwright wheel without ctrl → pan
|
2026-05-05 11:58:02 +00:00
|
|
|
await helper.page.mouse.wheel(100, 50)
|
2026-03-02 18:36:18 +00:00
|
|
|
await helper.waitForRender()
|
|
|
|
|
await helper.page.waitForTimeout(50)
|
|
|
|
|
|
|
|
|
|
const after = await helper.page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
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 () => {
|
2026-05-06 07:16:24 +00:00
|
|
|
const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
|
|
|
|
|
await helper.page.mouse.move(box.x + 400, box.y + 300)
|
2026-05-05 11:58:02 +00:00
|
|
|
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')
|
2026-03-02 18:36:18 +00:00
|
|
|
await helper.waitForRender()
|
|
|
|
|
await helper.page.waitForTimeout(50)
|
|
|
|
|
|
|
|
|
|
const state = await helper.page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-05-06 07:16:24 +00:00
|
|
|
const pageNode = store.graph.getNode(store.state.currentPageId)
|
|
|
|
|
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
|
2026-03-02 18:36:18 +00:00
|
|
|
const firstId = pageNode.childIds[0]
|
2026-05-06 07:16:24 +00:00
|
|
|
if (!firstId) throw new Error('First page child not found')
|
2026-03-02 18:36:18 +00:00
|
|
|
|
|
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-05-06 07:16:24 +00:00
|
|
|
const pageNode = store.graph.getNode(store.state.currentPageId)
|
|
|
|
|
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
|
2026-03-02 18:36:18 +00:00
|
|
|
const firstId = pageNode.childIds[0]
|
2026-05-06 07:16:24 +00:00
|
|
|
if (!firstId) throw new Error('First page child not found')
|
2026-03-02 18:36:18 +00:00
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-05-06 07:16:24 +00:00
|
|
|
const pageNode = store.graph.getNode(store.state.currentPageId)
|
|
|
|
|
if (!pageNode) throw new Error(`Page ${store.state.currentPageId} not found`)
|
2026-03-02 18:36:18 +00:00
|
|
|
const firstId = pageNode.childIds[0]
|
2026-05-06 07:16:24 +00:00
|
|
|
if (!firstId) throw new Error('First page child not found')
|
2026-03-02 18:36:18 +00:00
|
|
|
store.graph.updateNode(firstId, {
|
2026-05-03 18:03:40 +00:00
|
|
|
fills: [
|
|
|
|
|
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.8, a: 1 }, visible: true, opacity: 1 }
|
|
|
|
|
]
|
2026-03-02 18:36:18 +00:00
|
|
|
})
|
|
|
|
|
store.requestRender()
|
|
|
|
|
})
|
|
|
|
|
await helper.waitForRender()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('useRafFn loop picks up selection changes', async () => {
|
|
|
|
|
const before = await helper.screenshotCanvas()
|
|
|
|
|
|
|
|
|
|
await helper.page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-05-06 07:16:24 +00:00
|
|
|
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])
|
2026-03-02 18:36:18 +00:00
|
|
|
})
|
|
|
|
|
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(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-06 07:16:24 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
store.clearSelection()
|
2026-03-02 18:36:18 +00:00
|
|
|
})
|
|
|
|
|
await helper.waitForRender()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('benchmark: zoom/pan pipeline throughput', async () => {
|
|
|
|
|
const ITERATIONS = 500
|
|
|
|
|
|
|
|
|
|
const results = await helper.page.evaluate((iterations: number) => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-02 18:36:18 +00:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-05-15 10:02:41 +00:00
|
|
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-ready="1"]')
|
2026-05-06 07:16:24 +00:00
|
|
|
if (!canvas) throw new Error('Canvas element not found')
|
2026-03-02 18:36:18 +00:00
|
|
|
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 {
|
2026-05-03 18:03:40 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-02 18:36:18 +00:00
|
|
|
}
|
|
|
|
|
}, 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()
|
|
|
|
|
})
|
|
|
|
|
})
|