react-native-image-stitcher 0.15.2 → 0.16.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 (133) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +35 -16
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +48 -16
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -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
- });
@@ -1,276 +0,0 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- /**
3
- * Unit + integration coverage for the v0.10.0 PR B refine-progress
4
- * lifecycle on the `IncrementalStateUpdate` channel.
5
- *
6
- * What this test is for:
7
- *
8
- * - Contract: native emits a 4-stage sequence
9
- * `validating → stitching → writing → done` (and the failure
10
- * variant `validating → error`) with `refineStage` /
11
- * `refineProgress` / `refineFrames` / `refineError` keys.
12
- * - Regression: catches a future renamer of any of those keys
13
- * (subscribeIncrementalState would silently deliver `undefined`
14
- * for the missing fields, and the host's progress pill would
15
- * stop rendering — exactly the bug class we hit on iOS in PR B
16
- * before the bridgeless-interop fix).
17
- *
18
- * What this test is NOT for:
19
- *
20
- * - Exercising the real native bridge — RCTEventEmitter under RN
21
- * bridgeless interop can only be tested on-device. The bug we
22
- * fixed in PR B (sendEvent silently no-ops for certain body
23
- * shapes) is verified via the manual smoke test recorded in
24
- * CHANGELOG.md "Fixed — v0.10.0 PR B (iOS)". This file pins
25
- * the JS-side contract that bridge fix has to satisfy.
26
- *
27
- * Mock surface: per-test `jest.mock('react-native', ...)` so the
28
- * shared `jest.mocks/react-native.js` stays minimal (per the comment
29
- * in that file). We stub `NativeModules.IncrementalStitcher` and
30
- * `NativeEventEmitter` together because `subscribeIncrementalState`
31
- * wires them together internally.
32
- */
33
-
34
- import type { IncrementalState } from '../incremental';
35
-
36
- // Hand-rolled event-emitter fake we can drive synchronously from
37
- // tests. Modelled on RN's NativeEventEmitter shape: addListener
38
- // returns an object with a `.remove()` method.
39
- type Listener = (state: IncrementalState) => void;
40
-
41
- class FakeNativeEventEmitter {
42
- private listeners: Map<string, Set<Listener>> = new Map();
43
-
44
- constructor(_nativeModule: unknown) {
45
- // No-op: real RN reads addListener/removeListeners off the
46
- // native module for the listener-count contract; we don't.
47
- }
48
-
49
- addListener(eventType: string, listener: Listener) {
50
- let set = this.listeners.get(eventType);
51
- if (!set) {
52
- set = new Set();
53
- this.listeners.set(eventType, set);
54
- }
55
- set.add(listener);
56
- return {
57
- remove: () => {
58
- set!.delete(listener);
59
- },
60
- };
61
- }
62
-
63
- // Test-only helper: drive an event into all subscribers.
64
- _emit(eventType: string, state: IncrementalState) {
65
- const set = this.listeners.get(eventType);
66
- if (!set) return;
67
- for (const listener of set) {
68
- listener(state);
69
- }
70
- }
71
- }
72
-
73
- // Shared emitter handle the per-test setup writes its asserts
74
- // against. The mock factory below has to construct via `new`, so we
75
- // stash the latest instance here for the test to drive.
76
- let lastEmitter: FakeNativeEventEmitter | null = null;
77
-
78
- jest.mock('react-native', () => ({
79
- NativeModules: {
80
- IncrementalStitcher: {
81
- // RCTEventEmitter / NativeEventEmitter contract — RN's runtime
82
- // calls these when JS subscribes / unsubscribes so the native
83
- // side can track listener count. We just stub them.
84
- addListener: jest.fn(),
85
- removeListeners: jest.fn(),
86
- },
87
- },
88
- NativeEventEmitter: jest.fn().mockImplementation((nativeModule: unknown) => {
89
- lastEmitter = new FakeNativeEventEmitter(nativeModule);
90
- return lastEmitter;
91
- }),
92
- Platform: { OS: 'ios', select: (spec: { ios?: unknown; default?: unknown }) => spec.ios ?? spec.default },
93
- }));
94
-
95
- // Import AFTER jest.mock so the SUT picks up the mocked module.
96
- import { subscribeIncrementalState } from '../incremental';
97
-
98
- // Build the base state shape the native side emits — matches the
99
- // fields IncrementalStateObject.asDictionary() includes on iOS and
100
- // Arguments.createMap() includes on Android.
101
- function makeBaseState(): IncrementalState {
102
- return {
103
- width: 1920,
104
- height: 1080,
105
- acceptedCount: 3,
106
- outcome: 8, // acceptedHigh
107
- confidence: 0.92,
108
- overlapPercent: 18.5,
109
- processingMs: 0,
110
- isLandscape: false,
111
- paintedExtent: 1920,
112
- panExtent: 1920,
113
- keyframeMax: 0,
114
- } as IncrementalState;
115
- }
116
-
117
- describe('subscribeIncrementalState — refine progress lifecycle (v0.10.0 PR B)', () => {
118
- beforeEach(() => {
119
- lastEmitter = null;
120
- jest.clearAllMocks();
121
- });
122
-
123
- it('returns null when the native IncrementalStitcher module is missing', () => {
124
- // Temporarily blank the module.
125
- const RN = jest.requireMock('react-native') as { NativeModules: Record<string, unknown> };
126
- const saved = RN.NativeModules.IncrementalStitcher;
127
- RN.NativeModules.IncrementalStitcher = undefined;
128
- try {
129
- expect(subscribeIncrementalState(() => {})).toBeNull();
130
- } finally {
131
- RN.NativeModules.IncrementalStitcher = saved;
132
- }
133
- });
134
-
135
- it('returns an EmitterSubscription when subscribed; remove() stops delivery', () => {
136
- const events: IncrementalState[] = [];
137
- const sub = subscribeIncrementalState((s) => events.push(s));
138
- expect(sub).not.toBeNull();
139
- expect(lastEmitter).not.toBeNull();
140
-
141
- lastEmitter!._emit('IncrementalStateUpdate', {
142
- ...makeBaseState(),
143
- refineStage: 'validating',
144
- refineProgress: 0.05,
145
- refineFrames: 3,
146
- } as IncrementalState);
147
- expect(events).toHaveLength(1);
148
-
149
- sub!.remove();
150
- lastEmitter!._emit('IncrementalStateUpdate', {
151
- ...makeBaseState(),
152
- refineStage: 'done',
153
- refineProgress: 1.0,
154
- refineFrames: 3,
155
- } as IncrementalState);
156
- expect(events).toHaveLength(1); // unchanged after remove()
157
- });
158
-
159
- it('happy-path: delivers validating → stitching → writing → done in order with correct refineStage', () => {
160
- const stages: Array<{ stage: string | undefined; progress: number | undefined }> = [];
161
- subscribeIncrementalState((s) => {
162
- stages.push({ stage: s.refineStage, progress: s.refineProgress });
163
- });
164
-
165
- const sequence: Array<Pick<IncrementalState, 'refineStage' | 'refineProgress' | 'refineFrames'>> = [
166
- { refineStage: 'validating', refineProgress: 0.05, refineFrames: 3 },
167
- { refineStage: 'stitching', refineProgress: 0.10, refineFrames: 3 },
168
- { refineStage: 'writing', refineProgress: 0.90, refineFrames: 3 },
169
- { refineStage: 'done', refineProgress: 1.00, refineFrames: 3 },
170
- ];
171
- for (const ev of sequence) {
172
- lastEmitter!._emit('IncrementalStateUpdate', { ...makeBaseState(), ...ev } as IncrementalState);
173
- }
174
-
175
- expect(stages).toEqual([
176
- { stage: 'validating', progress: 0.05 },
177
- { stage: 'stitching', progress: 0.10 },
178
- { stage: 'writing', progress: 0.90 },
179
- { stage: 'done', progress: 1.00 },
180
- ]);
181
- });
182
-
183
- it('refineProgress is non-decreasing across the happy-path sequence (monotonicity contract)', () => {
184
- const progresses: number[] = [];
185
- subscribeIncrementalState((s) => {
186
- if (s.refineProgress !== undefined) progresses.push(s.refineProgress);
187
- });
188
-
189
- for (const p of [0.05, 0.10, 0.90, 1.00]) {
190
- lastEmitter!._emit('IncrementalStateUpdate', {
191
- ...makeBaseState(),
192
- refineStage: 'stitching', // stage is irrelevant for this assertion
193
- refineProgress: p,
194
- refineFrames: 3,
195
- } as IncrementalState);
196
- }
197
-
198
- expect(progresses).toEqual([0.05, 0.10, 0.90, 1.00]);
199
- for (let i = 1; i < progresses.length; i++) {
200
- expect(progresses[i]).toBeGreaterThanOrEqual(progresses[i - 1]);
201
- }
202
- });
203
-
204
- it('failure-path: validating → error carries refineError; no further stages emitted', () => {
205
- const events: IncrementalState[] = [];
206
- subscribeIncrementalState((s) => events.push(s));
207
-
208
- lastEmitter!._emit('IncrementalStateUpdate', {
209
- ...makeBaseState(),
210
- refineStage: 'validating',
211
- refineProgress: 0.05,
212
- refineFrames: 3,
213
- } as IncrementalState);
214
- lastEmitter!._emit('IncrementalStateUpdate', {
215
- ...makeBaseState(),
216
- refineStage: 'error',
217
- refineProgress: 1.0,
218
- refineFrames: 3,
219
- refineError: 'INVALID_FRAMES: missing JPEG at index 1',
220
- } as IncrementalState);
221
-
222
- expect(events).toHaveLength(2);
223
- expect(events[0].refineStage).toBe('validating');
224
- expect(events[1].refineStage).toBe('error');
225
- expect(events[1].refineError).toBe('INVALID_FRAMES: missing JPEG at index 1');
226
- // refineError is absent on the validating event.
227
- expect(events[0].refineError).toBeUndefined();
228
- });
229
-
230
- it('refineFrames passes through unchanged (regression guard for key rename)', () => {
231
- const seen: Array<number | undefined> = [];
232
- subscribeIncrementalState((s) => seen.push(s.refineFrames));
233
-
234
- for (const n of [3, 5, 8]) {
235
- lastEmitter!._emit('IncrementalStateUpdate', {
236
- ...makeBaseState(),
237
- refineStage: 'stitching',
238
- refineProgress: 0.5,
239
- refineFrames: n,
240
- } as IncrementalState);
241
- }
242
- expect(seen).toEqual([3, 5, 8]);
243
- });
244
-
245
- it('live (non-refine) state events leave refine fields undefined', () => {
246
- // Asserts that the contract is "refine fields are only populated
247
- // during a refine call" — so the example app's `if
248
- // (s.refineStage === undefined) return;` short-circuit is sound.
249
- const events: IncrementalState[] = [];
250
- subscribeIncrementalState((s) => events.push(s));
251
-
252
- lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
253
- expect(events).toHaveLength(1);
254
- expect(events[0].refineStage).toBeUndefined();
255
- expect(events[0].refineProgress).toBeUndefined();
256
- expect(events[0].refineFrames).toBeUndefined();
257
- expect(events[0].refineError).toBeUndefined();
258
- });
259
-
260
- it('subscribes on the correct channel name "IncrementalStateUpdate" (cross-platform contract)', () => {
261
- // If anyone renames the event constant on either side, the
262
- // subscriber stops receiving events. Pin the literal here.
263
- let receivedOnRight = false;
264
- let receivedOnWrong = false;
265
- subscribeIncrementalState(() => {
266
- receivedOnRight = true;
267
- });
268
- // Fire on a deliberately-wrong channel — should NOT deliver.
269
- lastEmitter!._emit('SomeOtherChannel', makeBaseState());
270
- expect(receivedOnRight).toBe(false);
271
- expect(receivedOnWrong).toBe(false);
272
- // Fire on the right channel — should deliver.
273
- lastEmitter!._emit('IncrementalStateUpdate', makeBaseState());
274
- expect(receivedOnRight).toBe(true);
275
- });
276
- });