@smileid/usesmileid 12.0.1 → 12.0.2

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 (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -0,0 +1,461 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+ import { Pressable, View } from "react-native";
5
+ import * as VisionCamera from "react-native-vision-camera";
6
+ import { FrameProcessorPluginNotRegisteredException } from "@smileid/usesmileid_bridge";
7
+
8
+ import { UseSmileIDCameraPreview } from "../use_smile_id_camera_preview";
9
+ import { UseSmileIDCameraSystem } from "../use_smile_id_camera_system";
10
+ import { CameraLens } from "../camera_lens";
11
+ import { CameraDefaults } from "../camera_defaults";
12
+ import type { CameraSystemState } from "../camera_configuration";
13
+ import type { FocusState } from "../focus_state";
14
+
15
+ /**
16
+ * Component-level tests for `<UseSmileIDCameraPreview>` — the seam
17
+ * between the camera orchestrator and vision-camera. The native
18
+ * bindings are mocked in `setup_react_native.ts`; these tests drive
19
+ * the boundary the preview owns: ref attach/detach, subscription
20
+ * fan-out, plugin initialisation + the missing-plugin error, the
21
+ * frame-processor worklet body, layout-normalised tap-to-focus, and
22
+ * the function-as-children overlay contract.
23
+ */
24
+
25
+ const mockedUseCameraDevice = vi.mocked(VisionCamera.useCameraDevice);
26
+ const mockedUseCameraFormat = vi.mocked(VisionCamera.useCameraFormat);
27
+ const mockedUseFrameProcessor = vi.mocked(VisionCamera.useFrameProcessor);
28
+ const mockedInitPlugin = vi.mocked(
29
+ VisionCamera.VisionCameraProxy.initFrameProcessorPlugin,
30
+ );
31
+
32
+ type CameraDevice = ReturnType<typeof VisionCamera.useCameraDevice>;
33
+
34
+ const fakeDevice = {
35
+ id: "test-front",
36
+ position: "front" as const,
37
+ hasFlash: false,
38
+ hasTorch: false,
39
+ formats: [],
40
+ isMultiCam: false,
41
+ supportsRawCapture: false,
42
+ supportsLowLightBoost: false,
43
+ supportsFocus: true,
44
+ name: "Front Camera (test)",
45
+ } as unknown as CameraDevice;
46
+
47
+ function render(element: React.ReactElement) {
48
+ let tree!: ReturnType<typeof createRenderer>;
49
+ act(() => {
50
+ tree = createRenderer(element);
51
+ });
52
+ return tree;
53
+ }
54
+
55
+ type CameraProps = {
56
+ readonly isActive: boolean;
57
+ readonly fps: number;
58
+ readonly photo: boolean;
59
+ readonly device: unknown;
60
+ readonly outputOrientation: string;
61
+ readonly format?: unknown;
62
+ readonly frameProcessor?: unknown;
63
+ readonly onOutputOrientationChanged: (orientation: string) => void;
64
+ };
65
+
66
+ function cameraProps(root: ReturnType<typeof render>["root"]): CameraProps {
67
+ return root.findByType(VisionCamera.Camera).props as unknown as CameraProps;
68
+ }
69
+
70
+ function findCameras(root: ReturnType<typeof render>["root"]) {
71
+ return root.findAllByType(VisionCamera.Camera);
72
+ }
73
+
74
+ /** Drives `onLayout` on the preview's root `<View>` so taps normalise. */
75
+ function layout(
76
+ root: ReturnType<typeof render>["root"],
77
+ width: number,
78
+ height: number,
79
+ ) {
80
+ const view = root.findAllByType(View)[0];
81
+ act(() => {
82
+ (
83
+ view.props as {
84
+ onLayout: (event: {
85
+ nativeEvent: { layout: { width: number; height: number } };
86
+ }) => void;
87
+ }
88
+ ).onLayout({ nativeEvent: { layout: { width, height } } });
89
+ });
90
+ }
91
+
92
+ function tap(
93
+ root: ReturnType<typeof render>["root"],
94
+ locationX: number,
95
+ locationY: number,
96
+ ) {
97
+ const pressable = root.findAllByType(Pressable)[0];
98
+ act(() => {
99
+ (
100
+ pressable.props as {
101
+ onPress: (event: {
102
+ nativeEvent: { locationX: number; locationY: number };
103
+ }) => void;
104
+ }
105
+ ).onPress({ nativeEvent: { locationX, locationY } });
106
+ });
107
+ }
108
+
109
+ describe("UseSmileIDCameraPreview", () => {
110
+ beforeEach(() => {
111
+ mockedUseCameraDevice.mockReset();
112
+ mockedUseCameraDevice.mockReturnValue(fakeDevice);
113
+ mockedUseCameraFormat.mockReset();
114
+ mockedUseCameraFormat.mockReturnValue(
115
+ undefined as unknown as ReturnType<typeof VisionCamera.useCameraFormat>,
116
+ );
117
+ mockedUseFrameProcessor.mockReset();
118
+ mockedUseFrameProcessor.mockImplementation(
119
+ (worklet) =>
120
+ worklet as unknown as ReturnType<typeof VisionCamera.useFrameProcessor>,
121
+ );
122
+ mockedInitPlugin.mockReset();
123
+ mockedInitPlugin.mockReturnValue({
124
+ call: vi.fn(() => ({})),
125
+ } as unknown as ReturnType<
126
+ typeof VisionCamera.VisionCameraProxy.initFrameProcessorPlugin
127
+ >);
128
+ });
129
+
130
+ it("renders the camera for the system's current lens", () => {
131
+ const system = new UseSmileIDCameraSystem(CameraLens.back);
132
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
133
+
134
+ expect(mockedUseCameraDevice).toHaveBeenCalledWith(CameraLens.back);
135
+ expect(cameraProps(root).photo).toBe(true);
136
+ expect(cameraProps(root).outputOrientation).toBe("device");
137
+ });
138
+
139
+ it("omits the camera entirely when no device is available", () => {
140
+ mockedUseCameraDevice.mockReturnValue(
141
+ undefined as unknown as CameraDevice as CameraDevice,
142
+ );
143
+ const system = new UseSmileIDCameraSystem();
144
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
145
+
146
+ expect(findCameras(root)).toHaveLength(0);
147
+ // The tap surface still mounts so overlays render without a device.
148
+ expect(root.findAllByType(Pressable)).toHaveLength(1);
149
+ });
150
+
151
+ it("drives isActive off the system's streaming state", async () => {
152
+ const system = new UseSmileIDCameraSystem();
153
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
154
+ expect(cameraProps(root).isActive).toBe(false);
155
+
156
+ await act(async () => {
157
+ await system.start();
158
+ });
159
+ expect(cameraProps(root).isActive).toBe(true);
160
+
161
+ act(() => {
162
+ system.stop();
163
+ });
164
+ expect(cameraProps(root).isActive).toBe(false);
165
+ });
166
+
167
+ it("pins the default fps and honours a configured override", () => {
168
+ const system = new UseSmileIDCameraSystem();
169
+ const first = render(<UseSmileIDCameraPreview cameraSystem={system} />);
170
+ expect(cameraProps(first.root).fps).toBe(CameraDefaults.analysisTargetFps);
171
+
172
+ const configured = new UseSmileIDCameraSystem();
173
+ configured.configure({ fps: 24 });
174
+ const second = render(
175
+ <UseSmileIDCameraPreview cameraSystem={configured} />,
176
+ );
177
+ expect(cameraProps(second.root).fps).toBe(24);
178
+ });
179
+
180
+ it("requests a 4:3 30fps analysis format and forwards it when resolved", () => {
181
+ const format = { videoWidth: 640, videoHeight: 480 };
182
+ mockedUseCameraFormat.mockReturnValue(
183
+ format as unknown as ReturnType<typeof VisionCamera.useCameraFormat>,
184
+ );
185
+ const system = new UseSmileIDCameraSystem();
186
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
187
+
188
+ expect(mockedUseCameraFormat).toHaveBeenCalledWith(fakeDevice, [
189
+ { videoAspectRatio: 4 / 3 },
190
+ {
191
+ videoResolution: {
192
+ width: CameraDefaults.analysisTargetResolution.width,
193
+ height: CameraDefaults.analysisTargetResolution.height,
194
+ },
195
+ },
196
+ { fps: CameraDefaults.analysisTargetFps },
197
+ ]);
198
+ expect(cameraProps(root).format).toBe(format);
199
+ });
200
+
201
+ it("omits the format key entirely when none resolves", () => {
202
+ const system = new UseSmileIDCameraSystem();
203
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
204
+ expect("format" in cameraProps(root)).toBe(false);
205
+ });
206
+
207
+ it("attaches the camera ref on mount and detaches it on unmount", () => {
208
+ const system = new UseSmileIDCameraSystem();
209
+ const attachRef = vi.spyOn(system, "attachRef");
210
+ const tree = render(<UseSmileIDCameraPreview cameraSystem={system} />);
211
+
212
+ expect(attachRef).toHaveBeenCalledTimes(1);
213
+ expect(attachRef.mock.calls[0]?.[0]).not.toBeNull();
214
+
215
+ act(() => {
216
+ tree.unmount();
217
+ });
218
+ expect(attachRef).toHaveBeenLastCalledWith(null);
219
+ });
220
+
221
+ it("unsubscribes from the system on unmount", () => {
222
+ const system = new UseSmileIDCameraSystem();
223
+ const unsubscribe = vi.fn();
224
+ vi.spyOn(system, "subscribe").mockReturnValue(unsubscribe);
225
+
226
+ const tree = render(<UseSmileIDCameraPreview cameraSystem={system} />);
227
+ expect(unsubscribe).not.toHaveBeenCalled();
228
+
229
+ act(() => {
230
+ tree.unmount();
231
+ });
232
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
233
+ });
234
+
235
+ it("passes live state and focus state to function-as-children overlays", async () => {
236
+ const system = new UseSmileIDCameraSystem();
237
+ const seen: Array<{ state: CameraSystemState; focusState: FocusState }> =
238
+ [];
239
+ render(
240
+ <UseSmileIDCameraPreview cameraSystem={system}>
241
+ {(args) => {
242
+ seen.push(args);
243
+ return null;
244
+ }}
245
+ </UseSmileIDCameraPreview>,
246
+ );
247
+
248
+ expect(seen[0]?.state.isStreaming).toBe(false);
249
+ expect(seen[0]?.focusState.kind).toBe("unspecified");
250
+
251
+ await act(async () => {
252
+ await system.start();
253
+ });
254
+ const latest = seen[seen.length - 1];
255
+ expect(latest?.state.isStreaming).toBe(true);
256
+ });
257
+
258
+ it("renders plain node children inside the tap surface", () => {
259
+ const system = new UseSmileIDCameraSystem();
260
+ const { root } = render(
261
+ <UseSmileIDCameraPreview cameraSystem={system}>
262
+ <View testID="si_overlay" />
263
+ </UseSmileIDCameraPreview>,
264
+ );
265
+ expect(
266
+ root.findAllByProps({ testID: "si_overlay" }).length,
267
+ ).toBeGreaterThan(0);
268
+ });
269
+
270
+ it("normalises a tap into [0..1] focus coordinates after layout", () => {
271
+ const system = new UseSmileIDCameraSystem();
272
+ const tapToFocus = vi
273
+ .spyOn(system, "tapToFocus")
274
+ .mockResolvedValue(undefined);
275
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
276
+
277
+ layout(root, 200, 400);
278
+ tap(root, 50, 100);
279
+
280
+ expect(tapToFocus).toHaveBeenCalledWith({ x: 0.25, y: 0.25 });
281
+ });
282
+
283
+ it("ignores taps before the preview has been laid out", () => {
284
+ const system = new UseSmileIDCameraSystem();
285
+ const tapToFocus = vi
286
+ .spyOn(system, "tapToFocus")
287
+ .mockResolvedValue(undefined);
288
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
289
+
290
+ tap(root, 50, 100);
291
+ expect(tapToFocus).not.toHaveBeenCalled();
292
+
293
+ // A zero-height layout is still degenerate — division would be Infinity.
294
+ layout(root, 200, 0);
295
+ tap(root, 50, 100);
296
+ expect(tapToFocus).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("skips plugin initialisation when no frame analysis is supplied", () => {
300
+ const system = new UseSmileIDCameraSystem();
301
+ const { root } = render(<UseSmileIDCameraPreview cameraSystem={system} />);
302
+
303
+ expect(mockedInitPlugin).not.toHaveBeenCalled();
304
+ expect("frameProcessor" in cameraProps(root)).toBe(false);
305
+ });
306
+
307
+ it("initialises the named plugin and attaches a frame processor", () => {
308
+ const system = new UseSmileIDCameraSystem();
309
+ const onResult = vi.fn();
310
+ const { root } = render(
311
+ <UseSmileIDCameraPreview
312
+ cameraSystem={system}
313
+ frameAnalysis={{ pluginName: "UseSmileIDFaceScan", onResult }}
314
+ />,
315
+ );
316
+
317
+ expect(mockedInitPlugin).toHaveBeenCalledWith("UseSmileIDFaceScan", {});
318
+ expect(cameraProps(root).frameProcessor).toBeTypeOf("function");
319
+ });
320
+
321
+ it("forwards each frame's raw result to onResult with the device rotation", () => {
322
+ const call = vi.fn(() => ({ state: { type: "initial" } }));
323
+ mockedInitPlugin.mockReturnValue({ call } as unknown as ReturnType<
324
+ typeof VisionCamera.VisionCameraProxy.initFrameProcessorPlugin
325
+ >);
326
+ const system = new UseSmileIDCameraSystem();
327
+ const onResult = vi.fn();
328
+ const { root } = render(
329
+ <UseSmileIDCameraPreview
330
+ cameraSystem={system}
331
+ frameAnalysis={{ pluginName: "UseSmileIDFaceScan", onResult }}
332
+ />,
333
+ );
334
+
335
+ act(() => {
336
+ cameraProps(root).onOutputOrientationChanged("landscape-left");
337
+ });
338
+
339
+ const frame = { width: 640, height: 480 };
340
+ const worklet = cameraProps(root).frameProcessor as (
341
+ frame: unknown,
342
+ ) => void;
343
+ worklet(frame);
344
+
345
+ expect(call).toHaveBeenCalledWith(frame, { rotation: 90 });
346
+ expect(onResult).toHaveBeenCalledWith({ state: { type: "initial" } });
347
+ });
348
+
349
+ it("frame processor is inert when the plugin failed to initialise", () => {
350
+ mockedInitPlugin.mockReturnValue(
351
+ null as unknown as ReturnType<
352
+ typeof VisionCamera.VisionCameraProxy.initFrameProcessorPlugin
353
+ >,
354
+ );
355
+ const system = new UseSmileIDCameraSystem();
356
+ const onResult = vi.fn();
357
+ render(
358
+ <UseSmileIDCameraPreview
359
+ cameraSystem={system}
360
+ frameAnalysis={{ pluginName: "UseSmileIDFaceScan", onResult }}
361
+ />,
362
+ );
363
+
364
+ const worklet = mockedUseFrameProcessor.mock.calls[0]?.[0] as (
365
+ frame: unknown,
366
+ ) => void;
367
+ expect(() => worklet({ width: 1, height: 1 })).not.toThrow();
368
+ expect(onResult).not.toHaveBeenCalled();
369
+ });
370
+
371
+ it("reports a missing native plugin exactly once through onError", () => {
372
+ mockedInitPlugin.mockReturnValue(
373
+ null as unknown as ReturnType<
374
+ typeof VisionCamera.VisionCameraProxy.initFrameProcessorPlugin
375
+ >,
376
+ );
377
+ const system = new UseSmileIDCameraSystem();
378
+ const onError = vi.fn();
379
+ const frameAnalysis = {
380
+ pluginName: "UseSmileIDMissingPlugin",
381
+ onResult: vi.fn(),
382
+ onError,
383
+ };
384
+ const tree = render(
385
+ <UseSmileIDCameraPreview
386
+ cameraSystem={system}
387
+ frameAnalysis={frameAnalysis}
388
+ />,
389
+ );
390
+
391
+ expect(onError).toHaveBeenCalledTimes(1);
392
+ expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(
393
+ FrameProcessorPluginNotRegisteredException,
394
+ );
395
+
396
+ // A re-render with the same plugin name must not re-fire.
397
+ act(() => {
398
+ tree.update(
399
+ <UseSmileIDCameraPreview
400
+ cameraSystem={system}
401
+ frameAnalysis={frameAnalysis}
402
+ />,
403
+ );
404
+ });
405
+ expect(onError).toHaveBeenCalledTimes(1);
406
+ });
407
+
408
+ it("stays silent when the plugin resolves", () => {
409
+ const system = new UseSmileIDCameraSystem();
410
+ const onError = vi.fn();
411
+ render(
412
+ <UseSmileIDCameraPreview
413
+ cameraSystem={system}
414
+ frameAnalysis={{
415
+ pluginName: "UseSmileIDFaceScan",
416
+ onResult: vi.fn(),
417
+ onError,
418
+ }}
419
+ />,
420
+ );
421
+ expect(onError).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it("picks up a swapped onError handler without re-firing the effect", () => {
425
+ mockedInitPlugin.mockReturnValue(
426
+ null as unknown as ReturnType<
427
+ typeof VisionCamera.VisionCameraProxy.initFrameProcessorPlugin
428
+ >,
429
+ );
430
+ const system = new UseSmileIDCameraSystem();
431
+ const first = vi.fn();
432
+ const second = vi.fn();
433
+ const onResult = vi.fn();
434
+ const tree = render(
435
+ <UseSmileIDCameraPreview
436
+ cameraSystem={system}
437
+ frameAnalysis={{
438
+ pluginName: "UseSmileIDMissingPlugin",
439
+ onResult,
440
+ onError: first,
441
+ }}
442
+ />,
443
+ );
444
+ expect(first).toHaveBeenCalledTimes(1);
445
+
446
+ act(() => {
447
+ tree.update(
448
+ <UseSmileIDCameraPreview
449
+ cameraSystem={system}
450
+ frameAnalysis={{
451
+ pluginName: "UseSmileIDMissingPlugin",
452
+ onResult,
453
+ onError: second,
454
+ }}
455
+ />,
456
+ );
457
+ });
458
+ expect(second).not.toHaveBeenCalled();
459
+ expect(first).toHaveBeenCalledTimes(1);
460
+ });
461
+ });
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import * as React from "react";
3
+ import { create as createRenderer, act } from "react-test-renderer";
4
+ import type {
5
+ DocumentScanResult,
6
+ FaceScanResult,
7
+ } from "@smileid/usesmileid_bridge";
8
+ import { FrameProcessingException } from "@smileid/usesmileid_bridge";
9
+
10
+ import {
11
+ useSmileIDFrameStream,
12
+ type UseSmileIDFrameStream,
13
+ } from "../use_smile_id_frame_stream";
14
+
15
+ /**
16
+ * React-level companion to `use_smile_id_frame_stream.test.ts` (which
17
+ * pins the pure dispatcher). These tests mount the hook so the ref
18
+ * plumbing — analyzer / callback swaps between renders, the mounted
19
+ * gate, and the memoised handle identity — is actually exercised.
20
+ */
21
+
22
+ const validFaceWirePayload = {
23
+ output: { faces: [], brightness: 0.5 },
24
+ state: { type: "initial" },
25
+ capturedEvents: [],
26
+ };
27
+
28
+ const validDocumentWirePayload = {
29
+ output: { documents: [], brightness: 0.5 },
30
+ state: { type: "found" },
31
+ capturedEvents: [],
32
+ };
33
+
34
+ type FaceAnalyzerStub = Parameters<typeof useSmileIDFrameStream>[0];
35
+
36
+ const faceAnalyzer = { kind: "face" } as unknown as FaceAnalyzerStub;
37
+ const documentAnalyzer = { kind: "document" } as unknown as FaceAnalyzerStub;
38
+
39
+ type HarnessProps = {
40
+ readonly analyzer: FaceAnalyzerStub;
41
+ readonly onResult: (result: FaceScanResult) => void;
42
+ readonly onError?: (error: unknown) => void;
43
+ readonly onHandle: (handle: UseSmileIDFrameStream) => void;
44
+ };
45
+
46
+ function Harness({ analyzer, onResult, onError, onHandle }: HarnessProps) {
47
+ const handle = useSmileIDFrameStream(
48
+ analyzer,
49
+ onResult,
50
+ onError !== undefined ? { onError } : undefined,
51
+ );
52
+ onHandle(handle);
53
+ return null;
54
+ }
55
+
56
+ type Rendered = {
57
+ readonly tree: ReturnType<typeof createRenderer>;
58
+ readonly handles: UseSmileIDFrameStream[];
59
+ readonly latest: () => UseSmileIDFrameStream;
60
+ };
61
+
62
+ function mount(props: Omit<HarnessProps, "onHandle">): Rendered {
63
+ const handles: UseSmileIDFrameStream[] = [];
64
+ let tree!: ReturnType<typeof createRenderer>;
65
+ act(() => {
66
+ tree = createRenderer(
67
+ <Harness {...props} onHandle={(handle) => handles.push(handle)} />,
68
+ );
69
+ });
70
+ return {
71
+ tree,
72
+ handles,
73
+ latest: () => handles[handles.length - 1] as UseSmileIDFrameStream,
74
+ };
75
+ }
76
+
77
+ function update(
78
+ rendered: Rendered,
79
+ props: Omit<HarnessProps, "onHandle">,
80
+ ): void {
81
+ act(() => {
82
+ rendered.tree.update(
83
+ <Harness
84
+ {...props}
85
+ onHandle={(handle) => rendered.handles.push(handle)}
86
+ />,
87
+ );
88
+ });
89
+ }
90
+
91
+ describe("useSmileIDFrameStream", () => {
92
+ it("validates the raw wire payload and forwards the typed face result", () => {
93
+ const onResult = vi.fn();
94
+ const rendered = mount({ analyzer: faceAnalyzer, onResult });
95
+
96
+ act(() => {
97
+ rendered.latest().onFrameResult(validFaceWirePayload);
98
+ });
99
+
100
+ expect(onResult).toHaveBeenCalledTimes(1);
101
+ const result = onResult.mock.calls[0]?.[0] as FaceScanResult;
102
+ expect(result.state.type).toBe("initial");
103
+ });
104
+
105
+ it("parses against the document schema when the analyzer is a document one", () => {
106
+ const onResult = vi.fn();
107
+ const rendered = mount({
108
+ analyzer: documentAnalyzer,
109
+ onResult: onResult as unknown as (result: FaceScanResult) => void,
110
+ });
111
+
112
+ act(() => {
113
+ rendered.latest().onFrameResult(validDocumentWirePayload);
114
+ });
115
+
116
+ const result = onResult.mock.calls[0]?.[0] as DocumentScanResult;
117
+ expect(result.state.type).toBe("found");
118
+ });
119
+
120
+ it("keeps a stable handle across re-renders so the frame processor is not rebuilt", () => {
121
+ const onResult = vi.fn();
122
+ const rendered = mount({ analyzer: faceAnalyzer, onResult });
123
+ const first = rendered.latest();
124
+
125
+ update(rendered, { analyzer: faceAnalyzer, onResult: vi.fn() });
126
+
127
+ expect(rendered.latest()).toBe(first);
128
+ expect(rendered.latest().onFrameResult).toBe(first.onFrameResult);
129
+ });
130
+
131
+ it("routes results to the latest onResult after a swap", () => {
132
+ const first = vi.fn();
133
+ const second = vi.fn();
134
+ const rendered = mount({ analyzer: faceAnalyzer, onResult: first });
135
+
136
+ update(rendered, { analyzer: faceAnalyzer, onResult: second });
137
+ act(() => {
138
+ rendered.latest().onFrameResult(validFaceWirePayload);
139
+ });
140
+
141
+ expect(first).not.toHaveBeenCalled();
142
+ expect(second).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it("stops dispatching once the analyzer is detached", () => {
146
+ const onResult = vi.fn();
147
+ const rendered = mount({ analyzer: faceAnalyzer, onResult });
148
+
149
+ update(rendered, { analyzer: null, onResult });
150
+ act(() => {
151
+ rendered.latest().onFrameResult(validFaceWirePayload);
152
+ });
153
+
154
+ expect(onResult).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("reports a native error marker through the latest onError", () => {
158
+ const onResult = vi.fn();
159
+ const firstOnError = vi.fn();
160
+ const secondOnError = vi.fn();
161
+ const rendered = mount({
162
+ analyzer: faceAnalyzer,
163
+ onResult,
164
+ onError: firstOnError,
165
+ });
166
+
167
+ update(rendered, {
168
+ analyzer: faceAnalyzer,
169
+ onResult,
170
+ onError: secondOnError,
171
+ });
172
+ act(() => {
173
+ rendered.latest().onFrameResult({ error: "session crashed" });
174
+ });
175
+
176
+ expect(firstOnError).not.toHaveBeenCalled();
177
+ expect(secondOnError).toHaveBeenCalledTimes(1);
178
+ expect(secondOnError.mock.calls[0]?.[0]).toBeInstanceOf(
179
+ FrameProcessingException,
180
+ );
181
+ expect(onResult).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("swallows a schema rejection when no error handler is supplied", () => {
185
+ const onResult = vi.fn();
186
+ const rendered = mount({ analyzer: faceAnalyzer, onResult });
187
+
188
+ expect(() => {
189
+ act(() => {
190
+ rendered.latest().onFrameResult({ nonsense: true });
191
+ });
192
+ }).not.toThrow();
193
+ expect(onResult).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it("drops results that arrive after unmount", () => {
197
+ const onResult = vi.fn();
198
+ const onError = vi.fn();
199
+ const rendered = mount({ analyzer: faceAnalyzer, onResult, onError });
200
+ const handle = rendered.latest();
201
+
202
+ act(() => {
203
+ rendered.tree.unmount();
204
+ });
205
+ handle.onFrameResult(validFaceWirePayload);
206
+ handle.onFrameResult({ error: "late failure" });
207
+
208
+ expect(onResult).not.toHaveBeenCalled();
209
+ expect(onError).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("gates re-entrant dispatch while a frame is in flight", () => {
213
+ const seen: string[] = [];
214
+ let handle: UseSmileIDFrameStream | null = null;
215
+ const onResult = vi.fn(() => {
216
+ seen.push("outer");
217
+ // A second frame arriving mid-dispatch must be dropped, not recursed into.
218
+ handle?.onFrameResult(validFaceWirePayload);
219
+ });
220
+ const rendered = mount({ analyzer: faceAnalyzer, onResult });
221
+ handle = rendered.latest();
222
+
223
+ act(() => {
224
+ handle?.onFrameResult(validFaceWirePayload);
225
+ });
226
+
227
+ expect(seen).toEqual(["outer"]);
228
+ });
229
+ });
@@ -12,6 +12,9 @@ export const CameraDefaults = {
12
12
  /** Document analysis resolution (16:9, matched to the visible preview so the guide-rect crop stays aligned). */
13
13
  documentAnalysisResolution: { width: 1280, height: 720 } as const,
14
14
 
15
+ /** How long a streaming capture waits for its first analyzed frame before reporting the camera unavailable. */
16
+ firstFrameTimeoutMs: 10_000,
17
+
15
18
  /** Longest-edge ceiling (px) for an imported document image; downscale-only. */
16
19
  documentMaxDimension: 1600,
17
20
  /** JPEG quality (0–1) for imported document images. */