react-native-image-stitcher 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for the JS→native settings bridge.
4
+ *
5
+ * Scope: every adapter (panoramaSettingsToNativeConfig,
6
+ * slitscanSettingsToNativeConfig, hybridSettingsToNativeConfig)
7
+ * round-trip from a hierarchical typed input to the flat wire dict
8
+ * the native side reads. Asserts both:
9
+ *
10
+ * 1. Naming — JS key `registration.ncc1d.searchRadius` becomes
11
+ * native key `nccSearchRadius1d` (and similar mappings). Each
12
+ * DEFAULT_* snapshot's expected wire dict is enumerated below;
13
+ * drift in either direction (lib drops a key, or adds a phantom
14
+ * one) is caught here.
15
+ *
16
+ * 2. Presence-as-enable — undefined optional sub-objects in the
17
+ * typed shape (`registration.ncc1d`, `registration.ncc2d`,
18
+ * `registration.ncc2d.emaSmoothing`, `registration.ncc2d.panAxisLock`,
19
+ * `frameSelection.flow`, `advanced`) translate to explicit
20
+ * `enable*: false` (or the absence of all the sub-object's
21
+ * payload keys) on the wire. Many of these have been silent
22
+ * drift hazards historically — the old flat type required the
23
+ * consumer to set BOTH `enable1dNcc: true` AND `nccSearchRadius1d:
24
+ * <value>`; v0.4 makes them inseparable by collapsing into a
25
+ * single optional sub-object, and this file is what guarantees
26
+ * the wire side still gets both halves.
27
+ *
28
+ * 3. Engine-discriminated coverage — plane source variants
29
+ * ('Disabled' / 'ARKitDetected' / 'Virtual') gate which optional
30
+ * plane fields are emitted; the bridge filters those at the
31
+ * adapter boundary so the modal's per-source rendering doesn't
32
+ * get mislead by stale-but-present keys from a previous source
33
+ * selection.
34
+ *
35
+ * These tests are pure-TS; no React Native module import. Jest config
36
+ * (`jest.config.js`) routes test files in `__tests__/` through ts-jest
37
+ * with the `node` testEnvironment.
38
+ */
39
+
40
+ import {
41
+ DEFAULT_FLOW_GATE_SETTINGS,
42
+ DEFAULT_HYBRID_SETTINGS,
43
+ DEFAULT_PANORAMA_SETTINGS,
44
+ DEFAULT_SLITSCAN_SETTINGS,
45
+ type HybridSettings,
46
+ type PanoramaSettings,
47
+ type SlitscanSettings,
48
+ } from '../PanoramaSettings';
49
+ import {
50
+ hybridSettingsToNativeConfig,
51
+ panoramaSettingsToNativeConfig,
52
+ slitscanSettingsToNativeConfig,
53
+ } from '../PanoramaSettingsBridge';
54
+
55
+
56
+ // ════════════════════════════════════════════════════════════════════
57
+ // PANORAMA — batch-keyframe engine
58
+ // ════════════════════════════════════════════════════════════════════
59
+
60
+ describe('panoramaSettingsToNativeConfig', () => {
61
+ it('round-trips DEFAULT_PANORAMA_SETTINGS to the expected flat dict', () => {
62
+ const cfg = panoramaSettingsToNativeConfig(DEFAULT_PANORAMA_SETTINGS);
63
+
64
+ // Cross-cutting
65
+ expect(cfg.captureSource).toBe('ar');
66
+
67
+ // BatchStitcherSettings
68
+ expect(cfg.stitchMode).toBe('auto');
69
+ expect(cfg.warperType).toBe('plane');
70
+ expect(cfg.blenderType).toBe('multiband');
71
+ expect(cfg.seamFinderType).toBe('graphcut');
72
+ expect(cfg.enableMaxInscribedRectCrop).toBe(false);
73
+
74
+ // FrameSelectionSettings
75
+ expect(cfg.frameSelectionMode).toBe('flow-based');
76
+ expect(cfg.keyframeMaxCount).toBe(6);
77
+ expect(cfg.keyframeOverlapThreshold).toBe(0.2);
78
+
79
+ // FlowGateSettings (flow is defined in the default)
80
+ expect(cfg.flowNoveltyPercentile).toBe(0.85);
81
+ expect(cfg.flowEvalEveryNFrames).toBe(5);
82
+ expect(cfg.flowMaxTranslationCm).toBe(50);
83
+ expect(cfg.flowMaxCorners).toBe(150);
84
+ expect(cfg.flowQualityLevel).toBe(0.01);
85
+ expect(cfg.flowMinDistance).toBe(10);
86
+ });
87
+
88
+ it('falls back to DEFAULT_FLOW_GATE_SETTINGS when frameSelection.flow is undefined', () => {
89
+ // F10 Phase 2 review B1 — native compiled-in defaults disagree
90
+ // with the JS defaults for two flow knobs (maxTranslationCm and
91
+ // evalEveryNFrames). The bridge must always emit every flow key
92
+ // so sparse-literal hosts get the JS defaults on the wire, not
93
+ // the native fallbacks.
94
+ const noFlow: PanoramaSettings = {
95
+ ...DEFAULT_PANORAMA_SETTINGS,
96
+ frameSelection: {
97
+ ...DEFAULT_PANORAMA_SETTINGS.frameSelection,
98
+ flow: undefined,
99
+ },
100
+ };
101
+ const cfg = panoramaSettingsToNativeConfig(noFlow);
102
+
103
+ expect(cfg.frameSelectionMode).toBe('flow-based');
104
+ expect(cfg.keyframeMaxCount).toBe(6);
105
+ expect(cfg.keyframeOverlapThreshold).toBe(0.2);
106
+
107
+ // Every flow.* native key present, matching DEFAULT_FLOW_GATE_SETTINGS.
108
+ expect(cfg.flowNoveltyPercentile).toBe(DEFAULT_FLOW_GATE_SETTINGS.noveltyPercentile);
109
+ expect(cfg.flowEvalEveryNFrames).toBe(DEFAULT_FLOW_GATE_SETTINGS.evalEveryNFrames);
110
+ expect(cfg.flowMaxTranslationCm).toBe(DEFAULT_FLOW_GATE_SETTINGS.maxTranslationCm);
111
+ expect(cfg.flowMaxCorners).toBe(DEFAULT_FLOW_GATE_SETTINGS.maxCorners);
112
+ expect(cfg.flowQualityLevel).toBe(DEFAULT_FLOW_GATE_SETTINGS.qualityLevel);
113
+ expect(cfg.flowMinDistance).toBe(DEFAULT_FLOW_GATE_SETTINGS.minDistance);
114
+ });
115
+
116
+ it('emits flow defaults to the wire when frameSelection.flow is undefined AND mode is flow-based', () => {
117
+ // F10 Phase 2 review N3 — the realistic user-facing case:
118
+ // host writes `mode: 'flow-based'` but omits the flow sub-tree.
119
+ // Pre-B1-fix, the gate would silently run with native fallbacks
120
+ // (flowMaxTranslationCm=0, flowEvalEveryNFrames=1) instead of
121
+ // the JS defaults (50 cm budget, 5× throttle).
122
+ const s: PanoramaSettings = {
123
+ ...DEFAULT_PANORAMA_SETTINGS,
124
+ frameSelection: {
125
+ mode: 'flow-based',
126
+ maxKeyframes: 6,
127
+ overlapThreshold: 0.20,
128
+ // flow omitted — legal per the optional `?` in the type
129
+ },
130
+ };
131
+ const cfg = panoramaSettingsToNativeConfig(s);
132
+
133
+ expect(cfg.flowMaxTranslationCm).toBe(50);
134
+ expect(cfg.flowEvalEveryNFrames).toBe(5);
135
+ expect(cfg.flowNoveltyPercentile).toBe(0.85);
136
+ expect(cfg.flowMaxCorners).toBe(150);
137
+ expect(cfg.flowQualityLevel).toBe(0.01);
138
+ expect(cfg.flowMinDistance).toBe(10);
139
+ });
140
+
141
+ it('locks down the full wire-key set for DEFAULT_PANORAMA_SETTINGS', () => {
142
+ // F10 Phase 2 review N4 — mirror the hybrid test below. Lock
143
+ // down which keys leave the bridge so a future field accidentally
144
+ // riding along (e.g. `debug` being treated as a wire knob) fails
145
+ // this test immediately.
146
+ const cfg = panoramaSettingsToNativeConfig(DEFAULT_PANORAMA_SETTINGS);
147
+ expect(Object.keys(cfg).sort()).toEqual([
148
+ 'blenderType',
149
+ 'captureSource',
150
+ 'enableMaxInscribedRectCrop',
151
+ 'flowEvalEveryNFrames',
152
+ 'flowMaxCorners',
153
+ 'flowMaxTranslationCm',
154
+ 'flowMinDistance',
155
+ 'flowNoveltyPercentile',
156
+ 'flowQualityLevel',
157
+ 'frameSelectionMode',
158
+ 'keyframeMaxCount',
159
+ 'keyframeOverlapThreshold',
160
+ 'seamFinderType',
161
+ 'stitchMode',
162
+ 'warperType',
163
+ ]);
164
+ });
165
+
166
+ it('honours captureSource and stitcher overrides', () => {
167
+ const overridden: PanoramaSettings = {
168
+ ...DEFAULT_PANORAMA_SETTINGS,
169
+ captureSource: 'non-ar',
170
+ debug: true,
171
+ stitcher: {
172
+ stitchMode: 'scans',
173
+ warperType: 'spherical',
174
+ blenderType: 'feather',
175
+ seamFinderType: 'skip',
176
+ enableMaxInscribedRectCrop: true,
177
+ },
178
+ };
179
+ const cfg = panoramaSettingsToNativeConfig(overridden);
180
+
181
+ expect(cfg.captureSource).toBe('non-ar');
182
+ expect(cfg.stitchMode).toBe('scans');
183
+ expect(cfg.warperType).toBe('spherical');
184
+ expect(cfg.blenderType).toBe('feather');
185
+ expect(cfg.seamFinderType).toBe('skip');
186
+ expect(cfg.enableMaxInscribedRectCrop).toBe(true);
187
+ // Note: `debug` is intentionally NOT on the wire — it's a
188
+ // JS-side UI gate, not a native config knob. The bridge MUST
189
+ // omit it; if a future change starts emitting it, the modal's
190
+ // operator-facing semantics will silently drift.
191
+ expect(cfg).not.toHaveProperty('debug');
192
+ });
193
+ });
194
+
195
+
196
+ // ════════════════════════════════════════════════════════════════════
197
+ // SLITSCAN — Layer 2 slit-scan engines
198
+ // ════════════════════════════════════════════════════════════════════
199
+
200
+ describe('slitscanSettingsToNativeConfig', () => {
201
+ it('round-trips DEFAULT_SLITSCAN_SETTINGS to the expected flat dict', () => {
202
+ const cfg = slitscanSettingsToNativeConfig(DEFAULT_SLITSCAN_SETTINGS);
203
+
204
+ expect(cfg.captureSource).toBe('ar');
205
+ expect(cfg.engineVariant).toBe('slitscan-rotate');
206
+
207
+ // Painting
208
+ expect(cfg.paintMode).toBe('FirstPaintedWins');
209
+ expect(cfg.sliverPosition).toBe('Bottom');
210
+ expect(cfg.firstFrameFullFrame).toBe(true);
211
+
212
+ // Registration (explicit booleans)
213
+ expect(cfg.enableTriangulation).toBe(false);
214
+ expect(cfg.enableTriAccumulator).toBe(false);
215
+ expect(cfg.enableRansacHomography).toBe(false);
216
+
217
+ // Plane
218
+ expect(cfg.planeSource).toBe('ARKitDetected');
219
+ expect(cfg.planeProjectionStyle).toBe('Rectified');
220
+ expect(cfg.arkitPlaneAlignmentThreshold).toBe(0.6);
221
+
222
+ // ncc1d / ncc2d both omitted in defaults
223
+ expect(cfg.enable1dNcc).toBe(false);
224
+ expect(cfg.enable2dNcc).toBe(false);
225
+ expect(cfg).not.toHaveProperty('nccSearchRadius1d');
226
+ expect(cfg).not.toHaveProperty('nccSearchMargin2d');
227
+ expect(cfg).not.toHaveProperty('nccConfidenceThreshold2d');
228
+ expect(cfg).not.toHaveProperty('ncc2dEmaAlpha');
229
+ expect(cfg).not.toHaveProperty('ncc2dCrossAxisLockPx');
230
+
231
+ // Plane: ARKitDetected — alignmentThreshold present, virtual depth absent
232
+ expect(cfg).not.toHaveProperty('virtualPlaneDepthMeters');
233
+
234
+ // Advanced: not set in defaults
235
+ expect(cfg).not.toHaveProperty('kPanAxisFractionRect');
236
+ expect(cfg).not.toHaveProperty('kMinAcceptDeltaPx');
237
+ });
238
+
239
+ it('expands `registration.ncc1d` presence-as-enable correctly', () => {
240
+ const withNcc1d: SlitscanSettings = {
241
+ ...DEFAULT_SLITSCAN_SETTINGS,
242
+ registration: {
243
+ ...DEFAULT_SLITSCAN_SETTINGS.registration,
244
+ ncc1d: { searchRadius: 25 },
245
+ },
246
+ };
247
+ const cfg = slitscanSettingsToNativeConfig(withNcc1d);
248
+ expect(cfg.enable1dNcc).toBe(true);
249
+ expect(cfg.nccSearchRadius1d).toBe(25);
250
+ });
251
+
252
+ it('expands `registration.ncc2d` presence-as-enable with nested optionals', () => {
253
+ const withNcc2dFull: SlitscanSettings = {
254
+ ...DEFAULT_SLITSCAN_SETTINGS,
255
+ registration: {
256
+ ...DEFAULT_SLITSCAN_SETTINGS.registration,
257
+ ncc2d: {
258
+ searchMargin: 14,
259
+ confidenceThreshold: 0.95,
260
+ emaSmoothing: { alpha: 0.5 },
261
+ panAxisLock: { crossAxisLockPx: 4 },
262
+ },
263
+ },
264
+ };
265
+ const cfg = slitscanSettingsToNativeConfig(withNcc2dFull);
266
+
267
+ expect(cfg.enable2dNcc).toBe(true);
268
+ expect(cfg.nccSearchMargin2d).toBe(14);
269
+ expect(cfg.nccConfidenceThreshold2d).toBe(0.95);
270
+ expect(cfg.enableNcc2dEmaSmoothing).toBe(true);
271
+ expect(cfg.ncc2dEmaAlpha).toBe(0.5);
272
+ expect(cfg.enableNcc2dPanAxisLock).toBe(true);
273
+ expect(cfg.ncc2dCrossAxisLockPx).toBe(4);
274
+ });
275
+
276
+ it('honours ncc2d nested-optional absence (ema + panAxisLock undefined)', () => {
277
+ const withNcc2dBare: SlitscanSettings = {
278
+ ...DEFAULT_SLITSCAN_SETTINGS,
279
+ registration: {
280
+ ...DEFAULT_SLITSCAN_SETTINGS.registration,
281
+ ncc2d: {
282
+ searchMargin: 12,
283
+ confidenceThreshold: 0.99,
284
+ // emaSmoothing + panAxisLock omitted → enable-flag false, no payload
285
+ },
286
+ },
287
+ };
288
+ const cfg = slitscanSettingsToNativeConfig(withNcc2dBare);
289
+
290
+ expect(cfg.enable2dNcc).toBe(true);
291
+ expect(cfg.enableNcc2dEmaSmoothing).toBe(false);
292
+ expect(cfg.enableNcc2dPanAxisLock).toBe(false);
293
+ // Critical: payload keys for the disabled sub-features must NOT
294
+ // ride along — Native engine would treat them as authoritative
295
+ // even with the enable flag off (defensive against a native bug).
296
+ expect(cfg).not.toHaveProperty('ncc2dEmaAlpha');
297
+ expect(cfg).not.toHaveProperty('ncc2dCrossAxisLockPx');
298
+ });
299
+
300
+ it.each([
301
+ ['Disabled', { virtualPlaneDepthMeters: false, arkitPlaneAlignmentThreshold: false, planeProjectionStyle: false }],
302
+ ['Virtual', { virtualPlaneDepthMeters: true, arkitPlaneAlignmentThreshold: false, planeProjectionStyle: true }],
303
+ ['ARKitDetected', { virtualPlaneDepthMeters: false, arkitPlaneAlignmentThreshold: true, planeProjectionStyle: true }],
304
+ ] as const)(
305
+ 'emits plane optionals consistent with source=%s',
306
+ (source, expected) => {
307
+ const s: SlitscanSettings = {
308
+ ...DEFAULT_SLITSCAN_SETTINGS,
309
+ plane: {
310
+ source,
311
+ projectionStyle: 'Rectified',
312
+ virtualDepthMeters: 2.0,
313
+ alignmentThreshold: 0.7,
314
+ },
315
+ };
316
+ const cfg = slitscanSettingsToNativeConfig(s);
317
+ expect(cfg.planeSource).toBe(source);
318
+ expect('virtualPlaneDepthMeters' in cfg).toBe(expected.virtualPlaneDepthMeters);
319
+ expect('arkitPlaneAlignmentThreshold' in cfg).toBe(expected.arkitPlaneAlignmentThreshold);
320
+ expect('planeProjectionStyle' in cfg).toBe(expected.planeProjectionStyle);
321
+ },
322
+ );
323
+
324
+ it('emits `advanced` knobs only when explicitly set', () => {
325
+ const withAdvanced: SlitscanSettings = {
326
+ ...DEFAULT_SLITSCAN_SETTINGS,
327
+ advanced: { panAxisFractionRect: 0.6, minAcceptDeltaPx: 30 },
328
+ };
329
+ const cfg = slitscanSettingsToNativeConfig(withAdvanced);
330
+ expect(cfg.kPanAxisFractionRect).toBe(0.6);
331
+ expect(cfg.kMinAcceptDeltaPx).toBe(30);
332
+
333
+ const onlyOne: SlitscanSettings = {
334
+ ...DEFAULT_SLITSCAN_SETTINGS,
335
+ advanced: { panAxisFractionRect: 0.6 },
336
+ // minAcceptDeltaPx omitted within the sub-object
337
+ };
338
+ const cfgOne = slitscanSettingsToNativeConfig(onlyOne);
339
+ expect(cfgOne.kPanAxisFractionRect).toBe(0.6);
340
+ expect(cfgOne).not.toHaveProperty('kMinAcceptDeltaPx');
341
+ });
342
+ });
343
+
344
+
345
+ // ════════════════════════════════════════════════════════════════════
346
+ // HYBRID — RetaiLens live engine
347
+ // ════════════════════════════════════════════════════════════════════
348
+
349
+ describe('hybridSettingsToNativeConfig', () => {
350
+ it('round-trips DEFAULT_HYBRID_SETTINGS to the expected flat dict', () => {
351
+ const cfg = hybridSettingsToNativeConfig(DEFAULT_HYBRID_SETTINGS);
352
+ expect(cfg.captureSource).toBe('ar');
353
+ expect(cfg.hybridProjection).toBe('Planar');
354
+ });
355
+
356
+ it('honours projection override', () => {
357
+ const cyl: HybridSettings = {
358
+ ...DEFAULT_HYBRID_SETTINGS,
359
+ projection: 'Cylindrical',
360
+ };
361
+ expect(hybridSettingsToNativeConfig(cyl).hybridProjection).toBe('Cylindrical');
362
+ });
363
+
364
+ it('emits only the documented hybrid surface (debug is JS-only)', () => {
365
+ // Hybrid presets internally clobber most fields; the bridge
366
+ // deliberately keeps the wire surface minimal. This test guards
367
+ // against future drift where someone adds a hybrid setting to the
368
+ // bridge without first validating that the engine actually reads it.
369
+ const cfg = hybridSettingsToNativeConfig({
370
+ ...DEFAULT_HYBRID_SETTINGS,
371
+ debug: true, // JS-only, must NOT reach the wire
372
+ });
373
+ expect(Object.keys(cfg).sort()).toEqual(['captureSource', 'hybridProjection']);
374
+ });
375
+ });
@@ -0,0 +1,119 @@
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
+ },
65
+ false,
66
+ );
67
+
68
+ expect(s.captureSource).toBe('non-ar');
69
+ expect(s.stitcher.stitchMode).toBe('scans');
70
+ expect(s.stitcher.blenderType).toBe('feather');
71
+ expect(s.stitcher.seamFinderType).toBe('skip');
72
+ expect(s.stitcher.warperType).toBe('cylindrical');
73
+ expect(s.frameSelection.flow?.noveltyPercentile).toBe(0.70);
74
+ expect(s.frameSelection.flow?.evalEveryNFrames).toBe(3);
75
+ expect(s.frameSelection.flow?.maxTranslationCm).toBe(12);
76
+ expect(s.frameSelection.maxKeyframes).toBe(8);
77
+ expect(s.frameSelection.overlapThreshold).toBe(0.30);
78
+ });
79
+
80
+ it('leaves non-overridden fields at the default (partial override)', () => {
81
+ const s = buildPanoramaInitialSettings(
82
+ { defaultStitchMode: 'panorama' },
83
+ false,
84
+ );
85
+
86
+ // The override took effect …
87
+ expect(s.stitcher.stitchMode).toBe('panorama');
88
+
89
+ // … and every other field stays at the corresponding default.
90
+ expect(s.stitcher.warperType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.warperType);
91
+ expect(s.stitcher.blenderType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.blenderType);
92
+ expect(s.stitcher.seamFinderType).toBe(DEFAULT_PANORAMA_SETTINGS.stitcher.seamFinderType);
93
+ expect(s.frameSelection).toEqual(DEFAULT_PANORAMA_SETTINGS.frameSelection);
94
+ expect(s.captureSource).toBe(DEFAULT_PANORAMA_SETTINGS.captureSource);
95
+ });
96
+
97
+ it('produces wire-format-clean output when piped through the bridge', () => {
98
+ // The end-to-end JS-side path: props → buildPanoramaInitialSettings →
99
+ // panoramaSettingsToNativeConfig. Verifying it here catches drift
100
+ // at any of the three layers (prop name, type-tree shape, bridge
101
+ // adapter) with a single assertion.
102
+ const overrides = {
103
+ defaultCaptureSource: 'non-ar' as const,
104
+ defaultStitchMode: 'scans' as const,
105
+ defaultFlowMaxTranslationCm: 25,
106
+ };
107
+ const settings: PanoramaSettings =
108
+ buildPanoramaInitialSettings(overrides, false);
109
+ const wire = panoramaSettingsToNativeConfig(settings);
110
+
111
+ expect(wire.captureSource).toBe('non-ar');
112
+ expect(wire.stitchMode).toBe('scans');
113
+ expect(wire.flowMaxTranslationCm).toBe(25);
114
+ // Defaulted fields still on the wire with their default value.
115
+ expect(wire.warperType).toBe('plane');
116
+ expect(wire.frameSelectionMode).toBe('flow-based');
117
+ expect(wire.flowNoveltyPercentile).toBe(0.85);
118
+ });
119
+ });
@@ -0,0 +1,52 @@
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
+ });
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * buildPanoramaInitialSettings — pure helper that materialises the
4
+ * initial `PanoramaSettings` snapshot from <Camera>'s `default*` props
5
+ * and a device-capability hint.
6
+ *
7
+ * Why a separate file?
8
+ * ────────────────────
9
+ *
10
+ * The settings tree lives in `PanoramaSettings.ts`; <Camera> consumes
11
+ * it and writes it into React state. The translation FROM the prop
12
+ * surface (flat names like `defaultStitchMode`) INTO the hierarchical
13
+ * settings tree is the part that:
14
+ *
15
+ * • is non-trivial enough to deserve direct unit-test coverage
16
+ * (covers the prop→sub-tree path mapping, which is easy to drift),
17
+ * • is pure TS — no React, no React Native — so the test runs in
18
+ * jest's `node` environment without needing the `react-native`
19
+ * preset (the rest of <Camera> is unmockable in pure TS).
20
+ *
21
+ * Living alongside `Camera.tsx` (vs. burying it as a private function
22
+ * inside) is the only way to get those two properties without taking
23
+ * on full React-Native jest setup just for this one helper.
24
+ *
25
+ * The exported `PanoramaPropOverrides` type is the prop-fragment
26
+ * <Camera> uses; `CameraProps` extends it. Keeping it explicit here
27
+ * means future Camera prop additions don't accidentally widen the
28
+ * settings-translation surface — every consumer of the helper sees
29
+ * exactly the prop fields that drive the settings tree.
30
+ */
31
+
32
+ import {
33
+ DEFAULT_FLOW_GATE_SETTINGS,
34
+ DEFAULT_PANORAMA_SETTINGS,
35
+ type PanoramaSettings,
36
+ } from './PanoramaSettings';
37
+
38
+
39
+ /**
40
+ * Subset of <Camera>'s props that map onto fields of the initial
41
+ * `PanoramaSettings` snapshot. Anything outside this interface
42
+ * (e.g. `defaultLens`, `enablePhotoMode`, callbacks) is irrelevant
43
+ * to the settings shape and stays in `CameraProps` only.
44
+ *
45
+ * Forward-looking `default*ResolMP` props are documented here but
46
+ * intentionally not translated yet — the new `PanoramaSettings` tree
47
+ * has no home for them (the v0.3 audit found cv::Stitcher's resol
48
+ * knobs aren't reached by the current native bridges).
49
+ */
50
+ export interface PanoramaPropOverrides {
51
+ defaultCaptureSource?: 'ar' | 'non-ar';
52
+ defaultStitchMode?: 'auto' | 'panorama' | 'scans';
53
+ defaultBlender?: 'multiband' | 'feather';
54
+ defaultSeamFinder?: 'graphcut' | 'skip';
55
+ defaultWarper?: 'plane' | 'cylindrical' | 'spherical';
56
+ defaultFlowNoveltyPercentile?: number;
57
+ defaultFlowEvalEveryNFrames?: number;
58
+ defaultFlowMaxTranslationCm?: number;
59
+ defaultKeyframeMaxCount?: number;
60
+ defaultKeyframeOverlapThreshold?: number;
61
+ }
62
+
63
+
64
+ /**
65
+ * Whether this device is low-memory enough to benefit from the
66
+ * feather+skip blender/seam fallback (vs. the heavier multiband+
67
+ * graphcut default). <Camera> derives this from
68
+ * `NativeModules.BatchStitcher.physicalMemoryBytes` at module load
69
+ * (RN-only — see `getIsLowMemDevice` in Camera.tsx); tests pass
70
+ * `false` explicitly to keep the prop-translation path the unit of
71
+ * the unit test.
72
+ *
73
+ * Why a parameter and not a constant import?
74
+ * The pre-v0.4 `DEFAULT_PANORAMA_SETTINGS` was a `let` mutated at
75
+ * module load — side-effect-heavy, untestable. v0.4 keeps the
76
+ * defaults static + side-effect-free; the device adaptation lives
77
+ * exactly where it needs to (Camera's mount-time `useState`).
78
+ */
79
+ export function buildPanoramaInitialSettings(
80
+ overrides: PanoramaPropOverrides,
81
+ isLowMemDevice: boolean,
82
+ ): PanoramaSettings {
83
+ // Start from the static, side-effect-free defaults.
84
+ const base = DEFAULT_PANORAMA_SETTINGS;
85
+
86
+ // Apply the low-memory device adaptation:
87
+ // - feather blender (streams warped frames, no peak-memory spike)
88
+ // - skip seam finder (no graphcut working set)
89
+ // Replaces the v0.3 module-load-time mutation; same semantics.
90
+ const stitcherDefaults = isLowMemDevice
91
+ ? {
92
+ ...base.stitcher,
93
+ blenderType: 'feather' as const,
94
+ seamFinderType: 'skip' as const,
95
+ }
96
+ : base.stitcher;
97
+
98
+ // Use the standalone DEFAULT_FLOW_GATE_SETTINGS constant rather
99
+ // than `base.frameSelection.flow!` — the non-null assertion would
100
+ // crash silently if a future refactor un-defines the default's
101
+ // flow sub-tree, but the constant lives at the same level as the
102
+ // type and is type-checked. See F10 Phase 2 review (NIT-4).
103
+ const flowDefaults = DEFAULT_FLOW_GATE_SETTINGS;
104
+
105
+ return {
106
+ captureSource: overrides.defaultCaptureSource ?? base.captureSource,
107
+ debug: base.debug,
108
+
109
+ stitcher: {
110
+ ...stitcherDefaults,
111
+ stitchMode: overrides.defaultStitchMode ?? stitcherDefaults.stitchMode,
112
+ warperType: overrides.defaultWarper ?? stitcherDefaults.warperType,
113
+ blenderType: overrides.defaultBlender ?? stitcherDefaults.blenderType,
114
+ seamFinderType:
115
+ overrides.defaultSeamFinder ?? stitcherDefaults.seamFinderType,
116
+ },
117
+
118
+ frameSelection: {
119
+ ...base.frameSelection,
120
+ maxKeyframes:
121
+ overrides.defaultKeyframeMaxCount ?? base.frameSelection.maxKeyframes,
122
+ overlapThreshold:
123
+ overrides.defaultKeyframeOverlapThreshold
124
+ ?? base.frameSelection.overlapThreshold,
125
+ flow: {
126
+ ...flowDefaults,
127
+ noveltyPercentile:
128
+ overrides.defaultFlowNoveltyPercentile
129
+ ?? flowDefaults.noveltyPercentile,
130
+ evalEveryNFrames:
131
+ overrides.defaultFlowEvalEveryNFrames
132
+ ?? flowDefaults.evalEveryNFrames,
133
+ maxTranslationCm:
134
+ overrides.defaultFlowMaxTranslationCm
135
+ ?? flowDefaults.maxTranslationCm,
136
+ },
137
+ },
138
+ };
139
+ }