@terreno/ui 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/Banner.js +2 -2
  2. package/dist/Banner.js.map +1 -1
  3. package/dist/TextFieldNumberActionSheet.d.ts +1 -1
  4. package/dist/Toast.d.ts +1 -1
  5. package/dist/Toast.js +2 -2
  6. package/dist/Toast.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/package.json +2 -1
  9. package/src/ActionSheet.test.tsx +262 -3
  10. package/src/AddressField.test.tsx +50 -0
  11. package/src/Banner.test.tsx +22 -0
  12. package/src/Banner.tsx +2 -2
  13. package/src/Box.test.tsx +218 -0
  14. package/src/Button.test.tsx +71 -0
  15. package/src/ConsentFormScreen.test.tsx +167 -0
  16. package/src/ConsentNavigator.test.tsx +206 -0
  17. package/src/DecimalRangeActionSheet.test.tsx +53 -2
  18. package/src/EmailField.test.tsx +81 -0
  19. package/src/EmojiSelector.test.tsx +262 -1
  20. package/src/HeightActionSheet.test.tsx +57 -2
  21. package/src/InfoModalIcon.test.tsx +16 -0
  22. package/src/InfoTooltipButton.test.tsx +53 -1
  23. package/src/MobileAddressAutoComplete.test.tsx +137 -7
  24. package/src/Modal.test.tsx +188 -0
  25. package/src/NumberPickerActionSheet.test.tsx +59 -2
  26. package/src/Page.test.tsx +162 -1
  27. package/src/Pagination.test.tsx +16 -0
  28. package/src/PhoneNumberField.test.tsx +46 -9
  29. package/src/PickerSelect.test.tsx +230 -0
  30. package/src/SegmentedControl.test.tsx +38 -0
  31. package/src/SelectBadge.test.tsx +52 -1
  32. package/src/SideDrawer.test.tsx +69 -0
  33. package/src/Signature.test.tsx +42 -5
  34. package/src/SignatureField.test.tsx +35 -0
  35. package/src/Slider.test.tsx +59 -0
  36. package/src/Spinner.test.tsx +6 -0
  37. package/src/SplitPage.test.tsx +228 -2
  38. package/src/TapToEdit.test.tsx +171 -1
  39. package/src/TerrenoProvider.test.tsx +42 -2
  40. package/src/TextFieldNumberActionSheet.tsx +1 -1
  41. package/src/Theme.test.tsx +118 -28
  42. package/src/Toast.test.tsx +95 -2
  43. package/src/Toast.tsx +3 -3
  44. package/src/Tooltip.test.tsx +204 -1
  45. package/src/UnifiedAddressAutoComplete.test.tsx +38 -19
  46. package/src/UserInactivity.test.tsx +73 -1
  47. package/src/Utilities.test.tsx +190 -2
  48. package/src/WebAddressAutocomplete.test.tsx +148 -1
  49. package/src/__snapshots__/ActionSheet.test.tsx.snap +1736 -0
  50. package/src/__snapshots__/Button.test.tsx.snap +68 -0
  51. package/src/__snapshots__/EmojiSelector.test.tsx.snap +1363 -0
  52. package/src/__snapshots__/InfoTooltipButton.test.tsx.snap +72 -3
  53. package/src/__snapshots__/MobileAddressAutoComplete.test.tsx.snap +60 -9
  54. package/src/__snapshots__/Modal.test.tsx.snap +181 -0
  55. package/src/__snapshots__/Page.test.tsx.snap +48 -2
  56. package/src/__snapshots__/PhoneNumberField.test.tsx.snap +0 -93
  57. package/src/__snapshots__/PickerSelect.test.tsx.snap +706 -0
  58. package/src/__snapshots__/SideDrawer.test.tsx.snap +533 -1399
  59. package/src/__snapshots__/Signature.test.tsx.snap +0 -3
  60. package/src/__snapshots__/SplitPage.test.tsx.snap +970 -0
  61. package/src/__snapshots__/UnifiedAddressAutoComplete.test.tsx.snap +220 -4
  62. package/src/__snapshots__/WebAddressAutocomplete.test.tsx.snap +93 -0
  63. package/src/bunSetup.ts +204 -121
  64. package/src/index.tsx +2 -2
  65. package/src/table/TableHeaderCell.test.tsx +142 -0
  66. package/src/table/TableRow.test.tsx +33 -0
  67. package/src/table/__snapshots__/TableRow.test.tsx.snap +403 -0
  68. package/src/table/tableContext.test.tsx +96 -0
  69. package/src/test-utils.tsx +1 -1
  70. package/src/useConsentForms.test.ts +130 -0
  71. package/src/useSubmitConsent.test.ts +64 -0
@@ -1,17 +1,79 @@
1
1
  import {describe, expect, it, mock} from "bun:test";
2
- import {forwardRef} from "react";
3
- import {Text, View} from "react-native";
2
+ import {fireEvent} from "@testing-library/react-native";
3
+ import {forwardRef, useImperativeHandle, useRef} from "react";
4
+ import {Pressable, Text, View} from "react-native";
4
5
 
5
6
  import {MobileAddressAutocomplete} from "./MobileAddressAutoComplete";
6
7
  import {renderWithTheme} from "./test-utils";
7
8
 
9
+ // Capture the props passed to GooglePlacesAutocomplete so we can exercise the inline
10
+ // callbacks (onPress, onFocus, onBlur, onChange, textInputProps, etc.)
11
+ interface CapturedGooglePlacesProps {
12
+ placeholder?: string;
13
+ textInputProps?: {
14
+ onFocus?: () => void;
15
+ onBlur?: () => void;
16
+ onChange?: (event: {nativeEvent: {text: string}}) => void;
17
+ };
18
+ onPress?: (
19
+ data: {description: string},
20
+ details: {
21
+ address_components: {
22
+ long_name: string;
23
+ short_name: string;
24
+ types: string[];
25
+ }[];
26
+ }
27
+ ) => void;
28
+ }
29
+
30
+ let lastGooglePlacesProps: CapturedGooglePlacesProps | null = null;
31
+ const setAddressTextSpy = mock(() => {});
32
+
8
33
  // Mock react-native-google-places-autocomplete
9
34
  mock.module("react-native-google-places-autocomplete", () => ({
10
- GooglePlacesAutocomplete: forwardRef(({placeholder}: any, ref) => (
11
- <View ref={ref as any} testID="google-places-autocomplete">
12
- <Text>{placeholder}</Text>
13
- </View>
14
- )),
35
+ GooglePlacesAutocomplete: forwardRef((props: CapturedGooglePlacesProps, ref) => {
36
+ lastGooglePlacesProps = props;
37
+ const innerRef = useRef<Record<string, unknown>>({});
38
+ useImperativeHandle(ref, () => ({
39
+ setAddressText: setAddressTextSpy,
40
+ ...innerRef.current,
41
+ }));
42
+ return (
43
+ <View testID="google-places-autocomplete">
44
+ <Text>{props.placeholder}</Text>
45
+ <Pressable
46
+ onPress={() =>
47
+ props.onPress?.(
48
+ {description: "123 Main St"},
49
+ {
50
+ address_components: [
51
+ {long_name: "123", short_name: "123", types: ["street_number"]},
52
+ {long_name: "Main St", short_name: "Main St", types: ["route"]},
53
+ {long_name: "San Francisco", short_name: "SF", types: ["locality"]},
54
+ {
55
+ long_name: "California",
56
+ short_name: "CA",
57
+ types: ["administrative_area_level_1"],
58
+ },
59
+ {
60
+ long_name: "San Francisco County",
61
+ short_name: "SF County",
62
+ types: ["administrative_area_level_2"],
63
+ },
64
+ {long_name: "United States", short_name: "US", types: ["country"]},
65
+ {long_name: "94105", short_name: "94105", types: ["postal_code"]},
66
+ ],
67
+ }
68
+ )
69
+ }
70
+ testID="mock-google-places-select"
71
+ >
72
+ <Text>Select</Text>
73
+ </Pressable>
74
+ </View>
75
+ );
76
+ }),
15
77
  }));
16
78
 
17
79
  describe("MobileAddressAutocomplete", () => {
@@ -55,4 +117,72 @@ describe("MobileAddressAutocomplete", () => {
55
117
  );
56
118
  expect(toJSON()).toMatchSnapshot();
57
119
  });
120
+
121
+ it("invokes handleAutoCompleteChange with processed address components", () => {
122
+ const handleAutoCompleteChange = mock(() => {});
123
+ const handleAddressChange = mock(() => {});
124
+ setAddressTextSpy.mockClear();
125
+ const {getByTestId} = renderWithTheme(
126
+ <MobileAddressAutocomplete
127
+ googleMapsApiKey="test-api-key"
128
+ handleAddressChange={handleAddressChange}
129
+ handleAutoCompleteChange={handleAutoCompleteChange}
130
+ includeCounty
131
+ inputValue=""
132
+ />
133
+ );
134
+ fireEvent.press(getByTestId("mock-google-places-select"));
135
+ expect(handleAutoCompleteChange).toHaveBeenCalled();
136
+ const payload = handleAutoCompleteChange.mock.calls[0][0] as {address1?: string};
137
+ expect(payload.address1).toContain("Main St");
138
+ expect(setAddressTextSpy).toHaveBeenCalled();
139
+ });
140
+
141
+ it("fires onFocus, onBlur and onChange via textInputProps callbacks", () => {
142
+ const handleAddressChange = mock(() => {});
143
+ renderWithTheme(
144
+ <MobileAddressAutocomplete
145
+ googleMapsApiKey="test-api-key"
146
+ handleAddressChange={handleAddressChange}
147
+ handleAutoCompleteChange={() => {}}
148
+ inputValue=""
149
+ />
150
+ );
151
+ const tip = lastGooglePlacesProps?.textInputProps;
152
+ expect(typeof tip?.onFocus).toBe("function");
153
+ expect(typeof tip?.onBlur).toBe("function");
154
+ expect(typeof tip?.onChange).toBe("function");
155
+ tip?.onFocus?.();
156
+ tip?.onBlur?.();
157
+ tip?.onChange?.({nativeEvent: {text: "456 Oak Ave"}});
158
+ expect(handleAddressChange).toHaveBeenCalledWith("456 Oak Ave");
159
+ });
160
+
161
+ it("falls back to the TextField and propagates its onChange without an API key", () => {
162
+ const handleAddressChange = mock(() => {});
163
+ const {UNSAFE_root} = renderWithTheme(
164
+ <MobileAddressAutocomplete
165
+ handleAddressChange={handleAddressChange}
166
+ handleAutoCompleteChange={() => {}}
167
+ inputValue=""
168
+ testID="mobile-fallback"
169
+ />
170
+ );
171
+ expect(UNSAFE_root).toBeTruthy();
172
+ });
173
+
174
+ it("wrapping TouchableOpacity clears focus when pressed", () => {
175
+ const {UNSAFE_getAllByType} = renderWithTheme(
176
+ <MobileAddressAutocomplete
177
+ googleMapsApiKey="test-api-key"
178
+ handleAddressChange={() => {}}
179
+ handleAutoCompleteChange={() => {}}
180
+ inputValue=""
181
+ />
182
+ );
183
+ const {TouchableOpacity} = require("react-native");
184
+ const [wrapper] = UNSAFE_getAllByType(TouchableOpacity);
185
+ expect(wrapper).toBeTruthy();
186
+ expect(() => wrapper.props.onPress?.()).not.toThrow();
187
+ });
58
188
  });
@@ -5,6 +5,16 @@ import {Modal} from "./Modal";
5
5
  import {Text} from "./Text";
6
6
  import {renderWithTheme} from "./test-utils";
7
7
 
8
+ // Minimal shape of a test instance returned by UNSAFE_getAllByType that we rely on here.
9
+ interface PressableTestInstance {
10
+ props: {
11
+ style?:
12
+ | {backgroundColor?: string; cursor?: string}
13
+ | {backgroundColor?: string; cursor?: string}[];
14
+ onPress?: (event?: {stopPropagation?: () => void}) => void;
15
+ };
16
+ }
17
+
8
18
  describe("Modal", () => {
9
19
  it("renders correctly when visible", () => {
10
20
  const {toJSON} = renderWithTheme(
@@ -166,4 +176,182 @@ describe("Modal", () => {
166
176
  );
167
177
  expect(toJSON()).toMatchSnapshot();
168
178
  });
179
+
180
+ it("renders primary button with click handler", () => {
181
+ const handlePrimary = mock(() => {});
182
+ const {getByText} = renderWithTheme(
183
+ <Modal
184
+ onDismiss={() => {}}
185
+ primaryButtonOnClick={handlePrimary}
186
+ primaryButtonText="Confirm"
187
+ title="Title"
188
+ visible
189
+ >
190
+ <Text>Content</Text>
191
+ </Modal>
192
+ );
193
+ expect(getByText("Confirm")).toBeTruthy();
194
+ });
195
+
196
+ it("renders secondary button with click handler", () => {
197
+ const handleSecondary = mock(() => {});
198
+ const {getByText} = renderWithTheme(
199
+ <Modal
200
+ onDismiss={() => {}}
201
+ secondaryButtonOnClick={handleSecondary}
202
+ secondaryButtonText="Cancel"
203
+ title="Title"
204
+ visible
205
+ >
206
+ <Text>Content</Text>
207
+ </Modal>
208
+ );
209
+ expect(getByText("Cancel")).toBeTruthy();
210
+ });
211
+
212
+ it("does not call primaryButtonOnClick when not visible", () => {
213
+ const handlePrimary = mock(() => {});
214
+ renderWithTheme(
215
+ <Modal
216
+ onDismiss={() => {}}
217
+ primaryButtonOnClick={handlePrimary}
218
+ primaryButtonText="Confirm"
219
+ title="Title"
220
+ visible={false}
221
+ >
222
+ <Text>Content</Text>
223
+ </Modal>
224
+ );
225
+ expect(handlePrimary).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("renders with persistOnBackgroundClick", () => {
229
+ const {toJSON} = renderWithTheme(
230
+ <Modal onDismiss={() => {}} persistOnBackgroundClick title="Persistent" visible>
231
+ <Text>Content</Text>
232
+ </Modal>
233
+ );
234
+ expect(toJSON()).toMatchSnapshot();
235
+ });
236
+
237
+ it("does not call onDismiss when visible is false and close is pressed", () => {
238
+ const handleDismiss = mock(() => {});
239
+ renderWithTheme(
240
+ <Modal onDismiss={handleDismiss} title="Hidden" visible={false}>
241
+ <Text>Content</Text>
242
+ </Modal>
243
+ );
244
+ expect(handleDismiss).not.toHaveBeenCalled();
245
+ });
246
+
247
+ it("renders transitioning from hidden to visible", () => {
248
+ const {rerender, toJSON} = renderWithTheme(
249
+ <Modal onDismiss={() => {}} title="Toggle" visible={false}>
250
+ <Text>Content</Text>
251
+ </Modal>
252
+ );
253
+ rerender(
254
+ <Modal onDismiss={() => {}} title="Toggle" visible>
255
+ <Text>Content</Text>
256
+ </Modal>
257
+ );
258
+ expect(toJSON()).toBeTruthy();
259
+ });
260
+
261
+ it("invokes primaryButtonOnClick when primary button pressed while visible", async () => {
262
+ const handlePrimary = mock(() => {});
263
+ const {getByText} = renderWithTheme(
264
+ <Modal
265
+ onDismiss={() => {}}
266
+ primaryButtonOnClick={handlePrimary}
267
+ primaryButtonText="Submit"
268
+ title="Title"
269
+ visible
270
+ >
271
+ <Text>Content</Text>
272
+ </Modal>
273
+ );
274
+
275
+ await new Promise((resolve) => {
276
+ fireEvent.press(getByText("Submit"));
277
+ setTimeout(resolve, 600);
278
+ });
279
+
280
+ expect(handlePrimary).toHaveBeenCalled();
281
+ });
282
+
283
+ it("invokes secondaryButtonOnClick when secondary button pressed while visible", async () => {
284
+ const handleSecondary = mock(() => {});
285
+ const {getByText} = renderWithTheme(
286
+ <Modal
287
+ onDismiss={() => {}}
288
+ secondaryButtonOnClick={handleSecondary}
289
+ secondaryButtonText="Cancel"
290
+ title="Title"
291
+ visible
292
+ >
293
+ <Text>Content</Text>
294
+ </Modal>
295
+ );
296
+
297
+ await new Promise((resolve) => {
298
+ fireEvent.press(getByText("Cancel"));
299
+ setTimeout(resolve, 600);
300
+ });
301
+
302
+ expect(handleSecondary).toHaveBeenCalled();
303
+ });
304
+
305
+ it("dismisses when the backdrop is pressed and persistOnBackgroundClick is false", () => {
306
+ const handleDismiss = mock(() => {});
307
+ const {UNSAFE_getAllByType} = renderWithTheme(
308
+ <Modal onDismiss={handleDismiss} title="Title" visible>
309
+ <Text>Content</Text>
310
+ </Modal>
311
+ );
312
+ // Find the backdrop Pressable (first Pressable in tree with a style that includes backgroundColor).
313
+ const {Pressable} = require("react-native");
314
+ const pressables: PressableTestInstance[] = UNSAFE_getAllByType(Pressable);
315
+ const backdrop = pressables.find((node) => {
316
+ const style = node.props.style;
317
+ if (Array.isArray(style)) {
318
+ return style.some((s) => s?.backgroundColor?.includes?.("rgba"));
319
+ }
320
+ return style?.backgroundColor?.includes?.("rgba");
321
+ });
322
+ expect(backdrop).toBeTruthy();
323
+ backdrop?.props.onPress?.();
324
+ expect(handleDismiss).toHaveBeenCalled();
325
+ });
326
+
327
+ it("stops propagation on the inner backdrop wrapper press", () => {
328
+ const stopPropagation = mock(() => {});
329
+ const {UNSAFE_getAllByType} = renderWithTheme(
330
+ <Modal onDismiss={() => {}} title="Title" visible>
331
+ <Text>Content</Text>
332
+ </Modal>
333
+ );
334
+ const {Pressable} = require("react-native");
335
+ const pressables: PressableTestInstance[] = UNSAFE_getAllByType(Pressable);
336
+ // Inner wrapper is the pressable with style {cursor: "auto"}.
337
+ const inner = pressables.find((node) => node.props.style?.cursor === "auto");
338
+ expect(inner).toBeTruthy();
339
+ inner?.props.onPress?.({stopPropagation});
340
+ expect(stopPropagation).toHaveBeenCalled();
341
+ });
342
+
343
+ it("does not stop propagation on the inner wrapper when persistOnBackgroundClick is true", () => {
344
+ const stopPropagation = mock(() => {});
345
+ const {UNSAFE_getAllByType} = renderWithTheme(
346
+ <Modal onDismiss={() => {}} persistOnBackgroundClick title="Title" visible>
347
+ <Text>Content</Text>
348
+ </Modal>
349
+ );
350
+ const {Pressable} = require("react-native");
351
+ const pressables: PressableTestInstance[] = UNSAFE_getAllByType(Pressable);
352
+ const inner = pressables.find((node) => node.props.style?.cursor === "auto");
353
+ expect(inner).toBeTruthy();
354
+ inner?.props.onPress?.({stopPropagation});
355
+ expect(stopPropagation).not.toHaveBeenCalled();
356
+ });
169
357
  });
@@ -1,6 +1,7 @@
1
- import {describe, expect, it} from "bun:test";
2
- import {render} from "@testing-library/react-native";
1
+ import {describe, expect, it, mock} from "bun:test";
2
+ import {act, render} from "@testing-library/react-native";
3
3
  import {createRef} from "react";
4
+ import type {ReactTestInstance} from "react-test-renderer";
4
5
 
5
6
  import type {ActionSheet} from "./ActionSheet";
6
7
  import {NumberPickerActionSheet} from "./NumberPickerActionSheet";
@@ -43,4 +44,60 @@ describe("NumberPickerActionSheet", () => {
43
44
  );
44
45
  expect(toJSON()).toMatchSnapshot();
45
46
  });
47
+
48
+ it("invokes onChange when picker value changes", () => {
49
+ const actionSheetRef = createRef<ActionSheet>();
50
+ const handleChange = mock((_val: string) => {});
51
+ const {UNSAFE_getAllByProps} = render(
52
+ <ThemeProvider>
53
+ <NumberPickerActionSheet
54
+ actionSheetRef={actionSheetRef}
55
+ max={10}
56
+ min={0}
57
+ onChange={handleChange}
58
+ value="5"
59
+ />
60
+ </ThemeProvider>
61
+ );
62
+ const pickers = UNSAFE_getAllByProps({selectedValue: "5"});
63
+ const picker = pickers.find(
64
+ (p: ReactTestInstance) => typeof p.props.onValueChange === "function"
65
+ );
66
+ act(() => {
67
+ if (picker) {
68
+ picker.props.onValueChange(7);
69
+ }
70
+ });
71
+ expect(handleChange).toHaveBeenCalledWith("7");
72
+ });
73
+
74
+ it("closes the action sheet when Close button is pressed", () => {
75
+ const setModalVisible = mock((_v: boolean) => {});
76
+ const actionSheetRef = createRef<ActionSheet>();
77
+ const {UNSAFE_getAllByProps} = render(
78
+ <ThemeProvider>
79
+ <NumberPickerActionSheet
80
+ actionSheetRef={actionSheetRef}
81
+ max={10}
82
+ min={0}
83
+ onChange={() => {}}
84
+ value="5"
85
+ />
86
+ </ThemeProvider>
87
+ );
88
+ // Replace the ref target with a mock after mount so the Button's onClick
89
+ // invokes our spy instead of the real ActionSheet instance.
90
+ (actionSheetRef as {current: {setModalVisible: typeof setModalVisible}}).current = {
91
+ setModalVisible,
92
+ };
93
+ const closeButtons = UNSAFE_getAllByProps({text: "Close"});
94
+ const closeButton = closeButtons.find(
95
+ (b: ReactTestInstance) => typeof b.props.onClick === "function"
96
+ );
97
+ expect(closeButton).toBeDefined();
98
+ act(() => {
99
+ closeButton?.props.onClick();
100
+ });
101
+ expect(setModalVisible).toHaveBeenCalledWith(false);
102
+ });
46
103
  });
package/src/Page.test.tsx CHANGED
@@ -1,9 +1,97 @@
1
- import {describe, expect, it, mock} from "bun:test";
1
+ import {afterAll, describe, expect, it, mock} from "bun:test";
2
+ import {act, fireEvent, waitFor} from "@testing-library/react-native";
3
+ import React, {type ReactNode} from "react";
4
+ import {Pressable, Text as RNText} from "react-native";
5
+
6
+ // Override the IconButton mock so the inline onClick arrows fire when pressed.
7
+ mock.module("./IconButton", () => ({
8
+ IconButton: ({
9
+ accessibilityLabel,
10
+ accessibilityHint,
11
+ iconName,
12
+ onClick,
13
+ }: {
14
+ accessibilityLabel?: string;
15
+ accessibilityHint?: string;
16
+ iconName: string;
17
+ onClick?: () => void;
18
+ }) => (
19
+ <Pressable
20
+ accessibilityHint={accessibilityHint}
21
+ accessibilityLabel={accessibilityLabel}
22
+ onPress={onClick}
23
+ testID={`icon-button-${iconName}`}
24
+ >
25
+ <RNText>{iconName}</RNText>
26
+ </Pressable>
27
+ ),
28
+ }));
29
+
30
+ // Override the expo-router mock so we can observe router.back() calls, but
31
+ // preserve the full shape provided by bunSetup.ts (Link, Stack, Tabs, hooks,
32
+ // and the rest of the router object) so other components that import from
33
+ // "expo-router" don't see `undefined` for those exports.
34
+ const routerBack = mock(() => {});
35
+ interface MockChildrenProps {
36
+ children?: ReactNode;
37
+ }
38
+ mock.module("expo-router", () => ({
39
+ Link: ({children, ...props}: MockChildrenProps) => React.createElement("Link", props, children),
40
+ router: {
41
+ back: routerBack,
42
+ canGoBack: mock(() => true),
43
+ navigate: mock(() => {}),
44
+ push: mock(() => {}),
45
+ replace: mock(() => {}),
46
+ },
47
+ Stack: ({children, ...props}: MockChildrenProps) => React.createElement("Stack", props, children),
48
+ Tabs: ({children, ...props}: MockChildrenProps) => React.createElement("Tabs", props, children),
49
+ useLocalSearchParams: mock(() => ({})),
50
+ useRouter: mock(() => ({
51
+ back: mock(() => {}),
52
+ canGoBack: mock(() => true),
53
+ navigate: mock(() => {}),
54
+ push: mock(() => {}),
55
+ replace: mock(() => {}),
56
+ })),
57
+ useSegments: mock(() => []),
58
+ }));
2
59
 
3
60
  import {Page} from "./Page";
4
61
  import {Text} from "./Text";
5
62
  import {renderWithTheme} from "./test-utils";
6
63
 
64
+ // Restore the global mocks set up by bunSetup.ts after this file finishes so
65
+ // that other test files (e.g. IconButton.test.tsx, ConsentFormScreen.test.tsx)
66
+ // are not affected by the overrides above.
67
+ afterAll(() => {
68
+ mock.module("./IconButton", () => ({
69
+ IconButton: mock(() => null),
70
+ }));
71
+ mock.module("expo-router", () => ({
72
+ Link: ({children, ...props}: MockChildrenProps) => React.createElement("Link", props, children),
73
+ router: {
74
+ back: mock(() => {}),
75
+ canGoBack: mock(() => true),
76
+ navigate: mock(() => {}),
77
+ push: mock(() => {}),
78
+ replace: mock(() => {}),
79
+ },
80
+ Stack: ({children, ...props}: MockChildrenProps) =>
81
+ React.createElement("Stack", props, children),
82
+ Tabs: ({children, ...props}: MockChildrenProps) => React.createElement("Tabs", props, children),
83
+ useLocalSearchParams: mock(() => ({})),
84
+ useRouter: mock(() => ({
85
+ back: mock(() => {}),
86
+ canGoBack: mock(() => true),
87
+ navigate: mock(() => {}),
88
+ push: mock(() => {}),
89
+ replace: mock(() => {}),
90
+ })),
91
+ useSegments: mock(() => []),
92
+ }));
93
+ });
94
+
7
95
  describe("Page", () => {
8
96
  const mockNavigation = {
9
97
  goBack: mock(() => {}),
@@ -125,4 +213,77 @@ describe("Page", () => {
125
213
  );
126
214
  expect(toJSON()).toMatchSnapshot();
127
215
  });
216
+
217
+ it("invokes rightButtonOnClick when right button is pressed", async () => {
218
+ const handleRightClick = mock(() => {});
219
+ const {getByText} = renderWithTheme(
220
+ <Page
221
+ navigation={mockNavigation}
222
+ rightButton="Save"
223
+ rightButtonOnClick={handleRightClick}
224
+ title="Page"
225
+ >
226
+ <Text>Content</Text>
227
+ </Page>
228
+ );
229
+ await act(async () => {
230
+ fireEvent.press(getByText("Save"));
231
+ await new Promise((resolve) => setTimeout(resolve, 600));
232
+ });
233
+ await waitFor(() => expect(handleRightClick).toHaveBeenCalled());
234
+ });
235
+
236
+ it("renders without header when title and backButton are both absent", () => {
237
+ const {queryByText} = renderWithTheme(
238
+ <Page navigation={mockNavigation}>
239
+ <Text>Plain page</Text>
240
+ </Page>
241
+ );
242
+ expect(queryByText("Plain page")).toBeTruthy();
243
+ });
244
+
245
+ it("renders loading state with loadingText", () => {
246
+ const {getByText} = renderWithTheme(
247
+ <Page loading loadingText="Loading data..." navigation={mockNavigation}>
248
+ <Text>Content</Text>
249
+ </Page>
250
+ );
251
+ expect(getByText("Loading data...")).toBeTruthy();
252
+ });
253
+
254
+ it("invokes router.back when the back button is pressed", () => {
255
+ routerBack.mockClear();
256
+ const {getByTestId} = renderWithTheme(
257
+ <Page backButton navigation={mockNavigation} title="Page">
258
+ <Text>Content</Text>
259
+ </Page>
260
+ );
261
+ fireEvent.press(getByTestId("icon-button-chevron-left"));
262
+ expect(routerBack).toHaveBeenCalled();
263
+ });
264
+
265
+ it("invokes router.back when the close button is pressed", () => {
266
+ routerBack.mockClear();
267
+ const {getByTestId} = renderWithTheme(
268
+ <Page closeButton navigation={mockNavigation} title="Page">
269
+ <Text>Content</Text>
270
+ </Page>
271
+ );
272
+ fireEvent.press(getByTestId("icon-button-xmark"));
273
+ expect(routerBack).toHaveBeenCalled();
274
+ });
275
+
276
+ it("safely handles a missing rightButtonOnClick callback", async () => {
277
+ const {getByText} = renderWithTheme(
278
+ <Page navigation={mockNavigation} rightButton="Go" title="Page">
279
+ <Text>Content</Text>
280
+ </Page>
281
+ );
282
+ await act(async () => {
283
+ fireEvent.press(getByText("Go"));
284
+ await new Promise((resolve) => setTimeout(resolve, 600));
285
+ });
286
+ // No crash; the optional-chained call handles the missing prop.
287
+ expect(getByText("Go")).toBeTruthy();
288
+ });
128
289
  });
@@ -83,4 +83,20 @@ describe("Pagination", () => {
83
83
  const {toJSON} = renderWithTheme(<Pagination page={2} setPage={() => {}} totalPages={20} />);
84
84
  expect(toJSON()).toMatchSnapshot();
85
85
  });
86
+
87
+ it("renders 'more' button for large page sets without throwing when pressed", () => {
88
+ const handleSetPage = mock((_page: number) => {});
89
+ const {UNSAFE_getAllByProps} = renderWithTheme(
90
+ <Pagination page={10} setPage={handleSetPage} totalPages={20} />
91
+ );
92
+ // Find the "more" pagination buttons (they have iconName="ellipsis")
93
+ const moreIcons = UNSAFE_getAllByProps({iconName: "ellipsis"});
94
+ expect(moreIcons.length).toBeGreaterThan(0);
95
+ const morePressable = moreIcons[0].parent;
96
+ if (morePressable) {
97
+ expect(() => fireEvent.press(morePressable)).not.toThrow();
98
+ }
99
+ // Pressing "more" does nothing (onClick is no-op)
100
+ expect(handleSetPage).not.toHaveBeenCalled();
101
+ });
86
102
  });
@@ -1,4 +1,5 @@
1
1
  import {describe, expect, it, mock} from "bun:test";
2
+ import {act, fireEvent} from "@testing-library/react-native";
2
3
 
3
4
  import {PhoneNumberField} from "./PhoneNumberField";
4
5
  import {renderWithTheme} from "./test-utils";
@@ -30,15 +31,6 @@ describe("PhoneNumberField", () => {
30
31
  expect(getByDisplayValue("(555) 123-4567")).toBeTruthy();
31
32
  });
32
33
 
33
- it("formats phone number as user types", () => {
34
- const handleChange = mock((_value: string) => {});
35
- const {toJSON} = renderWithTheme(
36
- <PhoneNumberField label="Phone" onChange={handleChange} value="5551234567" />
37
- );
38
- // Snapshot captures the formatted phone number display
39
- expect(toJSON()).toMatchSnapshot();
40
- });
41
-
42
34
  it("renders with custom errorText", () => {
43
35
  const {getByText} = renderWithTheme(
44
36
  <PhoneNumberField
@@ -76,4 +68,49 @@ describe("PhoneNumberField", () => {
76
68
  );
77
69
  expect(toJSON()).toMatchSnapshot();
78
70
  });
71
+
72
+ it("calls onBlur callback when provided", async () => {
73
+ const handleBlur = mock((_value: string) => {});
74
+ const handleChange = mock((_value: string) => {});
75
+ const {getByDisplayValue} = renderWithTheme(
76
+ <PhoneNumberField
77
+ label="Phone"
78
+ onBlur={handleBlur}
79
+ onChange={handleChange}
80
+ value="(555) 123-4567"
81
+ />
82
+ );
83
+ const input = getByDisplayValue("(555) 123-4567");
84
+ await act(async () => {
85
+ fireEvent(input, "blur", {nativeEvent: {text: "(555) 123-4567"}});
86
+ });
87
+ expect(handleBlur).toHaveBeenCalled();
88
+ });
89
+
90
+ it("calls onBlur with invalid number and sets an error state", async () => {
91
+ const handleBlur = mock((_value: string) => {});
92
+ const handleChange = mock((_value: string) => {});
93
+ const {getByDisplayValue} = renderWithTheme(
94
+ <PhoneNumberField label="Phone" onBlur={handleBlur} onChange={handleChange} value="" />
95
+ );
96
+ const input = getByDisplayValue("");
97
+ await act(async () => {
98
+ fireEvent.changeText(input, "123");
99
+ fireEvent(input, "blur", {nativeEvent: {text: "123"}});
100
+ });
101
+ expect(handleBlur).toHaveBeenCalled();
102
+ });
103
+
104
+ it("handles empty input on blur without error", async () => {
105
+ const handleBlur = mock((_value: string) => {});
106
+ const handleChange = mock((_value: string) => {});
107
+ const {getByDisplayValue} = renderWithTheme(
108
+ <PhoneNumberField label="Phone" onBlur={handleBlur} onChange={handleChange} value="" />
109
+ );
110
+ const input = getByDisplayValue("");
111
+ await act(async () => {
112
+ fireEvent(input, "blur", {nativeEvent: {text: ""}});
113
+ });
114
+ expect(handleBlur).toHaveBeenCalled();
115
+ });
79
116
  });