From 263a3a2111fddbc9ca14e3a5191befa252d12627 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:55:28 +0200 Subject: [PATCH 1/3] feat(core): add randomExcluding set extension Add utility function to get a random element from a set while excluding a specific element using a key selector for comparison. Includes comprehensive unit tests. --- .../randomboxd/core/domain/SetExtensions.kt | 29 ++++++++ .../core/domain/SetExtensionsTest.kt | 74 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt new file mode 100644 index 0000000..1e85437 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt @@ -0,0 +1,29 @@ +package com.nacchofer31.randomboxd.core.domain + +/** + * Returns a random element from the set, excluding the specified element if possible. + * Comparison is done by the provided key function instead of object equality. + * If [exclude] is null, returns a random element without filtering. + * If the set contains only the excluded element, returns a random element. + * + * @param exclude The element to exclude from the random selection, or null to not exclude anything + * @param keySelector Function to extract the comparison key from each element + * @return A random element from the set, preferably different from [exclude] + * @throws NoSuchElementException if the set is empty + */ +fun Set.randomExcluding( + exclude: T? = null, + keySelector: (T) -> K, +): T { + if (isEmpty()) throw NoSuchElementException("Set is empty.") + if (exclude == null) return random() + + val excludeKey = keySelector(exclude) + val candidates = filter { keySelector(it) != excludeKey } + return if (candidates.isNotEmpty()) { + candidates.random() + } else { + // Only the excluded element exists, return it + random() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt new file mode 100644 index 0000000..e8243d2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt @@ -0,0 +1,74 @@ +package com.nacchofer31.randomboxd.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SetExtensionsTest { + data class TestItem( + val id: Int, + val name: String, + ) + + @Test + fun `randomExcluding throws NoSuchElementException when set is empty`() { + val emptySet = emptySet() + + assertFailsWith { + emptySet.randomExcluding(keySelector = { it.id }) + } + } + + @Test + fun `randomExcluding returns random element when exclude is null`() { + val set = setOf(TestItem(1, "one"), TestItem(2, "two"), TestItem(3, "three")) + + val result = set.randomExcluding(exclude = null, keySelector = { it.id }) + + assertTrue(result in set) + } + + @Test + fun `randomExcluding returns element different from excluded using keySelector`() { + val excluded = TestItem(2, "two") + val set = setOf(TestItem(1, "one"), excluded, TestItem(3, "three")) + + repeat(10) { + val result = set.randomExcluding(exclude = excluded, keySelector = { it.id }) + assertTrue(result.id != excluded.id) + } + } + + @Test + fun `randomExcluding returns excluded element when it is the only one`() { + val onlyItem = TestItem(1, "one") + val set = setOf(onlyItem) + + val result = set.randomExcluding(exclude = onlyItem, keySelector = { it.id }) + + assertEquals(onlyItem, result) + } + + @Test + fun `randomExcluding uses keySelector for comparison not object equality`() { + val excluded = TestItem(1, "original") + val differentObject = TestItem(1, "different") + val set = setOf(excluded, differentObject, TestItem(2, "two")) + + repeat(10) { + val result = set.randomExcluding(exclude = excluded, keySelector = { it.id }) + assertTrue(result.id != 1, "Should exclude all items with id=1") + } + } + + @Test + fun `randomExcluding works with single element set and null exclude`() { + val item = TestItem(1, "one") + val set = setOf(item) + + val result = set.randomExcluding(exclude = null, keySelector = { it.id }) + + assertEquals(item, result) + } +} From e3741e488c86c618cf439e0640534684c308b40c Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:55:41 +0200 Subject: [PATCH 2/3] refactor(random-film): use injected dispatchers and prevent reroll repetition Replace hardcoded Dispatchers.IO with injected DispatcherProvider for better testability. Use randomExcluding to avoid showing the same film on reroll. Wrap repository calls in withContext(dispatchers.io). Conditionally show reroll button only when numberOfResults > 1. --- .../presentation/components/FilmPoster.kt | 8 ++-- .../viewmodel/RandomFilmViewModel.kt | 38 ++++++++++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt index 6cae40a..730350d 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt @@ -185,9 +185,11 @@ fun FilmPoster( fontSize = 17.sp, textAlign = TextAlign.Center, ) - RerollButton( - onClick = onRerollClick, - ) + if (numberOfResults > 1) { + RerollButton( + onClick = onRerollClick, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt index 41e2b1f..8b20d94 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nacchofer31.randomboxd.core.domain.DispatcherProvider import com.nacchofer31.randomboxd.core.domain.ResultData +import com.nacchofer31.randomboxd.core.domain.randomExcluding import com.nacchofer31.randomboxd.random_film.domain.model.Film import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Suppress("OPT_IN_USAGE") class RandomFilmViewModel( @@ -74,7 +76,9 @@ class RandomFilmViewModel( else -> { val userName = internalState.value.userName.trim() - userNameRepository.addUserName(userName) + withContext(dispatchers.io) { + userNameRepository.addUserName(userName) + } repository.getRandomMovies(userName, internalState.value.selectedGenres) } } @@ -92,7 +96,7 @@ class RandomFilmViewModel( inAppReviewRepository.requestInAppReview() } cachedResultFilms = result.data - var filmResult = repository.extractResultMovie(result.data.random()) + var filmResult = repository.extractResultMovie(result.data.randomExcluding(null) { it.name }) return@update when (filmResult) { is ResultData.Success -> { current.copy( @@ -150,12 +154,14 @@ class RandomFilmViewModel( if (internalState.value.userNameSearchList.contains(action.userName.username)) { addOrRemoveUserNameToList(action.userName.username) } - userNameRepository.deleteUserName(action.userName) + withContext(dispatchers.io) { + userNameRepository.deleteUserName(action.userName) + } } } is RandomFilmAction.OnUserNameAdded -> { - viewModelScope.launch { userNameRepository.addUserName(action.username.trim()) } + viewModelScope.launch(dispatchers.io) { userNameRepository.addUserName(action.username.trim()) } } is RandomFilmAction.OnAddOrRemoveUserNameSearchList -> { @@ -183,7 +189,12 @@ class RandomFilmViewModel( } is RandomFilmAction.OnGenreSelectionApplied -> { - internalState.update { it.copy(selectedGenres = action.genres, showGenreBottomSheet = false) } + internalState.update { + it.copy( + selectedGenres = action.genres, + showGenreBottomSheet = false, + ) + } } is RandomFilmAction.OnRerollClicked -> { @@ -240,16 +251,16 @@ class RandomFilmViewModel( } } - private fun rerollMovie() { - internalState.update { - it.copy(isLoading = true) - } - + private fun rerollMovie() = viewModelScope.launch { + internalState.update { + it.copy(isLoading = true) + } + val rerolledFilm = cachedResultFilms.randomExcluding(internalState.value.resultFilm) { it.name } val filmResult = - repository.extractResultMovie( - cachedResultFilms.random(), - ) + withContext(dispatchers.io) { + repository.extractResultMovie(rerolledFilm) + } internalState.update { when (filmResult) { is ResultData.Success -> it.copy(isLoading = false, resultFilm = filmResult.data, resultError = null, numberOfResults = cachedResultFilms.size) @@ -257,5 +268,4 @@ class RandomFilmViewModel( } } } - } } From 506643f42b6251ae98a1e6baccece3b29b2b17c5 Mon Sep 17 00:00:00 2001 From: Nacchofer31 <10453558+Nacchofer31@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:55:51 +0200 Subject: [PATCH 3/3] test(random-film): fix reroll test and add animation coverage Fix reroll button test by passing numberOfResults parameter. Add test to verify loading animation completes full cycle for better code coverage. --- .../presentation/LoadingOrPromptTest.kt | 15 +++++++++++++++ .../presentation/RandomFilmScreenTest.kt | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt index 5bc8dfe..4ca18d7 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt @@ -50,4 +50,19 @@ class LoadingOrPromptTest { composeTestRule.onNodeWithText("Finding your random movie").assertIsDisplayed() } + + @Test + fun animation_completes_full_cycle() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + LoadingOrPrompt(isLoading = true) + } + + composeTestRule.mainClock.advanceTimeBy(4000) + + // Verify components are still displayed after animation + composeTestRule.onNodeWithTag("test-loading-indicator").assertIsDisplayed() + composeTestRule.onNodeWithText("Rolling the dice", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Finding your random movie").assertIsDisplayed() + } } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index 33320d7..8e8ad50 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -23,6 +23,7 @@ import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.UserName import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreen import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreenRoot +import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Rule @@ -267,8 +268,9 @@ class RandomFilmScreenTest { releaseYear = 2000, imageUrl = "test-image-url", ), + numberOfResults = 2, ) { action -> - if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnRerollClicked) { + if (action is RandomFilmAction.OnRerollClicked) { rerollClicked = true } } @@ -302,7 +304,7 @@ class RandomFilmScreenTest { imageUrl = "test-image-url", ), ) { action -> - if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnFilmClicked) { + if (action is RandomFilmAction.OnFilmClicked) { filmClicked = true } }