diff --git a/tests/engine/vue/canvas/render-loop.test.ts b/tests/engine/vue/canvas/render-loop.test.ts new file mode 100644 index 000000000..269d4229a --- /dev/null +++ b/tests/engine/vue/canvas/render-loop.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' + +import { createCanvasRenderLoop } from '#vue/canvas/surface/render-loop' + +import type { Editor, EditorEvents } from '@open-pencil/core/editor' + +type EditorEventName = keyof EditorEvents + +type TestEditor = Pick + +function createFrameScheduler() { + let nextId = 1 + const callbacks = new Map() + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + const id = nextId++ + callbacks.set(id, callback) + return id + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = ((id: number) => { + callbacks.delete(id) + }) as typeof cancelAnimationFrame + + return { + get pendingCount() { + return callbacks.size + }, + flush() { + const pending = [...callbacks] + callbacks.clear() + for (const [id, callback] of pending) callback(id) + }, + restore() { + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + } +} + +function createEditor() { + const handlers = new Map void>>() + const editor: TestEditor = { + state: { + loading: false, + renderVersion: 0, + selectedIds: new Set() + } as Editor['state'], + onEditorEvent(event, handler) { + const listeners = handlers.get(event) ?? new Set() + listeners.add(handler as (...args: never[]) => void) + handlers.set(event, listeners) + return () => listeners.delete(handler as (...args: never[]) => void) + } + } + + return { + editor: editor as Editor, + emit(event: EditorEventName) { + for (const handler of handlers.get(event) ?? []) handler() + } + } +} + +describe('canvas render loop', () => { + test('waits for editor events before scheduling renders', () => { + const scheduler = createFrameScheduler() + try { + const { editor, emit } = createEditor() + let renders = 0 + createCanvasRenderLoop(editor, () => { + renders++ + }) + + expect(scheduler.pendingCount).toBe(0) + emit('repaint:requested') + expect(scheduler.pendingCount).toBe(1) + scheduler.flush() + expect(renders).toBe(1) + expect(scheduler.pendingCount).toBe(0) + } finally { + scheduler.restore() + } + }) + + test('coalesces multiple editor events into one animation frame', () => { + const scheduler = createFrameScheduler() + try { + const { editor, emit } = createEditor() + let renders = 0 + createCanvasRenderLoop(editor, () => { + renders++ + }) + + emit('render:requested') + emit('repaint:requested') + emit('selection:changed') + emit('viewport:changed') + + expect(scheduler.pendingCount).toBe(1) + scheduler.flush() + expect(renders).toBe(1) + } finally { + scheduler.restore() + } + }) + + test('cancels pending renders when paused', () => { + const scheduler = createFrameScheduler() + try { + const { editor, emit } = createEditor() + let renders = 0 + const loop = createCanvasRenderLoop(editor, () => { + renders++ + }) + + emit('render:requested') + expect(scheduler.pendingCount).toBe(1) + loop.pause() + expect(scheduler.pendingCount).toBe(0) + + emit('render:requested') + scheduler.flush() + expect(renders).toBe(0) + } finally { + scheduler.restore() + } + }) +})