git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on feature/macos-ios-support help sign in
406 lines 13 kB history raw
1import {
2 afterAll,
3 afterEach,
4 beforeAll,
5 beforeEach,
6 describe,
7 expect,
8 it,
9 vi,
10} from "vitest";
11import React from "react";
12import { act, create } from "react-test-renderer";
13import { Animated, PanResponder } from "react-native";
14import {
15 swipeGestureViewImpl,
16 swipePositionImpl,
17 swipeProgressImpl,
18 useNativeSwipeImpl,
19} from "../src/Yoga/React/Native/GestureHandler.js";
21const interpolationNode = (source, config) => ({
22 _interpolated: true,
23 _source: source,
24 _config: config,
25 interpolate(nextConfig) {
26 return interpolationNode(this, nextConfig);
27 },
28});
30const interpolateAt = ({ _config: config }, input) => {
31 const { inputRange, outputRange } = config;
32 const clamped = Math.max(inputRange[0], Math.min(inputRange.at(-1), input));
33 const upperIndex = inputRange.findIndex((boundary) => boundary >= clamped);
34 if (upperIndex <= 0) return outputRange[0];
35 const lowerIndex = upperIndex - 1;
36 const distance = inputRange[upperIndex] - inputRange[lowerIndex];
37 const progress = distance === 0 ? 0 : (clamped - inputRange[lowerIndex]) / distance;
38 return outputRange[lowerIndex] + progress * (outputRange[upperIndex] - outputRange[lowerIndex]);
39};
41const returnSpring = {
42 family: "physical",
43 stiffness: 280,
44 damping: 24,
45 mass: 0.9,
46 delay: 0,
47};
49const dismissalSpring = {
50 family: "physical",
51 stiffness: 210,
52 damping: 22,
53 mass: 0.9,
54 delay: 0,
55};
57const config = {
58 dismissalDistance: 110,
59 dismissalVelocity: 0.72,
60 returnSpring,
61 dismissalSpring,
62};
64const responderFromConfig = (responderConfig) => ({
65 panHandlers: {
66 onStartShouldSetResponder: responderConfig.onStartShouldSetPanResponder,
67 onStartShouldSetResponderCapture:
68 responderConfig.onStartShouldSetPanResponderCapture,
69 onMoveShouldSetResponder: responderConfig.onMoveShouldSetPanResponder,
70 onMoveShouldSetResponderCapture:
71 responderConfig.onMoveShouldSetPanResponderCapture,
72 onResponderGrant: responderConfig.onPanResponderGrant,
73 onResponderMove: responderConfig.onPanResponderMove,
74 onResponderRelease: responderConfig.onPanResponderRelease,
75 onResponderTerminate: responderConfig.onPanResponderTerminate,
76 onResponderTerminationRequest:
77 responderConfig.onPanResponderTerminationRequest,
78 onShouldBlockNativeResponder: responderConfig.onShouldBlockNativeResponder,
79 },
80});
82const deferredAnimation = () => {
83 let completion;
84 return {
85 start: vi.fn((callback) => {
86 completion = callback;
87 }),
88 stop: vi.fn(),
89 complete: (finished = true) => completion?.({ finished }),
90 };
91};
93const mountSwipe = ({
94 currentConfig = config,
95 onLeft = vi.fn(),
96 onRight = vi.fn(),
97} = {}) => {
98 const result = { current: undefined };
100 const SwipeContainer = (props) => {
101 const swipe = useNativeSwipeImpl(
102 props.currentConfig,
103 props.onLeft,
104 props.onRight,
105 );
106 result.current = swipe;
107 return swipeGestureViewImpl(
108 swipe,
109 React.createElement(Animated.View, {
110 testID: "swipe-card",
111 style: { transform: [{ translateX: swipePositionImpl(swipe) }] },
112 }),
113 );
114 };
116 let renderer;
117 act(() => {
118 renderer = create(
119 React.createElement(SwipeContainer, {
120 currentConfig,
121 onLeft,
122 onRight,
123 }),
124 );
125 });
127 return {
128 renderer,
129 result,
130 handlers: () => result.current.panHandlers,
131 position: () => swipePositionImpl(result.current),
132 rerender: (props) =>
133 act(() =>
134 renderer.update(
135 React.createElement(SwipeContainer, {
136 currentConfig,
137 onLeft,
138 onRight,
139 ...props,
140 }),
141 ),
142 ),
143 unmount: () => act(() => renderer.unmount()),
144 };
145};
147const release = (handlers, { dx, vx }) =>
148 act(() => handlers.onResponderRelease({}, { dx, vx }));
150const expectedSpring = (model, toValue, velocity) => ({
151 stiffness: model.stiffness,
152 damping: model.damping,
153 mass: model.mass,
154 delay: model.delay,
155 velocity,
156 toValue,
157 useNativeDriver: false,
158});
160describe("reusable native swipe gesture", () => {
161 const hadActEnvironment = Object.hasOwn(
162 globalThis,
163 "IS_REACT_ACT_ENVIRONMENT",
164 );
165 const previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
167 beforeAll(() => {
168 globalThis.IS_REACT_ACT_ENVIRONMENT = true;
169 });
171 afterAll(() => {
172 if (hadActEnvironment) {
173 globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
174 } else {
175 delete globalThis.IS_REACT_ACT_ENVIRONMENT;
176 }
177 });
179 beforeEach(() => {
180 vi.spyOn(Animated.Value.prototype, "interpolate").mockImplementation(
181 function interpolate(config) {
182 return interpolationNode(this, config);
183 },
184 );
185 });
187 afterEach(() => {
188 vi.restoreAllMocks();
189 });
191 it("maps either dismissal boundary to complete progress and the resting position to zero", () => {
192 const position = new Animated.Value(0);
193 const progress = swipeProgressImpl({
194 position,
195 dismissalDistance: config.dismissalDistance,
196 });
198 expect(progress._source).toBe(position);
199 expect(progress._config).toEqual({
200 inputRange: [-110, 0, 110],
201 outputRange: [1, 0, 1],
202 extrapolate: "clamp",
203 });
204 expect([
205 interpolateAt(progress, -110),
206 interpolateAt(progress, 0),
207 interpolateAt(progress, 110),
208 ]).toEqual([1, 0, 1]);
210 const chained = progress.interpolate({
211 inputRange: [0, 1],
212 outputRange: [0.58, 1],
213 extrapolate: "clamp",
214 });
215 expect(chained._source).toBe(progress);
216 expect([interpolateAt(chained, 0), interpolateAt(chained, 1)]).toEqual([
217 0.58, 1,
218 ]);
219 });
221 it("claims horizontal intent in both responder phases without stealing starts or vertical movement", () => {
222 const createResponder = vi
223 .spyOn(PanResponder, "create")
224 .mockImplementation(responderFromConfig);
225 const mounted = mountSwipe();
226 const handlers = mounted.handlers();
228 expect(createResponder).toHaveBeenCalledOnce();
229 expect(handlers.onStartShouldSetResponder()).toBe(false);
230 expect(handlers.onStartShouldSetResponderCapture()).toBe(false);
232 const cases = [
233 { name: "at positive slop", dx: 4, dy: 0, expected: false },
234 { name: "at negative slop", dx: -4, dy: 0, expected: false },
235 { name: "vertical dominant", dx: 6, dy: 7, expected: false },
236 { name: "equal axes", dx: -7, dy: 7, expected: false },
237 { name: "right horizontal", dx: 5, dy: 4, expected: true },
238 { name: "left horizontal", dx: -5, dy: 4, expected: true },
239 ];
241 for (const { name, dx, dy, expected } of cases) {
242 const gesture = { dx, dy };
243 expect(handlers.onMoveShouldSetResponder({}, gesture), name).toBe(expected);
244 expect(
245 handlers.onMoveShouldSetResponderCapture({}, gesture),
246 `${name} capture`,
247 ).toBe(expected);
248 }
250 expect(handlers.onResponderTerminationRequest()).toBe(false);
251 expect(handlers.onShouldBlockNativeResponder()).toBe(true);
252 expect(mounted.renderer.root.findByProps({ testID: "swipe-card" }).props).toMatchObject({
253 pointerEvents: "box-only",
254 collapsable: false,
255 });
256 mounted.unmount();
257 });
259 it("continues movement from the captured visible position", () => {
260 vi.spyOn(PanResponder, "create").mockImplementation(responderFromConfig);
261 const mounted = mountSwipe();
262 const position = mounted.position();
263 vi.spyOn(position, "stopAnimation").mockImplementation((callback) =>
264 callback?.(37),
265 );
266 const setValue = vi.spyOn(position, "setValue");
267 const handlers = mounted.handlers();
269 act(() => handlers.onResponderGrant({}, { dx: 999 }));
270 act(() => handlers.onResponderMove({}, { dx: 13 }));
271 act(() => handlers.onResponderMove({}, { dx: -8 }));
273 expect(setValue.mock.calls).toEqual([[37], [50], [29]]);
274 mounted.unmount();
275 });
277 it("springs subthreshold releases home and never dismisses", () => {
278 vi.spyOn(PanResponder, "create").mockImplementation(responderFromConfig);
279 const animation = deferredAnimation();
280 const spring = vi.spyOn(Animated, "spring").mockReturnValue(animation);
281 const onLeft = vi.fn();
282 const onRight = vi.fn();
283 const mounted = mountSwipe({ onLeft, onRight });
284 const position = mounted.position();
285 const setValue = vi.spyOn(position, "setValue");
287 release(mounted.handlers(), { dx: 109.99, vx: 0.719 });
289 expect(setValue).toHaveBeenCalledWith(109.99);
290 expect(spring).toHaveBeenCalledWith(
291 position,
292 expectedSpring(returnSpring, 0, 0.719),
293 );
294 animation.complete(true);
295 expect(onLeft).not.toHaveBeenCalled();
296 expect(onRight).not.toHaveBeenCalled();
297 mounted.unmount();
298 });
300 it.each([
301 { name: "left distance", dx: -110, vx: 0, destination: -485, side: "left" },
302 { name: "right distance", dx: 110, vx: 0, destination: 485, side: "right" },
303 { name: "left velocity", dx: 2, vx: -0.72, destination: -485, side: "left" },
304 { name: "right velocity", dx: -2, vx: 0.72, destination: 485, side: "right" },
305 ])(
306 "commits $name offscreen, resets first, and invokes only its latest callback once",
307 ({ dx, vx, destination, side }) => {
308 vi.spyOn(PanResponder, "create").mockImplementation(responderFromConfig);
309 const animation = deferredAnimation();
310 const spring = vi.spyOn(Animated, "spring").mockReturnValue(animation);
311 const initialLeft = vi.fn();
312 const initialRight = vi.fn();
313 const latestLeft = vi.fn();
314 const latestRight = vi.fn();
315 const mounted = mountSwipe({
316 onLeft: initialLeft,
317 onRight: initialRight,
318 });
319 const position = mounted.position();
320 const callback = side === "left" ? latestLeft : latestRight;
321 callback.mockImplementation(() => {
322 expect(position._value).toBe(0);
323 });
324 mounted.rerender({ onLeft: latestLeft, onRight: latestRight });
326 release(mounted.handlers(), { dx, vx });
327 release(mounted.handlers(), { dx: -dx || 200, vx: -vx || 2 });
329 expect(spring).toHaveBeenCalledOnce();
330 expect(spring).toHaveBeenCalledWith(
331 position,
332 expectedSpring(dismissalSpring, destination, vx),
333 );
334 expect(initialLeft).not.toHaveBeenCalled();
335 expect(initialRight).not.toHaveBeenCalled();
336 expect(latestLeft).not.toHaveBeenCalled();
337 expect(latestRight).not.toHaveBeenCalled();
339 animation.complete(true);
340 animation.complete(true);
342 expect(latestLeft).toHaveBeenCalledTimes(side === "left" ? 1 : 0);
343 expect(latestRight).toHaveBeenCalledTimes(side === "right" ? 1 : 0);
344 mounted.unmount();
345 },
346 );
348 it("resets and unlocks after an interrupted dismissal so the next swipe can complete", () => {
349 vi.spyOn(PanResponder, "create").mockImplementation(responderFromConfig);
350 const interruptedAnimation = deferredAnimation();
351 const completedAnimation = deferredAnimation();
352 vi.spyOn(Animated, "spring")
353 .mockReturnValueOnce(interruptedAnimation)
354 .mockReturnValueOnce(completedAnimation);
355 const onLeft = vi.fn();
356 const onRight = vi.fn();
357 const mounted = mountSwipe({ onLeft, onRight });
358 const position = mounted.position();
360 release(mounted.handlers(), { dx: -150, vx: -1 });
361 expect(position._value).toBe(-150);
362 expect(
363 mounted.handlers().onMoveShouldSetResponder({}, { dx: 6, dy: 2 }),
364 ).toBe(false);
366 act(() => interruptedAnimation.complete(false));
368 expect(position._value).toBe(0);
369 expect(onLeft).not.toHaveBeenCalled();
370 expect(onRight).not.toHaveBeenCalled();
371 expect(
372 mounted.handlers().onMoveShouldSetResponder({}, { dx: 6, dy: 2 }),
373 ).toBe(true);
375 release(mounted.handlers(), { dx: 150, vx: 1 });
376 act(() => completedAnimation.complete(true));
378 expect(position._value).toBe(0);
379 expect(onLeft).not.toHaveBeenCalled();
380 expect(onRight).toHaveBeenCalledOnce();
381 mounted.unmount();
382 });
384 it("always springs a terminated gesture home with zero velocity and no callback", () => {
385 vi.spyOn(PanResponder, "create").mockImplementation(responderFromConfig);
386 const animation = deferredAnimation();
387 const spring = vi.spyOn(Animated, "spring").mockReturnValue(animation);
388 const onLeft = vi.fn();
389 const onRight = vi.fn();
390 const mounted = mountSwipe({ onLeft, onRight });
391 const position = mounted.position();
393 act(() =>
394 mounted.handlers().onResponderTerminate({}, { dx: -200, vx: -9 }),
395 );
397 expect(spring).toHaveBeenCalledWith(
398 position,
399 expectedSpring(returnSpring, 0, 0),
400 );
401 animation.complete(true);
402 expect(onLeft).not.toHaveBeenCalled();
403 expect(onRight).not.toHaveBeenCalled();
404 mounted.unmount();
405 });
406});