From b018e16d31303db8e34c5726126b193d12fee37e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 5 Nov 2026 12:25:22 +0300 Subject: [PATCH] fix(throttler): threading Signed-off-by: alperozturk96 --- .../java/com/nextcloud/client/di/AppModule.kt | 4 - .../com/nextcloud/client/utils/Throttle.kt | 48 ++++++++++ .../com/nextcloud/client/utils/Throttler.kt | 35 ------- .../client/utils/TrailingThrottle.kt | 32 +++++++ .../android/ui/fragment/OCFileListFragment.kt | 18 ++-- .../ui/fragment/albums/AlbumItemsFragment.kt | 36 ++++---- .../ui/fragment/albums/AlbumsFragment.kt | 7 +- .../fragment/uploadList/UploadListFragment.kt | 16 ++-- .../android/ui/trashbin/TrashbinFragment.kt | 7 +- .../nextcloud/client/utils/ThrottleTest.kt | 82 +++++++++++++++++ .../nextcloud/client/utils/ThrottlerTest.kt | 92 ------------------- 11 files changed, 205 insertions(+), 172 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/utils/Throttle.kt delete mode 100644 app/src/main/java/com/nextcloud/client/utils/Throttler.kt create mode 100644 app/src/main/java/com/nextcloud/client/utils/TrailingThrottle.kt create mode 100644 app/src/test/java/com/nextcloud/client/utils/ThrottleTest.kt delete mode 100644 app/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.kt b/app/src/main/java/com/nextcloud/client/di/AppModule.kt index 7dad415d7bc1..8154074c608d 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.kt @@ -42,7 +42,6 @@ import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.notifications.AppNotificationManager import com.nextcloud.client.notifications.AppNotificationManagerImpl import com.nextcloud.client.preferences.AppPreferences -import com.nextcloud.client.utils.Throttler import com.nextcloud.repository.ClientRepository import com.nextcloud.repository.RemoteClientRepository import com.nextcloud.utils.e2ee.E2EEActionResolver @@ -205,9 +204,6 @@ internal class AppModule { @Provides fun localBroadcastManager(context: Context): LocalBroadcastManager = LocalBroadcastManager.getInstance(context) - @Provides - fun throttler(clock: Clock): Throttler = Throttler(clock) - @Provides @Singleton fun passCodeManager(preferences: AppPreferences, clock: Clock): PassCodeManager = diff --git a/app/src/main/java/com/nextcloud/client/utils/Throttle.kt b/app/src/main/java/com/nextcloud/client/utils/Throttle.kt new file mode 100644 index 000000000000..7d0a160feaac --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/utils/Throttle.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.utils + +import androidx.annotation.MainThread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** Runs an action at most once per interval and ignores calls while a launched action is still running. */ +class Throttle( + private val interval: Duration = DEFAULT_INTERVAL, + private val timeSource: TimeSource = TimeSource.Monotonic +) { + private var lastAccepted: TimeMark? = null + private var inFlight: Job? = null + + @MainThread + fun run(action: () -> Unit) { + if (tryAcquire()) action() + } + + @MainThread + fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) { + if (!tryAcquire()) return + inFlight = scope.launch(block = block) + } + + private fun tryAcquire(): Boolean { + if (inFlight?.isActive == true) return false + if (lastAccepted?.let { it.elapsedNow() < interval } == true) return false + + lastAccepted = timeSource.markNow() + return true + } + + companion object { + val DEFAULT_INTERVAL = 150.milliseconds + } +} diff --git a/app/src/main/java/com/nextcloud/client/utils/Throttler.kt b/app/src/main/java/com/nextcloud/client/utils/Throttler.kt deleted file mode 100644 index 3e5477287a44..000000000000 --- a/app/src/main/java/com/nextcloud/client/utils/Throttler.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2021 Álvaro Brey - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.utils - -import com.nextcloud.client.core.Clock - -/** - * Simple throttler that just discards new calls until interval has passed. - * - * @param clock the Clock to provide timestamps - */ -class Throttler(private val clock: Clock) { - - /** - * The interval, in milliseconds, between accepted calls - */ - @Suppress("MagicNumber") - var intervalMillis = 150L - private val timestamps: MutableMap = mutableMapOf() - - @Synchronized - fun run(key: String, runnable: Runnable) { - val time = clock.currentTime - val lastCallTimestamp = timestamps[key] ?: 0 - if (time - lastCallTimestamp > intervalMillis) { - runnable.run() - timestamps[key] = time - } - } -} diff --git a/app/src/main/java/com/nextcloud/client/utils/TrailingThrottle.kt b/app/src/main/java/com/nextcloud/client/utils/TrailingThrottle.kt new file mode 100644 index 000000000000..584209a0f578 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/utils/TrailingThrottle.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.transform +import kotlin.time.Duration + +/** Emits the first request immediately and merges later ones into a single emission after the interval. */ +class TrailingThrottle(private val interval: Duration) { + + private val requests = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val events: Flow = requests.transform { + emit(it) + delay(interval) + } + + fun request() { + requests.tryEmit(Unit) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.kt index 10a1bbebee36..cbc132784c9d 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.kt @@ -45,7 +45,7 @@ import com.nextcloud.client.device.DeviceInfo import com.nextcloud.client.di.Injectable import com.nextcloud.client.documentscan.AppScanOptionalFeature import com.nextcloud.client.documentscan.DocumentScanActivity -import com.nextcloud.client.utils.Throttler +import com.nextcloud.client.utils.Throttle import com.nextcloud.repository.ClientRepository import com.nextcloud.ui.fileactions.FileAction import com.nextcloud.ui.fileactions.FileActionHandler @@ -136,9 +136,6 @@ open class OCFileListFragment : @Inject lateinit var clientRepository: ClientRepository - @Inject - lateinit var throttler: Throttler - @Inject lateinit var themeUtils: ThemeUtils @@ -217,6 +214,8 @@ open class OCFileListFragment : private val multiChoiceModeListener by lazy { OCFileListMultiChoiceModeListener(this) } private val mainHandler = Handler(Looper.getMainLooper()) + private val actionsMenuThrottle = Throttle() + private val activeActionMode: ActionMode? get() = multiChoiceModeListener.activeActionMode @@ -874,10 +873,14 @@ open class OCFileListFragment : //region File actions fun openActionsMenu(filesCount: Int, checkedFiles: Set, isOverflow: Boolean) { - throttler.run(OVERFLOW_CLICK_THROTTLE_KEY) { + val storageManager = requireContainerActivity().storageManager + val accountName = accountManager.user.accountName + actionsMenuThrottle.launch(viewLifecycleOwner.lifecycleScope) { val actionsToHide = FileAction.getFileListActionsToHide(checkedFiles) val mimeType = checkedFiles.first().resolveMimeType() - val endpoints = capabilities.getClientIntegrationEndpoints(Type.CONTEXT_MENU, mimeType) + val endpoints = withContext(Dispatchers.IO) { + storageManager.getCapability(accountName).getClientIntegrationEndpoints(Type.CONTEXT_MENU, mimeType) + } val bottomSheet = FileActionsBottomSheet.newInstance( filesCount, checkedFiles, @@ -894,7 +897,7 @@ open class OCFileListFragment : if (currentFolder == null || currentFolder.isRootDirectory) return val checkedFiles = hashSetOf(currentFolder) - throttler.run(OVERFLOW_CLICK_THROTTLE_KEY) { + actionsMenuThrottle.run { val actionsToHide = FileAction.getCurrentFolderActionsToHide(currentFolder) val bottomSheet = FileActionsBottomSheet.newInstance(currentFolder, true, actionsToHide) showActionsBottomSheet(bottomSheet, checkedFiles) @@ -1527,7 +1530,6 @@ open class OCFileListFragment : private const val DIALOG_CREATE_FOLDER = "DIALOG_CREATE_FOLDER" private const val DIALOG_CREATE_DOCUMENT = "DIALOG_CREATE_DOCUMENT" private const val ACTIONS_BOTTOM_SHEET_TAG = "actions" - private const val OVERFLOW_CLICK_THROTTLE_KEY = "overflowClick" private const val ALL_MIME_TYPES = "*/*" private const val SCAN_TO_PDF_ACTION = "org.fairscan.app.action.SCAN_TO_PDF" diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt index 1255b6896983..6fb87e51a790 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumItemsFragment.kt @@ -39,12 +39,13 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.nextcloud.android.lib.resources.clientintegration.Endpoint import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.utils.IntentUtil -import com.nextcloud.client.utils.Throttler +import com.nextcloud.client.utils.Throttle import com.nextcloud.repository.ClientRepository import com.nextcloud.ui.albumItemActions.AlbumItemActionsBottomSheet import com.nextcloud.ui.fileactions.FileActionHandler @@ -128,8 +129,9 @@ class AlbumItemsFragment : @Inject lateinit var clientRepository: ClientRepository - @Inject - lateinit var throttler: Throttler + private val addMediaThrottle = Throttle() + private val actionsMenuThrottle = Throttle() + private val sharingSheetThrottle = Throttle() @Inject lateinit var thumbnailGenerator: ThumbnailGenerator @@ -496,7 +498,7 @@ class AlbumItemsFragment : } private fun openAddMediaMenu() { - throttler.run(THROTTLE_ADD_MEDIA) { + addMediaThrottle.run { val fragmentManager = requireActivity().supportFragmentManager AlbumItemActionsBottomSheet.newInstance() @@ -596,7 +598,7 @@ class AlbumItemsFragment : //region File actions private fun openActionsMenu(filesCount: Int, checkedFiles: Set) { - throttler.run(THROTTLE_OVERFLOW_CLICK) { + actionsMenuThrottle.launch(viewLifecycleOwner.lifecycleScope) { val actionsSheet = FileActionsBottomSheet.newInstance( filesCount, checkedFiles, @@ -604,7 +606,9 @@ class AlbumItemsFragment : hiddenFileActions(checkedFiles), false, contextMenuEndpoints(checkedFiles) - ).setResultListener(childFragmentManager, this) { id -> onFileActionChosen(id, checkedFiles) } + ).setResultListener(childFragmentManager, this@AlbumItemsFragment) { id -> + onFileActionChosen(id, checkedFiles) + } if (isDialogFragmentReady()) { actionsSheet.show(childFragmentManager, TAG_FILE_ACTIONS) @@ -622,11 +626,15 @@ class AlbumItemsFragment : return hidden } - private fun contextMenuEndpoints(checkedFiles: Set) = containerActivity - ?.storageManager - ?.getCapability(optionalUser?.get()) - ?.getClientIntegrationEndpoints(Type.CONTEXT_MENU, checkedFiles.first().mimeType) - .orEmpty() + private suspend fun contextMenuEndpoints(checkedFiles: Set): List { + val storageManager = containerActivity?.storageManager ?: return emptyList() + val user = optionalUser?.get() + val mimeType = checkedFiles.first().mimeType + + return withContext(Dispatchers.IO) { + storageManager.getCapability(user).getClientIntegrationEndpoints(Type.CONTEXT_MENU, mimeType) + } + } private fun onFileActionChosen(@IdRes itemId: Int, checkedFiles: Set) { if (checkedFiles.isEmpty()) return @@ -828,7 +836,7 @@ class AlbumItemsFragment : //region Album sharing private fun openAlbumSharingBottomSheet() { - throttler.run(THROTTLE_SHARING_SHEET) { + sharingSheetThrottle.run { val album = photoAlbumEntry ?: return@run val fragmentManager = requireActivity().supportFragmentManager @@ -883,10 +891,6 @@ class AlbumItemsFragment : private const val TAG_ALBUM_SHARING = "album_sharing_sheet" private const val TAG_FILE_ACTIONS = "actions" - private const val THROTTLE_ADD_MEDIA = "addMediaClick" - private const val THROTTLE_SHARING_SHEET = "albumSharingSheet" - private const val THROTTLE_OVERFLOW_CLICK = "overflowClick" - private const val ALBUMS_REMOTE_PATH = "/albums/" private const val ANY_MIME_TYPE = "*/*" diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt index 31a4ba1afedd..4a45ba427621 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/albums/AlbumsFragment.kt @@ -30,7 +30,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.Injectable import com.nextcloud.client.preferences.AppPreferences -import com.nextcloud.client.utils.Throttler +import com.nextcloud.client.utils.Throttle import com.nextcloud.utils.extensions.getTypedActivity import com.nextcloud.utils.extensions.isLandscape import com.nextcloud.utils.extensions.setVisibleIf @@ -77,8 +77,7 @@ class AlbumsFragment : @Inject lateinit var thumbnailGenerator: ThumbnailGenerator - @Inject - lateinit var throttler: Throttler + private val createAlbumThrottle = Throttle() private var containerActivity: FileFragment.ContainerActivity? = null @@ -142,7 +141,7 @@ class AlbumsFragment : } private fun showCreateAlbumDialog() { - throttler.run("onCreateAlbumClick") { + createAlbumThrottle.run { val fragment = requireActivity().supportFragmentManager.findFragmentByTag(CreateAlbumDialogFragment.TAG) if (fragment == null) { CreateAlbumDialogFragment.newInstance() diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt index 6395f5a0fd5b..74306573276b 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt @@ -30,8 +30,9 @@ import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences -import com.nextcloud.client.utils.Throttler +import com.nextcloud.client.utils.TrailingThrottle import com.nextcloud.ui.component.UploadWarningCard +import com.nextcloud.utils.extensions.collectWhenStarted import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getTypedActivity import com.nextcloud.utils.thumbnail.ThumbnailGenerator @@ -64,7 +65,7 @@ class UploadListFragment : @Inject lateinit var localBroadcastManager: LocalBroadcastManager - @Inject lateinit var throttler: Throttler + private val uploadListRefresh = TrailingThrottle(ONE_SECOND) @Inject lateinit var uploadFileOperationFactory: UploadFileOperationFactory @@ -98,19 +99,16 @@ class UploadListFragment : private val uploadFinishReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED) { - uploadListAdapter.loadUploadItemsFromDb() - return + when (intent?.action) { + FileUploadEventBroadcaster.ACTION_UPLOAD_COMPLETED -> uploadListAdapter.loadUploadItemsFromDb() + else -> uploadListRefresh.request() } - - throttler.run(UPDATE_UPLOAD_LIST_KEY) { uploadListAdapter.loadUploadItemsFromDb() } } } // region Lifecycle override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - throttler.intervalMillis = ONE_SECOND.inWholeMilliseconds if (savedInstanceState == null) { setUserFromIntent() } @@ -130,6 +128,7 @@ class UploadListFragment : setupWarningCard(activity, binding) setupEmptyList(binding) setupList(activity, binding) + viewLifecycleOwner.collectWhenStarted(uploadListRefresh.events) { uploadListAdapter.loadUploadItemsFromDb() } addMenuProvider() } @@ -296,6 +295,5 @@ class UploadListFragment : companion object { private const val SPAN_COUNT = 1 private const val EMPTY_LIST_ICON_ALPHA = 0.5f - private const val UPDATE_UPLOAD_LIST_KEY = "update_upload_list" } } diff --git a/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinFragment.kt b/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinFragment.kt index 926f2139d38f..bde190d61579 100644 --- a/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/trashbin/TrashbinFragment.kt @@ -40,7 +40,7 @@ import com.nextcloud.client.network.ClientFactory import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.client.systembars.SystemBarBackgroundCallbacks -import com.nextcloud.client.utils.Throttler +import com.nextcloud.client.utils.Throttle import com.nextcloud.ui.sort.SortOrderUi import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet import com.nextcloud.utils.SnackbarUtil @@ -81,8 +81,7 @@ class TrashbinFragment : @Inject lateinit var clientFactory: ClientFactory - @Inject - lateinit var throttler: Throttler + private val actionsMenuThrottle = Throttle() @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -419,7 +418,7 @@ class TrashbinFragment : } private fun openActionsMenu(filesCount: Int, checkedFiles: Set) { - throttler.run("overflowClick") { + actionsMenuThrottle.run { val navigatorActivity = getTypedActivity(NavigatorActivity::class.java) val supportFragmentManager = navigatorActivity?.supportFragmentManager ?: return@run diff --git a/app/src/test/java/com/nextcloud/client/utils/ThrottleTest.kt b/app/src/test/java/com/nextcloud/client/utils/ThrottleTest.kt new file mode 100644 index 000000000000..1497c45687df --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/utils/ThrottleTest.kt @@ -0,0 +1,82 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.utils + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TestTimeSource + +@OptIn(ExperimentalCoroutinesApi::class) +class ThrottleTest { + + private val timeSource = TestTimeSource() + private val sut = Throttle(INTERVAL, timeSource) + private var calls = 0 + + @Test + fun `repeated calls at the same instant run only once`() { + repeat(10) { sut.run { calls++ } } + + assertEquals(1, calls) + } + + @Test + fun `calls spaced by the interval all run`() { + repeat(4) { + sut.run { calls++ } + timeSource += INTERVAL + } + + assertEquals(4, calls) + } + + @Test + fun `calls inside the interval are dropped`() { + repeat(4) { + sut.run { calls++ } + timeSource += HALF_INTERVAL + } + + assertEquals(2, calls) + } + + @Test + fun `launch is ignored while the previous job is still running`() { + val scope = TestScope(StandardTestDispatcher()) + val gate = CompletableDeferred() + + sut.launch(scope) { + calls++ + gate.await() + } + scope.runCurrent() + timeSource += INTERVAL * 2 + sut.launch(scope) { calls++ } + scope.runCurrent() + + assertEquals(1, calls) + + gate.complete(Unit) + scope.advanceUntilIdle() + sut.launch(scope) { calls++ } + scope.advanceUntilIdle() + + assertEquals(2, calls) + } + + private companion object { + val INTERVAL = 150.milliseconds + val HALF_INTERVAL = 75.milliseconds + } +} diff --git a/app/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt b/app/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt deleted file mode 100644 index 18b9a51858e9..000000000000 --- a/app/src/test/java/com/nextcloud/client/utils/ThrottlerTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2021 Álvaro Brey - * SPDX-FileCopyrightText: 2021# Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.client.utils - -import com.nextcloud.client.core.Clock -import io.mockk.MockKAnnotations -import io.mockk.Runs -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.just -import io.mockk.verify -import org.junit.Before -import org.junit.Test - -class ThrottlerTest { - companion object { - private const val KEY = "TEST" - } - - @MockK - lateinit var runnable: Runnable - - @MockK - lateinit var clock: Clock - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - every { runnable.run() } just Runs - } - - private fun runWithThrottler(throttler: Throttler) { - throttler.run(KEY, runnable) - } - - @Test - fun unchangingTime_multipleCalls_calledExactlyOnce() { - // given - every { clock.currentTime } returns 300 - - val sut = Throttler(clock).apply { - intervalMillis = 150 - } - - // when - repeat(10) { - runWithThrottler(sut) - } - - // then - verify(exactly = 1) { runnable.run() } - } - - @Test - fun spacedCalls_noThrottle() { - // given - val sut = Throttler(clock).apply { - intervalMillis = 150 - } - every { clock.currentTime } returnsMany listOf(200, 400, 600, 800) - - // when - repeat(4) { - runWithThrottler(sut) - } - - // then - verify(exactly = 4) { runnable.run() } - } - - @Test - fun mixedIntervals_sometimesThrottled() { - // given - val sut = Throttler(clock).apply { - intervalMillis = 150 - } - every { clock.currentTime } returnsMany listOf(200, 300, 400, 500) - - // when - repeat(4) { - runWithThrottler(sut) - } - - // then - verify(exactly = 2) { runnable.run() } - } -}