From 3c43c4b05dc4f0e1c4e4ff97fd1896f85f1374e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltra=CC=81n=20Alarco=CC=81n?= Date: Wed, 21 Aug 2019 23:36:50 +0200 Subject: [PATCH] Add useSpring tests --- src/__stories__/useSpring.story.tsx | 2 +- src/__tests__/useSpring.test.ts | 119 ++++++++++++++++++++++++++++ src/useSpring.ts | 22 +++-- 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/useSpring.test.ts diff --git a/src/__stories__/useSpring.story.tsx b/src/__stories__/useSpring.story.tsx index cc7a2d9a..0727e5d7 100644 --- a/src/__stories__/useSpring.story.tsx +++ b/src/__stories__/useSpring.story.tsx @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useSpring } from '..'; +import useSpring from '../useSpring'; import ShowDocs from './util/ShowDocs'; const Demo = () => { diff --git a/src/__tests__/useSpring.test.ts b/src/__tests__/useSpring.test.ts new file mode 100644 index 00000000..a6a1c926 --- /dev/null +++ b/src/__tests__/useSpring.test.ts @@ -0,0 +1,119 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import useSpring from '../useSpring'; +import { Spring } from 'rebound'; + +// simulate Spring for testing +const mockSetCurrentValue = jest.fn(); +const mockAddListener = jest.fn(); +const mockSetEndValue = jest.fn(); +const mockRemoveListener = jest.fn(); +let triggerSpringUpdate = () => {}; +let springListener: Listener = { onSpringUpdate: () => {} }; + +interface Listener { + onSpringUpdate: (currentSpring: Spring) => void; +} + +const mockCreateSpring: Spring = jest.fn().mockImplementation(() => { + let currentValue = 0; + let endValue = 0; + + const getCloserValue = (a, b) => Math.round((a + b) / 2); + + const getCurrentValue = () => { + currentValue = getCloserValue(currentValue, endValue); + return currentValue; + }; + + triggerSpringUpdate = () => { + if (currentValue !== endValue) { + springListener.onSpringUpdate({ getCurrentValue } as any); + } + }; + + return { + setCurrentValue: val => { + currentValue = val; + mockSetCurrentValue(val); + }, + addListener: newListener => { + springListener = newListener; + mockAddListener(newListener); + }, + setEndValue: val => { + endValue = val; + mockSetEndValue(val); + }, + removeListener: mockRemoveListener, + }; +}) as any; + +jest.mock('rebound', () => { + return { + Sprint: {}, + SpringSystem: jest.fn().mockImplementation(() => { + return { createSpring: mockCreateSpring }; + }), + }; +}); + +it('should init value to provided target', () => { + const { result } = renderHook(() => useSpring(70)); + + expect(result.current).toBe(70); + expect(mockSetCurrentValue).toHaveBeenCalledTimes(1); + expect(mockSetCurrentValue).toHaveBeenCalledWith(70); + expect(mockCreateSpring).toHaveBeenCalledTimes(1); + expect(mockCreateSpring).toHaveBeenCalledWith(50, 3); +}); + +it('should create spring with custom tension and friction args provided', () => { + renderHook(() => useSpring(500, 20, 7)); + + expect(mockCreateSpring).toHaveBeenCalledTimes(1); + expect(mockCreateSpring).toHaveBeenCalledWith(20, 7); +}); + +it('should subscribe only once', () => { + const { rerender } = renderHook(() => useSpring()); + + expect(mockAddListener).toHaveBeenCalledTimes(1); + expect(mockAddListener).toHaveBeenCalledWith(springListener); + + rerender(); + + expect(mockAddListener).toHaveBeenCalledTimes(1); +}); + +it('should handle spring update', () => { + let targetValue = 70; + let lastSpringValue = targetValue; + const { result, rerender } = renderHook(() => useSpring(targetValue)); + + targetValue = 100; + rerender(); + expect(result.current).toBe(lastSpringValue); + + act(() => { + triggerSpringUpdate(); // simulate new spring value + }); + expect(result.current).toBeGreaterThan(lastSpringValue); + expect(result.current).toBeLessThanOrEqual(targetValue); + + lastSpringValue = result.current; + act(() => { + triggerSpringUpdate(); // simulate another new spring value + }); + expect(result.current).toBeGreaterThan(lastSpringValue); + expect(result.current).toBeLessThanOrEqual(targetValue); +}); + +it('should remove listener on unmount', () => { + const { unmount } = renderHook(() => useSpring()); + expect(mockRemoveListener).not.toHaveBeenCalled(); + + unmount(); + + expect(mockRemoveListener).toHaveBeenCalledTimes(1); + expect(mockRemoveListener).toHaveBeenCalledWith(springListener); +}); diff --git a/src/useSpring.ts b/src/useSpring.ts index 3567f2ee..7f4dee2a 100644 --- a/src/useSpring.ts +++ b/src/useSpring.ts @@ -1,31 +1,37 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { Spring, SpringSystem } from 'rebound'; const useSpring = (targetValue: number = 0, tension: number = 50, friction: number = 3) => { const [spring, setSpring] = useState(null); const [value, setValue] = useState(targetValue); - useEffect(() => { - const listener = { + // memoize listener to being able to unsubscribe later properly, otherwise + // listener fn will be different on each re-render and wouldn't unsubscribe properly. + const listener = useMemo( + () => ({ onSpringUpdate: currentSpring => { const newValue = currentSpring.getCurrentValue(); setValue(newValue); }, - }; + }), + [] + ); + useEffect(() => { if (!spring) { const newSpring = new SpringSystem().createSpring(tension, friction); newSpring.setCurrentValue(targetValue); setSpring(newSpring); newSpring.addListener(listener); - return; } return () => { - spring.removeListener(listener); - setSpring(null); + if (spring) { + spring.removeListener(listener); + setSpring(null); + } }; - }, [tension, friction]); + }, [tension, friction, spring]); useEffect(() => { if (spring) {