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.
- package/CHANGELOG.md +124 -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 +35 -16
- 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 +48 -16
- 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,160 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for buildPanoramaInitialSettings — the prop→settings-tree
|
|
4
|
-
* translation that runs once at <Camera>'s mount.
|
|
5
|
-
*
|
|
6
|
-
* Coverage:
|
|
7
|
-
*
|
|
8
|
-
* - Defaults (no prop overrides) reproduce DEFAULT_PANORAMA_SETTINGS
|
|
9
|
-
* when `isLowMemDevice=false`.
|
|
10
|
-
* - `isLowMemDevice=true` swaps blender + seamFinder defaults to the
|
|
11
|
-
* feather+skip fallback; other fields unchanged.
|
|
12
|
-
* - Every prop override routes to its hierarchical path: stitchMode →
|
|
13
|
-
* stitcher.stitchMode, defaultFlowMaxTranslationCm →
|
|
14
|
-
* frameSelection.flow.maxTranslationCm, etc.
|
|
15
|
-
* - Partial overrides leave non-overridden fields at the default.
|
|
16
|
-
*
|
|
17
|
-
* Plus a "wire-format integration" check: the produced settings tree,
|
|
18
|
-
* fed through `panoramaSettingsToNativeConfig`, lands at the expected
|
|
19
|
-
* flat dict. This is the seam where the prop translation, the
|
|
20
|
-
* hierarchical tree, and the bridge all meet — verifying it here means
|
|
21
|
-
* <Camera>'s `incremental.start({ config })` call is correctly wired
|
|
22
|
-
* end-to-end on the JS side (on-device run remains the integration
|
|
23
|
-
* check across the JS/native boundary itself).
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
DEFAULT_PANORAMA_SETTINGS,
|
|
28
|
-
type PanoramaSettings,
|
|
29
|
-
} from '../PanoramaSettings';
|
|
30
|
-
import { panoramaSettingsToNativeConfig } from '../PanoramaSettingsBridge';
|
|
31
|
-
import { buildPanoramaInitialSettings } from '../buildPanoramaInitialSettings';
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
describe('buildPanoramaInitialSettings', () => {
|
|
35
|
-
it('returns DEFAULT_PANORAMA_SETTINGS verbatim when no overrides and not low-mem', () => {
|
|
36
|
-
const s = buildPanoramaInitialSettings({}, false);
|
|
37
|
-
expect(s).toEqual(DEFAULT_PANORAMA_SETTINGS);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('swaps blender + seamFinder for the low-mem fallback', () => {
|
|
41
|
-
const s = buildPanoramaInitialSettings({}, true);
|
|
42
|
-
expect(s.stitcher.blenderType).toBe('feather');
|
|
43
|
-
expect(s.stitcher.seamFinderType).toBe('skip');
|
|
44
|
-
// Everything else stays at the static default.
|
|
45
|
-
expect(s.stitcher.stitchMode).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.stitchMode);
|
|
46
|
-
expect(s.stitcher.warperType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.warperType);
|
|
47
|
-
expect(s.frameSelection).toEqual(DEFAULT_PANORAMA_SETTINGS.frameSelection);
|
|
48
|
-
expect(s.captureSource).toBe(DEFAULT_PANORAMA_SETTINGS.captureSource);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('routes every prop override to its hierarchical path', () => {
|
|
52
|
-
const s = buildPanoramaInitialSettings(
|
|
53
|
-
{
|
|
54
|
-
defaultCaptureSource: 'non-ar',
|
|
55
|
-
defaultStitchMode: 'scans',
|
|
56
|
-
defaultBlender: 'feather',
|
|
57
|
-
defaultSeamFinder: 'skip',
|
|
58
|
-
defaultWarper: 'cylindrical',
|
|
59
|
-
defaultFlowNoveltyPercentile: 0.70,
|
|
60
|
-
defaultFlowEvalEveryNFrames: 3,
|
|
61
|
-
defaultFlowMaxTranslationCm: 12,
|
|
62
|
-
defaultKeyframeMaxCount: 8,
|
|
63
|
-
defaultKeyframeOverlapThreshold: 0.30,
|
|
64
|
-
maxInscribedRectCrop: true,
|
|
65
|
-
},
|
|
66
|
-
false,
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
expect(s.captureSource).toBe('non-ar');
|
|
70
|
-
expect(s.stitcher.stitchMode).toBe('scans');
|
|
71
|
-
expect(s.stitcher.blenderType).toBe('feather');
|
|
72
|
-
expect(s.stitcher.seamFinderType).toBe('skip');
|
|
73
|
-
expect(s.stitcher.warperType).toBe('cylindrical');
|
|
74
|
-
expect(s.frameSelection.flow?.noveltyPercentile).toBe(0.70);
|
|
75
|
-
expect(s.frameSelection.flow?.evalEveryNFrames).toBe(3);
|
|
76
|
-
expect(s.frameSelection.flow?.maxTranslationCm).toBe(12);
|
|
77
|
-
expect(s.frameSelection.maxKeyframes).toBe(8);
|
|
78
|
-
expect(s.frameSelection.overlapThreshold).toBe(0.30);
|
|
79
|
-
expect(s.stitcher.enableMaxInscribedRectCrop).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('maps maxInscribedRectCrop → stitcher.enableMaxInscribedRectCrop', () => {
|
|
83
|
-
expect(
|
|
84
|
-
buildPanoramaInitialSettings({ maxInscribedRectCrop: true }, false)
|
|
85
|
-
.stitcher.enableMaxInscribedRectCrop,
|
|
86
|
-
).toBe(true);
|
|
87
|
-
expect(
|
|
88
|
-
buildPanoramaInitialSettings({ maxInscribedRectCrop: false }, false)
|
|
89
|
-
.stitcher.enableMaxInscribedRectCrop,
|
|
90
|
-
).toBe(false);
|
|
91
|
-
// Omitted ⇒ default (false — inscribed-rect crop is opt-in), and the
|
|
92
|
-
// low-mem fallback must not flip it.
|
|
93
|
-
expect(
|
|
94
|
-
buildPanoramaInitialSettings({}, false)
|
|
95
|
-
.stitcher.enableMaxInscribedRectCrop,
|
|
96
|
-
).toBe(false);
|
|
97
|
-
expect(
|
|
98
|
-
buildPanoramaInitialSettings({}, true)
|
|
99
|
-
.stitcher.enableMaxInscribedRectCrop,
|
|
100
|
-
).toBe(false);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('maps defaultMaxKeyframeIntervalMs → frameSelection.maxKeyframeIntervalMs', () => {
|
|
104
|
-
expect(
|
|
105
|
-
buildPanoramaInitialSettings({ defaultMaxKeyframeIntervalMs: 3500 }, false)
|
|
106
|
-
.frameSelection.maxKeyframeIntervalMs,
|
|
107
|
-
).toBe(3500);
|
|
108
|
-
// 0 explicitly disables the time-budget force-accept — it is NOT
|
|
109
|
-
// nullish, so `??` does not replace it with the default.
|
|
110
|
-
expect(
|
|
111
|
-
buildPanoramaInitialSettings({ defaultMaxKeyframeIntervalMs: 0 }, false)
|
|
112
|
-
.frameSelection.maxKeyframeIntervalMs,
|
|
113
|
-
).toBe(0);
|
|
114
|
-
// Omitted ⇒ the 2000 ms default.
|
|
115
|
-
expect(
|
|
116
|
-
buildPanoramaInitialSettings({}, false)
|
|
117
|
-
.frameSelection.maxKeyframeIntervalMs,
|
|
118
|
-
).toBe(2000);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('leaves non-overridden fields at the default (partial override)', () => {
|
|
122
|
-
const s = buildPanoramaInitialSettings(
|
|
123
|
-
{ defaultStitchMode: 'panorama' },
|
|
124
|
-
false,
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
// The override took effect …
|
|
128
|
-
expect(s.stitcher.stitchMode).toBe('panorama');
|
|
129
|
-
|
|
130
|
-
// … and every other field stays at the corresponding default.
|
|
131
|
-
expect(s.stitcher.warperType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.warperType);
|
|
132
|
-
expect(s.stitcher.blenderType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.blenderType);
|
|
133
|
-
expect(s.stitcher.seamFinderType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.seamFinderType);
|
|
134
|
-
expect(s.frameSelection).toEqual(DEFAULT_PANORAMA_SETTINGS.frameSelection);
|
|
135
|
-
expect(s.captureSource).toBe(DEFAULT_PANORAMA_SETTINGS.captureSource);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('produces wire-format-clean output when piped through the bridge', () => {
|
|
139
|
-
// The end-to-end JS-side path: props → buildPanoramaInitialSettings →
|
|
140
|
-
// panoramaSettingsToNativeConfig. Verifying it here catches drift
|
|
141
|
-
// at any of the three layers (prop name, type-tree shape, bridge
|
|
142
|
-
// adapter) with a single assertion.
|
|
143
|
-
const overrides = {
|
|
144
|
-
defaultCaptureSource: 'non-ar' as const,
|
|
145
|
-
defaultStitchMode: 'scans' as const,
|
|
146
|
-
defaultFlowMaxTranslationCm: 25,
|
|
147
|
-
};
|
|
148
|
-
const settings: PanoramaSettings =
|
|
149
|
-
buildPanoramaInitialSettings(overrides, false);
|
|
150
|
-
const wire = panoramaSettingsToNativeConfig(settings);
|
|
151
|
-
|
|
152
|
-
expect(wire.captureSource).toBe('non-ar');
|
|
153
|
-
expect(wire.stitchMode).toBe('scans');
|
|
154
|
-
expect(wire.flowMaxTranslationCm).toBe(25);
|
|
155
|
-
// Defaulted fields still on the wire with their default value.
|
|
156
|
-
expect(wire.warperType).toBe('plane');
|
|
157
|
-
expect(wire.frameSelectionMode).toBe('flow-based');
|
|
158
|
-
expect(wire.flowNoveltyPercentile).toBe(0.85);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for the recoverable-stitch-failure guidance map.
|
|
4
|
-
*
|
|
5
|
-
* Guarantees: (1) every recoverable code yields non-empty, plain-language
|
|
6
|
-
* copy with no raw cv::Stitcher diagnostic leaking through; (2) the
|
|
7
|
-
* cause-specific guidance actually names its corrective action; (3) every
|
|
8
|
-
* non-recoverable code returns null so the host falls back to its generic
|
|
9
|
-
* error UI.
|
|
10
|
-
*/
|
|
11
|
-
import { userFacingStitchError } from '../cameraErrorMessages';
|
|
12
|
-
import type { CameraErrorCode } from '../Camera';
|
|
13
|
-
|
|
14
|
-
describe('userFacingStitchError', () => {
|
|
15
|
-
const RECOVERABLE: CameraErrorCode[] = [
|
|
16
|
-
'STITCH_NEED_MORE_IMGS',
|
|
17
|
-
'STITCH_HOMOGRAPHY_FAIL',
|
|
18
|
-
'STITCH_CAMERA_PARAMS_FAIL',
|
|
19
|
-
'STITCH_OOM',
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
it.each(RECOVERABLE)(
|
|
23
|
-
'returns non-empty, jargon-free title+message for %s',
|
|
24
|
-
(code) => {
|
|
25
|
-
const r = userFacingStitchError(code);
|
|
26
|
-
expect(r).not.toBeNull();
|
|
27
|
-
expect(r!.title.length).toBeGreaterThan(0);
|
|
28
|
-
expect(r!.message.length).toBeGreaterThan(0);
|
|
29
|
-
// No raw stitcher diagnostics should ever reach the user.
|
|
30
|
-
expect(r!.message).not.toMatch(/warpRoi|cv::|OpenCV|ERR_|StsOutOfRange|estimator/i);
|
|
31
|
-
expect(r!.title).not.toMatch(/cv::|OpenCV|ERR_/i);
|
|
32
|
-
// The title is the corrective ASK (e.g. "Please pan more slowly"),
|
|
33
|
-
// not a generic failure headline.
|
|
34
|
-
expect(r!.title).not.toMatch(/couldn't|can't|error|failed|too large/i);
|
|
35
|
-
},
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
it('camera-params guidance names the 0.5x sensitivity and the 1x fix', () => {
|
|
39
|
-
const r = userFacingStitchError('STITCH_CAMERA_PARAMS_FAIL');
|
|
40
|
-
expect(r).not.toBeNull();
|
|
41
|
-
// The actual root cause (translation) + the actionable lens advice.
|
|
42
|
-
expect(r!.message).toMatch(/0\.5x|ultra-wide/i);
|
|
43
|
-
expect(r!.message).toMatch(/\b1x\b/i);
|
|
44
|
-
expect(r!.message).toMatch(/pivot|turning|one spot|moved|shifted/i);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('need-more-images guidance is about overlap', () => {
|
|
48
|
-
expect(userFacingStitchError('STITCH_NEED_MORE_IMGS')!.message).toMatch(
|
|
49
|
-
/overlap/i,
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('oom guidance suggests a shorter sweep', () => {
|
|
54
|
-
expect(userFacingStitchError('STITCH_OOM')!.message).toMatch(
|
|
55
|
-
/shorter|narrower|memory/i,
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const NON_RECOVERABLE: CameraErrorCode[] = [
|
|
60
|
-
'CAMERA_PERMISSION_DENIED',
|
|
61
|
-
'CAMERA_DEVICE_UNAVAILABLE',
|
|
62
|
-
'PHOTO_CAPTURE_FAILED',
|
|
63
|
-
'PANORAMA_START_FAILED',
|
|
64
|
-
'PANORAMA_FINALIZE_FAILED',
|
|
65
|
-
'OUTPUT_WRITE_FAILED',
|
|
66
|
-
'VISION_CAMERA_RUNTIME',
|
|
67
|
-
'UNKNOWN',
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
it.each(NON_RECOVERABLE)(
|
|
71
|
-
'returns null for non-recoverable code %s',
|
|
72
|
-
(code) => {
|
|
73
|
-
expect(userFacingStitchError(code)).toBeNull();
|
|
74
|
-
},
|
|
75
|
-
);
|
|
76
|
-
});
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
/**
|
|
3
|
-
* Unit tests for `homeIndicatorEdge` + `isSideEdge` — the pure functions
|
|
4
|
-
* that produce the `vertical` flag driving PanoramaBandOverlay and
|
|
5
|
-
* CaptureThumbnailStrip layout under non-locked hosts.
|
|
6
|
-
*
|
|
7
|
-
* vertical = isSideEdge(homeIndicatorEdge(jsLandscape, deviceOrient))
|
|
8
|
-
*
|
|
9
|
-
* Contract (v0.12 orientation-aware Camera):
|
|
10
|
-
* - Portrait JS layout (jsLandscape=false) → 'bottom' edge → NOT a
|
|
11
|
-
* side edge → vertical=false (horizontal strip, the portrait-locked
|
|
12
|
-
* case that's the recommended config).
|
|
13
|
-
* - Landscape JS layout → 'right'/'left' edge → side edge →
|
|
14
|
-
* vertical=true (the strip/band stack along the home-indicator edge).
|
|
15
|
-
*
|
|
16
|
-
* Pure-TS test per jest.config.js (no component mount). The functions
|
|
17
|
-
* are imported via Camera.tsx's `_*ForTests` handles; react-native and
|
|
18
|
-
* the heavy native deps are mocked so the import resolves in node env.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
// The SUT lives in Camera.tsx, which transitively imports the entire
|
|
22
|
-
// camera surface (vision-camera, worklets, sensors, native modules).
|
|
23
|
-
// We only call two pure functions, so stub the whole dependency tree.
|
|
24
|
-
jest.mock('react-native', () => ({
|
|
25
|
-
NativeModules: {},
|
|
26
|
-
Platform: { OS: 'ios', select: (o: Record<string, unknown>) => o.ios },
|
|
27
|
-
Pressable: 'Pressable',
|
|
28
|
-
StyleSheet: { create: (s: Record<string, unknown>) => s, absoluteFill: {} },
|
|
29
|
-
Text: 'Text',
|
|
30
|
-
View: 'View',
|
|
31
|
-
Image: 'Image',
|
|
32
|
-
ScrollView: 'ScrollView',
|
|
33
|
-
Animated: { View: 'Animated.View', Value: class {}, timing: () => ({ start: () => undefined }) },
|
|
34
|
-
Modal: 'Modal',
|
|
35
|
-
ActivityIndicator: 'ActivityIndicator',
|
|
36
|
-
useWindowDimensions: () => ({ width: 0, height: 0 }),
|
|
37
|
-
requireNativeComponent: () => 'NativeComponent',
|
|
38
|
-
UIManager: { getViewManagerConfig: () => ({}) },
|
|
39
|
-
findNodeHandle: () => 1,
|
|
40
|
-
}));
|
|
41
|
-
jest.mock('react-native-safe-area-context', () => ({
|
|
42
|
-
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
43
|
-
}));
|
|
44
|
-
jest.mock('react-native-sensors', () => ({
|
|
45
|
-
accelerometer: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) },
|
|
46
|
-
setUpdateIntervalForType: jest.fn(),
|
|
47
|
-
SensorTypes: { accelerometer: 'accelerometer' },
|
|
48
|
-
}));
|
|
49
|
-
jest.mock('react-native-worklets-core', () => ({ Worklets: {} }));
|
|
50
|
-
jest.mock('react-native-vision-camera', () => ({
|
|
51
|
-
Camera: 'Camera',
|
|
52
|
-
useCameraDevice: jest.fn(),
|
|
53
|
-
useCameraPermission: jest.fn(),
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
import {
|
|
57
|
-
_homeIndicatorEdgeForTests as homeIndicatorEdge,
|
|
58
|
-
_isSideEdgeForTests as isSideEdge,
|
|
59
|
-
} from '../Camera';
|
|
60
|
-
import type { DeviceOrientation } from '../useDeviceOrientation';
|
|
61
|
-
|
|
62
|
-
const PORTRAIT: DeviceOrientation = 'portrait';
|
|
63
|
-
const UPSIDE: DeviceOrientation = 'portrait-upside-down';
|
|
64
|
-
const LEFT: DeviceOrientation = 'landscape-left';
|
|
65
|
-
const RIGHT: DeviceOrientation = 'landscape-right';
|
|
66
|
-
|
|
67
|
-
// The composed signal the band/strip actually consume.
|
|
68
|
-
const vertical = (jsLandscape: boolean, o: DeviceOrientation) =>
|
|
69
|
-
isSideEdge(homeIndicatorEdge(jsLandscape, o));
|
|
70
|
-
|
|
71
|
-
describe('homeIndicatorEdge', () => {
|
|
72
|
-
it('returns bottom for any portrait JS layout (jsLandscape=false)', () => {
|
|
73
|
-
// Portrait JS layout always anchors bottom regardless of the sensor
|
|
74
|
-
// value — this is the portrait-locked case (the recommended config).
|
|
75
|
-
for (const o of [PORTRAIT, UPSIDE, LEFT, RIGHT]) {
|
|
76
|
-
expect(homeIndicatorEdge(false, o)).toBe('bottom');
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('anchors RIGHT for landscape-left device in landscape JS layout', () => {
|
|
81
|
-
expect(homeIndicatorEdge(true, LEFT)).toBe('right');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('anchors LEFT for landscape-right device in landscape JS layout', () => {
|
|
85
|
-
expect(homeIndicatorEdge(true, RIGHT)).toBe('left');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('falls through to right for non-landscape sensor + landscape JS (transient)', () => {
|
|
89
|
-
// jsLandscape=true with a portrait sensor reading only happens
|
|
90
|
-
// mid-rotation; defensive default is 'right'.
|
|
91
|
-
expect(homeIndicatorEdge(true, PORTRAIT)).toBe('right');
|
|
92
|
-
expect(homeIndicatorEdge(true, UPSIDE)).toBe('right');
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('isSideEdge', () => {
|
|
97
|
-
it('is true only for left/right edges', () => {
|
|
98
|
-
expect(isSideEdge('left')).toBe(true);
|
|
99
|
-
expect(isSideEdge('right')).toBe(true);
|
|
100
|
-
expect(isSideEdge('bottom')).toBe(false);
|
|
101
|
-
expect(isSideEdge('top')).toBe(false);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('vertical flag (composed) — what the strip/band consume', () => {
|
|
106
|
-
it('is FALSE for portrait-locked layout (horizontal strip, recommended)', () => {
|
|
107
|
-
for (const o of [PORTRAIT, UPSIDE, LEFT, RIGHT]) {
|
|
108
|
-
expect(vertical(false, o)).toBe(false);
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('is TRUE for both landscape orientations under a non-locked host', () => {
|
|
113
|
-
expect(vertical(true, LEFT)).toBe(true);
|
|
114
|
-
expect(vertical(true, RIGHT)).toBe(true);
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -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
|
-
});
|