UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

523 lines (429 loc) • 15.1 kB
import { describe, expect, it, vi } from 'vitest' import { PerformanceManager } from './PerformanceManager' function createMockEditor() { const listeners: Record<string, ((...args: any[]) => void)[]> = {} return { on(event: string, handler: (...args: any[]) => void) { ;(listeners[event] ??= []).push(handler) }, off(event: string, handler: (...args: any[]) => void) { const arr = listeners[event] if (arr) { const idx = arr.indexOf(handler) if (idx !== -1) arr.splice(idx, 1) } }, emit(event: string, ...args: any[]) { for (const handler of listeners[event] ?? []) { handler(...args) } }, getSelectedShapes: () => [ { type: 'geo', id: '1' }, { type: 'geo', id: '2' }, { type: 'draw', id: '3' }, ], getCurrentPageShapeIds: () => ({ size: 10 }), getCulledShapes: () => ({ size: 3 }), getCamera: () => ({ x: 0, y: 0, z: 1 }), getShape: (id: string) => { const shapes: Record<string, { type: string }> = { 'shape:1': { type: 'geo' }, 'shape:2': { type: 'draw' }, 'shape:3': { type: 'geo' }, } return shapes[id] }, getViewportScreenBounds: () => ({ w: 1920, h: 1080 }), timers: { setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), }, _listeners: listeners, } as any } describe('PerformanceManager', () => { describe('listener management', () => { it('returns an unsubscribe function from on()', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() const unsub = pm.on('interaction-end', fn) expect(typeof unsub).toBe('function') unsub() }) it('attaches frame listener lazily when interaction listener subscribes', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) expect(editor._listeners['frame'] ?? []).toHaveLength(0) const unsub = pm.on('interaction-end', vi.fn()) expect(editor._listeners['frame']).toHaveLength(1) unsub() expect(editor._listeners['frame'] ?? []).toHaveLength(0) }) it('attaches shape listeners lazily', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const unsub = pm.on('shapes-created', vi.fn()) expect(editor._listeners['created-shapes']).toHaveLength(1) unsub() expect(editor._listeners['created-shapes'] ?? []).toHaveLength(0) }) it('does not double-attach frame listener', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const unsub1 = pm.on('interaction-end', vi.fn()) const unsub2 = pm.on('camera-end', vi.fn()) expect(editor._listeners['frame']).toHaveLength(1) unsub1() // still one camera-end listener expect(editor._listeners['frame']).toHaveLength(1) unsub2() expect(editor._listeners['frame'] ?? []).toHaveLength(0) }) }) describe('interaction tracking', () => { it('emits interaction-start when notified', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-start', fn) pm._notifyInteractionStart('translating', 'select.translating') expect(fn).toHaveBeenCalledTimes(1) expect(fn.mock.calls[0][0]).toMatchObject({ name: 'translating', path: 'select.translating', }) expect(fn.mock.calls[0][0].timestamp).toBeGreaterThan(0) }) it('emits interaction-end with frame stats', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('resizing', 'select.resizing') // Simulate frames editor.emit('frame', 16) editor.emit('frame', 16) editor.emit('frame', 32) editor.emit('frame', 16) editor.emit('frame', 50) pm._notifyInteractionEnd() expect(fn).toHaveBeenCalledTimes(1) const event = fn.mock.calls[0][0] expect(event.name).toBe('resizing') expect(event.path).toBe('select.resizing') expect(event.frameCount).toBe(5) expect(event.frameTimes).toEqual([16, 16, 32, 16, 50]) expect(event.shapeCount).toBe(10) expect(event.selectedShapeTypes).toEqual({ geo: 2, draw: 1 }) expect(event.avgFrameTime).toBe(26) expect(event.duration).toBeGreaterThan(0) }) it('does not emit when no listeners', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) // Should not throw pm._notifyInteractionStart('resizing', 'select.resizing') pm._notifyInteractionEnd() }) it('handles end without start gracefully', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionEnd() expect(fn).not.toHaveBeenCalled() }) }) describe('camera tracking', () => { it('emits camera-start and camera-end', () => { vi.useFakeTimers() const editor = createMockEditor() // Override timers.setTimeout to use fake timers editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any const pm = new PerformanceManager(editor) const startFn = vi.fn() const endFn = vi.fn() pm.on('camera-start', startFn) pm.on('camera-end', endFn) pm._notifyCameraOperation('panning') expect(startFn).toHaveBeenCalledTimes(1) expect(startFn.mock.calls[0][0].type).toBe('panning') // Simulate frames during camera operation editor.emit('frame', 16) editor.emit('frame', 16) // Wait for debounce timeout vi.advanceTimersByTime(100) expect(endFn).toHaveBeenCalledTimes(1) const event = endFn.mock.calls[0][0] expect(event.type).toBe('panning') expect(event.frameCount).toBe(2) expect(event.viewportWidth).toBe(1920) expect(event.viewportHeight).toBe(1080) vi.useRealTimers() }) it('extends camera session on repeated operations', () => { vi.useFakeTimers() const editor = createMockEditor() editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any const pm = new PerformanceManager(editor) const endFn = vi.fn() pm.on('camera-end', endFn) pm._notifyCameraOperation('panning') editor.emit('frame', 16) vi.advanceTimersByTime(30) pm._notifyCameraOperation('panning') editor.emit('frame', 16) vi.advanceTimersByTime(30) // Should not have ended yet expect(endFn).not.toHaveBeenCalled() vi.advanceTimersByTime(100) expect(endFn).toHaveBeenCalledTimes(1) expect(endFn.mock.calls[0][0].frameCount).toBe(2) vi.useRealTimers() }) it('ends old session and starts new on type change', () => { vi.useFakeTimers() const editor = createMockEditor() editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any const pm = new PerformanceManager(editor) const startFn = vi.fn() const endFn = vi.fn() pm.on('camera-start', startFn) pm.on('camera-end', endFn) pm._notifyCameraOperation('panning') editor.emit('frame', 16) pm._notifyCameraOperation('zooming') // Panning session should have ended expect(endFn).toHaveBeenCalledTimes(1) expect(endFn.mock.calls[0][0].type).toBe('panning') // Zooming session should have started expect(startFn).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(100) expect(endFn).toHaveBeenCalledTimes(2) expect(endFn.mock.calls[1][0].type).toBe('zooming') vi.useRealTimers() }) }) describe('frame time stats', () => { it('computes correct percentiles', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('translating', 'select.translating') // 10 frames: 10, 11, 12, 13, 14, 15, 16, 17, 18, 100 for (let i = 10; i <= 18; i++) editor.emit('frame', i) editor.emit('frame', 100) pm._notifyInteractionEnd() const event = fn.mock.calls[0][0] expect(event.minFrameTime).toBe(10) expect(event.maxFrameTime).toBe(100) expect(event.medianFrameTime).toBe(14) }) it('handles single frame', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('translating', 'select.translating') editor.emit('frame', 42) pm._notifyInteractionEnd() const event = fn.mock.calls[0][0] expect(event.avgFrameTime).toBe(42) expect(event.medianFrameTime).toBe(42) expect(event.p95FrameTime).toBe(42) expect(event.p99FrameTime).toBe(42) expect(event.minFrameTime).toBe(42) expect(event.maxFrameTime).toBe(42) }) it('handles zero frames', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('translating', 'select.translating') pm._notifyInteractionEnd() const event = fn.mock.calls[0][0] expect(event.avgFrameTime).toBe(0) expect(event.frameCount).toBe(0) }) }) describe('undo/redo tracking', () => { it('emits undo events', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('undo', fn) pm._notifyUndoRedo('undo', 5, 2) expect(fn).toHaveBeenCalledWith({ type: 'undo', undoDepth: 5, redoDepth: 2 }) }) it('emits redo events', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('redo', fn) pm._notifyUndoRedo('redo', 4, 3) expect(fn).toHaveBeenCalledWith({ type: 'redo', undoDepth: 4, redoDepth: 3 }) }) it('skips when no listeners', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) // Should not throw pm._notifyUndoRedo('undo', 1, 0) }) }) describe('shape operation tracking', () => { it('emits shapes-created events', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('shapes-created', fn) editor.emit('created-shapes', [ { typeName: 'shape', type: 'geo' }, { typeName: 'shape', type: 'geo' }, { typeName: 'shape', type: 'draw' }, ]) expect(fn).toHaveBeenCalledTimes(1) expect(fn.mock.calls[0][0]).toMatchObject({ operation: 'create', count: 3, shapeTypes: { geo: 2, draw: 1 }, }) }) it('ignores non-shape records', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('shapes-created', fn) editor.emit('created-shapes', [{ typeName: 'page', type: 'page' }]) expect(fn).not.toHaveBeenCalled() }) it('emits shapes-deleted with shape types', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('shapes-deleted', fn) editor.emit('deleted-shapes', ['shape:1', 'shape:2']) expect(fn).toHaveBeenCalledTimes(1) expect(fn.mock.calls[0][0]).toMatchObject({ operation: 'delete', count: 2, shapeTypes: { geo: 1, draw: 1 }, }) }) }) describe('frame event', () => { it('emits frame events with shape counts', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('frame', fn) editor.emit('frame', 16) expect(fn).toHaveBeenCalledWith({ elapsed: 16, shapeCount: 10, culledShapeCount: 3, visibleShapeCount: 7, }) }) }) describe('once()', () => { it('fires callback only once', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.once('interaction-start', fn) pm._notifyInteractionStart('resizing', 'select.resizing') pm._notifyInteractionEnd() pm._notifyInteractionStart('resizing', 'select.resizing') expect(fn).toHaveBeenCalledTimes(1) }) it('detaches frame listener after once fires', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) pm.once('interaction-start', vi.fn()) expect(editor._listeners['frame']).toHaveLength(1) pm._notifyInteractionStart('resizing', 'select.resizing') // frame listener should be detached after once fires expect(editor._listeners['frame'] ?? []).toHaveLength(0) }) }) describe('LoAF integration', () => { it('attaches LoAF entries to interaction-end events', () => { let observerCallback: ((list: any) => void) | null = null const mockDisconnect = vi.fn() const origPO = globalThis.PerformanceObserver globalThis.PerformanceObserver = class MockPO { constructor(cb: (list: any) => void) { observerCallback = cb } observe() {} disconnect = mockDisconnect static supportedEntryTypes = ['long-animation-frame'] } as any const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('resizing', 'select.resizing') editor.emit('frame', 16) // Simulate a LoAF entry observerCallback!({ getEntries: () => [ { startTime: 100, duration: 80, blockingDuration: 60, scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }], }, ], }) editor.emit('frame', 16) pm._notifyInteractionEnd() expect(fn).toHaveBeenCalledTimes(1) const event = fn.mock.calls[0][0] expect(event.longAnimationFrames).toHaveLength(1) expect(event.longAnimationFrames[0]).toEqual({ startTime: 100, duration: 80, blockingDuration: 60, scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }], }) // Cleanup pm.on('interaction-end', vi.fn())() // subscribe + unsub to trigger detach check globalThis.PerformanceObserver = origPO }) it('omits longAnimationFrames when none collected', () => { const editor = createMockEditor() const pm = new PerformanceManager(editor) const fn = vi.fn() pm.on('interaction-end', fn) pm._notifyInteractionStart('resizing', 'select.resizing') editor.emit('frame', 16) pm._notifyInteractionEnd() expect(fn.mock.calls[0][0].longAnimationFrames).toBeUndefined() }) it('starts LoAF observer only when interaction-end or camera-end listeners exist', () => { const origPO = globalThis.PerformanceObserver const mockObserve = vi.fn() globalThis.PerformanceObserver = class MockPO { constructor(_cb: any) {} observe = mockObserve disconnect = vi.fn() static supportedEntryTypes = ['long-animation-frame'] } as any const editor = createMockEditor() const pm = new PerformanceManager(editor) // interaction-start alone should NOT start LoAF observer const unsub1 = pm.on('interaction-start', vi.fn()) expect(mockObserve).not.toHaveBeenCalled() unsub1() // interaction-end should start LoAF observer const unsub2 = pm.on('interaction-end', vi.fn()) expect(mockObserve).toHaveBeenCalledTimes(1) unsub2() globalThis.PerformanceObserver = origPO }) }) })