fix: correct useSpring() hook behaviour

Add tests for "Animation" block.
This commit is contained in:
Vadim Dalecky 2019-08-23 20:54:14 +02:00 committed by GitHub
commit d7a622d7cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 508 additions and 158 deletions

View File

@ -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 = () => {

View File

@ -100,8 +100,7 @@ it('should log an error if set with a patch different than an object', () => {
const [, set] = result.current;
expect(mockConsoleError).not.toHaveBeenCalled();
// @ts-ignore
act(() => set('not an object'));
act(() => set('not an object' as any));
expect(mockConsoleError).toHaveBeenCalledTimes(1);
expect(mockConsoleError).toHaveBeenCalledWith('useGetSetState setter patch must be an object.');

View File

@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react-hooks';
import useMap from '../useMap';
const setUp = (initialMap?: object) => renderHook(() => useMap(initialMap));
const setUp = <T extends object>(initialMap?: T) => renderHook(() => useMap(initialMap));
it('should init map and utils', () => {
const { result } = setUp({ foo: 'bar', a: 1 });
@ -28,7 +28,6 @@ it('should get corresponding value for existing provided key', () => {
let value;
act(() => {
// @ts-ignore
value = utils.get('a');
});
@ -36,12 +35,11 @@ it('should get corresponding value for existing provided key', () => {
});
it('should get undefined for non-existing provided key', () => {
const { result } = setUp({ foo: 'bar', a: 1 });
const { result } = setUp<{ foo: string; a: number; nonExisting?: any }>({ foo: 'bar', a: 1 });
const [, utils] = result.current;
let value;
act(() => {
// @ts-ignore
value = utils.get('nonExisting');
});
@ -49,11 +47,10 @@ it('should get undefined for non-existing provided key', () => {
});
it('should set new key-value pair', () => {
const { result } = setUp({ foo: 'bar', a: 1 });
const { result } = setUp<{ foo: string; a: number; newKey?: number }>({ foo: 'bar', a: 1 });
const [, utils] = result.current;
act(() => {
// @ts-ignore
utils.set('newKey', 99);
});
@ -65,11 +62,10 @@ it('should override current value if setting existing key', () => {
const [, utils] = result.current;
act(() => {
// @ts-ignore
utils.set('foo', 99);
utils.set('foo', 'qux');
});
expect(result.current[0]).toEqual({ foo: 99, a: 1 });
expect(result.current[0]).toEqual({ foo: 'qux', a: 1 });
});
it('should remove corresponding key-value pair for existing provided key', () => {
@ -77,7 +73,6 @@ it('should remove corresponding key-value pair for existing provided key', () =>
const [, utils] = result.current;
act(() => {
// @ts-ignore
utils.remove('foo');
});
@ -85,11 +80,10 @@ it('should remove corresponding key-value pair for existing provided key', () =>
});
it('should do nothing if removing non-existing provided key', () => {
const { result } = setUp({ foo: 'bar', a: 1 });
const { result } = setUp<{ foo: string; a: number; nonExisting?: any }>({ foo: 'bar', a: 1 });
const [, utils] = result.current;
act(() => {
// @ts-ignore
utils.remove('nonExisting');
});
@ -97,11 +91,10 @@ it('should do nothing if removing non-existing provided key', () => {
});
it('should reset map to initial object provided', () => {
const { result } = setUp({ foo: 'bar', a: 1 });
const { result } = setUp<{ foo: string; a: number; z?: number }>({ foo: 'bar', a: 1 });
const [, utils] = result.current;
act(() => {
// @ts-ignore
utils.set('z', 99);
});

View File

@ -0,0 +1,153 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { replaceRaf } from 'raf-stub';
import useRaf from '../useRaf';
/**
* New requestAnimationFrame after being replaced with raf-stub for testing purposes.
*/
interface RequestAnimationFrame {
reset(): void;
step(): void;
}
declare var requestAnimationFrame: RequestAnimationFrame;
replaceRaf();
const fixedStart = 1564949709496;
const spyDateNow = jest.spyOn(Date, 'now').mockImplementation(() => fixedStart);
beforeEach(() => {
jest.useFakeTimers();
requestAnimationFrame.reset();
});
afterEach(() => {
jest.clearAllTimers();
requestAnimationFrame.reset();
});
it('should init percentage of time elapsed', () => {
const { result } = renderHook(() => useRaf());
const timeElapsed = result.current;
expect(timeElapsed).toBe(0);
});
it('should return corresponding percentage of time elapsed for default ms', () => {
const { result } = renderHook(() => useRaf());
expect(result.current).toBe(0);
act(() => {
jest.runOnlyPendingTimers(); // start after delay
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.25); // 25%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.25);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.5); // 50%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.5);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 0.75); // 75%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.75);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12); // 100%
requestAnimationFrame.step();
});
expect(result.current).toBe(1);
});
it('should return corresponding percentage of time elapsed for custom ms', () => {
const customMs = 2000;
const { result } = renderHook(() => useRaf(customMs));
expect(result.current).toBe(0);
act(() => {
jest.runOnlyPendingTimers(); // start after delay
spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.25); // 25%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.25);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.5); // 50%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.5);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.75); // 75%
requestAnimationFrame.step();
});
expect(result.current).toBe(0.75);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + customMs); // 100%
requestAnimationFrame.step();
});
expect(result.current).toBe(1);
});
it('should return always 1 after corresponding ms reached', () => {
const { result } = renderHook(() => useRaf());
expect(result.current).toBe(0);
act(() => {
jest.runOnlyPendingTimers(); // start after delay
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12); // 100%
requestAnimationFrame.step();
});
expect(result.current).toBe(1);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 1.1); // 110%
requestAnimationFrame.step();
});
expect(result.current).toBe(1);
act(() => {
spyDateNow.mockImplementationOnce(() => fixedStart + 1e12 * 3); // 300%
requestAnimationFrame.step();
});
expect(result.current).toBe(1);
});
it('should wait until delay reached to start calculating elapsed percentage', () => {
const { result } = renderHook(() => useRaf(undefined, 500));
expect(result.current).toBe(0);
act(() => {
jest.advanceTimersByTime(250); // fast-forward only half of custom delay
});
expect(result.current).toBe(0);
act(() => {
jest.advanceTimersByTime(249); // fast-forward 1ms less than custom delay
});
expect(result.current).toBe(0);
act(() => {
jest.advanceTimersByTime(1); // fast-forward exactly to custom delay
});
expect(result.current).not.toBe(0);
});
it('should clear pending timers on unmount', () => {
const spyRafStop = jest.spyOn(global, 'cancelAnimationFrame' as any);
const { unmount } = renderHook(() => useRaf());
expect(clearTimeout).not.toHaveBeenCalled();
expect(spyRafStop).not.toHaveBeenCalled();
unmount();
expect(clearTimeout).toHaveBeenCalledTimes(2);
expect(spyRafStop).toHaveBeenCalledTimes(1);
});

View File

@ -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);
});

View File

@ -2,137 +2,135 @@ import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'
import { useTimeout } from '../index';
import { UseTimeoutReturn } from '../useTimeout';
describe('useTimeout', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('should be defined', () => {
expect(useTimeout).toBeDefined();
});
it('should return three functions', () => {
const hook = renderHook(() => useTimeout(5));
expect(hook.result.current.length).toBe(3);
expect(typeof hook.result.current[0]).toBe('function');
expect(typeof hook.result.current[1]).toBe('function');
expect(typeof hook.result.current[2]).toBe('function');
});
function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutReturn>] {
const spy = jest.fn();
return [
spy,
renderHook(
({ delay = 5 }) => {
spy();
return useTimeout(delay);
},
{ initialProps: { delay: ms } }
),
];
}
it('should re-render component after given amount of time', done => {
const [spy, hook] = getHook();
expect(spy).toHaveBeenCalledTimes(1);
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(2);
done();
});
jest.advanceTimersByTime(5);
});
it('should cancel timeout on unmount', () => {
const [spy, hook] = getHook();
expect(spy).toHaveBeenCalledTimes(1);
hook.unmount();
jest.advanceTimersByTime(5);
expect(spy).toHaveBeenCalledTimes(1);
});
it('first function should return actual state of timeout', done => {
let [, hook] = getHook();
let [isReady] = hook.result.current;
expect(isReady()).toBe(false);
hook.unmount();
expect(isReady()).toBe(null);
[, hook] = getHook();
[isReady] = hook.result.current;
hook.waitForNextUpdate().then(() => {
expect(isReady()).toBe(true);
done();
});
jest.advanceTimersByTime(5);
});
it('second function should cancel timeout', () => {
const [spy, hook] = getHook();
const [isReady, cancel] = hook.result.current;
expect(spy).toHaveBeenCalledTimes(1);
expect(isReady()).toBe(false);
act(() => {
cancel();
});
jest.advanceTimersByTime(5);
expect(spy).toHaveBeenCalledTimes(1);
expect(isReady()).toBe(null);
});
it('third function should reset timeout', done => {
const [spy, hook] = getHook();
const [isReady, cancel, reset] = hook.result.current;
expect(isReady()).toBe(false);
act(() => {
cancel();
});
jest.advanceTimersByTime(5);
expect(isReady()).toBe(null);
act(() => {
reset();
});
expect(isReady()).toBe(false);
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(2);
expect(isReady()).toBe(true);
done();
});
jest.advanceTimersByTime(5);
});
it('should reset timeout on delay change', done => {
const [spy, hook] = getHook(15);
expect(spy).toHaveBeenCalledTimes(1);
hook.rerender({ delay: 5 });
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(3);
done();
});
jest.advanceTimersByTime(15);
});
beforeAll(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('should be defined', () => {
expect(useTimeout).toBeDefined();
});
it('should return three functions', () => {
const hook = renderHook(() => useTimeout(5));
expect(hook.result.current.length).toBe(3);
expect(typeof hook.result.current[0]).toBe('function');
expect(typeof hook.result.current[1]).toBe('function');
expect(typeof hook.result.current[2]).toBe('function');
});
function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutReturn>] {
const spy = jest.fn();
return [
spy,
renderHook(
({ delay = 5 }) => {
spy();
return useTimeout(delay);
},
{ initialProps: { delay: ms } }
),
];
}
it('should re-render component after given amount of time', done => {
const [spy, hook] = getHook();
expect(spy).toHaveBeenCalledTimes(1);
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(2);
done();
});
jest.advanceTimersByTime(5);
});
it('should cancel timeout on unmount', () => {
const [spy, hook] = getHook();
expect(spy).toHaveBeenCalledTimes(1);
hook.unmount();
jest.advanceTimersByTime(5);
expect(spy).toHaveBeenCalledTimes(1);
});
it('first function should return actual state of timeout', done => {
let [, hook] = getHook();
let [isReady] = hook.result.current;
expect(isReady()).toBe(false);
hook.unmount();
expect(isReady()).toBe(null);
[, hook] = getHook();
[isReady] = hook.result.current;
hook.waitForNextUpdate().then(() => {
expect(isReady()).toBe(true);
done();
});
jest.advanceTimersByTime(5);
});
it('second function should cancel timeout', () => {
const [spy, hook] = getHook();
const [isReady, cancel] = hook.result.current;
expect(spy).toHaveBeenCalledTimes(1);
expect(isReady()).toBe(false);
act(() => {
cancel();
});
jest.advanceTimersByTime(5);
expect(spy).toHaveBeenCalledTimes(1);
expect(isReady()).toBe(null);
});
it('third function should reset timeout', done => {
const [spy, hook] = getHook();
const [isReady, cancel, reset] = hook.result.current;
expect(isReady()).toBe(false);
act(() => {
cancel();
});
jest.advanceTimersByTime(5);
expect(isReady()).toBe(null);
act(() => {
reset();
});
expect(isReady()).toBe(false);
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(2);
expect(isReady()).toBe(true);
done();
});
jest.advanceTimersByTime(5);
});
it('should reset timeout on delay change', done => {
const [spy, hook] = getHook(15);
expect(spy).toHaveBeenCalledTimes(1);
hook.rerender({ delay: 5 });
hook.waitForNextUpdate().then(() => {
expect(spy).toHaveBeenCalledTimes(3);
done();
});
jest.advanceTimersByTime(15);
});

View File

@ -0,0 +1,56 @@
import { renderHook } from '@testing-library/react-hooks';
import useTween from '../useTween';
import * as useRaf from '../useRaf';
import { easing } from 'ts-easing';
let spyUseRaf;
let spyEasingInCirc;
let spyEasingOutCirc;
beforeEach(() => {
spyUseRaf = jest.spyOn(useRaf, 'default').mockReturnValue(17);
spyEasingInCirc = jest.spyOn(easing, 'inCirc').mockReturnValue(999999);
spyEasingOutCirc = jest.spyOn(easing, 'outCirc').mockReturnValue(101010);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should init corresponding utils with default values', () => {
const { result } = renderHook(() => useTween());
expect(result.current).toBe(999999);
expect(spyEasingInCirc).toHaveBeenCalledTimes(1);
expect(spyEasingInCirc).toHaveBeenCalledWith(17);
expect(spyUseRaf).toHaveBeenCalledTimes(1);
expect(spyUseRaf).toHaveBeenCalledWith(200, 0);
});
it('should init corresponding utils with custom values', () => {
const { result } = renderHook(() => useTween('outCirc', 500, 15));
expect(result.current).toBe(101010);
expect(spyEasingOutCirc).toHaveBeenCalledTimes(1);
expect(spyEasingOutCirc).toHaveBeenCalledWith(17);
expect(spyUseRaf).toHaveBeenCalledTimes(1);
expect(spyUseRaf).toHaveBeenCalledWith(500, 15);
});
describe('when invalid easing name is provided', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(console, 'trace').mockImplementation(() => {});
});
it('should log an error', () => {
const { result } = renderHook(() => useTween('grijanderl'));
expect(result.current).toBe(0);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('useTween() expected "easingName" property to be a valid easing function name')
);
expect(console.trace).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,26 @@
import { act, renderHook } from '@testing-library/react-hooks';
import useUpdate from '../useUpdate';
it('should init update function', () => {
const { result } = renderHook(() => useUpdate());
const update = result.current;
expect(update).toBeInstanceOf(Function);
});
it('should forces a re-render every time update function is called', () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount++;
return useUpdate();
});
const update = result.current;
expect(renderCount).toBe(1);
act(() => update());
expect(renderCount).toBe(2);
act(() => update());
expect(renderCount).toBe(3);
});

View File

@ -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<Spring | null>(null);
const [value, setValue] = useState<number>(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) {