react-native-image-stitcher 0.15.2 → 0.16.1

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 (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -1,52 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for the pure `isBelowMemThreshold` classifier.
4
- *
5
- * The other two exports (`getPhysicalMemoryBytes` and `isLowMemDevice`)
6
- * read the React Native bridge and can only be exercised on a real
7
- * device. This file covers the threshold logic exhaustively so the
8
- * classification rule is unit-tested without needing the RN runtime.
9
- */
10
-
11
- import {
12
- LOW_MEM_THRESHOLD_BYTES,
13
- isBelowMemThreshold,
14
- } from '../lowMemDevice';
15
-
16
-
17
- describe('isBelowMemThreshold', () => {
18
- it('returns true for positive byte counts below the threshold', () => {
19
- expect(isBelowMemThreshold(1)).toBe(true);
20
- expect(isBelowMemThreshold(1024)).toBe(true);
21
- expect(isBelowMemThreshold(LOW_MEM_THRESHOLD_BYTES - 1)).toBe(true);
22
- });
23
-
24
- it('returns false at exactly the threshold (strict < comparison)', () => {
25
- expect(isBelowMemThreshold(LOW_MEM_THRESHOLD_BYTES)).toBe(false);
26
- });
27
-
28
- it('returns false for byte counts above the threshold', () => {
29
- expect(isBelowMemThreshold(LOW_MEM_THRESHOLD_BYTES + 1)).toBe(false);
30
- expect(isBelowMemThreshold(4 * 1024 * 1024 * 1024)).toBe(false);
31
- expect(isBelowMemThreshold(Number.MAX_SAFE_INTEGER)).toBe(false);
32
- });
33
-
34
- it('returns false for zero (unknown — safe default to high-quality combo)', () => {
35
- expect(isBelowMemThreshold(0)).toBe(false);
36
- });
37
-
38
- it('returns false for negative values (defensive)', () => {
39
- expect(isBelowMemThreshold(-1)).toBe(false);
40
- expect(isBelowMemThreshold(-LOW_MEM_THRESHOLD_BYTES)).toBe(false);
41
- });
42
-
43
- it('returns false for non-finite values (NaN / Infinity)', () => {
44
- expect(isBelowMemThreshold(Number.NaN)).toBe(false);
45
- expect(isBelowMemThreshold(Number.POSITIVE_INFINITY)).toBe(false);
46
- expect(isBelowMemThreshold(Number.NEGATIVE_INFINITY)).toBe(false);
47
- });
48
-
49
- it('threshold is exactly 2 GB', () => {
50
- expect(LOW_MEM_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
51
- });
52
- });
@@ -1,210 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for `selectCaptureDevice` + `zoomForLens` — the pure
4
- * capability-aware back-camera selection (v0.13.2).
5
- *
6
- * Covers the device matrix from the plan
7
- * (docs/plans/2026-06-01-v0.13.2-multilens-device-selection.md),
8
- * including the critical edge cases:
9
- * - ultra-wide ONLY inside a multi-cam device (Symptom 1 fix)
10
- * - ultra-wide ONLY as a standalone device (Android; must NOT regress)
11
- * - ultra-wide present BOTH ways (prefer multicam)
12
- *
13
- * Pure — no mocks needed; we build synthetic DeviceLike lists.
14
- */
15
-
16
- import {
17
- selectCaptureDevice,
18
- zoomForLens,
19
- type DeviceLike,
20
- } from '../selectCaptureDevice';
21
-
22
- // ── Synthetic device builders ───────────────────────────────────────
23
- let idCounter = 0;
24
- function dev(partial: Partial<DeviceLike>): DeviceLike {
25
- idCounter += 1;
26
- return {
27
- id: `dev-${idCounter}`,
28
- position: 'back',
29
- physicalDevices: ['wide-angle-camera'],
30
- isMultiCam: false,
31
- hasTorch: true,
32
- minZoom: 1,
33
- neutralZoom: 1,
34
- maxZoom: 10,
35
- ...partial,
36
- };
37
- }
38
-
39
- const tripleCam = (p: Partial<DeviceLike> = {}) =>
40
- dev({
41
- physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera', 'telephoto-camera'],
42
- isMultiCam: true,
43
- hasTorch: true,
44
- minZoom: 0.5,
45
- neutralZoom: 1,
46
- maxZoom: 30,
47
- ...p,
48
- });
49
- const dualWide = (p: Partial<DeviceLike> = {}) =>
50
- dev({
51
- physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera'],
52
- isMultiCam: true,
53
- hasTorch: true,
54
- minZoom: 0.5,
55
- neutralZoom: 1,
56
- maxZoom: 6,
57
- ...p,
58
- });
59
- const standaloneWide = (p: Partial<DeviceLike> = {}) =>
60
- dev({ physicalDevices: ['wide-angle-camera'], isMultiCam: false, hasTorch: true, ...p });
61
- const standaloneUltraWide = (p: Partial<DeviceLike> = {}) =>
62
- dev({ physicalDevices: ['ultra-wide-angle-camera'], isMultiCam: false, hasTorch: false, ...p });
63
-
64
- describe('selectCaptureDevice', () => {
65
- it('picks the MULTICAM device when one spans wide + ultra-wide (triple cam)', () => {
66
- const triple = tripleCam();
67
- const sel = selectCaptureDevice([triple, standaloneWide(), standaloneUltraWide()]);
68
- expect(sel.mode).toBe('multicam');
69
- expect(sel.device).toBe(triple);
70
- expect(sel.ultraWideDevice).toBeNull();
71
- expect(sel.has0_5x).toBe(true);
72
- expect(sel.hasTorch).toBe(true);
73
- });
74
-
75
- it('picks the MULTICAM device for a dual-wide grouping', () => {
76
- const dual = dualWide();
77
- const sel = selectCaptureDevice([dual, standaloneWide()]);
78
- expect(sel.mode).toBe('multicam');
79
- expect(sel.device).toBe(dual);
80
- expect(sel.has0_5x).toBe(true);
81
- });
82
-
83
- it('SYMPTOM 1 FIX: ultra-wide ONLY in a multi-cam device → multicam (not wide fallback)', () => {
84
- // The exact bug: a phone where ultra-wide is bundled in a multicam
85
- // device and there is NO standalone ultra-wide. Old single-lens
86
- // filter fell back to wide-angle; we must pick the multicam.
87
- const dual = dualWide();
88
- const wide = standaloneWide();
89
- const sel = selectCaptureDevice([wide, dual]);
90
- expect(sel.mode).toBe('multicam');
91
- expect(sel.device).toBe(dual);
92
- expect(sel.has0_5x).toBe(true);
93
- });
94
-
95
- it('EDGE: ultra-wide ONLY as a standalone device (Android) → standalone-uw (no regression)', () => {
96
- // No multicam grouping at all. Must still expose 0.5× via the
97
- // standalone ultra-wide, mounting the wide-angle as primary.
98
- const wide = standaloneWide();
99
- const uw = standaloneUltraWide();
100
- const sel = selectCaptureDevice([wide, uw]);
101
- expect(sel.mode).toBe('standalone-uw');
102
- expect(sel.device).toBe(wide); // primary = torch-bearing wide
103
- expect(sel.ultraWideDevice).toBe(uw);
104
- expect(sel.has0_5x).toBe(true);
105
- expect(sel.hasTorch).toBe(true); // the 1× mount has a torch
106
- });
107
-
108
- it('EDGE: ultra-wide present BOTH standalone AND in multicam → prefer multicam', () => {
109
- const dual = dualWide();
110
- const wide = standaloneWide();
111
- const uw = standaloneUltraWide();
112
- const sel = selectCaptureDevice([uw, wide, dual]);
113
- expect(sel.mode).toBe('multicam');
114
- expect(sel.device).toBe(dual);
115
- });
116
-
117
- it('wide-angle ONLY (no ultra-wide anywhere) → wide-only, no 0.5×', () => {
118
- const wide = standaloneWide();
119
- const sel = selectCaptureDevice([wide]);
120
- expect(sel.mode).toBe('wide-only');
121
- expect(sel.device).toBe(wide);
122
- expect(sel.has0_5x).toBe(false);
123
- expect(sel.ultraWideDevice).toBeNull();
124
- });
125
-
126
- it('prefers a TORCH-bearing multicam device over a torchless one', () => {
127
- const noTorch = dualWide({ hasTorch: false });
128
- const withTorch = tripleCam({ hasTorch: true });
129
- const sel = selectCaptureDevice([noTorch, withTorch]);
130
- expect(sel.mode).toBe('multicam');
131
- expect(sel.device).toBe(withTorch);
132
- expect(sel.hasTorch).toBe(true);
133
- });
134
-
135
- it('ignores front-facing devices', () => {
136
- const front = dev({ position: 'front', physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera'], isMultiCam: true });
137
- const backWide = standaloneWide();
138
- const sel = selectCaptureDevice([front, backWide]);
139
- expect(sel.mode).toBe('wide-only'); // front multicam doesn't count
140
- expect(sel.device).toBe(backWide);
141
- });
142
-
143
- it('empty device list → null device, wide-only, no 0.5×', () => {
144
- const sel = selectCaptureDevice([]);
145
- expect(sel.device).toBeNull();
146
- expect(sel.mode).toBe('wide-only');
147
- expect(sel.has0_5x).toBe(false);
148
- expect(sel.hasTorch).toBe(false);
149
- });
150
-
151
- it('standalone-uw: primary prefers a torch-bearing wide when multiple wides exist', () => {
152
- const wideNoTorch = standaloneWide({ hasTorch: false });
153
- const wideTorch = standaloneWide({ hasTorch: true });
154
- const uw = standaloneUltraWide();
155
- const sel = selectCaptureDevice([wideNoTorch, uw, wideTorch]);
156
- expect(sel.mode).toBe('standalone-uw');
157
- expect(sel.device).toBe(wideTorch);
158
- expect(sel.hasTorch).toBe(true);
159
- });
160
-
161
- it('S24: multicam LISTS ultra-wide but zoom cannot reach it (minZoom~1) + standalone uw swaps', () => {
162
- // Samsung/Camera2: the logical device lists the ultra-wide but its zoom
163
- // range starts at 1.0, so zoom cannot reach it. A separate ultra-wide id
164
- // exists -> keep the multicam for 1x (torch) and swap to the standalone
165
- // ultra-wide on 0.5x.
166
- const multicamNoReach = dualWide({ minZoom: 1, hasTorch: true });
167
- const uw = standaloneUltraWide();
168
- const sel = selectCaptureDevice([multicamNoReach, uw]);
169
- expect(sel.mode).toBe('standalone-uw');
170
- expect(sel.device).toBe(multicamNoReach); // 1x primary keeps the torch
171
- expect(sel.ultraWideDevice).toBe(uw); // 0.5x swaps to the real ultra-wide
172
- expect(sel.has0_5x).toBe(true);
173
- expect(sel.hasTorch).toBe(true);
174
- });
175
-
176
- it('multicam lists ultra-wide, zoom cannot reach (minZoom~1), NO standalone uw -> hide', () => {
177
- // The ultra-wide exists ONLY inside a non-zoomable logical device with no
178
- // separate id to swap to -> undeliverable -> hide the chooser.
179
- const multicamNoReach = dualWide({ minZoom: 1 });
180
- const sel = selectCaptureDevice([multicamNoReach]);
181
- expect(sel.mode).toBe('wide-only');
182
- expect(sel.has0_5x).toBe(false);
183
- expect(sel.ultraWideDevice).toBeNull();
184
- });
185
-
186
- it('minZoom threshold: <=0.7 zoom-switches, >0.7 falls through to swap', () => {
187
- const atThreshold = dualWide({ minZoom: 0.7 });
188
- expect(selectCaptureDevice([atThreshold]).mode).toBe('multicam');
189
- const aboveThreshold = dualWide({ minZoom: 0.71 });
190
- const uw = standaloneUltraWide();
191
- expect(selectCaptureDevice([aboveThreshold, uw]).mode).toBe('standalone-uw');
192
- });
193
- });
194
-
195
- describe('zoomForLens (multicam lens→zoom mapping)', () => {
196
- const d = { minZoom: 0.5, neutralZoom: 1 };
197
-
198
- it('maps 0.5× to the device minZoom (ultra-wide end)', () => {
199
- expect(zoomForLens(d, '0.5x')).toBe(0.5);
200
- });
201
-
202
- it('maps 1× to the device neutralZoom (wide-angle baseline)', () => {
203
- expect(zoomForLens(d, '1x')).toBe(1);
204
- });
205
-
206
- it('handles a device whose neutralZoom differs from 1', () => {
207
- expect(zoomForLens({ minZoom: 0.6, neutralZoom: 2 }, '1x')).toBe(2);
208
- expect(zoomForLens({ minZoom: 0.6, neutralZoom: 2 }, '0.5x')).toBe(0.6);
209
- });
210
- });
@@ -1,89 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for `contentRotationDeg` — the pure rotation computation
4
- * behind `useContentRotation`, which keeps control content (AR toggle,
5
- * lens/zoom pill, flash, thumbnails) upright relative to gravity
6
- * regardless of host portrait-lock state.
7
- *
8
- * Covers the full truth table from the hook's docstring plus the
9
- * mid-rotation transients (jsLandscape=true with a non-landscape device
10
- * reading, which can briefly happen while the OS catches up).
11
- *
12
- * Pure-TS test per jest.config.js. `useContentRotation` transitively
13
- * imports `useDeviceOrientation` → `react-native-sensors` (an ES module
14
- * the no-RN-preset jest infra can't parse), so stub it before importing
15
- * the SUT. We only call the pure `contentRotationDeg` export.
16
- */
17
-
18
- jest.mock('react-native-sensors', () => ({
19
- accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
20
- gyroscope: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
21
- setUpdateIntervalForType: jest.fn(),
22
- SensorTypes: { accelerometer: 'accelerometer', gyroscope: 'gyroscope' },
23
- }));
24
-
25
- import { contentRotationDeg } from '../useContentRotation';
26
-
27
- describe('contentRotationDeg', () => {
28
- // Locked-portrait host: jsLandscape is ALWAYS false (window dims stay
29
- // portrait regardless of device tilt). The OS doesn't rotate the
30
- // framebuffer, so content rotation must match device-physical for
31
- // labels to read upright. THIS is the case task #5b targets.
32
-
33
- it('locked-portrait + device-portrait → 0° (no-op)', () => {
34
- expect(contentRotationDeg(false, 'portrait')).toBe(0);
35
- });
36
-
37
- it('locked-portrait + device-landscape-left → 90° (CW)', () => {
38
- expect(contentRotationDeg(false, 'landscape-left')).toBe(90);
39
- });
40
-
41
- it('locked-portrait + device-landscape-right → -90° (CCW)', () => {
42
- expect(contentRotationDeg(false, 'landscape-right')).toBe(-90);
43
- });
44
-
45
- it('locked-portrait + device-upside-down → 180°', () => {
46
- expect(contentRotationDeg(false, 'portrait-upside-down')).toBe(180);
47
- });
48
-
49
- // Non-locked host + device-landscape: OS rotated the framebuffer for
50
- // us; we must NOT double-rotate. Net rotation must be 0.
51
-
52
- it('non-locked + device-landscape-left (jsLandscape=true) → 0°', () => {
53
- expect(contentRotationDeg(true, 'landscape-left')).toBe(0);
54
- });
55
-
56
- it('non-locked + device-landscape-right (jsLandscape=true) → 0°', () => {
57
- expect(contentRotationDeg(true, 'landscape-right')).toBe(0);
58
- });
59
-
60
- it('non-locked + device-portrait (jsLandscape=false) → 0°', () => {
61
- expect(contentRotationDeg(false, 'portrait')).toBe(0);
62
- });
63
-
64
- // Mid-rotation transients: jsLandscape=true with a non-landscape
65
- // device reading. Falls through to 0 framebuffer rotation and
66
- // applies device rotation directly; settles once the transient clears.
67
-
68
- it('jsLandscape=true mid-rotation with device-portrait → 0°', () => {
69
- expect(contentRotationDeg(true, 'portrait')).toBe(0);
70
- });
71
-
72
- it('jsLandscape=true mid-rotation with device-upside-down → 180°', () => {
73
- expect(contentRotationDeg(true, 'portrait-upside-down')).toBe(180);
74
- });
75
-
76
- it('all returned values are in {0, 90, -90, 180} (no off-by-360°)', () => {
77
- const orientations = [
78
- 'portrait',
79
- 'portrait-upside-down',
80
- 'landscape-left',
81
- 'landscape-right',
82
- ] as const;
83
- for (const o of orientations) {
84
- for (const jsl of [true, false]) {
85
- expect([0, 90, -90, 180]).toContain(contentRotationDeg(jsl, o));
86
- }
87
- }
88
- });
89
- });
@@ -1,169 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit tests for `useOrientationDrift` — exercises the pure
4
- * state-transition function `_computeDriftStateForTests` directly.
5
- *
6
- * Why not test the hook end-to-end via render: the lib's jest
7
- * config is `preset: 'ts-jest'` + `testEnvironment: 'node'` — no
8
- * React Native preset, no `@testing-library/react-native`. See the
9
- * jest.config.js header comment: "If we ever add component-render
10
- * tests we'd flip to the RN preset then." The component-render
11
- * tests for `<OrientationDriftModal>`, `<PanoramaBandOverlay>`,
12
- * `<ViewportCropOverlay>`, and `<Camera>` composition (all called
13
- * out in the v0.12 plan) will all need that flip. Setting it up
14
- * is grouped in Phase 5 of the plan (Tests) rather than scattered
15
- * across each PR. For PR-1, the pure state-transition function
16
- * carries the full behavioural contract — same approach
17
- * `useThrottledFrameProcessor.test.ts` uses for its throttle gate.
18
- *
19
- * The 5 cases below cover the full state machine per the plan
20
- * (lines 119, 277):
21
- *
22
- * (a) no change → not drifted
23
- * (b) orientation changes during active=true → drifted
24
- * (c) drift state survives further changes (latching)
25
- * (d) inactive → captureOrientation undefined
26
- * (e) active resets snapshot (false → true → false → true cycle)
27
- */
28
-
29
- // Mock `react-native-sensors` BEFORE importing the SUT. The hook
30
- // itself transitively pulls in `useDeviceOrientation` which imports
31
- // `accelerometer` from `react-native-sensors` — an ES module that
32
- // jest can't parse without the RN preset (which jest.config.js
33
- // intentionally avoids; see config header comment). We're only
34
- // testing the pure transition function below, but TS imports are
35
- // transitive so we still need to silence the chain.
36
- jest.mock('react-native-sensors', () => ({
37
- accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
38
- setUpdateIntervalForType: jest.fn(),
39
- SensorTypes: { accelerometer: 'accelerometer' },
40
- }));
41
-
42
- // eslint-disable-next-line import/first
43
- import { _computeDriftStateForTests } from '../useOrientationDrift';
44
-
45
- const INITIAL = { captureOrientation: undefined, drifted: false };
46
-
47
- describe('_computeDriftStateForTests (useOrientationDrift core logic)', () => {
48
- describe('(a) no change → not drifted', () => {
49
- it('stays in initial state when active is false from the start', () => {
50
- const next = _computeDriftStateForTests(INITIAL, false, 'portrait');
51
- expect(next).toEqual({ captureOrientation: undefined, drifted: false });
52
- });
53
-
54
- it('snapshots orientation when active flips true, drifted starts false', () => {
55
- const next = _computeDriftStateForTests(INITIAL, true, 'portrait');
56
- expect(next).toEqual({ captureOrientation: 'portrait', drifted: false });
57
- });
58
-
59
- it('stays clean when active=true and orientation does not change', () => {
60
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
61
- const after2 = _computeDriftStateForTests(after1, true, 'portrait');
62
- const after3 = _computeDriftStateForTests(after2, true, 'portrait');
63
- expect(after3).toEqual({ captureOrientation: 'portrait', drifted: false });
64
- // Reference equality: once steady, returns the prev ref so
65
- // React's setState becomes a no-op (no re-render).
66
- expect(after2).toBe(after1);
67
- expect(after3).toBe(after2);
68
- });
69
- });
70
-
71
- describe('(b) orientation changes during active=true → drifted', () => {
72
- it('latches drifted=true when orientation changes mid-active', () => {
73
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
74
- const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
75
- expect(after2).toEqual({ captureOrientation: 'portrait', drifted: true });
76
- });
77
-
78
- it('captures the ORIGINAL orientation in captureOrientation, not the new one', () => {
79
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
80
- const after2 = _computeDriftStateForTests(after1, true, 'landscape-right');
81
- // captureOrientation MUST remain the snapshot (portrait), not
82
- // the current rotation — that's how the drift modal copy
83
- // ("captured in PORTRAIT, now LANDSCAPE-RIGHT") works.
84
- expect(after2.captureOrientation).toBe('portrait');
85
- });
86
-
87
- it('detects drift to any of the 3 other orientations', () => {
88
- const cases: Array<['portrait', 'portrait-upside-down' | 'landscape-left' | 'landscape-right']> = [
89
- ['portrait', 'portrait-upside-down'],
90
- ['portrait', 'landscape-left'],
91
- ['portrait', 'landscape-right'],
92
- ];
93
- for (const [captured, drifted] of cases) {
94
- const after1 = _computeDriftStateForTests(INITIAL, true, captured);
95
- const after2 = _computeDriftStateForTests(after1, true, drifted);
96
- expect(after2.drifted).toBe(true);
97
- }
98
- });
99
- });
100
-
101
- describe('(c) drift state survives further changes (latching)', () => {
102
- it('stays drifted even if the user rotates back to the captured orientation', () => {
103
- // User rotates portrait → landscape (drift triggers) → portrait
104
- // (back to original). The flag MUST stay latched. Rationale:
105
- // the engine docstring says cross-mode capture is "best-effort,
106
- // not supported" — a brief rotation pollutes the buffer even
107
- // if the user rotates back, so the safe action is decisive
108
- // abandonment regardless of post-detection orientation.
109
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
110
- const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
111
- const after3 = _computeDriftStateForTests(after2, true, 'portrait');
112
- expect(after3).toEqual({ captureOrientation: 'portrait', drifted: true });
113
- });
114
-
115
- it('stays drifted across multiple subsequent orientation changes', () => {
116
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
117
- const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
118
- const after3 = _computeDriftStateForTests(after2, true, 'landscape-right');
119
- const after4 = _computeDriftStateForTests(after3, true, 'portrait-upside-down');
120
- expect(after4.drifted).toBe(true);
121
- expect(after4.captureOrientation).toBe('portrait');
122
- });
123
- });
124
-
125
- describe('(d) inactive → captureOrientation undefined', () => {
126
- it('clears the snapshot when active flips back to false', () => {
127
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
128
- const after2 = _computeDriftStateForTests(after1, false, 'portrait');
129
- expect(after2).toEqual({ captureOrientation: undefined, drifted: false });
130
- });
131
-
132
- it('clears the drift flag when active flips back to false', () => {
133
- const after1 = _computeDriftStateForTests(INITIAL, true, 'portrait');
134
- const after2 = _computeDriftStateForTests(after1, true, 'landscape-left');
135
- expect(after2.drifted).toBe(true);
136
- const after3 = _computeDriftStateForTests(after2, false, 'landscape-left');
137
- expect(after3).toEqual({ captureOrientation: undefined, drifted: false });
138
- });
139
-
140
- it('is idempotent — no state change when inactive and already clear', () => {
141
- const after1 = _computeDriftStateForTests(INITIAL, false, 'portrait');
142
- const after2 = _computeDriftStateForTests(after1, false, 'landscape-left');
143
- // Same ref → setState becomes a no-op.
144
- expect(after2).toBe(after1);
145
- });
146
- });
147
-
148
- describe('(e) active resets snapshot', () => {
149
- it('re-snapshots on a fresh active cycle (false → true → false → true)', () => {
150
- // Cycle 1: capture in portrait, drift.
151
- const c1a = _computeDriftStateForTests(INITIAL, true, 'portrait');
152
- const c1b = _computeDriftStateForTests(c1a, true, 'landscape-left');
153
- expect(c1b).toEqual({ captureOrientation: 'portrait', drifted: true });
154
-
155
- // Stop the capture.
156
- const cleared = _computeDriftStateForTests(c1b, false, 'landscape-left');
157
- expect(cleared).toEqual({ captureOrientation: undefined, drifted: false });
158
-
159
- // Cycle 2: re-capture, now in landscape-left. Snapshot
160
- // should be landscape-left, NOT carry over the old portrait.
161
- const c2a = _computeDriftStateForTests(cleared, true, 'landscape-left');
162
- expect(c2a).toEqual({ captureOrientation: 'landscape-left', drifted: false });
163
-
164
- // And staying in landscape-left should not drift.
165
- const c2b = _computeDriftStateForTests(c2a, true, 'landscape-left');
166
- expect(c2b.drifted).toBe(false);
167
- });
168
- });
169
- });