react-native-image-stitcher 0.15.1 → 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.
- package/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- 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
|
-
});
|