react-native-image-stitcher 0.9.0 → 0.10.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 CHANGED
@@ -16,6 +16,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.10.0] — 2026-05-28
20
+
21
+ ### Added — v0.10.0 PR A: host-side test infrastructure (`#9A` + `#11A`)
22
+
23
+ Two parallel test harnesses landed so future tech-debt PRs in the
24
+ v0.10.0 sweep (and beyond) can pin invariants without standing up a
25
+ device build per change.
26
+
27
+ #### Shared C++ Google Test runner (`#9A`)
28
+
29
+ - `cpp/tests/CMakeLists.txt` — standalone CMake project that fetches
30
+ Google Test `v1.14.0` via `FetchContent` and compiles a single
31
+ `stitcher_cpp_tests` executable. No system-wide gtest install
32
+ required.
33
+ - `scripts/run-cpp-tests.sh` — one-shot configure / build / `ctest`
34
+ driver. Output lands under gitignored `build/cpp-tests/`.
35
+ - Initial suite (17 cases):
36
+ - `Pose` / `PlaneTransform` POD layout, size, field-offset
37
+ invariants (pinned to the cross-platform marshalling contract
38
+ in `cpp/ar_frame_pose.h`).
39
+ - `StitcherFrameData` default-construction invariants the JSI
40
+ host-object `get()` dispatch depends on (e.g. `qw=1.0`,
41
+ `hasTranslation=false`).
42
+ - `PixelBufferReader` `copyTo` clipping contract — validated via
43
+ a `FakePixelBufferReader` test helper.
44
+ - `StitcherWorkletRegistry` lifecycle: shared-instance, install
45
+ /uninstall/count/snapshot, snapshot independence from later
46
+ mutations, concurrent installs (16 threads × 32 each) yield
47
+ unique IDs without lock contention bugs.
48
+ - New test-only registry seam `_installEntryForTests(invoker)` (in
49
+ `cpp/stitcher_worklet_registry.{hpp,cpp}`) — mirrors the existing
50
+ `_resetForTests` pattern. Bypasses the JSI runtime path so tests
51
+ don't need Hermes + worklets-core; `nullptr` invokers are safe
52
+ because the registry never dereferences them.
53
+ - JSI / worklets-core stubs under `cpp/tests/stubs/` let
54
+ `stitcher_worklet_registry.cpp` compile in the host-side test
55
+ target without pulling in React Native's JSI headers or the
56
+ worklets-core library. Stubs are scoped exclusively to the test
57
+ include path; production builds never see them.
58
+ - See `cpp/tests/README.md` for the strategy + a list of what's
59
+ deferred to v0.11.0+ (KeyframeGate / OpenCV-dependent code; JSI
60
+ host-object dispatch).
61
+
62
+ #### Android JUnit scaffold (`#11A`)
63
+
64
+ - `android/build.gradle` — adds `testImplementation
65
+ "junit:junit:4.13.2"`. Minimal — only JUnit 4 (matches AGP's
66
+ default test runner).
67
+ - `android/src/test/java/io/imagestitcher/rn/TransferredNV21Test.kt`
68
+ — 6 tests covering the v0.10.0 `TransferredNV21` single-use
69
+ ownership wrapper: constructor empty/non-empty, takeOnce returns
70
+ the original reference, takeOnce throws on second call, thread-
71
+ safe single-winner under 16-thread contention, distinct wrappers
72
+ are independent.
73
+ - Run via `./gradlew :react-native-image-stitcher:testDebugUnitTest`.
74
+
75
+ Neither suite changes runtime behaviour — both are additive test
76
+ infrastructure.
77
+
78
+ ### Added — v0.10.0 PR B: `refinePanorama` progress events + cleanup audit (`#15A` + `#16C`)
79
+
80
+ #### `#15A` — phase-milestone progress emit from `refinePanorama`
81
+
82
+ `refinePanorama` (both the explicit JS `module.refinePanorama(...)` API
83
+ and the hybrid-engine auto-refine path that calls it internally) now
84
+ emits coarse phase events on the existing `IncrementalStateUpdate`
85
+ device-event channel. Five stages cover one refine lifetime:
86
+
87
+ | Stage | `refineProgress` | When |
88
+ | ------------- | ---------------- | ------------------------------------ |
89
+ | `validating` | 0.05 | start of method, before any I/O |
90
+ | `stitching` | 0.10 | OpenCV stitch in flight |
91
+ | `writing` | 0.90 | stitch returned, JPEG written |
92
+ | `done` | 1.00 | success — promise about to resolve |
93
+ | `error` | 1.00 | failure — `refineError` is set |
94
+
95
+ `refineStage` carries the stage string; `refineProgress` carries the
96
+ fraction; `refineFrames` reports the input keyframe count; `refineError`
97
+ is populated on the failure path so the host can render a one-line
98
+ failure pill.
99
+
100
+ Coarse on purpose: OpenCV's `Stitcher` doesn't expose mid-pipeline
101
+ progress, so the `0.10 → 0.90` jump is one opaque step. JS uses
102
+ `refineStage` for the UI label and `refineProgress` purely for the
103
+ spinner.
104
+
105
+ Reuses the existing channel (no second listener wiring required).
106
+ Existing JS consumers that don't read the new fields are unaffected.
107
+
108
+ #### `#16C` — moderate cleanup audit sweep
109
+
110
+ - `src/camera/useCapture.ts` — removed a stale "`useVideoCapture` (TODO)"
111
+ reference; the hook has existed since v0.4.
112
+ - `ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift` — removed
113
+ a self-flagged "remove this comment after" reference left over from a
114
+ past PiP investigation.
115
+ - `console.*` audit: every call in `src/` was reviewed; all 13 are
116
+ legitimate (warn/error for surfaceable failures; `console.info`
117
+ one-shots that document known tradeoffs). No removals needed.
118
+ - TODO/FIXME triage: 4 remaining own-code TODOs all reference tracked
119
+ future work (lens-probe follow-up, shared-stitcher-port-part-2,
120
+ EXIF writer). Left in place.
121
+ - `ts-prune`: 3 surface-level orphans (`PanoramaConfirmModal`,
122
+ `IncrementalStitcherView`, `stitchFrames`) are intentional public
123
+ deep-import API; not re-exported from `src/index.ts` but
124
+ documented and consumed by hosts. Left in place.
125
+
126
+ No production behaviour changed — these are docstring + dead-comment
127
+ removals only.
128
+
129
+ ### Fixed — v0.10.0 PR B (iOS): refine state events not reaching JS under RN bridgeless interop
130
+
131
+ Switched `IncrementalStitcherBridge` state-event delivery from
132
+ `RCTEventEmitter.sendEvent` to `bridge.enqueueJSCall("RCTDeviceEventEmitter", "emit", ...)`.
133
+ Root cause: under RN bridgeless interop (RN 0.84), `sendEvent`
134
+ silently no-ops for some event-body shapes even when the bridge is
135
+ non-nil and the listener count is > 0 — refine events with the
136
+ `refineStage` / `refineProgress` / `refineFrames` keys were not
137
+ reaching any JS subscriber while live state events with a smaller
138
+ body shape on the same channel were. Also defensively
139
+ `removeObserver` before `addObserver` in `init()` so the
140
+ NotificationCenter registration is idempotent if RN re-invokes
141
+ `init()` on the same instance (also observed on bridgeless interop).
142
+ Android is unaffected — Android's bridge already emits via
143
+ `DeviceEventManagerModule.RCTDeviceEventEmitter.emit(...)` directly.
144
+
19
145
  ## [0.9.0] — 2026-05-27
20
146
 
21
147
  ### Added — layered frame-access helpers
@@ -125,6 +251,30 @@ the Layer 1 + 2 + 3 pipeline working end-to-end on both iPhone
125
251
  - New hooks return `useFrameProcessor`-shape objects compatible
126
252
  with `<Camera frameProcessor={...}>` (Phase 5 from v0.8.0).
127
253
 
254
+ ### Known limitations (v0.9.0 — addressed in v0.11.0)
255
+
256
+ - **Layer 3 `useFrameStream` in AR mode**: the Layer 1
257
+ `save_frame_as_jpeg` vc Frame Processor plugin expects a vision-
258
+ camera `Frame` with `.buffer = CMSampleBufferRef`. In AR mode
259
+ the worklet receives a `StitcherFrameHostObject` (v0.8.0
260
+ Phase 4b's JSI host object) without `.buffer` — the plugin call
261
+ returns `{ok: false, ...}` and `useFrameStream` silently skips
262
+ the sample. Hosts needing per-frame native processing in AR
263
+ mode should use **Layer 2 (`useThrottledFrameProcessor`)** —
264
+ it works in both modes and is the right primitive for the
265
+ worklet-native use cases listed in `docs/frame-access-tiers.md`
266
+ (OCR via Vision/ML Kit, TFLite ML detection, LiDAR depth).
267
+ AR-mode Layer 3 support tracked in v0.11.0's plan
268
+ (`docs/plans/2026-05-27-v0.11.0-non-ar-composition.md`) —
269
+ bundled with `useStitcherWorklet` since both extend the
270
+ `__stitcherProxy` host-function infrastructure.
271
+ - **Layer 3 `useFrameStream` in non-AR mode**: wiring the host's
272
+ frameProcessor through `<Camera>` displaces the lib's
273
+ first-party stitching driver (the documented Phase 5 either-or
274
+ constraint from v0.8.0). Non-AR panorama capture won't produce
275
+ stitched output while a host frameProcessor is wired. Tracked
276
+ in v0.11.0 (`useStitcherWorklet` composition).
277
+
128
278
  ### Notes
129
279
 
130
280
  - Formal SSIM parity gate (Phase 7 of the v0.9.0 plan) was NOT
@@ -282,6 +282,16 @@ dependencies {
282
282
  if (findProject(':react-native-worklets-core') != null) {
283
283
  implementation project(':react-native-worklets-core')
284
284
  }
285
+
286
+ // v0.10.0 audit #11A — Android JUnit test scaffold. JVM unit
287
+ // tests for pure-Kotlin data wrappers + algorithm helpers that
288
+ // don't need an Android device. Run via
289
+ // `gradlew :react-native-image-stitcher:test`.
290
+ //
291
+ // Kept minimal — only JUnit 4 (matches AGP's default test
292
+ // runner). Hosts that want to add their own android-test
293
+ // dependencies can do so independently.
294
+ testImplementation "junit:junit:4.13.2"
285
295
  }
286
296
 
287
297
  // Helper from the React Native gradle convention to read host-app
@@ -1673,12 +1673,26 @@ class IncrementalStitcher(
1673
1673
  @ReactMethod
1674
1674
  fun refinePanorama(options: ReadableMap, promise: Promise) {
1675
1675
  val framePathsArr = options.getArray("framePaths")
1676
+ val requestedCount = framePathsArr?.size() ?: 0
1677
+ // v0.10.0 #15A — emit `validating` at the very top so JS sees
1678
+ // refine activity even when validation fails fast. Frames may
1679
+ // be empty here; report whatever the caller asked for.
1680
+ emitRefineProgress(
1681
+ stage = "validating",
1682
+ fraction = 0.05,
1683
+ frames = requestedCount,
1684
+ errorMessage = null,
1685
+ )
1676
1686
  if (framePathsArr == null || framePathsArr.size() < 2) {
1677
- promise.reject(
1678
- "incremental-refine-invalid-input",
1679
- "refinePanorama requires at least 2 framePaths (got " +
1680
- "${framePathsArr?.size() ?: 0}).",
1687
+ val msg = "refinePanorama requires at least 2 framePaths (got " +
1688
+ "$requestedCount)."
1689
+ emitRefineProgress(
1690
+ stage = "error",
1691
+ fraction = 1.0,
1692
+ frames = requestedCount,
1693
+ errorMessage = msg,
1681
1694
  )
1695
+ promise.reject("incremental-refine-invalid-input", msg)
1682
1696
  return
1683
1697
  }
1684
1698
  val framePaths = Array(framePathsArr.size()) {
@@ -1686,10 +1700,14 @@ class IncrementalStitcher(
1686
1700
  }
1687
1701
  val outputPathOpt = options.getString("outputPath")
1688
1702
  if (outputPathOpt.isNullOrEmpty()) {
1689
- promise.reject(
1690
- "incremental-refine-invalid-input",
1691
- "refinePanorama requires a non-empty outputPath.",
1703
+ val msg = "refinePanorama requires a non-empty outputPath."
1704
+ emitRefineProgress(
1705
+ stage = "error",
1706
+ fraction = 1.0,
1707
+ frames = framePaths.size,
1708
+ errorMessage = msg,
1692
1709
  )
1710
+ promise.reject("incremental-refine-invalid-input", msg)
1693
1711
  return
1694
1712
  }
1695
1713
  val outputPath = stripFileScheme(outputPathOpt)
@@ -1709,16 +1727,26 @@ class IncrementalStitcher(
1709
1727
  // Pre-flight existence check — same defensive layer iOS has.
1710
1728
  for (p in framePaths) {
1711
1729
  if (!File(p).exists()) {
1712
- promise.reject(
1713
- "incremental-refine-missing-keyframe",
1714
- "refinePanorama: keyframe missing on disk — $p",
1730
+ val msg = "refinePanorama: keyframe missing on disk — $p"
1731
+ emitRefineProgress(
1732
+ stage = "error",
1733
+ fraction = 1.0,
1734
+ frames = framePaths.size,
1735
+ errorMessage = msg,
1715
1736
  )
1737
+ promise.reject("incremental-refine-missing-keyframe", msg)
1716
1738
  return
1717
1739
  }
1718
1740
  }
1719
1741
 
1720
1742
  refineScope.launch {
1721
1743
  try {
1744
+ emitRefineProgress(
1745
+ stage = "stitching",
1746
+ fraction = 0.1,
1747
+ frames = framePaths.size,
1748
+ errorMessage = null,
1749
+ )
1722
1750
  val stitcher = BatchStitcher.bridgeInstance
1723
1751
  ?: throw IllegalStateException(
1724
1752
  "BatchStitcher.bridgeInstance is null — " +
@@ -1742,6 +1770,18 @@ class IncrementalStitcher(
1742
1770
  useInscribedRectCrop,
1743
1771
  stitchMode = effectiveMode,
1744
1772
  )
1773
+ // Stitch returned — BatchStitcher writes the JPEG
1774
+ // synchronously, so "writing" reflects the final
1775
+ // assembly + file I/O cost (which has already been
1776
+ // paid by this point in practice). Emit so JS can
1777
+ // flip its label from "Stitching" to "Writing"
1778
+ // before the done event fires.
1779
+ emitRefineProgress(
1780
+ stage = "writing",
1781
+ fraction = 0.9,
1782
+ frames = framePaths.size,
1783
+ errorMessage = null,
1784
+ )
1745
1785
  val framesRequested =
1746
1786
  if (dims.size > 2) dims[2] else framePaths.size
1747
1787
  val framesIncluded =
@@ -1757,8 +1797,20 @@ class IncrementalStitcher(
1757
1797
  putInt("framesDropped", framesRequested - framesIncluded)
1758
1798
  putDouble("finalConfidenceThresh", finalConfidenceThresh)
1759
1799
  }
1800
+ emitRefineProgress(
1801
+ stage = "done",
1802
+ fraction = 1.0,
1803
+ frames = framePaths.size,
1804
+ errorMessage = null,
1805
+ )
1760
1806
  promise.resolve(map)
1761
1807
  } catch (t: Throwable) {
1808
+ emitRefineProgress(
1809
+ stage = "error",
1810
+ fraction = 1.0,
1811
+ frames = framePaths.size,
1812
+ errorMessage = t.message ?: t.javaClass.simpleName,
1813
+ )
1762
1814
  promise.reject("incremental-refine-failed", t.message, t)
1763
1815
  }
1764
1816
  }
@@ -1883,6 +1935,59 @@ class IncrementalStitcher(
1883
1935
  emitState(state)
1884
1936
  }
1885
1937
 
1938
+ /**
1939
+ * v0.10.0 #15A — emit a refine-pipeline phase update on the same
1940
+ * `IncrementalStateUpdate` channel that carries `isRefining` /
1941
+ * `refinedPanoramaPath`. Five `stage` values fire across the
1942
+ * lifetime of one `refinePanorama` call:
1943
+ *
1944
+ * - "validating" (fraction 0.05) — synchronous input checks
1945
+ * - "stitching" (fraction 0.10) — start of the OpenCV stitch
1946
+ * - "writing" (fraction 0.90) — stitch returned, JPEG written
1947
+ * - "done" (fraction 1.00) — promise about to resolve
1948
+ * - "error" (fraction 1.00) — failure path (errorMessage
1949
+ * is non-null)
1950
+ *
1951
+ * Coarse on purpose: OpenCV's Stitcher doesn't expose stage-by-
1952
+ * stage callbacks, so the 0.10 → 0.90 jump is one opaque step.
1953
+ * JS uses `stage` for the UI label and `fraction` for the spinner.
1954
+ *
1955
+ * iOS sibling: IncrementalStitcher.swift::emitRefineProgress.
1956
+ * Field names + stage strings are kept identical so the JS
1957
+ * subscriber in src/stitching/incremental.ts doesn't branch on
1958
+ * platform.
1959
+ */
1960
+ private fun emitRefineProgress(
1961
+ stage: String,
1962
+ fraction: Double,
1963
+ frames: Int?,
1964
+ errorMessage: String?,
1965
+ ) {
1966
+ val state = Arguments.createMap().apply {
1967
+ putNull("panoramaPath")
1968
+ putInt("width", 0)
1969
+ putInt("height", 0)
1970
+ putInt("acceptedCount", 0)
1971
+ putInt("outcome", 0) // AcceptedHigh
1972
+ putDouble("confidence", 1.0)
1973
+ putDouble("overlapPercent", -1.0)
1974
+ putInt("processingMs", 0)
1975
+ putBoolean("isLandscape", false)
1976
+ putInt("paintedExtent", 0)
1977
+ putInt("panExtent", 0)
1978
+ putInt("keyframeMax", 0)
1979
+ putString("refineStage", stage)
1980
+ putDouble("refineProgress", fraction)
1981
+ if (frames != null) {
1982
+ putInt("refineFrames", frames)
1983
+ }
1984
+ if (errorMessage != null) {
1985
+ putString("refineError", errorMessage)
1986
+ }
1987
+ }
1988
+ emitState(state)
1989
+ }
1990
+
1886
1991
  /**
1887
1992
  * 2026-05-16 — given the live panorama path, derive a sibling
1888
1993
  * path for the refined output. Same algorithm iOS uses:
@@ -78,4 +78,14 @@ void StitcherWorkletRegistry::_resetForTests() {
78
78
  _nextId = 0;
79
79
  }
80
80
 
81
+ std::string StitcherWorkletRegistry::_installEntryForTests(
82
+ std::shared_ptr<RNWorklet::WorkletInvoker> invoker) {
83
+ std::lock_guard<std::mutex> lock(_mutex);
84
+ std::ostringstream idStream;
85
+ idStream << "host-" << _nextId++;
86
+ std::string id = idStream.str();
87
+ _entries.push_back({id, std::move(invoker)});
88
+ return id;
89
+ }
90
+
81
91
  } // namespace retailens
@@ -123,6 +123,16 @@ class StitcherWorkletRegistry {
123
123
  /// exposed through the JSI surface.
124
124
  void _resetForTests();
125
125
 
126
+ /// Test-only — install a pre-constructed entry directly, bypassing
127
+ /// the JSI runtime path. Mirrors `install` but accepts an already-
128
+ /// constructed (or null) `WorkletInvoker` so tests can exercise
129
+ /// `count`/`snapshot`/`uninstall`/thread-safety without standing
130
+ /// up a full JSI runtime + worklets-core stack. Tests typically
131
+ /// pass `nullptr` — the registry never dereferences the pointer.
132
+ /// Not exposed through the JSI surface.
133
+ std::string _installEntryForTests(
134
+ std::shared_ptr<RNWorklet::WorkletInvoker> invoker);
135
+
126
136
  private:
127
137
  StitcherWorkletRegistry() = default;
128
138
  StitcherWorkletRegistry(const StitcherWorkletRegistry&) = delete;
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ #
3
+ # cpp/tests/CMakeLists.txt — v0.10.0 audit #9A
4
+ #
5
+ # Standalone Google Test runner for the shared C++ port under `cpp/`.
6
+ # Build + run via:
7
+ #
8
+ # cmake -S cpp/tests -B build/cpp-tests
9
+ # cmake --build build/cpp-tests
10
+ # (cd build/cpp-tests && ctest --output-on-failure)
11
+ #
12
+ # Or, from the repo root: `scripts/run-cpp-tests.sh`.
13
+ #
14
+ # What this DOES test (v0.10.0 scope):
15
+ # - Pure-C++ types in cpp/: `Pose`, `PlaneTransform`,
16
+ # `StitcherFrameData`, `PixelBufferReader` interface contract.
17
+ # - `StitcherWorkletRegistry` lifecycle (count, snapshot, uninstall,
18
+ # thread-safety) — compiled against JSI/worklets-core stubs under
19
+ # `cpp/tests/stubs/`. See `stubs/jsi/jsi.h` for the strategy.
20
+ #
21
+ # What this DOES NOT test yet (deferred to v0.11.0+):
22
+ # - `KeyframeGate` — depends on OpenCV. Will need an OpenCV-aware
23
+ # CMake config (link the same opencv_world the prod build uses).
24
+ # - JSI host-object dispatch — needs a real Hermes runtime.
25
+ # - Anything in `stitcher.cpp` (uses OpenCV stitching pipeline).
26
+
27
+ cmake_minimum_required(VERSION 3.20)
28
+ project(stitcher_cpp_tests CXX)
29
+
30
+ set(CMAKE_CXX_STANDARD 17)
31
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
32
+ set(CMAKE_CXX_EXTENSIONS OFF)
33
+
34
+ # Must precede `gtest_discover_tests` — without this the discovered
35
+ # cases aren't registered into a CTestTestfile.cmake at this directory
36
+ # level, and `ctest` reports "No tests were found".
37
+ enable_testing()
38
+
39
+ # Fetch GoogleTest pinned to v1.14.0. Pin matches what the AOSP /
40
+ # Android NDK test ecosystem uses today; bumps should be deliberate.
41
+ include(FetchContent)
42
+ FetchContent_Declare(
43
+ googletest
44
+ GIT_REPOSITORY https://github.com/google/googletest.git
45
+ GIT_TAG v1.14.0
46
+ )
47
+ # Prevent GoogleTest from overriding our compiler/linker options
48
+ # (Windows-only quirk; harmless on macOS/Linux).
49
+ set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
50
+ FetchContent_MakeAvailable(googletest)
51
+
52
+ # ─────────────────────────────────────────────────────────────────────
53
+ # Include paths
54
+ # ─────────────────────────────────────────────────────────────────────
55
+ # Order matters: stubs/ comes FIRST so `#include <jsi/jsi.h>` and
56
+ # `#include <react-native-worklets-core/WKTJsiWorklet.h>` resolve to
57
+ # the test-only stubs before any system header. The production cpp/
58
+ # directory comes after so retailens:: headers (e.g. ar_frame_pose.h,
59
+ # stitcher_frame_data.hpp) are found as the production code expects.
60
+ include_directories(
61
+ ${CMAKE_CURRENT_SOURCE_DIR}/stubs
62
+ ${CMAKE_CURRENT_SOURCE_DIR}/..
63
+ )
64
+
65
+ # ─────────────────────────────────────────────────────────────────────
66
+ # Test executable
67
+ # ─────────────────────────────────────────────────────────────────────
68
+ add_executable(stitcher_cpp_tests
69
+ # Production sources under test (only those whose deps we can satisfy
70
+ # without OpenCV / a real JSI runtime).
71
+ ${CMAKE_CURRENT_SOURCE_DIR}/../stitcher_worklet_registry.cpp
72
+
73
+ # Test sources.
74
+ ${CMAKE_CURRENT_SOURCE_DIR}/pose_test.cpp
75
+ ${CMAKE_CURRENT_SOURCE_DIR}/stitcher_frame_data_test.cpp
76
+ ${CMAKE_CURRENT_SOURCE_DIR}/stitcher_worklet_registry_test.cpp
77
+ )
78
+
79
+ target_link_libraries(stitcher_cpp_tests
80
+ PRIVATE
81
+ GTest::gtest_main
82
+ )
83
+
84
+ # Treat warnings as errors in the test build to catch the kind of
85
+ # silent regressions (unused variables, signed/unsigned comparisons,
86
+ # narrowing conversions) that production cross-compilation flags would
87
+ # otherwise suppress.
88
+ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
89
+ target_compile_options(stitcher_cpp_tests PRIVATE
90
+ -Wall -Wextra -Wpedantic -Werror
91
+ )
92
+ endif()
93
+
94
+ # Register with CTest so `ctest --output-on-failure` picks up the
95
+ # individual TEST() cases (gtest_discover_tests scans the binary at
96
+ # build time, no manual ADD_TEST per case).
97
+ include(GoogleTest)
98
+ gtest_discover_tests(stitcher_cpp_tests)
@@ -0,0 +1,86 @@
1
+ # `cpp/tests/` — shared C++ unit test suite
2
+
3
+ v0.10.0 audit `#9A` introduced this directory to give the cross-platform
4
+ shared C++ code under `cpp/` a Google Test harness that runs on the
5
+ developer's host machine (not on a device or emulator). Pairs with the
6
+ Android-side JUnit suite added in `#11A` (see
7
+ `android/src/test/java/io/imagestitcher/rn/`).
8
+
9
+ ## Run
10
+
11
+ ```sh
12
+ scripts/run-cpp-tests.sh # configure + build + ctest
13
+ scripts/run-cpp-tests.sh --clean # nuke build/cpp-tests/ first
14
+ ```
15
+
16
+ Requires `cmake ≥ 3.20` and a C++17 toolchain (the macOS AppleClang
17
+ shipped with Xcode 14+ is fine; Linux GCC 9+ / Clang 10+ also work).
18
+
19
+ Build artefacts land under `build/cpp-tests/` (gitignored). Google
20
+ Test is fetched at configure time via CMake `FetchContent` pinned to
21
+ `v1.14.0`; no system-wide install required.
22
+
23
+ ## Scope (v0.10.0)
24
+
25
+ Covered:
26
+
27
+ - `Pose`, `PlaneTransform` (POD layout / size / field offsets — pinned
28
+ to the cross-platform marshalling contract documented in
29
+ `cpp/ar_frame_pose.h`).
30
+ - `StitcherFrameData` (default-construction invariants the JSI host
31
+ object's `get()` dispatch relies on).
32
+ - `PixelBufferReader` interface contract (clipping behaviour of
33
+ `copyTo` — validated via the `FakePixelBufferReader` test helper).
34
+ - `StitcherWorkletRegistry` storage lifecycle: shared-instance,
35
+ install/uninstall/count/snapshot, snapshot independence, concurrent
36
+ installs yield unique IDs (16 threads × 32 installs).
37
+
38
+ Not yet covered (intentional deferrals):
39
+
40
+ - `KeyframeGate` (`cpp/keyframe_gate.cpp`) — depends on OpenCV
41
+ (`opencv2/imgproc.hpp`, `opencv2/video.hpp` for `calcOpticalFlowPyrLK`).
42
+ Linking the production OpenCV xcframework / Android SDK into the
43
+ host-side test target would balloon CI time and disk usage; the
44
+ alternative is to land a stripped-down `libopencv-core` host build
45
+ just for tests. Deferred — comes with the v0.11.0 cross-platform
46
+ parity suite (`#2C`).
47
+ - `stitcher.cpp` — uses the full OpenCV stitching pipeline; same
48
+ reason as above.
49
+ - JSI host-object dispatch (`stitcher_frame_jsi.cpp`,
50
+ `stitcher_proxy_jsi.cpp`, `stitcher_worklet_dispatch.cpp`) — needs
51
+ a real Hermes runtime. The `StitcherWorkletRegistry` tests sidestep
52
+ this via the `_installEntryForTests` seam + JSI stubs under
53
+ `stubs/`; the JSI dispatch paths can't be similarly stubbed because
54
+ they actively call into the runtime.
55
+
56
+ ## How the JSI-dependent registry tests work without a real JSI
57
+
58
+ `stitcher_worklet_registry.cpp` `#include`s
59
+ `<jsi/jsi.h>` and `<react-native-worklets-core/WKTJsiWorklet.h>` to
60
+ construct `WorkletInvoker` instances from a real JS runtime. The test
61
+ target sidesteps both by:
62
+
63
+ 1. Putting `cpp/tests/stubs/` first on the compiler's include path so
64
+ `#include <jsi/jsi.h>` resolves to `stubs/jsi/jsi.h` (which declares
65
+ `facebook::jsi::Runtime` / `Value` as empty classes — enough for
66
+ the registry's reference-only usage), and
67
+ `#include <react-native-worklets-core/WKTJsiWorklet.h>` resolves to
68
+ `stubs/react-native-worklets-core/WKTJsiWorklet.h` (which declares
69
+ `RNWorklet::WorkletInvoker` with a no-op constructor).
70
+ 2. Calling `_installEntryForTests(nullptr)` instead of the production
71
+ `install(runtime, value)` path. The registry stores the
72
+ `shared_ptr<WorkletInvoker>` but never dereferences it (it only
73
+ hands it back via `snapshot`), so `nullptr` is safe.
74
+
75
+ The stubs live exclusively under `cpp/tests/stubs/`; production
76
+ builds never see them. See `stubs/jsi/jsi.h`'s docstring for the
77
+ guard-rails.
78
+
79
+ ## When NOT to add a test here
80
+
81
+ - If the test needs a real JSI runtime, real OpenCV operations, or
82
+ real-device sensor data, it belongs in `android/src/androidTest/`
83
+ (instrumented), the iOS Swift test target, or the v0.11.0 parity
84
+ harness — NOT here.
85
+ - If the test verifies TypeScript/JS-side behaviour, it belongs under
86
+ `src/**/__tests__/` (Jest).
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // pose_test.cpp — v0.10.0 audit #9A
4
+ //
5
+ // Layout / size invariants for the cross-platform POD structs that
6
+ // marshal AR-frame pose data between Swift/Kotlin and shared C++.
7
+ // The Pose / PlaneTransform structs MUST stay binary-compatible
8
+ // across iOS (Swift → C++) and Android (Kotlin → JNI → C++) — any
9
+ // silent field reorder, padding shift, or size change would diverge
10
+ // gate decisions between platforms.
11
+ //
12
+ // These are pinned to the contract in `cpp/ar_frame_pose.h`'s
13
+ // docstring; if the struct shape evolves intentionally, update both
14
+ // the docstring and these tests in the same commit.
15
+
16
+ #include "ar_frame_pose.h"
17
+
18
+ #include <gtest/gtest.h>
19
+
20
+ #include <cstddef>
21
+ #include <type_traits>
22
+
23
+ using retailens::Pose;
24
+ using retailens::PlaneTransform;
25
+
26
+ TEST(PoseLayoutTest, IsStandardLayoutPod) {
27
+ // Required for `memcpy` marshalling and for the iOS Obj-C++ /
28
+ // Android JNI bridges to write into the struct directly.
29
+ EXPECT_TRUE(std::is_standard_layout<Pose>::value);
30
+ EXPECT_TRUE(std::is_trivially_copyable<Pose>::value);
31
+ }
32
+
33
+ TEST(PoseLayoutTest, SizeMatchesExpectedFields) {
34
+ // 11 floats (tx, ty, tz, qx, qy, qz, qw, fx, fy, cx, cy) + 2 int32_t
35
+ // (imageWidth, imageHeight) = 11*4 + 2*4 = 52 bytes. No padding
36
+ // expected: every field is 4-byte aligned and the struct contains
37
+ // only 4-byte primitives.
38
+ EXPECT_EQ(sizeof(Pose), static_cast<std::size_t>(11 * 4 + 2 * 4));
39
+ }
40
+
41
+ TEST(PoseLayoutTest, FieldOrderMatchesContract) {
42
+ // Translation comes before rotation; rotation before intrinsics;
43
+ // intrinsics before image dimensions. Swift / Kotlin marshallers
44
+ // assume this order — flipping any pair silently breaks the
45
+ // memcpy-based bridge.
46
+ EXPECT_EQ(offsetof(Pose, tx), 0u);
47
+ EXPECT_EQ(offsetof(Pose, ty), sizeof(float) * 1);
48
+ EXPECT_EQ(offsetof(Pose, tz), sizeof(float) * 2);
49
+ EXPECT_EQ(offsetof(Pose, qx), sizeof(float) * 3);
50
+ EXPECT_EQ(offsetof(Pose, qy), sizeof(float) * 4);
51
+ EXPECT_EQ(offsetof(Pose, qz), sizeof(float) * 5);
52
+ EXPECT_EQ(offsetof(Pose, qw), sizeof(float) * 6);
53
+ EXPECT_EQ(offsetof(Pose, fx), sizeof(float) * 7);
54
+ EXPECT_EQ(offsetof(Pose, fy), sizeof(float) * 8);
55
+ EXPECT_EQ(offsetof(Pose, cx), sizeof(float) * 9);
56
+ EXPECT_EQ(offsetof(Pose, cy), sizeof(float) * 10);
57
+ EXPECT_EQ(offsetof(Pose, imageWidth), sizeof(float) * 11);
58
+ EXPECT_EQ(offsetof(Pose, imageHeight),
59
+ sizeof(float) * 11 + sizeof(int32_t));
60
+ }
61
+
62
+ TEST(PlaneTransformLayoutTest, IsStandardLayoutPod) {
63
+ EXPECT_TRUE(std::is_standard_layout<PlaneTransform>::value);
64
+ EXPECT_TRUE(std::is_trivially_copyable<PlaneTransform>::value);
65
+ }
66
+
67
+ TEST(PlaneTransformLayoutTest, SixteenFloatsContiguous) {
68
+ // The `m[16]` array MUST be a contiguous 64-byte block — both
69
+ // bridges call `memcpy(planeTransform.m, source, 64)`. No leading
70
+ // padding, no field reorder (there's only one field, but pinning the
71
+ // size catches any accidental wrapper/struct change).
72
+ EXPECT_EQ(sizeof(PlaneTransform), static_cast<std::size_t>(16 * 4));
73
+ EXPECT_EQ(offsetof(PlaneTransform, m), 0u);
74
+ }