@shortkitsdk/react-native 0.2.34 → 0.2.36

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 (52) hide show
  1. package/android/build.gradle.kts +8 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +100 -47
  4. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +54 -8
  5. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +240 -27
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +151 -1
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
  10. package/android/src/main/java/com/shortkit/reactnative/ShortKitPackage.kt +0 -2
  11. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
  12. package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
  13. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
  14. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
  15. package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
  16. package/ios/ReactOverlayHost.swift +10 -8
  17. package/ios/ReactVideoCarouselOverlayHost.swift +6 -5
  18. package/ios/ShortKitBridge.swift +18 -0
  19. package/ios/ShortKitModule.mm +5 -0
  20. package/ios/ShortKitPlayerNativeView.swift +36 -0
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +1252 -82
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +28 -2
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +28 -2
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +1252 -82
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +28 -2
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +28 -2
  33. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +1252 -82
  34. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +28 -2
  35. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  36. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +28 -2
  37. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  39. package/ios/ShortKitWidgetNativeView.swift +30 -3
  40. package/ios/ShortKitWidgetNativeViewManager.mm +1 -0
  41. package/package.json +1 -1
  42. package/src/ShortKitCommands.ts +20 -0
  43. package/src/ShortKitFeed.tsx +21 -0
  44. package/src/ShortKitPlayer.tsx +20 -1
  45. package/src/ShortKitWidget.tsx +63 -15
  46. package/src/specs/NativeShortKitModule.ts +10 -0
  47. package/src/specs/ShortKitWidgetViewNativeComponent.ts +15 -3
  48. package/src/types.ts +40 -0
  49. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerNativeView.kt +0 -149
  50. package/android/src/main/java/com/shortkit/reactnative/ShortKitPlayerViewManager.kt +0 -35
  51. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetNativeView.kt +0 -149
  52. package/android/src/main/java/com/shortkit/reactnative/ShortKitWidgetViewManager.kt +0 -30
@@ -30,6 +30,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
30
30
  var feedId: String? = null
31
31
  var startAtItemId: String? = null
32
32
  var preloadId: String? = null
33
+ /**
34
+ * JSON-encoded `FeedInput[]` from the `feedItems` JS prop. Parsed at
35
+ * fragment-construction time only — post-mount changes are a no-op
36
+ * (matches iOS PR #150). Host apps that need post-mount item updates
37
+ * use the imperative `ShortKit.setFeedItems(feedId, items)` API.
38
+ *
39
+ * `preloadId` takes precedence when both are set.
40
+ */
41
+ var feedItemsJSON: String? = null
33
42
  // Fabric's generated delegate maps an absent boolean prop to the Java
34
43
  // primitive false, so Boolean? is always non-null from the bridge. Track
35
44
  // whether the prop was explicitly set so we can fall back to the provider
@@ -37,6 +46,62 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
37
46
  var debugPanel: Boolean = false
38
47
  var debugPanelPropSet: Boolean = false
39
48
 
49
+ /**
50
+ * URL of the thumbnail the host app is already rendering for the item that
51
+ * will be the first active cell. Looked up synchronously via Glide's cache
52
+ * (10ms cap; supports expo-image / FastImage clients) and seeded onto the
53
+ * fragment before its first lifecycle event. Falls back to the existing
54
+ * patchMissingThumbnailIfNeeded helper on cache miss or timeout.
55
+ *
56
+ * Mirrors iOS ShortKitFeedView.seedThumbnailUrl
57
+ * (react_native_sdk/ios/ShortKitFeedView.swift:28).
58
+ */
59
+ var seedThumbnailUrl: String? = null
60
+
61
+ /**
62
+ * Host-driven activation state, set by the `active` JS prop. Defaults to
63
+ * `true`. Mirrors iOS [`ShortKitFeedView.active`](react_native_sdk/ios/ShortKitFeedView.swift:46).
64
+ */
65
+ var active: Boolean = true
66
+ internal set
67
+
68
+ /**
69
+ * Tracks whether `active` was explicitly set by the host (vs. defaulted).
70
+ * Same purpose as `debugPanelPropSet` — distinguishes "absent" from "false"
71
+ * since Fabric maps absent boolean props to the Java primitive `false`.
72
+ *
73
+ * Used only by the first-mount race fix in [embedFeedFragmentIfNeeded] to
74
+ * decide whether a `false` value is a real host intent or a Fabric default.
75
+ */
76
+ var activePropSet: Boolean = false
77
+ internal set
78
+
79
+ /**
80
+ * Set the `active` prop from the bridge. The canonical entry point for
81
+ * all `active` state transitions — do not mutate the field directly.
82
+ *
83
+ * Behavior:
84
+ * - Always sets `activePropSet = true`.
85
+ * - On transition while a fragment is embedded:
86
+ * - `true → false`: calls `fragment.deactivate()`.
87
+ * - `false → true`: calls `fragment.activate()`.
88
+ * - Returns early if no fragment is yet embedded — the first-mount race
89
+ * in [embedFeedFragmentIfNeeded] handles the deferred deactivate case.
90
+ *
91
+ * Invoked by [ShortKitFeedViewManager.setActive].
92
+ */
93
+ fun setActiveFromBridge(value: Boolean) {
94
+ val prev = active
95
+ active = value
96
+ activePropSet = true
97
+ val fragment = feedFragment ?: return
98
+ if (prev && !value) {
99
+ fragment.deactivate()
100
+ } else if (!prev && value) {
101
+ fragment.activate()
102
+ }
103
+ }
104
+
40
105
  // -----------------------------------------------------------------------
41
106
  // Fragment management
42
107
  // -----------------------------------------------------------------------
@@ -140,7 +205,15 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
140
205
  synchronized(ShortKitBridge.staticPendingFeedViews) {
141
206
  ShortKitBridge.staticPendingFeedViews.remove(this)
142
207
  }
143
- suspendFeedFragment()
208
+ // Mirror iOS willMove(toWindow:) at react_native_sdk/ios/ShortKitFeedView.swift:91-99 —
209
+ // suspend only if leaving the window AND the prop says we should be active.
210
+ // When active=false the host has already driven the fragment into deactivate
211
+ // (or will once setActiveFromBridge runs), and we mustn't double-suspend.
212
+ // Default active=true preserves legacy window-inference behavior for hosts
213
+ // that never set the active prop.
214
+ if (active) {
215
+ suspendFeedFragment()
216
+ }
144
217
  super.onDetachedFromWindow()
145
218
  }
146
219
 
@@ -167,7 +240,11 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
167
240
  .beginTransaction()
168
241
  .show(existingFragment)
169
242
  .commitNowAllowingStateLoss()
170
- existingFragment.activate()
243
+ // Mirror iOS: only auto-activate if the host's active prop says so.
244
+ // Default active=true preserves legacy "always activate on un-hide" behavior.
245
+ if (active) {
246
+ existingFragment.activate()
247
+ }
171
248
  } catch (e: Exception) {
172
249
  Log.e(TAG, "Failed to show feed fragment", e)
173
250
  }
@@ -188,24 +265,58 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
188
265
 
189
266
  var feedConfig = ShortKitBridge.parseFeedConfig(config ?: "{}", context)
190
267
 
191
- preloadId?.let { id ->
192
- val preload = ShortKitBridge.shared?.consumePreload(id)
268
+ val explicitPreloadId = preloadId
269
+ val explicitFeedItemsJSON = feedItemsJSON
270
+ if (explicitPreloadId != null) {
271
+ val preload = ShortKitBridge.shared?.consumePreload(explicitPreloadId)
193
272
  if (preload != null) {
194
273
  feedConfig = feedConfig.copy(preload = preload)
195
274
  }
275
+ } else if (explicitFeedItemsJSON != null) {
276
+ // Mount-time custom-feed fast-path (SHO-29 / iOS PR #150).
277
+ // Parse JSON → FeedInput[] → sdk.preloadFeed(items=) which
278
+ // internally calls FeedPreload.forCustomItems with the SDK's
279
+ // own scope. Assign to feedConfig.preload BEFORE constructing
280
+ // the fragment, so FeedPreload.consume() returns synchronously
281
+ // on the first cell bind. Requires Task 1's sync-payload fix
282
+ // in FeedPreload.kt.
283
+ //
284
+ // Using sdk.preloadFeed(items=) (the public SDK entry point at
285
+ // ShortKit.kt:232) instead of FeedPreload.forCustomItems
286
+ // directly — avoids needing to promote internal SDK helpers
287
+ // for cross-module access from the bridge.
288
+ val parsed = ShortKitBridge.parseFeedInputs(explicitFeedItemsJSON)
289
+ if (parsed != null && parsed.isNotEmpty()) {
290
+ val preload = sdk.preloadFeed(items = parsed)
291
+ feedConfig = feedConfig.copy(preload = preload)
292
+ } else {
293
+ Log.w(TAG, "feedItemsJSON parsed to null/empty — ignoring (config feedSource will drive the fetch path)")
294
+ }
196
295
  }
197
296
 
198
297
  val fragment = ShortKitFeedFragment.newInstance(sdk, feedConfig, startAtItemId)
199
298
 
200
299
  val debugPanelEnabled = if (debugPanelPropSet) debugPanel else sdk.debugPanelEnabled
201
300
  if (debugPanelEnabled) {
202
- fragment.debugPanelFactory = { active, prev, next, lifecycleOwner ->
301
+ fragment.debugPanelFactory = { activeMetrics, prev, next, lifecycleOwner ->
203
302
  com.shortkit.sdk.debug.DebugPanelView(context).also { panel ->
204
- panel.subscribe(active, prev, next, lifecycleOwner)
303
+ panel.subscribe(activeMetrics, prev, next, lifecycleOwner)
205
304
  }
206
305
  }
207
306
  }
208
307
 
308
+ // SHO-11: seed a thumbnail from the host app's image cache so the first
309
+ // cell renders with a visible thumbnail from frame zero. Synchronous
310
+ // Glide lookup (memory-only via 10ms cap) — no network. If null (cache
311
+ // miss, timeout, or Glide not linked), the existing
312
+ // patchMissingThumbnailIfNeeded helper handles the fallback at feed-open
313
+ // time. Mirrors iOS ShortKitFeedView.swift:170-173.
314
+ seedThumbnailUrl?.let { url ->
315
+ com.shortkit.sdk.feed.SeedThumbnailResolver.resolveFromCache(context, url)?.let { bitmap ->
316
+ fragment.seedThumbnail = bitmap
317
+ }
318
+ }
319
+
209
320
  try {
210
321
  activity.supportFragmentManager
211
322
  .beginTransaction()
@@ -213,6 +324,24 @@ class ShortKitFeedView(context: Context) : FrameLayout(context) {
213
324
  .commitNowAllowingStateLoss()
214
325
  this.feedFragment = fragment
215
326
 
327
+ // Android-specific first-mount race: Fabric may have set active=false
328
+ // BEFORE we got to embed the fragment. The fragment self-claims the
329
+ // player pool in onResume (ShortKitFeedFragment.kt:2112), so by the
330
+ // time commitNowAllowingStateLoss returns the fragment is already
331
+ // active. Retroactively deactivate to honor the host's intent.
332
+ //
333
+ // Note: this produces a brief claim-then-yield window (microseconds).
334
+ // Acceptable for normal use; if a host needs strictly never-active
335
+ // mounts, the fragment's onResume self-claim would need a managed-mode
336
+ // flag to suppress (separate ticket).
337
+ //
338
+ // activePropSet guard: only fires when the host explicitly set the prop.
339
+ // Fabric maps absent booleans to Java false, so without this guard
340
+ // every fragment would deactivate at mount.
341
+ if (activePropSet && !active) {
342
+ fragment.deactivate()
343
+ }
344
+
216
345
  // View.generateViewId() can return IDs that collide with Fabric-
217
346
  // managed views (e.g. ReactSurfaceView also gets id=1). When that
218
347
  // happens, FragmentManager.replace() places the fragment view in
@@ -35,12 +35,27 @@ class ShortKitFeedViewManager : SimpleViewManager<ShortKitFeedView>() {
35
35
  view.preloadId = preloadId
36
36
  }
37
37
 
38
+ @ReactProp(name = "feedItemsJSON")
39
+ fun setFeedItemsJSON(view: ShortKitFeedView, feedItemsJSON: String?) {
40
+ view.feedItemsJSON = feedItemsJSON
41
+ }
42
+
38
43
  @ReactProp(name = "debugPanel")
39
44
  fun setDebugPanel(view: ShortKitFeedView, debugPanel: Boolean) {
40
45
  view.debugPanel = debugPanel
41
46
  view.debugPanelPropSet = true
42
47
  }
43
48
 
49
+ @ReactProp(name = "active")
50
+ fun setActive(view: ShortKitFeedView, active: Boolean) {
51
+ view.setActiveFromBridge(active)
52
+ }
53
+
54
+ @ReactProp(name = "seedThumbnailUrl")
55
+ fun setSeedThumbnailUrl(view: ShortKitFeedView, url: String?) {
56
+ view.seedThumbnailUrl = url
57
+ }
58
+
44
59
  override fun onDropViewInstance(view: ShortKitFeedView) {
45
60
  view.destroy()
46
61
  super.onDropViewInstance(view)
@@ -136,6 +136,11 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
136
136
  @ReactMethod
137
137
  override fun pause() { bridge?.pause() }
138
138
 
139
+ @ReactMethod
140
+ override fun setFeedScrollEnabled(enabled: Boolean) {
141
+ bridge?.setFeedScrollEnabled(enabled)
142
+ }
143
+
139
144
  @ReactMethod
140
145
  override fun seek(seconds: Double) { bridge?.seek(seconds) }
141
146
 
@@ -149,28 +154,30 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
149
154
  override fun skipToPrevious() { bridge?.skipToPrevious() }
150
155
 
151
156
  // -----------------------------------------------------------------
152
- // Carousel commands — stubs for PR 1.
153
- // TODO: PR 2 replace these stubs with real bridge to ShortKit.activeInstance.get().carousel
157
+ // Carousel commands — bridge to ShortKit.activeInstance().carousel.
158
+ // Mirrors iOS RN bridge from PR #136. SHO-15.
159
+ // Boundary semantics: returns false / -1 / 0 when no ShortKit instance
160
+ // is active or no fragment is bound, matching iOS `?? false` / `?? nil`
161
+ // fallbacks via the namespace's onMain shim + null-tolerance.
154
162
  // -----------------------------------------------------------------
155
163
 
156
164
  @ReactMethod(isBlockingSynchronousMethod = true)
157
- override fun carouselNext(): Boolean = false
165
+ override fun carouselNext(): Boolean = bridge?.carouselNext() ?: false
158
166
 
159
167
  @ReactMethod(isBlockingSynchronousMethod = true)
160
- override fun carouselPrevious(): Boolean = false
168
+ override fun carouselPrevious(): Boolean = bridge?.carouselPrevious() ?: false
161
169
 
162
170
  @ReactMethod(isBlockingSynchronousMethod = true)
163
- override fun carouselSetActiveIndex(index: Double): Boolean = false
164
-
165
- // Carousel accessors — stubs for PR 1.
166
- // onCarouselActiveVideoCompleted emitter intentionally unwired — never fires on Android in PR 1.
167
- // PR 2 will subscribe to ShortKit.activeInstance.get().carousel.activeVideoCompleted.
171
+ override fun carouselSetActiveIndex(index: Double): Boolean =
172
+ bridge?.carouselSetActiveIndex(index.toInt()) ?: false
168
173
 
169
174
  @ReactMethod(isBlockingSynchronousMethod = true)
170
- override fun getCarouselActiveIndex(): Double = -1.0
175
+ override fun getCarouselActiveIndex(): Double =
176
+ (bridge?.carouselActiveIndex() ?: -1).toDouble()
171
177
 
172
178
  @ReactMethod(isBlockingSynchronousMethod = true)
173
- override fun getCarouselVideoCount(): Double = 0.0
179
+ override fun getCarouselVideoCount(): Double =
180
+ (bridge?.carouselVideoCount() ?: 0).toDouble()
174
181
 
175
182
  @ReactMethod
176
183
  override fun setMuted(muted: Boolean) { bridge?.setMuted(muted) }
@@ -326,6 +333,7 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
326
333
  "onContentTapped" -> emitOnContentTapped(params)
327
334
  "onDismiss" -> emitOnDismiss(params)
328
335
  "onRefreshStateChanged" -> emitOnRefreshStateChanged(params)
336
+ "onRefreshStateChangedPerFeed" -> emitOnRefreshStateChangedPerFeed(params)
329
337
  "onDidFetchContentItems" -> emitOnDidFetchContentItems(params)
330
338
  "onFeedReady" -> emitOnFeedReady(params)
331
339
  "onOverlayActiveChanged" -> emitOnOverlayActiveChanged(params)
@@ -341,8 +349,10 @@ class ShortKitModule(reactContext: ReactApplicationContext) :
341
349
  "onCarouselActiveImageChanged" -> emitOnCarouselActiveImageChanged(params)
342
350
  "onCarouselItemChanged" -> emitOnCarouselItemChanged(params)
343
351
  "onVideoCarouselActiveVideoChanged" -> emitOnVideoCarouselActiveVideoChanged(params)
352
+ "onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
344
353
  "onVideoCarouselItemChanged" -> emitOnVideoCarouselItemChanged(params)
345
354
  "onCarouselActiveVideoCompleted" -> emitOnCarouselActiveVideoCompleted(params)
355
+ "onVideoCarouselCellTap" -> emitOnVideoCarouselCellTap(params)
346
356
  else -> {
347
357
  android.util.Log.w("SK:Module", "sendEvent: unknown event name '$name', using legacy emitter")
348
358
  reactApplicationContext
@@ -37,8 +37,6 @@ class ShortKitPackage : TurboReactPackage() {
37
37
  ): List<ViewManager<*, *>> {
38
38
  return listOf(
39
39
  ShortKitFeedViewManager(),
40
- ShortKitPlayerViewManager(),
41
- ShortKitWidgetViewManager(),
42
40
  )
43
41
  }
44
42
  }
@@ -0,0 +1,134 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import android.view.ViewGroup
6
+ import androidx.test.core.app.ApplicationProvider
7
+ import com.facebook.react.bridge.WritableMap
8
+ import com.facebook.react.interfaces.TaskInterface
9
+ import com.facebook.react.interfaces.fabric.ReactSurface
10
+ import com.shortkit.sdk.model.CarouselImage
11
+ import com.shortkit.sdk.model.ImageCarouselItem
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertFalse
14
+ import org.junit.Assert.assertTrue
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import org.robolectric.RobolectricTestRunner
18
+ import java.util.concurrent.TimeUnit
19
+
20
+ /**
21
+ * Tests SHO-8 emit-on-change gating in ReactCarouselOverlayHost.
22
+ *
23
+ * Behavior under test (mirrors iOS ReactCarouselOverlayHost.swift:132-145):
24
+ * - First configure() with item A → applySurfaceProps path; no emit.
25
+ * - Second configure() with same item A → no emit, no setProperties.
26
+ * - Second configure() with different item B → emit onCarouselItemChanged
27
+ * with payload { surfaceId, item, isActive: false, activeImageIndex: 0 }.
28
+ */
29
+ @RunWith(RobolectricTestRunner::class)
30
+ class ReactCarouselOverlayHostEmitTest {
31
+
32
+ /** No-op TaskInterface returned by stub surface lifecycle methods. */
33
+ private object NoOpTask : TaskInterface<Void> {
34
+ override fun waitForCompletion() = Unit
35
+ override fun waitForCompletion(duration: Long, timeUnit: TimeUnit): Boolean = true
36
+ override fun getResult(): Void? = null
37
+ override fun getError(): Exception? = null
38
+ override fun isCompleted(): Boolean = true
39
+ override fun isCancelled(): Boolean = false
40
+ override fun isFaulted(): Boolean = false
41
+ }
42
+
43
+ /**
44
+ * Minimal ReactSurface stub for Robolectric tests. ApplicationProvider does
45
+ * not supply a ReactApplication, so createSurfaceIfNeeded() would normally
46
+ * fail silently with reactHost == null. The createSurface seam lets tests
47
+ * inject this stub so surface != null after pushProps(), allowing
48
+ * hasPushedInitialProps to flip correctly (the bug-fix gate under test).
49
+ *
50
+ * isRunning is false so applySurfaceProps() calls start() (no-op here) and
51
+ * the updateInitProps cast to ReactSurfaceImpl is a safe no-op via `as?`.
52
+ */
53
+ private class StubReactSurface(private val ctx: Context) : ReactSurface {
54
+ override val surfaceID: Int = 0
55
+ override val moduleName: String = "StubSurface"
56
+ override val isRunning: Boolean = false
57
+ override val view: ViewGroup? = null
58
+ override val context: Context get() = ctx
59
+ override fun prerender(): TaskInterface<Void> = NoOpTask
60
+ override fun start(): TaskInterface<Void> = NoOpTask
61
+ override fun stop(): TaskInterface<Void> = NoOpTask
62
+ override fun clear() = Unit
63
+ override fun detach() = Unit
64
+ }
65
+
66
+ private fun makeHost(): ReactCarouselOverlayHost {
67
+ val ctx = ApplicationProvider.getApplicationContext<Context>()
68
+ return ReactCarouselOverlayHost(ctx).also {
69
+ // JavaOnlyMap is a pure-Java WritableMap that avoids the JNI init
70
+ // requirement of WritableNativeMap in Robolectric unit tests.
71
+ it.createMap = { com.facebook.react.bridge.JavaOnlyMap() }
72
+ // Inject a stub surface so createSurfaceIfNeeded() succeeds and
73
+ // hasPushedInitialProps flips after the first configure().
74
+ it.createSurface = { _, _ -> StubReactSurface(ctx) }
75
+ }
76
+ }
77
+
78
+ private fun item(id: String) = ImageCarouselItem(
79
+ id = id,
80
+ images = listOf(CarouselImage(url = "https://example.com/a.jpg", alt = null)),
81
+ )
82
+
83
+ @Test
84
+ fun firstConfigureSetsHasPushedAndCurrentItemNoEmit() {
85
+ val host = makeHost()
86
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
87
+ host.emitEvent = { name, params -> emitted.add(name to params) }
88
+
89
+ host.configure(item("A"))
90
+
91
+ assertEquals("A", host.currentItemId)
92
+ assertTrue(host.hasPushedInitialProps)
93
+ assertTrue("first mount must not emit", emitted.isEmpty())
94
+ }
95
+
96
+ @Test
97
+ fun sameItemConfigureIsNoOpAfterFirstMount() {
98
+ val host = makeHost()
99
+ host.configure(item("A"))
100
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
101
+ host.emitEvent = { name, params -> emitted.add(name to params) }
102
+
103
+ host.configure(item("A"))
104
+
105
+ assertEquals("A", host.currentItemId)
106
+ assertTrue("same item must not emit", emitted.isEmpty())
107
+ }
108
+
109
+ @Test
110
+ fun differentItemEmitsCarouselItemChanged() {
111
+ val host = makeHost()
112
+ host.configure(item("A"))
113
+ val emitted = mutableListOf<Pair<String, WritableMap>>()
114
+ host.emitEvent = { name, params -> emitted.add(name to params) }
115
+
116
+ host.configure(item("B"))
117
+
118
+ assertEquals("B", host.currentItemId)
119
+ assertEquals(1, emitted.size)
120
+ assertEquals("onCarouselItemChanged", emitted[0].first)
121
+ val params = emitted[0].second
122
+ assertEquals(host.surfaceId, params.getString("surfaceId"))
123
+ assertFalse(params.getBoolean("isActive"))
124
+ assertEquals(0, params.getInt("activeImageIndex"))
125
+ // `item` is a JSON string of the ImageCarouselItem; assert it contains
126
+ // the new id. Payload-shape correctness is covered by the cross-platform
127
+ // JS subscriber's destructuring, not asserted here.
128
+ val itemJson = params.getString("item")
129
+ assertTrue(
130
+ "item JSON should contain id=B but was: $itemJson",
131
+ itemJson?.contains("\"id\":\"B\"") == true,
132
+ )
133
+ }
134
+ }
@@ -0,0 +1,45 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Test
6
+ import org.junit.runner.RunWith
7
+ import org.robolectric.RobolectricTestRunner
8
+
9
+ /**
10
+ * Tests SHO-9 drag-phase field on ReactOverlayHost.
11
+ * The full state-machine wiring (flow → field, emissions gated, full
12
+ * state on settle) is verified via the manual / instrumented test in
13
+ * the spec test plan. This test verifies the field's existence and
14
+ * resettability via configure().
15
+ */
16
+ @RunWith(RobolectricTestRunner::class)
17
+ class ReactOverlayHostDragTest {
18
+
19
+ private fun makeHost() = ReactOverlayHost(
20
+ ApplicationProvider.getApplicationContext()
21
+ )
22
+
23
+ @Test
24
+ fun isDraggingDefaultsFalse() {
25
+ val host = makeHost()
26
+ assertFalse(host.isDragging)
27
+ }
28
+
29
+ @Test
30
+ fun configureResetsIsDragging() {
31
+ val host = makeHost()
32
+ host.isDraggingForTest = true
33
+ host.configure(makeContentItem("A"))
34
+ assertFalse(host.isDragging)
35
+ }
36
+
37
+ private fun makeContentItem(id: String) = com.shortkit.sdk.model.ContentItem(
38
+ id = id,
39
+ title = "t",
40
+ duration = 5.0,
41
+ streamingUrl = "https://example.com/$id.m3u8",
42
+ thumbnailUrl = "https://example.com/$id.jpg",
43
+ captionTracks = emptyList(),
44
+ )
45
+ }
@@ -0,0 +1,69 @@
1
+ package com.shortkit.reactnative
2
+
3
+ import androidx.test.core.app.ApplicationProvider
4
+ import com.facebook.react.bridge.JavaOnlyMap
5
+ import com.shortkit.sdk.model.VideoCarouselItem
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Test
8
+ import org.junit.runner.RunWith
9
+ import org.robolectric.RobolectricTestRunner
10
+
11
+ /**
12
+ * Tests SHO-9 drag-phase field on ReactVideoCarouselOverlayHost.
13
+ * The full state-machine wiring requires a ShortKitPlayer instance with
14
+ * controllable flows; verified via manual / instrumented testing per the
15
+ * spec test plan. This test verifies the field's existence and reset
16
+ * behavior on configure().
17
+ *
18
+ * configure() always calls emitItemChanged(), so the createMap and
19
+ * createSurface seams must be injected here too (same as the emit test).
20
+ */
21
+ @RunWith(RobolectricTestRunner::class)
22
+ class ReactVideoCarouselOverlayHostDragTest {
23
+
24
+ private fun makeHost(): ReactVideoCarouselOverlayHost {
25
+ val host = ReactVideoCarouselOverlayHost(ApplicationProvider.getApplicationContext())
26
+ host.createMap = { JavaOnlyMap() }
27
+ // Stub surface so hasPushedInitialProps can flip and configure()
28
+ // doesn't retry on every call due to null surface.
29
+ host.createSurface = { _, _ ->
30
+ // Re-use the same stub shape from the emit test — no-arg style.
31
+ object : com.facebook.react.interfaces.fabric.ReactSurface {
32
+ override val surfaceID: Int = 0
33
+ override val moduleName: String = "StubSurface"
34
+ override val isRunning: Boolean = false
35
+ override val view: android.view.ViewGroup? = null
36
+ override val context: android.content.Context
37
+ get() = ApplicationProvider.getApplicationContext()
38
+ override fun prerender() = object : com.facebook.react.interfaces.TaskInterface<Void> {
39
+ override fun waitForCompletion() = Unit
40
+ override fun waitForCompletion(d: Long, u: java.util.concurrent.TimeUnit) = true
41
+ override fun getResult(): Void? = null
42
+ override fun getError(): Exception? = null
43
+ override fun isCompleted() = true
44
+ override fun isCancelled() = false
45
+ override fun isFaulted() = false
46
+ }
47
+ override fun start() = prerender()
48
+ override fun stop() = prerender()
49
+ override fun clear() = Unit
50
+ override fun detach() = Unit
51
+ }
52
+ }
53
+ return host
54
+ }
55
+
56
+ @Test
57
+ fun isDraggingDefaultsFalse() {
58
+ val host = makeHost()
59
+ assertFalse(host.isDragging)
60
+ }
61
+
62
+ @Test
63
+ fun configureResetsIsDragging() {
64
+ val host = makeHost()
65
+ host.isDraggingForTest = true
66
+ host.configure(VideoCarouselItem(id = "A", videos = emptyList()))
67
+ assertFalse(host.isDragging)
68
+ }
69
+ }