From 38ae2a8d20335ddf660e2998bcddf708d4158f4c Mon Sep 17 00:00:00 2001 From: sozinov Date: Fri, 9 Oct 2026 07:46:37 +0300 Subject: [PATCH 1/2] MOBILE-452: Leave a block's time off screen out of its timeToDisplay --- .../di/modules/PresentationModule.kt | 2 +- .../EmbeddedBlockContentController.kt | 69 +++----- .../embedded/EmbeddedBlockContentFactory.kt | 4 +- .../embedded/EmbeddedBlockStopwatch.kt | 27 +++ .../embedded/EmbeddedBlocksRegistry.kt | 54 +++--- .../embedded/MindboxEmbeddedBlockView.kt | 4 +- .../webview/EmbeddedBlockWebViewHolder.kt | 15 +- .../EmbeddedBlockContentControllerTest.kt | 164 +++++++++++++----- .../EmbeddedBlockContentFactoryTest.kt | 4 +- .../embedded/EmbeddedBlockStopwatchTest.kt | 36 ++++ .../embedded/EmbeddedBlockViewTestFixtures.kt | 5 + .../embedded/EmbeddedBlocksRegistryTest.kt | 93 +++++++++- .../webview/EmbeddedBlockWebViewHolderTest.kt | 86 ++++++++- 13 files changed, 427 insertions(+), 136 deletions(-) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatch.kt create mode 100644 sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatchTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index 29270e1ed..310d76e0f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -46,7 +46,7 @@ internal fun PresentationModule( } private val embeddedBlocksRegistryLazy: Lazy = lazy { - EmbeddedBlocksRegistryImpl(inAppInteractor = inAppInteractor) + EmbeddedBlocksRegistryImpl(inAppInteractor = inAppInteractor, monotonicNow = timeProvider::monotonicMillis) } override val embeddedBlocksRegistry: EmbeddedBlocksRegistry diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt index 41c0418f3..acf798c4e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentController.kt @@ -76,8 +76,11 @@ internal class EmbeddedBlockContentController( private var registration: Closeable? = null private var configJob: Job? = null - private var pendingContent: InAppType.Embedded? = null - private var hasPendingContent = false + private class PendingAnswer(val content: InAppType.Embedded?, val selectionTime: Milliseconds) + + private var pendingAnswer: PendingAnswer? = null + private val hasPendingAnswer: Boolean + get() = pendingAnswer != null /** What the shown page was built from — the "same content" dedup key. */ private var appliedDescriptor: PageDescriptor? = null @@ -112,8 +115,6 @@ internal class EmbeddedBlockContentController( private var attemptStartTick: Milliseconds? = null - private var pendingSinceTick: Milliseconds? = null - private var hasPendingDelivery = false private val mainHandler = Handler(Looper.getMainLooper()) @@ -138,10 +139,9 @@ internal class EmbeddedBlockContentController( appliedDescriptor = null } - if (hasPendingContent) { - val deferred = pendingContent - clearPendingContent() - applyResolved(deferred) + pendingAnswer?.let { deferred -> + pendingAnswer = null + applyResolved(deferred.content, deferred.selectionTime) } provider?.let { current -> @@ -154,7 +154,7 @@ internal class EmbeddedBlockContentController( beginWaitingForContent() return } - if (provider == null && !hasPendingContent) { + if (provider == null && !hasPendingAnswer) { beginWaitingForContent() } blocksRegistry()?.onBlockAppeared(place) @@ -181,27 +181,25 @@ internal class EmbeddedBlockContentController( notifyContentDropped() } - override fun onContentResolved(content: InAppType.Embedded?) { + override fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) { if (!acceptAnswer("Content")) return if (!isActive) { mindboxLogI("[EmbeddedBlock] Content for '$placeSystemName' arrived while paused, deferring it") - pendingContent = content - hasPendingContent = true + pendingAnswer = PendingAnswer(content, selectionTime) return } - applyResolved(content) + applyResolved(content, selectionTime) } override fun onContentPending() { if (isReleased || hasGivenUp) return configBudget.reset() hasPendingDelivery = true - pendingSinceTick = pendingSinceTick ?: monotonicNow() } override fun onConfigUnavailable() { if (!acceptAnswer("The SDK's answer")) return - clearPendingContent() + pendingAnswer = null forgetAppliedContent() mindboxLogW("[EmbeddedBlock] The SDK has no config to answer for '$placeSystemName', reporting failure") sendWaitBudgetExceeded( @@ -222,35 +220,17 @@ internal class EmbeddedBlockContentController( return false } configBudget.reset() - // The pending window closes at the delivery, not at the application: a delivery deferred - // while the block is off screen keeps the off-screen span inside the measure — only the - // campaign's delay leaves it. - settlePendingWindow() + hasPendingDelivery = false return true } - private fun clearPendingContent() { - pendingContent = null - hasPendingContent = false - } - private fun sendWaitBudgetExceeded(waited: Milliseconds, phase: WaitBudgetPhase) { val place = placeSystemName ?: return val tracker = resolveFailureTracker() ?: return loggingRunCatching { tracker.sendPlaceWaitBudgetExceeded(place, waited, phase) } } - private fun settlePendingWindow() { - hasPendingDelivery = false - pendingSinceTick?.let { pendingSince -> - attemptStartTick = attemptStartTick?.let { started -> - Milliseconds(started.interval + elapsedSince(pendingSince).interval) - } - } - pendingSinceTick = null - } - - private fun applyResolved(content: InAppType.Embedded?) { + private fun applyResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) { if (content == null) { mindboxLogI("[EmbeddedBlock] Nothing to show for place '$placeSystemName'") forgetAppliedContent() @@ -271,7 +251,7 @@ internal class EmbeddedBlockContentController( } if (lastReportedState?.nothingToShow == true) { mindboxLogI("[EmbeddedBlock] Delivery for a collapsed block '$placeSystemName', rebuilding the content") - recreateProvider(content) + recreateProvider(content, selectionTime) return } if (current is EmbeddedUpdatableContentProvider && @@ -290,17 +270,17 @@ internal class EmbeddedBlockContentController( appliedContent = content } else { mindboxLogW("[EmbeddedBlock] In-place update over the bridge failed, recreating the content") - recreateProvider(content) + recreateProvider(content, selectionTime) } } } }.onFailure { error -> mindboxLogW("[EmbeddedBlock] In-place update crashed ($error), recreating the content") - recreateProvider(content) + recreateProvider(content, selectionTime) } return } - recreateProvider(content) + recreateProvider(content, selectionTime) } private fun refreshMetricsSnapshot(current: EmbeddedContentProvider, content: InAppType.Embedded) { @@ -312,13 +292,12 @@ internal class EmbeddedBlockContentController( appliedContent = content } - private fun recreateProvider(content: InAppType.Embedded) { + private fun recreateProvider(content: InAppType.Embedded, selectionTime: Milliseconds) { dropProvider() - if (lastReportedState != EmbeddedBlockState.Loading) { + if (lastReportedState != EmbeddedBlockState.Loading || attemptStartTick == null) { attemptStartTick = monotonicNow() } - val startTick = attemptStartTick ?: monotonicNow().also { freshStart -> attemptStartTick = freshStart } - val created = loggingRunCatching(defaultValue = null) { providerFactory(content, startTick) } ?: run { + val created = loggingRunCatching(defaultValue = null) { providerFactory(content, selectionTime) } ?: run { failInternally(content, "Could not build content for ${content.inAppId}") return } @@ -337,7 +316,7 @@ internal class EmbeddedBlockContentController( override val isHoldingContent: Boolean get() = lastReportedState is EmbeddedBlockState.Loading || lastReportedState == EmbeddedBlockState.Ready || - hasPendingContent + hasPendingAnswer private fun onProviderState(state: EmbeddedBlockState) { if (state is EmbeddedBlockState.Ready && provider?.contentView == null) { @@ -462,7 +441,7 @@ internal class EmbeddedBlockContentController( } private fun hasEverResolved(): Boolean = - provider != null || hasPendingContent || appliedDescriptor != null || hasPendingDelivery + provider != null || hasPendingAnswer || appliedDescriptor != null || hasPendingDelivery private fun onReadyTimeout() { if (!isStarted || provider == null) return diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt index 10df04cda..b10769343 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactory.kt @@ -14,7 +14,7 @@ internal object EmbeddedBlockContentFactory { fun createProvider( context: Context, content: InAppType.Embedded, - startTick: Milliseconds, + selectionTime: Milliseconds, ): EmbeddedContentProvider? { val layer = content.layers.filterIsInstance().firstOrNull() ?: run { mindboxLogE("[EmbeddedBlock] Winner ${content.inAppId} has no webview layer") @@ -27,7 +27,7 @@ internal object EmbeddedBlockContentFactory { context = context, frequency = content.frequency, tags = content.tags, - startTick = startTick, + selectionTime = selectionTime, ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatch.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatch.kt new file mode 100644 index 000000000..96d4841e6 --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatch.kt @@ -0,0 +1,27 @@ +package cloud.mindbox.mobile_sdk.embedded + +import cloud.mindbox.mobile_sdk.models.Milliseconds + +internal class EmbeddedBlockStopwatch( + private val now: () -> Milliseconds, +) { + + private var settled = Milliseconds(0L) + private var runningSince: Milliseconds? = null + + val elapsed: Milliseconds + get() = Milliseconds(settled.interval + (runningSince?.let(::spanSince)?.interval ?: 0L)) + + fun resume() { + if (runningSince == null) runningSince = now() + } + + fun pause() { + val since = runningSince ?: return + settled = Milliseconds(settled.interval + spanSince(since).interval) + runningSince = null + } + + private fun spanSince(since: Milliseconds): Milliseconds = + Milliseconds((now().interval - since.interval).coerceAtLeast(0L)) +} diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt index 1e83d1faf..406253b55 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistry.kt @@ -9,6 +9,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.models.InAppEventType +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.PlaceKey import cloud.mindbox.mobile_sdk.utils.loggingRunCatching import kotlinx.coroutines.CancellationException @@ -28,7 +29,7 @@ internal interface EmbeddedBlockHandle { val isHoldingContent: Boolean - fun onContentResolved(content: InAppType.Embedded?) + fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) fun onContentPending() {} @@ -50,6 +51,7 @@ internal class EmbeddedBlocksRegistryImpl( private val inAppInteractor: InAppInteractor, // Read on every use, never captured: the SDK scope is recreated on a soft reinitialization. private val scopeProvider: () -> CoroutineScope = { Mindbox.mindboxScope }, + private val monotonicNow: () -> Milliseconds, ) : EmbeddedBlocksRegistry { // Weak on purpose: a host with no lifecycle owner to say goodbye — a plain Dialog, a @@ -60,7 +62,11 @@ internal class EmbeddedBlocksRegistryImpl( private val reResolveQueuedPlaces = mutableMapOf() - private class PendingDelay(val inAppId: String, val job: Job) + private class PendingDelay( + val job: Job, + var winner: InAppType.Embedded, + var selectionTime: Milliseconds, + ) private val delayJobsByPlace = mutableMapOf() @@ -170,6 +176,7 @@ internal class EmbeddedBlocksRegistryImpl( reResolveQueuedPlaces[place] = triggerEvent ?: reResolveQueuedPlaces[place] return } + val requestedAt = monotonicNow() val job = scopeProvider().launch { val resolved = try { Result.success( @@ -184,10 +191,11 @@ internal class EmbeddedBlocksRegistryImpl( mindboxLogW("[EmbeddedBlock] Resolving place '$place' failed: $error") Result.failure(error) } + val selectionTime = Milliseconds(monotonicNow().interval - requestedAt.interval) runOnMain { resolved.fold( - onSuccess = { result -> handleResolved(place, result) }, - onFailure = { handleResolveFailure(place) }, + onSuccess = { result -> handleResolved(place, result, selectionTime) }, + onFailure = { handleResolveFailure(place, selectionTime) }, ) } } @@ -218,27 +226,29 @@ internal class EmbeddedBlocksRegistryImpl( } } - private fun handleResolved(place: PlaceKey, outcome: EmbeddedResolveOutcome) { + private fun handleResolved(place: PlaceKey, outcome: EmbeddedResolveOutcome, selectionTime: Milliseconds) { when (outcome) { - is EmbeddedResolveOutcome.Content -> handleContent(place, outcome) - EmbeddedResolveOutcome.Empty -> deliverNow(place, null) + is EmbeddedResolveOutcome.Content -> handleContent(place, outcome, selectionTime) + EmbeddedResolveOutcome.Empty -> deliverNow(place, null, selectionTime) EmbeddedResolveOutcome.ConfigUnavailable -> handleConfigUnavailable(place) } } - private fun handleContent(place: PlaceKey, outcome: EmbeddedResolveOutcome.Content) { + private fun handleContent(place: PlaceKey, outcome: EmbeddedResolveOutcome.Content, selectionTime: Milliseconds) { val winner = outcome.variant val delayTime = outcome.delayTime?.takeIf { delay -> delay.interval > 0 } if (delayTime == null) { - deliverNow(place, winner) + deliverNow(place, winner, selectionTime) return } val running = delayJobsByPlace[place] - if (running != null && running.job.isActive && running.inAppId == winner.inAppId) { + if (running != null && running.job.isActive && running.winner.inAppId == winner.inAppId) { mindboxLogI( "[EmbeddedBlock] Winner ${winner.inAppId} for place '$place' is already " + - "waiting out its delay, keeping the running timer" + "waiting out its delay, keeping the running timer for the fresh answer" ) + running.winner = winner + running.selectionTime = selectionTime notifyPending(place) return } @@ -252,18 +262,18 @@ internal class EmbeddedBlocksRegistryImpl( delay(delayTime.interval) val self = coroutineContext[Job] runOnMain { - if (delayJobsByPlace[place]?.job !== self) return@runOnMain + val due = delayJobsByPlace[place]?.takeIf { pending -> pending.job === self } ?: return@runOnMain delayJobsByPlace.remove(place) - inAppInteractor.markEmbeddedDelayWaitedOut(place, winner.inAppId) - deliver(place, winner) + inAppInteractor.markEmbeddedDelayWaitedOut(place, due.winner.inAppId) + deliver(place, due.winner, due.selectionTime) } } - delayJobsByPlace[place] = PendingDelay(winner.inAppId, job) + delayJobsByPlace[place] = PendingDelay(job, winner, selectionTime) } - private fun handleResolveFailure(place: PlaceKey) { + private fun handleResolveFailure(place: PlaceKey, selectionTime: Milliseconds) { if (keepsRunningDelay(place, "Resolving place '$place' failed")) return - deliver(place, null) + deliver(place, null, selectionTime) } private fun handleConfigUnavailable(place: PlaceKey) { @@ -273,14 +283,14 @@ internal class EmbeddedBlocksRegistryImpl( private fun keepsRunningDelay(place: PlaceKey, what: String): Boolean { val running = delayJobsByPlace[place]?.takeIf { pending -> pending.job.isActive } ?: return false - mindboxLogI("[EmbeddedBlock] $what while winner ${running.inAppId} waits out its delay, keeping the running timer") + mindboxLogI("[EmbeddedBlock] $what while winner ${running.winner.inAppId} waits out its delay, keeping the running timer") notifyPending(place) return true } - private fun deliverNow(place: PlaceKey, content: InAppType.Embedded?) { + private fun deliverNow(place: PlaceKey, content: InAppType.Embedded?, selectionTime: Milliseconds) { delayJobsByPlace.remove(place)?.job?.cancel() - deliver(place, content) + deliver(place, content, selectionTime) } private fun deliverConfigUnavailable(place: PlaceKey) { @@ -302,7 +312,7 @@ internal class EmbeddedBlocksRegistryImpl( } } - private fun deliver(place: PlaceKey, content: InAppType.Embedded?) { + private fun deliver(place: PlaceKey, content: InAppType.Embedded?, selectionTime: Milliseconds) { val handles = liveHandles(place) if (handles.isEmpty()) { mindboxLogW("[EmbeddedBlock] No block is registered for place '$place', dropping the content") @@ -323,7 +333,7 @@ internal class EmbeddedBlocksRegistryImpl( } if (delivered == null) inAppInteractor.releasePlaceShow(place) handles.forEach { handle -> - loggingRunCatching { handle.onContentResolved(delivered) } + loggingRunCatching { handle.onContentResolved(delivered, selectionTime) } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt index 66b05e762..e3d295413 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/MindboxEmbeddedBlockView.kt @@ -85,8 +85,8 @@ public class MindboxEmbeddedBlockView internal constructor( contentController: EmbeddedBlockContentController = EmbeddedBlockContentController( placeSystemName = placeSystemName.orNullIfBlank(), configTimeout = configTimeout ?: readConfigTimeout(context, attrs), - providerFactory = { content, attemptStartedAt -> - EmbeddedBlockContentFactory.createProvider(context, content, attemptStartedAt) + providerFactory = { content, selectionTime -> + EmbeddedBlockContentFactory.createProvider(context, content, selectionTime) }, failureTracker = { loggingRunCatching(defaultValue = null) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt index 675c166ea..7c3793d1d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolder.kt @@ -10,6 +10,7 @@ import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi import cloud.mindbox.mobile_sdk.Mindbox import cloud.mindbox.mobile_sdk.di.mindboxInject import cloud.mindbox.mobile_sdk.embedded.EmbeddedBlockState +import cloud.mindbox.mobile_sdk.embedded.EmbeddedBlockStopwatch import cloud.mindbox.mobile_sdk.findActivity import cloud.mindbox.mobile_sdk.gatedTags import cloud.mindbox.mobile_sdk.models.PlaceKey @@ -78,7 +79,7 @@ internal class EmbeddedBlockWebViewHolder( private val context: Context, @Volatile private var frequency: Frequency, @Volatile private var tags: Map?, - private val startTick: Milliseconds, + private val selectionTime: Milliseconds, private val ackBudget: Milliseconds = Constants.WebView.readyTimeout, ) : EmbeddedUpdatableContentProvider, MindboxWebPage { @@ -171,11 +172,17 @@ internal class EmbeddedBlockWebViewHolder( @Volatile private var renderedTimeToDisplay: Milliseconds? = null + private val onScreenTime = EmbeddedBlockStopwatch(now = { timeProvider.monotonicMillis() }) + + private val currentTimeToDisplay: Milliseconds + get() = Milliseconds(selectionTime.interval + onScreenTime.elapsed.interval) + @Volatile private var pendingAckJob: Job? = null override fun start() { if (isReleased) return presence.value = true + onScreenTime.resume() flushHeldFailure() if (!isLoadRequested) { isLoadRequested = true @@ -188,6 +195,7 @@ internal class EmbeddedBlockWebViewHolder( override fun pause() { presence.value = false + onScreenTime.pause() } override fun release() { @@ -410,7 +418,7 @@ internal class EmbeddedBlockWebViewHolder( return BridgeMessage.SUCCESS_PAYLOAD } didReportShownContent = true - renderedTimeToDisplay = timeProvider.monotonicElapsedSince(startTick) + renderedTimeToDisplay = renderedTimeToDisplay ?: currentTimeToDisplay report(EmbeddedBlockState.Ready) if (isActive) accountForShow() return BridgeMessage.SUCCESS_PAYLOAD @@ -445,7 +453,8 @@ internal class EmbeddedBlockWebViewHolder( return } didAccountForShow = true - val timeToDisplay = renderedTimeToDisplay ?: timeProvider.monotonicElapsedSince(startTick) + val timeToDisplay = renderedTimeToDisplay ?: currentTimeToDisplay + mindboxLogI("[EmbeddedBlock] In-app $inAppId is shown, timeToDisplay=${timeToDisplay.interval} ms") Mindbox.mindboxScope.launch { loggingRunCatchingSuspending { inAppInteractor.recordBlockShow(placeSystemName, inAppId, frequency, timeToDisplay, gatedTags()) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt index 512417096..c685e263a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt @@ -52,8 +52,12 @@ class EmbeddedBlockContentControllerTest { override fun startListening() = Unit - fun pushContent(placeSystemName: String, content: InAppType.Embedded) { - lastHandle?.onContentResolved(content) + fun pushContent( + placeSystemName: String, + content: InAppType.Embedded, + selectionTime: Milliseconds = Milliseconds(0L), + ) { + lastHandle?.onContentResolved(content, selectionTime) } } @@ -141,56 +145,60 @@ class EmbeddedBlockContentControllerTest { } @Test - fun `the delay window leaves the attempt clock`() { - var clock = 1_000L - var receivedStart: Long? = null - val controller = EmbeddedBlockContentController( - placeSystemName = "main-screen-top", - configTimeout = Milliseconds(30_000L), - providerFactory = { _, startTick -> - receivedStart = startTick.interval + fun `a block back on screen before the campaign's delay ends builds its page with the selection time`() { + val selectionTimes = mutableListOf() + val controller = controller( + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) FakeProvider() }, - blocksRegistry = { blocksRegistry }, - monotonicNow = { Milliseconds(clock) }, - ).apply { onStateChange = { state -> states.add(state) } } - + ) controller.start() - // The campaign's delay begins at 2s and delivers at 7s: those five seconds are the - // campaign's choice, not the user's wait for the SDK — the clock base slides past them. - clock = 2_000L + blocksRegistry.lastHandle?.onContentResolved(null) blocksRegistry.lastHandle?.onContentPending() - clock = 7_000L - blocksRegistry.pushContent("main-screen-top", content) + controller.pause() + controller.start() + + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(40L)) - assertEquals(6_000L, receivedStart) + assertEquals(listOf(Milliseconds(40L)), selectionTimes) } @Test - fun `the delay window leaves the attempt clock of a block that was off screen`() { - var clock = 1_000L - var receivedStart: Long? = null - val controller = EmbeddedBlockContentController( - placeSystemName = "main-screen-top", - configTimeout = Milliseconds(30_000L), - providerFactory = { _, startTick -> - receivedStart = startTick.interval + fun `a page built for a collapsed block carries the selection time of the answer that revived it`() { + val selectionTimes = mutableListOf() + val controller = controller( + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) FakeProvider() }, - blocksRegistry = { blocksRegistry }, - monotonicNow = { Milliseconds(clock) }, - ).apply { onStateChange = { state -> states.add(state) } } + ) + controller.start() + blocksRegistry.lastHandle?.onContentResolved(null) + assertEquals(EmbeddedBlockState.Empty, states.last()) + + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(40L)) + + assertEquals(listOf(Milliseconds(40L)), selectionTimes) + } + + @Test + fun `content deferred while the block is away is built with the selection time it came with`() { + val selectionTimes = mutableListOf() + val controller = controller( + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) + FakeProvider() + }, + ) controller.start() controller.pause() - clock = 2_000L blocksRegistry.lastHandle?.onContentPending() - clock = 7_000L - blocksRegistry.pushContent("main-screen-top", content) - clock = 8_000L + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(250L)) controller.start() - assertEquals(6_000L, receivedStart) + assertEquals(listOf(Milliseconds(250L)), selectionTimes) } @Test @@ -606,26 +614,86 @@ class EmbeddedBlockContentControllerTest { } @Test - fun `provider is built with the attempt start, not the delivery moment`() { - var clock = 1_000L - var receivedStart: Long? = null + fun `each page is built with the selection time of its own answer`() { + val selectionTimes = mutableListOf() + val controller = controller( + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) + FakeProvider() + }, + ) + controller.start() + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(2_000L)) + controller.pause() + controller.start() + + blocksRegistry.pushContent("main-screen-top", content.copy(inAppId = "another-winner"), selectionTime = Milliseconds(1_000L)) + + assertEquals(listOf(Milliseconds(2_000L), Milliseconds(1_000L)), selectionTimes) + } + + @Test + fun `another page arriving while the first one still loads is built with its own selection time`() { + val selectionTimes = mutableListOf() + val controller = controller( + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) + FakeProvider(onStart = {}) + }, + ) + controller.start() + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(50L)) + assertEquals(EmbeddedBlockState.Loading, states.last()) + + val movedLayer = (content.layers.single() as Layer.WebViewLayer) + .copy(contentUrl = "https://static.example/another-page.html") + blocksRegistry.pushContent("main-screen-top", content.copy(layers = listOf(movedLayer)), selectionTime = Milliseconds(30L)) + + assertEquals(listOf(Milliseconds(50L), Milliseconds(30L)), selectionTimes) + } + + @Test + fun `a page rebuilt after a failed in-place update carries the selection time of the answer that asked for it`() { + val selectionTimes = mutableListOf() val controller = EmbeddedBlockContentController( placeSystemName = "main-screen-top", configTimeout = Milliseconds(30_000L), - providerFactory = { _, startTick -> - receivedStart = startTick.interval - FakeProvider() + providerFactory = { _, selectionTime -> + selectionTimes.add(selectionTime) + object : EmbeddedUpdatableContentProvider { + override var onStateChange: ((EmbeddedBlockState) -> Unit)? = null + override val contentView: View? = View(RuntimeEnvironment.getApplication()) + + override fun start() { + onStateChange?.invoke(EmbeddedBlockState.Ready) + } + + override fun pause() = Unit + + override fun release() = Unit + + override fun refreshMetricsSnapshot(frequency: cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency, tags: Map?) = Unit + + override fun updateParams(params: Map, onResult: (Boolean) -> Unit) { + onResult(false) + } + } }, blocksRegistry = { blocksRegistry }, - monotonicNow = { Milliseconds(clock) }, ).apply { onStateChange = { state -> states.add(state) } } - controller.start() - // The place answers five seconds later; the wait belongs to timeToDisplay. - clock = 6_000L - blocksRegistry.pushContent("main-screen-top", content) + blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(100L)) + + val refreshedLayer = (content.layers.single() as Layer.WebViewLayer) + .copy(params = mapOf("items" to "[]")) + blocksRegistry.pushContent( + "main-screen-top", + content.copy(layers = listOf(refreshedLayer)), + selectionTime = Milliseconds(700L), + ) + idleFor(Duration.ZERO) - assertEquals(1_000L, receivedStart) + assertEquals(listOf(Milliseconds(100L), Milliseconds(700L)), selectionTimes) } @Test diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt index 534995c88..74bd5eced 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentFactoryTest.kt @@ -18,7 +18,7 @@ class EmbeddedBlockContentFactoryTest { val provider = EmbeddedBlockContentFactory.createProvider( ApplicationProvider.getApplicationContext(), InAppStub.getEmbedded(), - startTick = Milliseconds(0L), + selectionTime = Milliseconds(0L), ) assertTrue(provider is EmbeddedBlockWebViewHolder) @@ -29,7 +29,7 @@ class EmbeddedBlockContentFactoryTest { val provider = EmbeddedBlockContentFactory.createProvider( ApplicationProvider.getApplicationContext(), InAppStub.getEmbedded().copy(layers = emptyList()), - startTick = Milliseconds(0L), + selectionTime = Milliseconds(0L), ) assertNull(provider) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatchTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatchTest.kt new file mode 100644 index 000000000..021b90bc5 --- /dev/null +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockStopwatchTest.kt @@ -0,0 +1,36 @@ +package cloud.mindbox.mobile_sdk.embedded + +import cloud.mindbox.mobile_sdk.models.Milliseconds +import org.junit.Assert.assertEquals +import org.junit.Test + +class EmbeddedBlockStopwatchTest { + + private var clock = Milliseconds(1_000L) + private val stopwatch = EmbeddedBlockStopwatch(now = { clock }) + + private fun advance(by: Milliseconds) { + clock = Milliseconds(clock.interval + by.interval) + } + + @Test + fun `a repeated resume keeps the running stretch from its first start`() { + stopwatch.resume() + advance(Milliseconds(400L)) + stopwatch.resume() + advance(Milliseconds(100L)) + + assertEquals(Milliseconds(500L), stopwatch.elapsed) + } + + @Test + fun `a repeated pause counts the stretch once`() { + stopwatch.resume() + advance(Milliseconds(400L)) + stopwatch.pause() + advance(Milliseconds(5_000L)) + stopwatch.pause() + + assertEquals(Milliseconds(400L), stopwatch.elapsed) + } +} diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockViewTestFixtures.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockViewTestFixtures.kt index 89c874c5f..e85a75eab 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockViewTestFixtures.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockViewTestFixtures.kt @@ -4,10 +4,15 @@ import android.app.Activity import android.os.Looper import android.view.View import android.widget.LinearLayout +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.PlaceKey import org.robolectric.Shadows.shadowOf import java.io.Closeable +internal fun EmbeddedBlockHandle.onContentResolved(content: InAppType.Embedded?) = + onContentResolved(content, Milliseconds(0L)) + internal class RecordingBlocksRegistry : EmbeddedBlocksRegistry { var lastHandle: EmbeddedBlockHandle? = null diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt index 5706736f0..d39f07774 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlocksRegistryTest.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedReso import cloud.mindbox.mobile_sdk.inapp.domain.models.EmbeddedPlaceEvent import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.models.EventType import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.Milliseconds @@ -42,11 +43,13 @@ class EmbeddedBlocksRegistryTest { private class RecordingHandle(override var isActive: Boolean = true) : EmbeddedBlockHandle { override val isHoldingContent: Boolean = false val received = mutableListOf() + val selectionTimes = mutableListOf() var pendingCount = 0 var unavailableCount = 0 - override fun onContentResolved(content: InAppType.Embedded?) { + override fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) { received.add(content) + selectionTimes.add(selectionTime) } override fun onContentPending() { @@ -73,6 +76,12 @@ class EmbeddedBlocksRegistryTest { private val placeEvents = MutableSharedFlow() private val configUpdates = MutableSharedFlow() + private var clock = Milliseconds(0L) + + private fun advance(by: Milliseconds) { + clock = Milliseconds(clock.interval + by.interval) + } + private fun controller(): EmbeddedBlocksRegistryImpl { coEvery { interactor.listenEmbeddedPlaceEvents() } returns placeEvents coEvery { interactor.listenConfigUpdates() } returns configUpdates @@ -80,6 +89,7 @@ class EmbeddedBlocksRegistryTest { return EmbeddedBlocksRegistryImpl( inAppInteractor = interactor, scopeProvider = { scope }, + monotonicNow = { clock }, ) } @@ -288,6 +298,42 @@ class EmbeddedBlocksRegistryTest { assertEquals(listOf(content), handle.received) } + @Test + fun `the same winner re-selected mid-delay is delivered with the fresh answer`() { + val refreshed = content.copy( + layers = listOf((content.layers.single() as Layer.WebViewLayer).copy(params = mapOf("items" to "[]"))) + ) + var selections = 0 + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } coAnswers { + selections++ + if (selections == 1) { + advance(Milliseconds(320L)) + winner(delay = Milliseconds(5_000L)) + } else { + advance(Milliseconds(40L)) + winner(variant = refreshed, delay = Milliseconds(5_000L)) + } + } + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + scope.testScheduler.advanceTimeBy(3_000L) + scope.testScheduler.runCurrent() + controller.onBlockAppeared(place) + idleMain() + scope.testScheduler.advanceTimeBy(2_001L) + scope.testScheduler.runCurrent() + idleMain() + + assertEquals(listOf(refreshed), handle.received) + assertEquals(listOf(Milliseconds(40L)), handle.selectionTimes) + verify(exactly = 1) { interactor.reservePlaceShow(place, refreshed) } + } + @Test fun `a delay that elapsed while the block was away is delivered at once and not waited out again`() { coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns @@ -427,7 +473,48 @@ class EmbeddedBlocksRegistryTest { } @Test - fun `pending is announced to paused handles too so the delay leaves their clock`() { + fun `the content comes with the time its selection took`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } coAnswers { + advance(Milliseconds(320L)) + winner() + } + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + clock = Milliseconds(1_000L) + + controller.onBlockAppeared(place) + idleMain() + + assertEquals(listOf(content), handle.received) + assertEquals(listOf(Milliseconds(320L)), handle.selectionTimes) + } + + @Test + fun `a delayed winner comes with the time its selection took, not the delay`() { + coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } coAnswers { + advance(Milliseconds(320L)) + winner(delay = Milliseconds(5_000L)) + } + val handle = RecordingHandle() + val controller = controller() + controller.register(place, handle) + idleMain() + + controller.onBlockAppeared(place) + idleMain() + advance(Milliseconds(5_000L)) + scope.testScheduler.advanceTimeBy(5_001L) + scope.testScheduler.runCurrent() + idleMain() + + assertEquals(listOf(content), handle.received) + assertEquals(listOf(Milliseconds(320L)), handle.selectionTimes) + } + + @Test + fun `pending is announced to paused handles too so their wait budget stays quiet on return`() { coEvery { interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) } returns winner(delay = Milliseconds(5_000L)) val active = RecordingHandle() @@ -536,7 +623,7 @@ class EmbeddedBlocksRegistryTest { override val isActive: Boolean = true override val isHoldingContent: Boolean = true - override fun onContentResolved(content: InAppType.Embedded?) {} + override fun onContentResolved(content: InAppType.Embedded?, selectionTime: Milliseconds) {} override fun onConfigUnavailable() {} } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt index b17d27177..63acf8acd 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt @@ -27,6 +27,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.models.PlaceKey import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.utils.Constants import cloud.mindbox.mobile_sdk.utils.SystemTimeProvider import com.google.gson.JsonObject import cloud.mindbox.mobile_sdk.inapp.presentation.OnShowInAppOutcome @@ -129,7 +130,7 @@ class EmbeddedBlockWebViewHolderTest { context = application, frequency = Frequency(Frequency.Delay.Unlimited), tags = null, - startTick = Milliseconds(0L), + selectionTime = Milliseconds(0L), ) holder.onStateChange = { state -> states.add(state) } } @@ -310,15 +311,43 @@ class EmbeddedBlockWebViewHolderTest { startAndAwaitPageLoad() setMindboxScope(CoroutineScope(Dispatchers.Unconfined).also { it.cancel() }) + elapsed = 1_000L postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) await { lastOutgoingMessage()?.get("action")?.asString == "contentRendered" } verify(exactly = 0) { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } setMindboxScope(originalScope) + elapsed = 61_000L holder.pause() holder.start() - verify(exactly = 1, timeout = 5_000L) { inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), any(), any()) } + verify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(1_000L), any()) + } + } finally { + setMindboxScope(originalScope) + } + } + + @Test + fun `a page that reports itself again after a data push keeps the time of its first render`() { + every { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + val originalScope = Mindbox.mindboxScope + try { + startAndAwaitPageLoad() + setMindboxScope(CoroutineScope(Dispatchers.Unconfined).also { it.cancel() }) + elapsed = 1_000L + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + await { lastOutgoingMessage()?.get("action")?.asString == "contentRendered" } + setMindboxScope(originalScope) + + holder.updateParams(mapOf("items" to "[]")) {} + elapsed = 5_000L + postFromPage(request(action = "contentRendered", payload = """{"count":3}""", id = "req-2")) + + verify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(1_000L), any()) + } } finally { setMindboxScope(originalScope) } @@ -948,7 +977,10 @@ class EmbeddedBlockWebViewHolderTest { assertTrue(viewBeforeUpdate === webView) } - private fun rebuildHolder(ackBudget: Milliseconds) { + private fun rebuildHolder( + ackBudget: Milliseconds = Constants.WebView.readyTimeout, + selectionTime: Milliseconds = Milliseconds(0L), + ) { holder.release() states.clear() holder = EmbeddedBlockWebViewHolder( @@ -958,7 +990,7 @@ class EmbeddedBlockWebViewHolderTest { context = application, frequency = Frequency(Frequency.Delay.Unlimited), tags = null, - startTick = Milliseconds(0L), + selectionTime = selectionTime, ackBudget = ackBudget, ) holder.onStateChange = { state -> states.add(state) } @@ -1208,19 +1240,57 @@ class EmbeddedBlockWebViewHolderTest { } @Test - fun `the counted show carries the time the render took, not the time off screen`() { + fun `the counted show is the selection time plus the time on screen, not the time off screen`() { coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + rebuildHolder(selectionTime = Milliseconds(2_000L)) startAndAwaitPageLoad() + elapsed = 750L holder.pause() - elapsed = 1_000L + elapsed = 45_750L + holder.start() + elapsed = 46_000L postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) - elapsed = 121_000L + coVerify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(3_000L), any()) + } + } + + @Test + fun `a page that rendered off screen reports every stretch on screen before the render and nothing after it`() { + coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + rebuildHolder(selectionTime = Milliseconds(2_000L)) + startAndAwaitPageLoad() + + elapsed = 500L + holder.pause() + elapsed = 10_500L + holder.start() + elapsed = 11_000L + holder.pause() + elapsed = 41_000L + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + elapsed = 121_000L holder.start() coVerify(exactly = 1, timeout = 5_000L) { - inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(1_000L), any()) + inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(3_000L), any()) + } + } + + @Test + fun `a page counts nothing before the block first starts it`() { + coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs + rebuildHolder(selectionTime = Milliseconds(300L)) + + elapsed = 20_000L + startAndAwaitPageLoad() + elapsed = 20_500L + postFromPage(request(action = "contentRendered", payload = """{"count":3}""")) + + coVerify(exactly = 1, timeout = 5_000L) { + inAppInteractor.recordBlockShow(PlaceKey.of("main-screen-top"), "embedded-id", any(), Milliseconds(800L), any()) } } From 974b866de5abbfc48909f4457aaaef3681249b2d Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 12 Oct 2026 18:32:45 +0300 Subject: [PATCH 2/2] MOBILE-452: follow review --- .../embedded/EmbeddedBlockContentControllerTest.kt | 10 ++++++++++ .../embedded/webview/EmbeddedBlockWebViewHolderTest.kt | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt index c685e263a..70d33d432 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockContentControllerTest.kt @@ -8,6 +8,7 @@ import io.mockk.mockk import io.mockk.verify import org.junit.Assert.assertFalse import android.os.Looper +import android.os.SystemClock import android.view.View import cloud.mindbox.mobile_sdk.embedded.webview.EmbeddedUpdatableContentProvider import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType @@ -90,6 +91,7 @@ class EmbeddedBlockContentControllerTest { private fun controller( configTimeout: Milliseconds = Milliseconds(30_000L), tracker: InAppFailureTracker? = null, + monotonicNow: () -> Milliseconds = { Milliseconds(SystemClock.elapsedRealtime()) }, providerFactory: (InAppType.Embedded, Milliseconds) -> EmbeddedContentProvider? = { _, _ -> FakeProvider().also { createdProviders.add(it) } }, @@ -99,6 +101,7 @@ class EmbeddedBlockContentControllerTest { configTimeout = configTimeout, providerFactory = providerFactory, blocksRegistry = { blocksRegistry }, + monotonicNow = monotonicNow, failureTracker = { tracker }, isTagsFeatureEnabled = { true }, ).apply { @@ -146,8 +149,10 @@ class EmbeddedBlockContentControllerTest { @Test fun `a block back on screen before the campaign's delay ends builds its page with the selection time`() { + var clock = Milliseconds(0L) val selectionTimes = mutableListOf() val controller = controller( + monotonicNow = { clock }, providerFactory = { _, selectionTime -> selectionTimes.add(selectionTime) FakeProvider() @@ -157,6 +162,7 @@ class EmbeddedBlockContentControllerTest { blocksRegistry.lastHandle?.onContentResolved(null) blocksRegistry.lastHandle?.onContentPending() controller.pause() + clock = Milliseconds(45_000L) controller.start() blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(40L)) @@ -184,8 +190,10 @@ class EmbeddedBlockContentControllerTest { @Test fun `content deferred while the block is away is built with the selection time it came with`() { + var clock = Milliseconds(0L) val selectionTimes = mutableListOf() val controller = controller( + monotonicNow = { clock }, providerFactory = { _, selectionTime -> selectionTimes.add(selectionTime) FakeProvider() @@ -195,7 +203,9 @@ class EmbeddedBlockContentControllerTest { controller.pause() blocksRegistry.lastHandle?.onContentPending() + clock = Milliseconds(20_000L) blocksRegistry.pushContent("main-screen-top", content, selectionTime = Milliseconds(250L)) + clock = Milliseconds(45_000L) controller.start() assertEquals(listOf(Milliseconds(250L)), selectionTimes) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt index 63acf8acd..a25f742f6 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/embedded/webview/EmbeddedBlockWebViewHolderTest.kt @@ -1258,7 +1258,7 @@ class EmbeddedBlockWebViewHolderTest { } @Test - fun `a page that rendered off screen reports every stretch on screen before the render and nothing after it`() { + fun `a page that rendered off screen reports every stretch on screen before the render`() { coEvery { inAppInteractor.recordBlockShow(any(), any(), any(), any(), any()) } just runs rebuildHolder(selectionTime = Milliseconds(2_000L)) startAndAwaitPageLoad()