From 72a0dc97dacc359ed85b1b66d9f62f943730a0b9 Mon Sep 17 00:00:00 2001 From: Mohit ahlawat <65100859+mohitahlawat2001@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:11:47 +0000 Subject: [PATCH] Add comprehensive tests for animation frame schedulers and helper functions - Implement tests for adaptive animation frame scheduler, including single and multiple task scenarios, edge cases, and performance considerations. - Create tests for animation frame by node scheduler with similar coverage, ensuring proper task execution and debouncing behavior. - Develop tests for EMA animation frame scheduler, focusing on task execution, debouncing, and performance tracking. - Introduce a new test helper module with functions to create mock tasks and nodes, wait for animation frames, and mock performance and requestAnimationFrame for consistent testing. --- .../adaptive-animation-frame.test.ts | 310 ++++++++++++++ .../animation-frame-by-node.test.ts | 285 +++++++++++++ src/schedulers/animation-frame.test.ts | 232 +++++++++++ src/schedulers/ema-animation-frame.test.ts | 391 ++++++++++++++++++ src/schedulers/test-helpers.ts | 130 ++++++ 5 files changed, 1348 insertions(+) create mode 100644 src/schedulers/adaptive-animation-frame.test.ts create mode 100644 src/schedulers/animation-frame-by-node.test.ts create mode 100644 src/schedulers/animation-frame.test.ts create mode 100644 src/schedulers/ema-animation-frame.test.ts create mode 100644 src/schedulers/test-helpers.ts diff --git a/src/schedulers/adaptive-animation-frame.test.ts b/src/schedulers/adaptive-animation-frame.test.ts new file mode 100644 index 0000000..a12f7f7 --- /dev/null +++ b/src/schedulers/adaptive-animation-frame.test.ts @@ -0,0 +1,310 @@ +import adaptiveAnimationFrameScheduler from './adaptive-animation-frame'; +import { + createMockTask, + createMockNode, + waitForAnimationFrame, + mockRequestAnimationFrame, + mockPerformanceNow, +} from './test-helpers'; + +// Setup browser APIs for testing +if (typeof global.requestAnimationFrame === 'undefined') { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 16) as unknown as number; + }; +} + +if (typeof global.performance === 'undefined') { + global.performance = { + now: () => Date.now(), + } as Performance; +} + +describe('Adaptive Animation Frame Scheduler', () => { + + describe('Given a single task', () => { + + it('should schedule the task for the next animation frame', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + scheduledTask('arg1'); + + expect(callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + }); + + it('should pass arguments to the task', async () => { + const node = createMockNode(); + const { task, lastCall } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + const args = ['foo', 42, { data: 'bar' }]; + scheduledTask(...args); + + await waitForAnimationFrame(); + + expect(lastCall()).toEqual(args); + }); + + }); + + describe('Given multiple tasks on the same node', () => { + + it('should debounce tasks and execute only the latest', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + scheduledTask('first'); + scheduledTask('second'); + scheduledTask('third'); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual(['third']); + }); + + }); + + describe('Given multiple tasks on different nodes', () => { + + it('should execute one task per node', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + const node3 = createMockNode('node3'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + const task3 = createMockTask('task3'); + + const scheduled1 = adaptiveAnimationFrameScheduler(node1, task1.task); + const scheduled2 = adaptiveAnimationFrameScheduler(node2, task2.task); + const scheduled3 = adaptiveAnimationFrameScheduler(node3, task3.task); + + scheduled1('a'); + scheduled2('b'); + scheduled3('c'); + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task2.callCount()).toBe(1); + expect(task3.callCount()).toBe(1); + }); + + }); + + describe('Given the adaptive time-slicing behavior', () => { + + it('should execute all tasks when they fit within the frame budget', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + adaptiveAnimationFrameScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + await waitForAnimationFrame(); + + // All tasks should execute when they fit within budget + expect(tasks.every(t => t.callCount() === 1)).toBe(true); + }); + + it('should handle tasks that might exceed frame budget gracefully', async () => { + const nodes = Array.from({ length: 20 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + adaptiveAnimationFrameScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + // Wait for first frame + await waitForAnimationFrame(); + + // The scheduler adapts - it may process all or defer some + // At minimum, some tasks should execute + const executedCount = tasks.filter(t => t.callCount() > 0).length; + expect(executedCount).toBeGreaterThan(0); + + // If not all executed, wait for next frame + if (executedCount < tasks.length) { + await waitForAnimationFrame(); + } + + // Eventually all should execute + expect(tasks.every(t => t.callCount() >= 1)).toBe(true); + }); + + it('should adapt to varying task counts', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + // First frame - single task + scheduledTask('frame1'); + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + + // Second frame - multiple rapid calls (debounced to 1) + for (let i = 0; i < 10; i++) { + scheduledTask(`frame2-${i}`); + } + await waitForAnimationFrame(); + expect(callCount()).toBe(2); // Total of 2 executions + }); + + }); + + describe('Given the average task time calculation', () => { + + it('should adapt to task execution times', async () => { + const perfMock = mockPerformanceNow(); + const rafMock = mockRequestAnimationFrame(); + + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = adaptiveAnimationFrameScheduler(node1, task1.task); + const scheduled2 = adaptiveAnimationFrameScheduler(node2, task2.task); + + // First batch - establish baseline + scheduled1('first'); + perfMock.advance(0); + rafMock.flush(perfMock.time); + perfMock.advance(2); // Simulate 2ms execution + + // Second batch - should use learned average + scheduled1('second'); + scheduled2('second'); + perfMock.advance(0); + rafMock.flush(perfMock.time); + + perfMock.restore(); + rafMock.restore(); + }); + + }); + + describe('Given tasks scheduled across multiple frames', () => { + + it('should continue execution in subsequent frames', async () => { + const node = createMockNode(); + const { task, callCount, calls } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + scheduledTask('frame1'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + expect(calls[0]).toEqual(['frame1']); + + scheduledTask('frame2'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + expect(calls[1]).toEqual(['frame2']); + }); + + }); + + describe('Given the requestAnimationFrame implementation', () => { + + it('should debounce updates for the same node', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + // Schedule multiple updates rapidly + scheduledTask('update1'); + scheduledTask('update2'); + scheduledTask('update3'); + + await waitForAnimationFrame(); + + // Should execute only once (debounced) + expect(callCount()).toBe(1); + }); + + }); + + describe('Given edge cases', () => { + + it('should handle tasks with no arguments', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + scheduledTask(); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([]); + }); + + it('should handle rapid successive scheduling on the same node', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + for (let i = 0; i < 100; i++) { + scheduledTask(i); + } + + await waitForAnimationFrame(); + + // Should debounce to only the last value + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([99]); + }); + + it('should clear completed tasks from queue', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = adaptiveAnimationFrameScheduler(node, task); + + scheduledTask('call1'); + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + + // Second batch should work independently + scheduledTask('call2'); + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + }); + + it('should handle different nodes independently', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = adaptiveAnimationFrameScheduler(node1, task1.task); + const scheduled2 = adaptiveAnimationFrameScheduler(node2, task2.task); + + scheduled1('a'); + scheduled2('b'); + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task2.callCount()).toBe(1); + expect(task1.lastCall()).toEqual(['a']); + expect(task2.lastCall()).toEqual(['b']); + }); + + }); + +}); diff --git a/src/schedulers/animation-frame-by-node.test.ts b/src/schedulers/animation-frame-by-node.test.ts new file mode 100644 index 0000000..879beb7 --- /dev/null +++ b/src/schedulers/animation-frame-by-node.test.ts @@ -0,0 +1,285 @@ +import animationFrameByNodeScheduler from './animation-frame-by-node'; +import { + createMockTask, + createMockNode, + waitForAnimationFrame, + mockRequestAnimationFrame, +} from './test-helpers'; + +// Setup browser APIs for testing +if (typeof global.requestAnimationFrame === 'undefined') { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 16) as unknown as number; + }; +} + +if (typeof global.performance === 'undefined') { + global.performance = { + now: () => Date.now(), + } as Performance; +} + +describe('Animation Frame By Node Scheduler', () => { + + describe('Given a single task on a node', () => { + + it('should schedule the task for the next animation frame', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask('arg1'); + + expect(callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + }); + + it('should pass arguments to the task', async () => { + const node = createMockNode(); + const { task, lastCall } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + const args = ['foo', 42, { data: 'bar' }]; + scheduledTask(...args); + + await waitForAnimationFrame(); + + expect(lastCall()).toEqual(args); + }); + + }); + + describe('Given multiple tasks on the same node', () => { + + it('should debounce tasks and execute only the latest', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask('first'); + scheduledTask('second'); + scheduledTask('third'); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual(['third']); + }); + + it('should override previous scheduled tasks for the same node', async () => { + const node = createMockNode(); + const { task, calls } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask({ value: 1 }); + scheduledTask({ value: 2 }); + scheduledTask({ value: 3 }); + + await waitForAnimationFrame(); + + expect(calls.length).toBe(1); + expect(calls[0]).toEqual([{ value: 3 }]); + }); + + }); + + describe('Given multiple tasks on different nodes', () => { + + it('should execute one task per node', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + const node3 = createMockNode('node3'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + const task3 = createMockTask('task3'); + + const scheduled1 = animationFrameByNodeScheduler(node1, task1.task); + const scheduled2 = animationFrameByNodeScheduler(node2, task2.task); + const scheduled3 = animationFrameByNodeScheduler(node3, task3.task); + + scheduled1('a1'); + scheduled1('a2'); // Should override a1 + scheduled2('b1'); + scheduled3('c1'); + scheduled3('c2'); // Should override c1 + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task1.lastCall()).toEqual(['a2']); + expect(task2.callCount()).toBe(1); + expect(task2.lastCall()).toEqual(['b1']); + expect(task3.callCount()).toBe(1); + expect(task3.lastCall()).toEqual(['c2']); + }); + + it('should batch all node tasks into a single animation frame', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + animationFrameByNodeScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + expect(tasks.every(t => t.callCount() === 0)).toBe(true); + + await waitForAnimationFrame(); + + expect(tasks.every(t => t.callCount() === 1)).toBe(true); + }); + + }); + + describe('Given tasks scheduled across multiple frames', () => { + + it('should execute debounced tasks in separate animation frames', async () => { + const node = createMockNode(); + const { task, callCount, calls } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask('frame1-call1'); + scheduledTask('frame1-call2'); + scheduledTask('frame1-call3'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + expect(calls[0]).toEqual(['frame1-call3']); + + scheduledTask('frame2-call1'); + scheduledTask('frame2-call2'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + expect(calls[1]).toEqual(['frame2-call2']); + }); + + }); + + describe('Given the requestAnimationFrame implementation', () => { + + it('should request animation frame only once per batch', () => { + const rafMock = mockRequestAnimationFrame(); + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + const { task } = createMockTask(); + + const scheduled1 = animationFrameByNodeScheduler(node1, task); + const scheduled2 = animationFrameByNodeScheduler(node2, task); + + scheduled1('call1'); + scheduled2('call2'); + scheduled1('call3'); + + expect(rafMock.pendingCount).toBe(1); + + rafMock.flush(); + rafMock.restore(); + }); + + it('should not request animation frame if queue is already scheduled', () => { + const rafMock = mockRequestAnimationFrame(); + const node = createMockNode(); + const { task } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask('call1'); + const firstCallCount = rafMock.pendingCount; + + scheduledTask('call2'); + scheduledTask('call3'); + + expect(rafMock.pendingCount).toBe(firstCallCount); + + rafMock.flush(); + rafMock.restore(); + }); + + }); + + describe('Given the debouncing behavior', () => { + + it('should maintain separate state for different nodes', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = animationFrameByNodeScheduler(node1, task1.task); + const scheduled2 = animationFrameByNodeScheduler(node2, task2.task); + + scheduled1('node1-first'); + scheduled2('node2-first'); + scheduled1('node1-second'); + scheduled2('node2-second'); + scheduled1('node1-third'); + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task1.lastCall()).toEqual(['node1-third']); + expect(task2.callCount()).toBe(1); + expect(task2.lastCall()).toEqual(['node2-second']); + }); + + it('should allow different tasks for the same node', async () => { + const node = createMockNode(); + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = animationFrameByNodeScheduler(node, task1.task); + const scheduled2 = animationFrameByNodeScheduler(node, task2.task); + + scheduled1('task1-data'); + scheduled2('task2-data'); + + await waitForAnimationFrame(); + + // The second task should override the first for the same node + expect(task1.callCount()).toBe(0); + expect(task2.callCount()).toBe(1); + expect(task2.lastCall()).toEqual(['task2-data']); + }); + + }); + + describe('Given edge cases', () => { + + it('should handle tasks with no arguments', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask(); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([]); + }); + + it('should clear queue after execution', async () => { + const rafMock = mockRequestAnimationFrame(); + const node = createMockNode(); + const { task } = createMockTask(); + const scheduledTask = animationFrameByNodeScheduler(node, task); + + scheduledTask('call1'); + rafMock.flush(); + + // Second batch should request a new animation frame + scheduledTask('call2'); + expect(rafMock.pendingCount).toBe(1); + + rafMock.flush(); + rafMock.restore(); + }); + + }); + +}); diff --git a/src/schedulers/animation-frame.test.ts b/src/schedulers/animation-frame.test.ts new file mode 100644 index 0000000..e68a5b1 --- /dev/null +++ b/src/schedulers/animation-frame.test.ts @@ -0,0 +1,232 @@ +import animationFrameScheduler from './animation-frame'; +import { + createMockTask, + createMockNode, + waitForAnimationFrame, + waitForAnimationFrames, + mockRequestAnimationFrame, +} from './test-helpers'; + +// Setup browser APIs for testing +if (typeof global.requestAnimationFrame === 'undefined') { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 16) as unknown as number; + }; +} + +if (typeof global.performance === 'undefined') { + global.performance = { + now: () => Date.now(), + } as Performance; +} + +describe('Animation Frame Scheduler', () => { + + describe('Given a single task', () => { + + it('should schedule the task for the next animation frame', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('arg1', 'arg2'); + + expect(callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + }); + + it('should pass arguments to the task', async () => { + const node = createMockNode(); + const { task, lastCall } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + const args = ['foo', 42, { data: 'bar' }]; + scheduledTask(...args); + + await waitForAnimationFrame(); + + expect(lastCall()).toEqual(args); + }); + + }); + + describe('Given multiple tasks on the same node', () => { + + it('should batch all tasks into a single animation frame', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('call1'); + scheduledTask('call2'); + scheduledTask('call3'); + + expect(callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(3); + }); + + it('should execute all tasks in the order they were scheduled', async () => { + const node = createMockNode(); + const { task, calls } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('first'); + scheduledTask('second'); + scheduledTask('third'); + + await waitForAnimationFrame(); + + expect(calls[0]).toEqual(['first']); + expect(calls[1]).toEqual(['second']); + expect(calls[2]).toEqual(['third']); + }); + + }); + + describe('Given multiple tasks on different nodes', () => { + + it('should execute all tasks in the same animation frame', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + const node3 = createMockNode('node3'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + const task3 = createMockTask('task3'); + + const scheduled1 = animationFrameScheduler(node1, task1.task); + const scheduled2 = animationFrameScheduler(node2, task2.task); + const scheduled3 = animationFrameScheduler(node3, task3.task); + + scheduled1('a'); + scheduled2('b'); + scheduled3('c'); + + expect(task1.callCount()).toBe(0); + expect(task2.callCount()).toBe(0); + expect(task3.callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task2.callCount()).toBe(1); + expect(task3.callCount()).toBe(1); + }); + + }); + + describe('Given tasks scheduled across multiple frames', () => { + + it('should execute tasks in separate animation frames', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('frame1-call1'); + scheduledTask('frame1-call2'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + + scheduledTask('frame2-call1'); + scheduledTask('frame2-call2'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(4); + }); + + }); + + describe('Given the requestAnimationFrame implementation', () => { + + it('should request animation frame only once per batch', async () => { + const rafMock = mockRequestAnimationFrame(); + const node = createMockNode(); + const { task } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('call1'); + scheduledTask('call2'); + scheduledTask('call3'); + + expect(rafMock.pendingCount).toBe(1); + + rafMock.flush(); + rafMock.restore(); + }); + + it('should not request animation frame if queue is already scheduled', () => { + const rafMock = mockRequestAnimationFrame(); + const node = createMockNode(); + const { task } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('call1'); + const firstCallCount = rafMock.pendingCount; + + scheduledTask('call2'); + scheduledTask('call3'); + + expect(rafMock.pendingCount).toBe(firstCallCount); + + rafMock.flush(); + rafMock.restore(); + }); + + }); + + describe('Given edge cases', () => { + + it('should handle tasks with no arguments', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask(); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([]); + }); + + it('should handle rapid successive scheduling', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + for (let i = 0; i < 100; i++) { + scheduledTask(i); + } + + await waitForAnimationFrame(); + + expect(callCount()).toBe(100); + }); + + it('should clear queue after execution', async () => { + const rafMock = mockRequestAnimationFrame(); + const node = createMockNode(); + const { task } = createMockTask(); + const scheduledTask = animationFrameScheduler(node, task); + + scheduledTask('call1'); + rafMock.flush(); + + // Second batch should request a new animation frame + scheduledTask('call2'); + expect(rafMock.pendingCount).toBe(1); + + rafMock.flush(); + rafMock.restore(); + }); + + }); + +}); diff --git a/src/schedulers/ema-animation-frame.test.ts b/src/schedulers/ema-animation-frame.test.ts new file mode 100644 index 0000000..3d8045c --- /dev/null +++ b/src/schedulers/ema-animation-frame.test.ts @@ -0,0 +1,391 @@ +import emaAnimationFrameScheduler from './ema-animation-frame'; +import { + createMockTask, + createMockNode, + waitForAnimationFrame, + mockRequestAnimationFrame, + mockPerformanceNow, +} from './test-helpers'; + +// Setup browser APIs for testing +if (typeof global.requestAnimationFrame === 'undefined') { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 16) as unknown as number; + }; +} + +if (typeof global.performance === 'undefined') { + global.performance = { + now: () => Date.now(), + } as Performance; +} + +describe('EMA Animation Frame Scheduler', () => { + + describe('Given a single task', () => { + + it('should schedule the task for the next animation frame', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask('arg1'); + + expect(callCount()).toBe(0); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + }); + + it('should pass arguments to the task', async () => { + const node = createMockNode(); + const { task, lastCall } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + const args = ['foo', 42, { data: 'bar' }]; + scheduledTask(...args); + + await waitForAnimationFrame(); + + expect(lastCall()).toEqual(args); + }); + + }); + + describe('Given multiple tasks on the same node', () => { + + it('should debounce tasks and execute only the latest', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask('first'); + scheduledTask('second'); + scheduledTask('third'); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual(['third']); + }); + + it('should update the task in the queue for the same node', async () => { + const node = createMockNode(); + const { task, calls } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask({ value: 1 }); + scheduledTask({ value: 2 }); + scheduledTask({ value: 3 }); + + await waitForAnimationFrame(); + + expect(calls.length).toBe(1); + expect(calls[0]).toEqual([{ value: 3 }]); + }); + + }); + + describe('Given multiple tasks on different nodes', () => { + + it('should execute one task per node', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + const node3 = createMockNode('node3'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + const task3 = createMockTask('task3'); + + const scheduled1 = emaAnimationFrameScheduler(node1, task1.task); + const scheduled2 = emaAnimationFrameScheduler(node2, task2.task); + const scheduled3 = emaAnimationFrameScheduler(node3, task3.task); + + scheduled1('a1'); + scheduled1('a2'); // Should override a1 + scheduled2('b1'); + scheduled3('c1'); + scheduled3('c2'); // Should override c1 + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task1.lastCall()).toEqual(['a2']); + expect(task2.callCount()).toBe(1); + expect(task2.lastCall()).toEqual(['b1']); + expect(task3.callCount()).toBe(1); + expect(task3.lastCall()).toEqual(['c2']); + }); + + it('should batch all node tasks into a single animation frame when possible', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + emaAnimationFrameScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + expect(tasks.every(t => t.callCount() === 0)).toBe(true); + + await waitForAnimationFrame(); + + expect(tasks.every(t => t.callCount() === 1)).toBe(true); + }); + + }); + + describe('Given the EMA (Exponential Moving Average) calculation', () => { + + it('should use EMA to track average task execution time', async () => { + const perfMock = mockPerformanceNow(); + const rafMock = mockRequestAnimationFrame(); + + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = emaAnimationFrameScheduler(node1, task1.task); + const scheduled2 = emaAnimationFrameScheduler(node2, task2.task); + + // First execution - establish initial average + scheduled1('first'); + perfMock.advance(0); + rafMock.flush(perfMock.time); + perfMock.advance(2); // Simulate 2ms execution + + // Second execution - EMA should be updated + scheduled2('second'); + perfMock.advance(0); + rafMock.flush(perfMock.time); + perfMock.advance(4); // Simulate 4ms execution + + // The EMA should be between 2 and 4, weighted by alpha (0.8) + // EMA = 0.8 * 4 + 0.2 * 2 = 3.2 + 0.4 = 3.6 + + perfMock.restore(); + rafMock.restore(); + }); + + }); + + describe('Given the time-slicing behavior', () => { + + it('should execute all tasks when they fit within the frame budget', async () => { + const nodes = Array.from({ length: 5 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + emaAnimationFrameScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + await waitForAnimationFrame(); + + // All tasks should execute when they fit within budget + expect(tasks.every(t => t.callCount() === 1)).toBe(true); + }); + + it('should handle large numbers of tasks gracefully', async () => { + const nodes = Array.from({ length: 20 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + emaAnimationFrameScheduler(node, tasks[i].task) + ); + + scheduled.forEach((s, i) => s(`data${i}`)); + + // Wait for first frame + await waitForAnimationFrame(); + + // At least some tasks should execute + const executedCount = tasks.filter(t => t.callCount() > 0).length; + expect(executedCount).toBeGreaterThan(0); + + // If not all executed, wait for next frame + if (executedCount < tasks.length) { + await waitForAnimationFrame(); + } + + // Eventually all should execute + expect(tasks.every(t => t.callCount() >= 1)).toBe(true); + }); + + it('should adapt to varying workloads', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + // First frame - single task + scheduledTask('frame1'); + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + + // Second frame - multiple rapid updates (debounced) + for (let i = 0; i < 10; i++) { + scheduledTask(`frame2-${i}`); + } + await waitForAnimationFrame(); + expect(callCount()).toBe(2); // Total of 2 executions + }); + + }); + + describe('Given tasks scheduled across multiple frames', () => { + + it('should execute debounced tasks in separate animation frames', async () => { + const node = createMockNode(); + const { task, callCount, calls } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask('frame1-call1'); + scheduledTask('frame1-call2'); + scheduledTask('frame1-call3'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + expect(calls[0]).toEqual(['frame1-call3']); + + scheduledTask('frame2-call1'); + scheduledTask('frame2-call2'); + + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + expect(calls[1]).toEqual(['frame2-call2']); + }); + + }); + + describe('Given the requestAnimationFrame implementation', () => { + + it('should debounce multiple updates to the same node', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + // Schedule multiple updates rapidly + scheduledTask('update1'); + scheduledTask('update2'); + scheduledTask('update3'); + + await waitForAnimationFrame(); + + // Should execute only once (debounced) + expect(callCount()).toBe(1); + }); + + it('should handle successive batches independently', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask('batch1'); + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + + scheduledTask('batch2'); + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + }); + + }); + + describe('Given the debouncing behavior', () => { + + it('should maintain separate state for different nodes', async () => { + const node1 = createMockNode('node1'); + const node2 = createMockNode('node2'); + + const task1 = createMockTask('task1'); + const task2 = createMockTask('task2'); + + const scheduled1 = emaAnimationFrameScheduler(node1, task1.task); + const scheduled2 = emaAnimationFrameScheduler(node2, task2.task); + + scheduled1('node1-first'); + scheduled2('node2-first'); + scheduled1('node1-second'); + scheduled2('node2-second'); + scheduled1('node1-third'); + + await waitForAnimationFrame(); + + expect(task1.callCount()).toBe(1); + expect(task1.lastCall()).toEqual(['node1-third']); + expect(task2.callCount()).toBe(1); + expect(task2.lastCall()).toEqual(['node2-second']); + }); + + }); + + describe('Given edge cases', () => { + + it('should handle tasks with no arguments', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask(); + + await waitForAnimationFrame(); + + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([]); + }); + + it('should handle rapid successive scheduling on the same node', async () => { + const node = createMockNode(); + const { task, callCount, lastCall } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + for (let i = 0; i < 100; i++) { + scheduledTask(i); + } + + await waitForAnimationFrame(); + + // Should debounce to only the last value + expect(callCount()).toBe(1); + expect(lastCall()).toEqual([99]); + }); + + it('should clear completed tasks from queue', async () => { + const node = createMockNode(); + const { task, callCount } = createMockTask(); + const scheduledTask = emaAnimationFrameScheduler(node, task); + + scheduledTask('call1'); + await waitForAnimationFrame(); + expect(callCount()).toBe(1); + + // Second batch should work independently + scheduledTask('call2'); + await waitForAnimationFrame(); + expect(callCount()).toBe(2); + }); + + it('should handle multiple nodes in parallel', async () => { + const nodes = Array.from({ length: 3 }, (_, i) => createMockNode(`node${i}`)); + const tasks = nodes.map((_, i) => createMockTask(`task${i}`)); + const scheduled = nodes.map((node, i) => + emaAnimationFrameScheduler(node, tasks[i].task) + ); + + // Schedule tasks on all nodes + scheduled.forEach((s, i) => s(`data${i}`)); + + await waitForAnimationFrame(); + + // All should execute + expect(tasks.every(t => t.callCount() === 1)).toBe(true); + tasks.forEach((t, i) => { + expect(t.lastCall()).toEqual([`data${i}`]); + }); + }); + + }); + +}); \ No newline at end of file diff --git a/src/schedulers/test-helpers.ts b/src/schedulers/test-helpers.ts new file mode 100644 index 0000000..93b22ec --- /dev/null +++ b/src/schedulers/test-helpers.ts @@ -0,0 +1,130 @@ +/** + * Test helper utilities for scheduler tests + */ + +import type { RenderingTask } from '../types/schedulers'; +import { MockElement } from '../test-support'; + +/** + * Creates a mock rendering task that tracks execution + */ +export const createMockTask = (name = 'task') => { + const calls: any[][] = []; + const task: RenderingTask = (...args: any[]) => { + calls.push(args); + }; + + return { + task, + calls, + callCount: () => calls.length, + lastCall: () => calls[calls.length - 1], + nthCall: (n: number) => calls[n], + reset: () => calls.length = 0, + }; +}; + +/** + * Creates a mock node for testing + */ +export const createMockNode = (name = 'node') => { + return MockElement({ id: name }); +}; + +/** + * Waits for the next animation frame + */ +export const waitForAnimationFrame = (): Promise => { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +}; + +/** + * Waits for multiple animation frames + */ +export const waitForAnimationFrames = async (count: number): Promise => { + for (let i = 0; i < count; i++) { + await waitForAnimationFrame(); + } +}; + +/** + * Waits for a specified time in milliseconds + */ +export const wait = (ms: number): Promise => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +/** + * Flushes all pending microtasks and animation frames + */ +export const flushScheduler = async (): Promise => { + await waitForAnimationFrame(); + await wait(0); // Flush microtasks +}; + +/** + * Mock performance.now() for deterministic testing + */ +export const mockPerformanceNow = () => { + let time = 0; + const original = performance.now; + + const advance = (ms: number) => { + time += ms; + }; + + const reset = () => { + time = 0; + }; + + const restore = () => { + performance.now = original; + }; + + // Simple mock without jest.fn() for Bun compatibility + performance.now = (() => time) as typeof performance.now; + + return { + advance, + reset, + restore, + get time() { + return time; + }, + }; +}; + +/** + * Mock requestAnimationFrame for testing + */ +export const mockRequestAnimationFrame = () => { + const callbacks: FrameRequestCallback[] = []; + let frameId = 0; + const original = global.requestAnimationFrame; + + // Use a simple mock function (Bun compatible) + global.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callbacks.push(callback); + return ++frameId; + }) as typeof requestAnimationFrame; + + const flush = (time = performance.now()) => { + const toExecute = [...callbacks]; + callbacks.length = 0; + toExecute.forEach(cb => cb(time)); + }; + + const restore = () => { + global.requestAnimationFrame = original; + }; + + return { + flush, + restore, + get pendingCount() { + return callbacks.length; + }, + }; +};