react-native-image-stitcher 0.8.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/android/build.gradle +10 -0
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +115 -10
  4. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +17 -3
  5. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  6. package/cpp/stitcher_worklet_registry.cpp +10 -0
  7. package/cpp/stitcher_worklet_registry.hpp +10 -0
  8. package/cpp/tests/CMakeLists.txt +98 -0
  9. package/cpp/tests/README.md +86 -0
  10. package/cpp/tests/pose_test.cpp +74 -0
  11. package/cpp/tests/stitcher_frame_data_test.cpp +132 -0
  12. package/cpp/tests/stitcher_worklet_registry_test.cpp +195 -0
  13. package/cpp/tests/stubs/jsi/jsi.h +33 -0
  14. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +34 -0
  15. package/dist/camera/useCapture.d.ts +1 -1
  16. package/dist/camera/useCapture.js +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +20 -1
  19. package/dist/stitching/incremental.d.ts +41 -0
  20. package/dist/stitching/useFrameStream.d.ts +34 -0
  21. package/dist/stitching/useFrameStream.js +234 -0
  22. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  23. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  24. package/dist/types.d.ts +87 -0
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +138 -9
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +50 -14
  27. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  28. package/package.json +1 -1
  29. package/src/camera/useCapture.ts +1 -1
  30. package/src/index.ts +19 -0
  31. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +276 -0
  32. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  33. package/src/stitching/incremental.ts +42 -0
  34. package/src/stitching/useFrameStream.ts +271 -0
  35. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  36. package/src/types.ts +95 -0
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // stitcher_frame_data_test.cpp — v0.10.0 audit #9A
4
+ //
5
+ // Sanity coverage for the `StitcherFrameData` POD payload + the
6
+ // `PixelBufferReader` interface contract. The shared C++
7
+ // `StitcherFrameData` is constructed by both the iOS Obj-C++ side
8
+ // (`StitcherFrameHostObject.mm`) and the Android JNI side
9
+ // (`stitcher_frame_jni.cpp`); these tests pin the default-construction
10
+ // invariants both sides depend on (e.g. `hasTranslation=false`,
11
+ // `qw=1.0`).
12
+ //
13
+ // The `PixelBufferReader` tests use a fake-buffer implementation
14
+ // (`FakePixelBufferReader`) to validate the `copyTo` clipping
15
+ // behaviour the docstring promises.
16
+
17
+ #include "stitcher_frame_data.hpp"
18
+
19
+ #include <gtest/gtest.h>
20
+
21
+ #include <cstdint>
22
+ #include <cstring>
23
+ #include <memory>
24
+ #include <vector>
25
+
26
+ using retailens::PixelBufferReader;
27
+ using retailens::StitcherFrameData;
28
+
29
+ namespace {
30
+
31
+ /// Minimal fake reader — backs a fixed byte vector. Used by the
32
+ /// `PixelBufferReader` contract tests below to verify
33
+ /// `copyTo` clips at the smaller of (maxBytes, byteSize).
34
+ class FakePixelBufferReader : public PixelBufferReader {
35
+ public:
36
+ explicit FakePixelBufferReader(std::vector<uint8_t> bytes)
37
+ : _bytes(std::move(bytes)) {}
38
+
39
+ std::size_t byteSize() const override { return _bytes.size(); }
40
+
41
+ std::size_t copyTo(uint8_t* dst, std::size_t maxBytes) override {
42
+ const std::size_t n =
43
+ maxBytes < _bytes.size() ? maxBytes : _bytes.size();
44
+ std::memcpy(dst, _bytes.data(), n);
45
+ return n;
46
+ }
47
+
48
+ private:
49
+ std::vector<uint8_t> _bytes;
50
+ };
51
+
52
+ } // namespace
53
+
54
+ // ─── StitcherFrameData default-construction invariants ─────────────
55
+
56
+ TEST(StitcherFrameDataTest, DefaultsAreSafeForJSIDispatch) {
57
+ // The JSI host object's `get()` dispatch keys off these defaults to
58
+ // expose `undefined` for unset fields. In particular:
59
+ // - `hasTranslation == false` → pose.translation === undefined
60
+ // - `arTrackingState.empty()` → arTrackingState === undefined
61
+ // - `qw == 1.0` with rest zero → identity rotation (safe default
62
+ // for non-AR mode where rotation is unknown)
63
+ StitcherFrameData d;
64
+ EXPECT_EQ(d.width, 0);
65
+ EXPECT_EQ(d.height, 0);
66
+ EXPECT_TRUE(d.source.empty());
67
+ EXPECT_TRUE(d.pixelFormat.empty());
68
+ EXPECT_TRUE(d.orientation.empty());
69
+ EXPECT_DOUBLE_EQ(d.timestampNs, 0.0);
70
+ EXPECT_DOUBLE_EQ(d.qx, 0.0);
71
+ EXPECT_DOUBLE_EQ(d.qy, 0.0);
72
+ EXPECT_DOUBLE_EQ(d.qz, 0.0);
73
+ EXPECT_DOUBLE_EQ(d.qw, 1.0); // identity rotation
74
+ EXPECT_DOUBLE_EQ(d.tx, 0.0);
75
+ EXPECT_DOUBLE_EQ(d.ty, 0.0);
76
+ EXPECT_DOUBLE_EQ(d.tz, 0.0);
77
+ EXPECT_FALSE(d.hasTranslation);
78
+ EXPECT_TRUE(d.arTrackingState.empty());
79
+ EXPECT_EQ(d.pixelReader, nullptr);
80
+ }
81
+
82
+ TEST(StitcherFrameDataTest, IsCopyable) {
83
+ // `StitcherFrameData` is documented as "value-typed (cheap to copy;
84
+ // ~100 bytes)". Copy needs to deep-copy the strings + bump the
85
+ // pixelReader shared_ptr refcount.
86
+ StitcherFrameData a;
87
+ a.source = "ar";
88
+ a.width = 1920;
89
+ a.height = 1080;
90
+ a.pixelReader = std::make_shared<FakePixelBufferReader>(
91
+ std::vector<uint8_t>{1, 2, 3});
92
+
93
+ StitcherFrameData b = a;
94
+ EXPECT_EQ(b.source, "ar");
95
+ EXPECT_EQ(b.width, 1920);
96
+ EXPECT_EQ(b.height, 1080);
97
+ ASSERT_NE(b.pixelReader, nullptr);
98
+ EXPECT_EQ(b.pixelReader.use_count(), 2); // both a and b hold a ref
99
+ EXPECT_EQ(b.pixelReader->byteSize(), 3u);
100
+ }
101
+
102
+ // ─── PixelBufferReader contract ────────────────────────────────────
103
+
104
+ TEST(PixelBufferReaderTest, CopyToReturnsAllBytesWhenMaxBytesExceedsSize) {
105
+ FakePixelBufferReader reader({0x11, 0x22, 0x33});
106
+ uint8_t buf[8] = {0};
107
+ const std::size_t written = reader.copyTo(buf, sizeof(buf));
108
+ EXPECT_EQ(written, 3u);
109
+ EXPECT_EQ(buf[0], 0x11);
110
+ EXPECT_EQ(buf[1], 0x22);
111
+ EXPECT_EQ(buf[2], 0x33);
112
+ EXPECT_EQ(buf[3], 0u); // untouched tail
113
+ }
114
+
115
+ TEST(PixelBufferReaderTest, CopyToClipsWhenMaxBytesIsSmaller) {
116
+ // Contract per stitcher_frame_data.hpp: "Implementations MUST handle
117
+ // the case where maxBytes < byteSize() (clip silently)."
118
+ FakePixelBufferReader reader({0xAA, 0xBB, 0xCC, 0xDD});
119
+ uint8_t buf[2] = {0};
120
+ const std::size_t written = reader.copyTo(buf, sizeof(buf));
121
+ EXPECT_EQ(written, 2u);
122
+ EXPECT_EQ(buf[0], 0xAA);
123
+ EXPECT_EQ(buf[1], 0xBB);
124
+ }
125
+
126
+ TEST(PixelBufferReaderTest, CopyToWithZeroMaxBytesReturnsZero) {
127
+ FakePixelBufferReader reader({0x01, 0x02, 0x03});
128
+ uint8_t dummy = 0xFF;
129
+ const std::size_t written = reader.copyTo(&dummy, 0);
130
+ EXPECT_EQ(written, 0u);
131
+ EXPECT_EQ(dummy, 0xFF); // dst untouched when maxBytes == 0
132
+ }
@@ -0,0 +1,195 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // stitcher_worklet_registry_test.cpp — v0.10.0 audit #9A
4
+ //
5
+ // Lifecycle + concurrency coverage for the process-scope native
6
+ // `StitcherWorkletRegistry` introduced in v0.8.0 Phase 4b. See
7
+ // `cpp/stitcher_worklet_registry.hpp` for the public contract.
8
+ //
9
+ // The registry's production `install(runtime, workletValue)` path
10
+ // constructs an `RNWorklet::WorkletInvoker` from a JSI `Runtime` and
11
+ // `Value`. Standing up a real worklets-core runtime under gtest would
12
+ // pull in Hermes + JSI + the whole worklets-core library — too heavy
13
+ // for these scope-limited storage tests. Instead we exercise the
14
+ // equivalent `_installEntryForTests(invoker)` test seam (mirrors
15
+ // `_resetForTests` in pattern) with `nullptr` invokers. The registry
16
+ // never dereferences the pointer — it only stores the shared_ptr and
17
+ // hands it back via `snapshot` — so nullptr is safe.
18
+ //
19
+ // What this covers (lifting from the production `install`/`uninstall`/
20
+ // `snapshot`/`count` contract):
21
+ // - shared() returns the same instance across calls
22
+ // - count/snapshot start empty after _resetForTests
23
+ // - install assigns monotonically increasing host-N IDs
24
+ // - count tracks installs / uninstalls
25
+ // - uninstall of an unknown ID is a no-op (matches JS side)
26
+ // - snapshot returns an independent copy (mutations after snapshot
27
+ // don't affect the snapshot's view)
28
+ // - concurrent installs from many threads serialise correctly and
29
+ // yield unique IDs (no double-issue under contention)
30
+
31
+ #include "stitcher_worklet_registry.hpp"
32
+ #include <react-native-worklets-core/WKTJsiWorklet.h>
33
+
34
+ #include <gtest/gtest.h>
35
+
36
+ #include <algorithm>
37
+ #include <atomic>
38
+ #include <chrono>
39
+ #include <set>
40
+ #include <string>
41
+ #include <thread>
42
+ #include <vector>
43
+
44
+ using retailens::StitcherWorkletEntry;
45
+ using retailens::StitcherWorkletRegistry;
46
+
47
+ namespace {
48
+
49
+ /// Test fixture — resets the singleton before each test so cases
50
+ /// don't leak state into each other. Without this every test would
51
+ /// see the cumulative entries from prior tests in the run.
52
+ class StitcherWorkletRegistryTest : public ::testing::Test {
53
+ protected:
54
+ void SetUp() override {
55
+ StitcherWorkletRegistry::shared()._resetForTests();
56
+ }
57
+ void TearDown() override {
58
+ StitcherWorkletRegistry::shared()._resetForTests();
59
+ }
60
+ };
61
+
62
+ } // namespace
63
+
64
+ TEST_F(StitcherWorkletRegistryTest, SharedReturnsSameInstance) {
65
+ // The singleton invariant is load-bearing for the JS-side mental
66
+ // model: `useFrameProcessor` mounts on one component and unmounts
67
+ // from another but the registry stays.
68
+ StitcherWorkletRegistry& a = StitcherWorkletRegistry::shared();
69
+ StitcherWorkletRegistry& b = StitcherWorkletRegistry::shared();
70
+ EXPECT_EQ(&a, &b);
71
+ }
72
+
73
+ TEST_F(StitcherWorkletRegistryTest, StartsEmptyAfterReset) {
74
+ auto& r = StitcherWorkletRegistry::shared();
75
+ EXPECT_EQ(r.count(), 0u);
76
+ EXPECT_TRUE(r.snapshot().empty());
77
+ }
78
+
79
+ TEST_F(StitcherWorkletRegistryTest, InstallAssignsHostPrefixedIncrementingIds) {
80
+ auto& r = StitcherWorkletRegistry::shared();
81
+ const std::string id0 = r._installEntryForTests(nullptr);
82
+ const std::string id1 = r._installEntryForTests(nullptr);
83
+ const std::string id2 = r._installEntryForTests(nullptr);
84
+
85
+ // IDs are "host-N" with N monotonically increasing from 0 after a
86
+ // reset. The format is part of the public contract — uninstall
87
+ // callers store these IDs verbatim.
88
+ EXPECT_EQ(id0, "host-0");
89
+ EXPECT_EQ(id1, "host-1");
90
+ EXPECT_EQ(id2, "host-2");
91
+ EXPECT_EQ(r.count(), 3u);
92
+ }
93
+
94
+ TEST_F(StitcherWorkletRegistryTest, UninstallRemovesByIdAndUpdatesCount) {
95
+ auto& r = StitcherWorkletRegistry::shared();
96
+ const std::string id0 = r._installEntryForTests(nullptr);
97
+ const std::string id1 = r._installEntryForTests(nullptr);
98
+ const std::string id2 = r._installEntryForTests(nullptr);
99
+ EXPECT_EQ(r.count(), 3u);
100
+
101
+ r.uninstall(id1); // remove the middle entry
102
+ EXPECT_EQ(r.count(), 2u);
103
+
104
+ // Snapshot should contain only id0 and id2, in some order — the
105
+ // contract doesn't promise insertion order survives uninstall
106
+ // (the erase-remove pattern is stable in practice but we don't
107
+ // pin that publicly).
108
+ const auto snap = r.snapshot();
109
+ std::vector<std::string> remainingIds;
110
+ remainingIds.reserve(snap.size());
111
+ for (const auto& entry : snap) {
112
+ remainingIds.push_back(entry.id);
113
+ }
114
+ std::sort(remainingIds.begin(), remainingIds.end());
115
+ EXPECT_EQ(remainingIds, (std::vector<std::string>{id0, id2}));
116
+ }
117
+
118
+ TEST_F(StitcherWorkletRegistryTest, UninstallOfUnknownIdIsNoop) {
119
+ // Matches the JS-side `StitcherWorkletRegistry.uninstall` semantics
120
+ // (idempotent, no throw on unknown). Critical because the JS
121
+ // useEffect cleanup can fire after an unmount/remount race where the
122
+ // ID has already been removed.
123
+ auto& r = StitcherWorkletRegistry::shared();
124
+ r._installEntryForTests(nullptr);
125
+ EXPECT_EQ(r.count(), 1u);
126
+ EXPECT_NO_THROW(r.uninstall("host-does-not-exist"));
127
+ EXPECT_NO_THROW(r.uninstall(""));
128
+ EXPECT_EQ(r.count(), 1u); // existing entry untouched
129
+ }
130
+
131
+ TEST_F(StitcherWorkletRegistryTest, SnapshotIsIndependentOfFutureMutations) {
132
+ // Per header docstring: "mutations against the registry after
133
+ // `snapshot` returns do not affect the snapshot." This matters for
134
+ // the AR-session dispatch path, which snapshots and then iterates
135
+ // without holding the registry lock — concurrent uninstall on the
136
+ // JS thread must NOT invalidate the snapshot.
137
+ auto& r = StitcherWorkletRegistry::shared();
138
+ const std::string id0 = r._installEntryForTests(nullptr);
139
+ const std::string id1 = r._installEntryForTests(nullptr);
140
+
141
+ const auto snap = r.snapshot();
142
+ ASSERT_EQ(snap.size(), 2u);
143
+
144
+ r.uninstall(id0);
145
+ r.uninstall(id1);
146
+ EXPECT_EQ(r.count(), 0u);
147
+
148
+ // Snapshot still has both entries.
149
+ EXPECT_EQ(snap.size(), 2u);
150
+ EXPECT_EQ(snap[0].id, id0);
151
+ EXPECT_EQ(snap[1].id, id1);
152
+ }
153
+
154
+ TEST_F(StitcherWorkletRegistryTest, ConcurrentInstallsYieldUniqueIds) {
155
+ // Many threads racing `_installEntryForTests` simultaneously must
156
+ // not see duplicate IDs (would indicate the mutex around _nextId is
157
+ // missing or broken). This is the per-instance equivalent of
158
+ // TransferredNV21Test's "only one of N concurrent callers wins"
159
+ // pattern, adapted for "all N callers succeed but produce distinct
160
+ // IDs".
161
+ auto& r = StitcherWorkletRegistry::shared();
162
+ constexpr int kThreads = 16;
163
+ constexpr int kPerThread = 32; // 512 total installs
164
+
165
+ std::vector<std::thread> workers;
166
+ workers.reserve(kThreads);
167
+ std::vector<std::vector<std::string>> idsPerThread(kThreads);
168
+
169
+ std::atomic<bool> go{false};
170
+ for (int t = 0; t < kThreads; ++t) {
171
+ workers.emplace_back([&, t]() {
172
+ while (!go.load(std::memory_order_acquire)) {
173
+ std::this_thread::yield();
174
+ }
175
+ for (int i = 0; i < kPerThread; ++i) {
176
+ idsPerThread[t].push_back(r._installEntryForTests(nullptr));
177
+ }
178
+ });
179
+ }
180
+ go.store(true, std::memory_order_release);
181
+ for (auto& w : workers) w.join();
182
+
183
+ // Aggregate every ID issued. Total count = kThreads * kPerThread;
184
+ // uniqueness = the set size matches.
185
+ std::set<std::string> allIds;
186
+ for (const auto& v : idsPerThread) {
187
+ for (const auto& id : v) {
188
+ allIds.insert(id);
189
+ }
190
+ }
191
+ EXPECT_EQ(allIds.size(),
192
+ static_cast<std::size_t>(kThreads * kPerThread));
193
+ EXPECT_EQ(r.count(),
194
+ static_cast<std::size_t>(kThreads * kPerThread));
195
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // jsi.h — TEST-ONLY stub of facebook::jsi types.
4
+ //
5
+ // The real jsi.h ships with React Native and pulls in a large surface
6
+ // area (Runtime, Value, Object, Function, HostObject, HostFunction,
7
+ // Array, ArrayBuffer, PropNameID, …) along with the build infra to
8
+ // link them. For pure-C++ unit tests that exercise data-structure
9
+ // invariants of code that REFERENCES jsi types but never CALLS into
10
+ // them (e.g. `StitcherWorkletRegistry` storing a `shared_ptr` and
11
+ // forwarding `Runtime&` to a constructor stub), we only need the
12
+ // types to be NAMED so headers compile.
13
+ //
14
+ // Pattern: this stub is placed first on the test target's include
15
+ // path so `#include <jsi/jsi.h>` resolves here instead of to RN's
16
+ // real header. Production builds NEVER see this file — it lives
17
+ // only under `cpp/tests/stubs/`, which is referenced exclusively by
18
+ // `cpp/tests/CMakeLists.txt`.
19
+ //
20
+ // Tests that need to actually CONSTRUCT or CALL into JSI types should
21
+ // not use this stub — they should run against a real JSI runtime (a
22
+ // future v0.11.0+ test target that links Hermes).
23
+
24
+ #pragma once
25
+
26
+ namespace facebook {
27
+ namespace jsi {
28
+
29
+ class Runtime {};
30
+ class Value {};
31
+
32
+ } // namespace jsi
33
+ } // namespace facebook
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // WKTJsiWorklet.h — TEST-ONLY stub of RNWorklet::WorkletInvoker.
4
+ //
5
+ // `cpp/stitcher_worklet_registry.cpp` constructs a
6
+ // `std::make_shared<RNWorklet::WorkletInvoker>(runtime, value)` inside
7
+ // `install`. The real WorkletInvoker (from react-native-worklets-core)
8
+ // captures the worklet's source / closure / runtime affinity and is
9
+ // non-trivial to stand up in a unit-test context.
10
+ //
11
+ // This stub provides JUST the symbols needed for the registry to
12
+ // compile and link. The constructor and destructor are no-ops; calling
13
+ // methods on a stub invoker is undefined behaviour, but the registry
14
+ // itself never does (it only stores the shared_ptr and hands it out
15
+ // via `snapshot`). Tests construct entries directly via
16
+ // `_installEntryForTests(nullptr)` to avoid even the trivial
17
+ // allocation.
18
+ //
19
+ // See cpp/tests/stubs/jsi/jsi.h for the parallel stub of facebook::jsi.
20
+
21
+ #pragma once
22
+
23
+ #include <jsi/jsi.h>
24
+
25
+ namespace RNWorklet {
26
+
27
+ class WorkletInvoker {
28
+ public:
29
+ WorkletInvoker(facebook::jsi::Runtime& /*runtime*/,
30
+ const facebook::jsi::Value& /*workletValue*/) {}
31
+ ~WorkletInvoker() = default;
32
+ };
33
+
34
+ } // namespace RNWorklet
@@ -17,7 +17,7 @@
17
17
  * - This hook does NOT persist captures. Host apps hand the
18
18
  * returned CaptureResult to their own storage layer (WatermelonDB
19
19
  * insert, Redux dispatch, whatever).
20
- * - Video recording lives in useVideoCapture (TODO).
20
+ * - Video recording lives in useVideoCapture.
21
21
  *
22
22
  * The public API is designed to be minimal and replaceable: host apps
23
23
  * that prefer the raw vision-camera API can opt out of this hook and