git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on feature/macos-ios-support help sign in
438 lines 14 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 { SafeAreaProvider } from "react-native-safe-area-context";
14import { Animated } from "react-native";
16vi.mock("@react-navigation/native", async () => {
17 const React = await import("react");
18 return {
19 NavigationContainer: ({ children }) =>
20 React.createElement(React.Fragment, null, children),
21 };
22});
24vi.mock("@react-navigation/native-stack", async () => {
25 const React = await import("react");
27 const Screen = ({ children: _children, ...props }) =>
28 React.createElement("NativeStackScreen", props);
29 const Navigator = ({ children, initialRouteName }) => {
30 const [route, setRoute] = React.useState(initialRouteName);
31 const screens = React.Children.toArray(children);
32 const activeScreen = screens.find((screen) => screen.props.name === route);
34 if (activeScreen == null) return null;
36 const renderedScreens = screens.map((screen) =>
37 React.cloneElement(screen, {
38 key: screen.props.name,
39 children: undefined,
40 }),
41 );
43 return React.createElement(
44 React.Fragment,
45 null,
46 renderedScreens,
47 activeScreen.props.children({
48 navigation: {
49 navigate: setRoute,
50 goBack: () => setRoute("landing"),
51 },
52 }),
53 );
54 };
56 return {
57 createNativeStackNavigator: () => ({ Navigator, Screen }),
58 };
59});
61vi.mock("react-native-screens", () => ({ enableScreens: vi.fn() }));
63import { iosDemo } from "../output/Demo.IOSBindings/index.js";
65const press = (renderer, accessibilityLabel) => {
66 const target = renderer.root.findByProps({ accessibilityLabel });
67 act(() => target.props.onPress());
68};
70const visibleText = (renderer) =>
71 renderer.root.findAllByType("Text").map(({ children }) => children.join(""));
73const expectVisible = (renderer, text) => {
74 expect(visibleText(renderer)).toContain(text);
75};
77const expectProfile = (renderer, position, name) => {
78 expect(
79 renderer.root.findByProps({ accessibilityLabel: `Profile ${position} of 4` }),
80 ).toBeDefined();
81 expectVisible(renderer, name);
82};
84const profileCard = (renderer, name) =>
85 renderer.root.findByProps({
86 accessible: true,
87 accessibilityRole: "adjustable",
88 accessibilityLabel: `Profile card for ${name}`,
89 });
91const interpolationNode = (source, config) => ({
92 _interpolated: true,
93 _source: source,
94 _config: config,
95 interpolate(nextConfig) {
96 return interpolationNode(this, nextConfig);
97 },
98});
100const interpolateAt = ({ _config: config }, input) => {
101 const { inputRange, outputRange } = config;
102 const clamped = Math.max(
103 inputRange[0],
104 Math.min(inputRange.at(-1), input),
105 );
106 const upperIndex = inputRange.findIndex(
107 (boundary) => boundary >= clamped,
108 );
109 if (upperIndex <= 0) return outputRange[0];
110 const lowerIndex = upperIndex - 1;
111 const distance = inputRange[upperIndex] - inputRange[lowerIndex];
112 const progress =
113 distance === 0 ? 0 : (clamped - inputRange[lowerIndex]) / distance;
114 return (
115 outputRange[lowerIndex] +
116 progress * (outputRange[upperIndex] - outputRange[lowerIndex])
117 );
118};
120const profileImage = (renderer, name) =>
121 renderer.root.findByProps({
122 accessibilityLabel: {
123 Mara: "Portrait of Mara outdoors in warm evening light. Mara, 29",
124 Sol: "Portrait of Sol smiling against a soft city background. Sol, 31",
125 }[name],
126 });
128const loadingIndicators = (image) => image.findAllByType("ActivityIndicator");
130const renderCatalogue = () => {
131 let renderer;
132 act(() => {
133 renderer = create(
134 React.createElement(SafeAreaProvider, null, iosDemo({})),
135 );
136 });
137 return renderer;
138};
140describe("Lumen dating demo integration", () => {
141 const hadActEnvironment = Object.hasOwn(
142 globalThis,
143 "IS_REACT_ACT_ENVIRONMENT",
144 );
145 const previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT;
146 let renderer;
147 let consoleError;
149 beforeAll(() => {
150 globalThis.IS_REACT_ACT_ENVIRONMENT = true;
151 });
153 beforeEach(() => {
154 vi.spyOn(Animated.Value.prototype, "interpolate").mockImplementation(
155 function interpolate(config) {
156 return interpolationNode(this, config);
157 },
158 );
159 consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
160 });
162 afterEach(() => {
163 if (renderer != null) {
164 act(() => renderer.unmount());
165 renderer = undefined;
166 }
168 const unexpectedErrors = consoleError.mock.calls.filter(
169 ([message]) =>
170 typeof message !== "string" ||
171 !message.includes("react-test-renderer is deprecated"),
172 );
173 expect(unexpectedErrors).toEqual([]);
174 vi.restoreAllMocks();
175 });
177 afterAll(() => {
178 if (hadActEnvironment) {
179 globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
180 } else {
181 delete globalThis.IS_REACT_ACT_ENVIRONMENT;
182 }
183 });
185 it("drives the behind card opacity, lift, and scale from shared swipe progress", () => {
186 renderer = renderCatalogue();
187 press(renderer, "Open Lumen dating demo examples");
189 const animatedViews = renderer.root.findAllByType("Animated.View");
190 const behindCard = animatedViews.find(
191 ({ props }) => props.style?.opacity?._config?.outputRange?.[0] === 0.58,
192 );
193 const scaledCard = animatedViews.find(
194 ({ props }) => props.style?.transform?.[0]?.scale?._config != null,
195 );
197 expect(behindCard).toBeDefined();
198 expect(scaledCard).toBeDefined();
200 const opacity = behindCard.props.style.opacity;
201 const translateY = behindCard.props.style.transform[0].translateY;
202 const scale = scaledCard.props.style.transform[0].scale;
203 const progress = opacity._source;
205 expect(progress._config).toEqual({
206 inputRange: [-110, 0, 110],
207 outputRange: [1, 0, 1],
208 extrapolate: "clamp",
209 });
210 expect(translateY._source).toBe(progress);
211 expect(scale._source).toBe(progress);
212 expect(opacity._config).toEqual({
213 inputRange: [0, 1],
214 outputRange: [0.58, 1],
215 extrapolate: "clamp",
216 });
217 expect(translateY._config).toEqual({
218 inputRange: [0, 1],
219 outputRange: [12, 0],
220 extrapolate: "clamp",
221 });
222 expect(scale._config).toEqual({
223 inputRange: [0, 1],
224 outputRange: [0.94, 1],
225 extrapolate: "clamp",
226 });
227 expect([
228 interpolateAt(opacity, 0),
229 interpolateAt(translateY, 0),
230 interpolateAt(scale, 0),
231 ]).toEqual([0.58, 12, 0.94]);
232 expect([
233 interpolateAt(opacity, 1),
234 interpolateAt(translateY, 1),
235 interpolateAt(scale, 1),
236 ]).toEqual([1, 0, 1]);
237 });
239 it("shows image loading feedback through completion and promotes a preloaded profile without a flash", () => {
240 renderer = renderCatalogue();
241 press(renderer, "Open Lumen dating demo examples");
242 const loadingMara = profileImage(renderer, "Mara");
243 expect(loadingIndicators(loadingMara)).toHaveLength(1);
245 act(() => loadingMara.props.onError({}));
246 expect(loadingIndicators(profileImage(renderer, "Mara"))).toHaveLength(0);
247 const preloadedSol = profileImage(renderer, "Sol");
248 expect(loadingIndicators(preloadedSol)).toHaveLength(1);
250 act(() => preloadedSol.props.onLoad({}));
251 expect(loadingIndicators(profileImage(renderer, "Sol"))).toHaveLength(0);
253 const maraCard = profileCard(renderer, "Mara");
254 act(() => maraCard.props.onResponderRelease({}, { dx: -110, vx: 0 }));
256 expectProfile(renderer, 2, "Sol");
257 expect(loadingIndicators(profileImage(renderer, "Sol"))).toHaveLength(0);
258 });
260 it("advances and matches through the library swipe responder attached to the profile card", () => {
261 renderer = renderCatalogue();
262 press(renderer, "Open Lumen dating demo examples");
264 const maraCard = renderer.root.findByProps({
265 accessibilityLabel: "Profile card for Mara",
266 });
267 expect(maraCard.props.onMoveShouldSetResponder({}, { dx: -6, dy: 2 })).toBe(
268 true,
269 );
271 act(() => maraCard.props.onResponderRelease({}, { dx: -110, vx: 0 }));
272 expectProfile(renderer, 2, "Sol");
274 press(renderer, "Undo last choice");
275 const restoredMaraCard = renderer.root.findByProps({
276 accessibilityLabel: "Profile card for Mara",
277 });
278 act(() =>
279 restoredMaraCard.props.onResponderRelease({}, { dx: 110, vx: 0 }),
280 );
282 expect(
283 renderer.root.findByProps({ accessibilityLabel: "You and Mara matched" }),
284 ).toBeDefined();
285 expectProfile(renderer, 2, "Sol");
286 });
288 it("completes three consecutive left swipes through one responder after each card rerender", () => {
289 const pendingAnimations = [];
290 const spring = vi.spyOn(Animated, "spring").mockImplementation((value, options) => {
291 let completion;
292 const animation = {
293 start: vi.fn((callback) => {
294 completion = callback;
295 }),
296 stop: vi.fn(),
297 complete: (finished = true) => completion?.({ finished }),
298 };
299 pendingAnimations.push({ value, options, animation });
300 return animation;
301 });
303 renderer = renderCatalogue();
304 press(renderer, "Open Lumen dating demo examples");
306 const profiles = ["Mara", "Sol", "Noor"];
307 const nextProfiles = [
308 { position: 2, name: "Sol" },
309 { position: 3, name: "Noor" },
310 { position: 4, name: "Inez" },
311 ];
312 let sharedPosition;
313 let sharedRelease;
315 profiles.forEach((name, index) => {
316 const card = renderer.root.findByProps({
317 accessibilityLabel: `Profile card for ${name}`,
318 });
319 const position = card.props.style.transform[0].translateX;
321 if (index === 0) {
322 sharedPosition = position;
323 sharedRelease = card.props.onResponderRelease;
324 } else {
325 expect(position).toBe(sharedPosition);
326 expect(card.props.onResponderRelease).toBe(sharedRelease);
327 }
329 expect(card.props.onMoveShouldSetResponder({}, { dx: -6, dy: 2 })).toBe(
330 true,
331 );
332 act(() => card.props.onResponderRelease({}, { dx: -110, vx: 0 }));
334 expect(sharedPosition._value).toBe(-110);
335 expect(card.props.onMoveShouldSetResponder({}, { dx: -6, dy: 2 })).toBe(
336 false,
337 );
338 expect(pendingAnimations).toHaveLength(index + 1);
339 expect(pendingAnimations[index]).toMatchObject({
340 value: sharedPosition,
341 options: { toValue: -485, useNativeDriver: false },
342 });
344 act(() => pendingAnimations[index].animation.complete(true));
346 expect(sharedPosition._value).toBe(0);
347 expectProfile(
348 renderer,
349 nextProfiles[index].position,
350 nextProfiles[index].name,
351 );
352 });
354 expect(spring).toHaveBeenCalledTimes(3);
355 });
357 it("keeps the responder reusable while a right-swipe match overlay is rendered", () => {
358 renderer = renderCatalogue();
359 press(renderer, "Open Lumen dating demo examples");
361 const maraCard = renderer.root.findByProps({
362 accessibilityLabel: "Profile card for Mara",
363 });
364 act(() => maraCard.props.onResponderRelease({}, { dx: 110, vx: 0 }));
366 expect(
367 renderer.root.findByProps({ accessibilityLabel: "You and Mara matched" }),
368 ).toBeDefined();
369 const coveredSolCard = renderer.root.findByProps({
370 accessibilityLabel: "Profile card for Sol",
371 });
372 expect(
373 coveredSolCard.props.onMoveShouldSetResponder({}, { dx: -6, dy: 2 }),
374 ).toBe(true);
376 press(renderer, "Dismiss match");
377 const exposedSolCard = renderer.root.findByProps({
378 accessibilityLabel: "Profile card for Sol",
379 });
380 act(() =>
381 exposedSolCard.props.onResponderRelease({}, { dx: -110, vx: 0 }),
382 );
384 expectProfile(renderer, 3, "Noor");
385 });
387 it("navigates from category 08 and preserves discovery, match, and tab transitions", () => {
388 renderer = renderCatalogue();
390 expectVisible(renderer, "08");
391 expectVisible(renderer, "Lumen dating demo");
392 press(renderer, "Open Lumen dating demo examples");
394 expect(
395 renderer.root.findByProps({ accessibilityLabel: "Lumen dating discovery" }),
396 ).toBeDefined();
397 expectVisible(renderer, "LUMEN");
398 expectProfile(renderer, 1, "Mara");
400 press(renderer, "Pass on this profile");
401 expectProfile(renderer, 2, "Sol");
403 press(renderer, "Undo last choice");
404 expectProfile(renderer, 1, "Mara");
406 expect(
407 renderer.root.findByProps({ accessibilityLabel: "Discover, selected" }),
408 ).toBeDefined();
409 press(renderer, "Signals");
410 expect(
411 renderer.root.findByProps({ accessibilityLabel: "Signals, selected" }),
412 ).toBeDefined();
413 expect(
414 renderer.root.findAllByProps({ accessibilityLabel: "Discover, selected" }),
415 ).toHaveLength(0);
417 press(renderer, "Like this profile");
418 expect(
419 renderer.root.findByProps({ accessibilityLabel: "You and Mara matched" }),
420 ).toBeDefined();
421 expectVisible(renderer, "The feeling is mutual.");
422 press(renderer, "Keep browsing");
423 expect(
424 renderer.root.findAllByProps({ accessibilityLabel: "You and Mara matched" }),
425 ).toHaveLength(0);
426 expectProfile(renderer, 2, "Sol");
428 press(renderer, "Undo last choice");
429 press(renderer, "Like this profile");
430 expect(
431 renderer.root.findByProps({ accessibilityLabel: "You and Mara matched" }),
432 ).toBeDefined();
433 press(renderer, "Dismiss match");
434 expect(
435 renderer.root.findAllByProps({ accessibilityLabel: "You and Mara matched" }),
436 ).toHaveLength(0);
437 });
438});