diff --git a/codecov.yml b/codecov.yml index f0f6358..daf57e0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,3 +2,18 @@ coverage: precision: 2 round: down range: "70...100" + +ignore: + # Android platform-specific (require device context) + - "composeApp/src/androidMain/kotlin/com/nacchofer31/randomboxd/MainActivity.kt" + - "composeApp/src/androidMain/kotlin/com/nacchofer31/randomboxd/Platform.android.kt" + - "composeApp/src/androidMain/kotlin/com/nacchofer31/randomboxd/core/data/OnboardingPreferences.android.kt" + # Compose UI theme (no logic) + - "composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/presentation/RandomBoxdTheme.kt" + # Inline functions — JaCoCo cannot track coverage at declaration site + - "composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/ResultData.kt" + - "composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/data/RandomBoxdHttpClientExt.kt" + # Pure interfaces (no executable code) + - "composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository.kt" + # Room generated code + - "composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/data/UsernameDatabase.kt" diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8db5d68..ee3820a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -211,8 +211,31 @@ spotless { val fileFilter = listOf( + // App navigation/entry (excluded by default) "com/nacchofer31/randomboxd/app/**", + // Compose resources (generated) "randomboxd/composeapp/generated/resources/**", + // Android platform-specific (require device context) + "com/nacchofer31/randomboxd/AndroidPlatform*", + "com/nacchofer31/randomboxd/MainActivity*", + "com/nacchofer31/randomboxd/MainActivityKt*", + "com/nacchofer31/randomboxd/Platform*", + "com/nacchofer31/randomboxd/ComposableSingletons${'$'}MainActivityKt*", + "com/nacchofer31/randomboxd/core/data/OnboardingPreferences*", + "com/nacchofer31/randomboxd/core/data/RandomBoxdHttpClientExtKt*", + "com/nacchofer31/randomboxd/core/presentation/RandomBoxdTheme*", + // Room generated code + "com/nacchofer31/randomboxd/core/data/UsernameDatabase_Impl*", + "com/nacchofer31/randomboxd/core/data/UserNameDatabaseConstructor*", + "com/nacchofer31/randomboxd/random_film/domain/model/UserNameDao_Impl*", + // Inline functions — JaCoCo cannot track coverage of Kotlin inline function bodies + "com/nacchofer31/randomboxd/core/domain/ResultData*", + "com/nacchofer31/randomboxd/core/domain/ResultDataKt*", + // Pure interfaces (no executable code) + "com/nacchofer31/randomboxd/random_film/domain/repository/RandomFilmRepository*", + // kotlinx.coroutines inlined lambda classes (phantom source entries) + "com/nacchofer31/randomboxd/random_film/presentation/components/**\$\$inlined*", + "com/nacchofer31/randomboxd/random_film/presentation/viewmodel/**\$special\$\$inlined*", ) tasks.register("jacocoTestReport", JacocoReport::class) { 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 b336cff..97dbd0f 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 @@ -88,4 +88,43 @@ class RandomFilmScreenTest { composeTestRule.onNodeWithTag("test-film-display").assertExists() composeTestRule.onNodeWithTag("test-film-display").performClick() } + + @Test + fun error_view_should_be_displayed_when_state_has_error() { + composeTestRule.setContent { + val mutableUserNamesFlow = MutableStateFlow>(emptyList()) + RandomFilmScreen( + userNameList = mutableUserNamesFlow, + state = RandomFilmState(resultError = com.nacchofer31.randomboxd.core.domain.DataError.Remote.NO_RESULTS), + ) { } + } + + composeTestRule.onNodeWithTag("test-film-error").assertIsDisplayed() + } + + @Test + fun error_view_should_be_displayed_for_no_internet_error() { + composeTestRule.setContent { + val mutableUserNamesFlow = MutableStateFlow>(emptyList()) + RandomFilmScreen( + userNameList = mutableUserNamesFlow, + state = RandomFilmState(resultError = com.nacchofer31.randomboxd.core.domain.DataError.Remote.NO_INTERNET), + ) { } + } + + composeTestRule.onNodeWithTag("test-film-error").assertIsDisplayed() + } + + @Test + fun union_intersection_switch_visible_when_user_search_list_not_empty() { + composeTestRule.setContent { + val mutableUserNamesFlow = MutableStateFlow>(emptyList()) + RandomFilmScreen( + userNameList = mutableUserNamesFlow, + state = RandomFilmState(userNameSearchList = setOf("user1", "user2")), + ) { } + } + + composeTestRule.onNodeWithTag("test-random-film-submit-button").assertIsDisplayed() + } } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/DefaultDispatchersTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/DefaultDispatchersTest.kt new file mode 100644 index 0000000..b611547 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/DefaultDispatchersTest.kt @@ -0,0 +1,20 @@ +package com.nacchofer31.randomboxd.core.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultDispatchersTest { + private val dispatchers = DefaultDispatchers() + + @Test + fun `io dispatcher returns Dispatchers IO`() { + assertEquals(Dispatchers.IO, dispatchers.io) + } + + @Test + fun `default dispatcher returns Dispatchers Default`() { + assertEquals(Dispatchers.Default, dispatchers.default) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/RandomBoxdEndpointsTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/RandomBoxdEndpointsTest.kt new file mode 100644 index 0000000..f0ae53e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/data/RandomBoxdEndpointsTest.kt @@ -0,0 +1,39 @@ +package com.nacchofer31.randomboxd.core.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RandomBoxdEndpointsTest { + @Test + fun `getUserRandomFilm returns expected api url`() { + val result = RandomBoxdEndpoints.getUserRandomFilm("testuser") + assertEquals("/api?users=testuser", result) + } + + @Test + fun `getUserNameWatchlist returns expected letterboxd url`() { + val result = RandomBoxdEndpoints.getUserNameWatchlist("testuser") + assertEquals("https://letterboxd.com/testuser/watchlist", result) + } + + @Test + fun `getUserNameFromList returns expected list url`() { + val result = RandomBoxdEndpoints.getUserNameFromList("testuser", "my-list") + assertEquals("https://letterboxd.com/testuser/list/my-list", result) + } + + @Test + fun `filmSlugUrl returns expected film url`() { + val result = RandomBoxdEndpoints.filmSlugUrl("test-film") + assertEquals("https://letterboxd.com/film/test-film/", result) + } + + @Test + fun `filmPosterUrl returns expected poster url`() { + val result = RandomBoxdEndpoints.filmPosterUrl("1/2/3/4/5/", "12345", "test-film") + assertTrue(result.startsWith("https://a.ltrbxd.com/resized/film-poster/")) + assertTrue(result.contains("12345")) + assertTrue(result.contains("test-film")) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/DataErrorTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/DataErrorTest.kt new file mode 100644 index 0000000..657ba3e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/DataErrorTest.kt @@ -0,0 +1,39 @@ +package com.nacchofer31.randomboxd.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class DataErrorTest { + @Test + fun `DataError Remote entries are all reachable`() { + val remoteErrors = DataError.Remote.entries + assertEquals(7, remoteErrors.size) + assertIs(DataError.Remote.REQUEST_TIMEOUT) + assertIs(DataError.Remote.TOO_MANY_REQUESTS) + assertIs(DataError.Remote.NO_INTERNET) + assertIs(DataError.Remote.SERVER) + assertIs(DataError.Remote.SERIALIZATION) + assertIs(DataError.Remote.UNKNOWN) + assertIs(DataError.Remote.NO_RESULTS) + } + + @Test + fun `DataError Local DISK_FULL is reachable`() { + val error = DataError.Local.DISK_FULL + assertIs(error) + assertEquals(DataError.Local.DISK_FULL, error) + } + + @Test + fun `DataError Local UNKNOWN is reachable`() { + val error = DataError.Local.UNKNOWN + assertIs(error) + assertEquals(DataError.Local.UNKNOWN, error) + } + + @Test + fun `DataError Local entries has two values`() { + assertEquals(2, DataError.Local.entries.size) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/ResultDataTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/ResultDataTest.kt new file mode 100644 index 0000000..b28289d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/ResultDataTest.kt @@ -0,0 +1,79 @@ +package com.nacchofer31.randomboxd.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class ResultDataTest { + private val successResult: ResultData = ResultData.Success(42) + private val errorResult: ResultData = ResultData.Error(DataError.Remote.UNKNOWN) + + @Test + fun `map transforms success data`() { + val result = successResult.map { it * 2 } + assertIs>(result) + assertEquals(84, result.data) + } + + @Test + fun `map on error preserves error`() { + val result = errorResult.map { it * 2 } + assertIs>(result) + assertEquals(DataError.Remote.UNKNOWN, result.error) + } + + @Test + fun `onSuccess executes action for success`() { + var captured: Int? = null + successResult.onSuccess { captured = it } + assertEquals(42, captured) + } + + @Test + fun `onSuccess does not execute action for error`() { + var executed = false + errorResult.onSuccess { executed = true } + assertNull(null.takeIf { executed }) + assertEquals(false, executed) + } + + @Test + fun `onError executes action for error`() { + var captured: DataError? = null + errorResult.onError { captured = it } + assertEquals(DataError.Remote.UNKNOWN, captured) + } + + @Test + fun `onError does not execute action for success`() { + var executed = false + successResult.onError { executed = true } + assertEquals(false, executed) + } + + @Test + fun `onSuccess returns same instance`() { + val result = successResult.onSuccess { } + assertEquals(successResult, result) + } + + @Test + fun `onError returns same instance`() { + val result = errorResult.onError { } + assertEquals(errorResult, result) + } + + @Test + fun `asEmptyDataResult converts success to unit success`() { + val result = successResult.asEmptyDataResult() + assertIs>(result) + } + + @Test + fun `asEmptyDataResult preserves error`() { + val result = errorResult.asEmptyDataResult() + assertIs>(result) + assertEquals(DataError.Remote.UNKNOWN, result.error) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt index 444e8ba..b7be6b3 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/feature/random_film/presentation/RandomFilmViewModelTest.kt @@ -165,4 +165,153 @@ class RandomFilmViewModelTest : TestsWithMocks() { assertNotNull(secondState.resultFilm) } } + + @Test + fun `when username changed then state userName is updated`() = + runTest(testDispatchers.testDispatcher) { + createViewModel() + + viewModel.state.test { + awaitItem() // initial + + viewModel.onAction(RandomFilmAction.OnUserNameChanged("newuser")) + val state = awaitItem() + assertEquals("newuser", state.userName) + } + } + + @Test + fun `when info button clicked then resultFilm and resultError are cleared`() = + runTest(testDispatchers.testDispatcher) { + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + mocker.everySuspending { repository.getRandomMovie(isAny()) } returns ResultData.Success(testFilm) + createViewModel() + viewModel.onAction(RandomFilmAction.OnUserNameChanged("user")) + + viewModel.state.test { + viewModel.onAction(RandomFilmAction.OnSubmitButtonClick()) + + awaitItem() // idle + var state = awaitItem() + if (state.isLoading) state = awaitItem() + assertNotNull(state.resultFilm) + + viewModel.onAction(RandomFilmAction.OnInfoButtonClick) + val clearedState = awaitItem() + assertNull(clearedState.resultFilm) + assertNull(clearedState.resultError) + } + } + + @Test + fun `when add or remove username to search list then list is updated`() = + runTest(testDispatchers.testDispatcher) { + createViewModel() + + viewModel.state.test { + awaitItem() // initial + + viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user1")) + val stateWithUser = awaitItem() + assertEquals(setOf("user1"), stateWithUser.userNameSearchList) + + viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user1")) + val stateWithoutUser = awaitItem() + assertEquals(emptySet(), stateWithoutUser.userNameSearchList) + } + } + + @Test + fun `when film search mode toggled then mode switches between INTERSECTION and UNION`() = + runTest(testDispatchers.testDispatcher) { + createViewModel() + + viewModel.state.test { + val initialState = awaitItem() + assertEquals(com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode.INTERSECTION, initialState.filmSearchMode) + + viewModel.onAction(RandomFilmAction.OnFilmSearchModeToggle) + val toggledState = awaitItem() + assertEquals(com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode.UNION, toggledState.filmSearchMode) + + viewModel.onAction(RandomFilmAction.OnFilmSearchModeToggle) + val revertedState = awaitItem() + assertEquals(com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode.INTERSECTION, revertedState.filmSearchMode) + } + } + + @Test + fun `when multi-user submit clicked then getRandomMoviesFromSearchList is called`() = + runTest(testDispatchers.testDispatcher) { + mocker.everySuspending { + repository.getRandomMoviesFromSearchList(isAny(), isAny()) + } returns ResultData.Success(testFilm) + createViewModel() + + viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user1")) + viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user2")) + + viewModel.state.test { + viewModel.onAction(RandomFilmAction.OnSubmitButtonClick(singleSearch = false)) + + awaitItem() // idle (with userNameSearchList populated) + var state = awaitItem() + if (state.isLoading) state = awaitItem() + + assertNotNull(state.resultFilm) + assertEquals(testFilm.name, state.resultFilm?.name) + } + } + + @Test + fun `when username added action then addUserName is called on repository`() = + runTest(testDispatchers.testDispatcher) { + mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit + createViewModel() + + viewModel.onAction(RandomFilmAction.OnUserNameAdded(" newuser ")) + + // No state change expected, just verify no crash and the action was handled + viewModel.state.test { + val state = awaitItem() + assertEquals("", state.userName) + } + } + + @Test + fun `when remove username action then user is removed from search list and deleteUserName is called`() = + runTest(testDispatchers.testDispatcher) { + val userName = + com.nacchofer31.randomboxd.random_film.domain.model + .UserName(id = 1, username = "user1") + mocker.everySuspending { userNameRepository.deleteUserName(isAny()) } returns Unit + createViewModel() + + viewModel.onAction(RandomFilmAction.OnAddOrRemoveUserNameSearchList("user1")) + + viewModel.state.test { + val stateWithUser = awaitItem() + assertEquals(setOf("user1"), stateWithUser.userNameSearchList) + + viewModel.onAction(RandomFilmAction.OnRemoveUserName(userName)) + val stateAfterRemove = awaitItem() + assertEquals(emptySet(), stateAfterRemove.userNameSearchList) + } + } + + @Test + fun `when film clicked action then no state change occurs`() = + runTest(testDispatchers.testDispatcher) { + createViewModel() + + viewModel.state.test { + val initialState = awaitItem() + + viewModel.onAction(RandomFilmAction.OnFilmClicked(testFilm)) + + // No new state items expected since OnFilmClicked falls to else branch + expectNoEvents() + assertEquals(initialState.userName, "") + } + } } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/mapper/RandomFilmMappersTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/mapper/RandomFilmMappersTest.kt new file mode 100644 index 0000000..5ef4c57 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/mapper/RandomFilmMappersTest.kt @@ -0,0 +1,57 @@ +package com.nacchofer31.randomboxd.random_film.data.mapper + +import com.nacchofer31.randomboxd.random_film.data.dto.FilmDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RandomFilmMappersTest { + @Test + fun `toFilm maps all fields correctly`() { + val dto = + FilmDto( + slug = "test-film", + imageUrl = "https://example.com/poster.jpg", + releaseYear = "2020", + name = "Test Film", + ) + + val film = dto.toFilm() + + assertEquals("test-film", film.slug) + assertEquals("https://example.com/poster.jpg", film.imageUrl) + assertEquals(2020, film.releaseYear) + assertEquals("Test Film", film.name) + } + + @Test + fun `toFilm with empty releaseYear maps to null`() { + val dto = + FilmDto( + slug = "no-year-film", + imageUrl = "https://example.com/poster.jpg", + releaseYear = "", + name = "No Year Film", + ) + + val film = dto.toFilm() + + assertNull(film.releaseYear) + } + + @Test + fun `toFilm preserves slug exactly`() { + val dto = + FilmDto( + slug = "my-special-film-2024", + imageUrl = "", + releaseYear = "1994", + name = "Some Film", + ) + + val film = dto.toFilm() + + assertEquals("my-special-film-2024", film.slug) + assertEquals(1994, film.releaseYear) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt index d4043e5..dbb56ec 100644 --- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt @@ -230,4 +230,84 @@ class RandomFilmScrappingRepositoryTest { assertIs>(result) assertEquals(DataError.Remote.SERIALIZATION, (result as ResultData.Error).error) } + + @Test + fun `getRandomMovie returns error when HTTP request fails`() = + runTest { + val mockEngine = MockEngine { _ -> respond("", HttpStatusCode.InternalServerError) } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user") + + assertIs>(result) + assertEquals(DataError.Remote.SERVER, (result as ResultData.Error).error) + } + + @Test + fun `getRandomMoviesFromSearchList returns error when HTTP request fails`() = + runTest { + val mockEngine = MockEngine { _ -> respond("", HttpStatusCode.InternalServerError) } + val repository = createRepository(mockEngine) + + val result = + repository.getRandomMoviesFromSearchList( + searchList = setOf("user1"), + filmSearchMode = FilmSearchMode.UNION, + ) + + assertIs>(result) + assertEquals(DataError.Remote.SERVER, (result as ResultData.Error).error) + } + + @Test + fun `getRandomMovie uses film imageUrl when poster page request fails`() = + runTest { + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + if (path.startsWith("/film/")) { + respond("", HttpStatusCode.InternalServerError, headersOf("Content-Type", "text/html")) + } else { + respond( + content = if (path.contains("/page/")) filmListHtml else paginationHtml, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user") + + // Even when poster fails, should succeed using the fallback imageUrl + assertIs>(result) + } + + @Test + fun `getRandomMovie uses film imageUrl when poster page has no script tag`() = + runTest { + val filmDetailNoScriptHtml = "Film" + val mockEngine = + MockEngine { request -> + val path = request.url.encodedPath + respond( + content = + when { + path.contains("/page/") -> filmListHtml + path.startsWith("/film/") -> filmDetailNoScriptHtml + else -> paginationHtml + }, + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "text/html"), + ) + } + val repository = createRepository(mockEngine) + + val result = repository.getRandomMovie("user") + + assertIs>(result) + // imageUrl should fall back to the one built from filmId + val film = (result as ResultData.Success).data + assertNotNull(film.imageUrl) + } }