diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..d3df1ea
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,21 @@
+name: Build
+
+on:
+ workflow_call:
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Assemble Debug
+ run: ./gradlew composeApp:assembleDebug
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 0000000..0c0c799
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,43 @@
+name: Coverage
+
+on:
+ workflow_call:
+ secrets:
+ CODECOV_TOKEN:
+ required: true
+
+jobs:
+ coverage:
+ name: Coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Download unit test execution data
+ uses: actions/download-artifact@v4
+ with:
+ name: jacoco-unit-exec
+ path: composeApp/build/jacoco/
+
+ - name: Download instrumented test coverage data
+ uses: actions/download-artifact@v4
+ with:
+ name: jacoco-instrumented-ec
+ path: composeApp/build/outputs/code_coverage/
+
+ - name: Generate coverage report
+ run: ./gradlew composeApp:jacocoTestReport -x connectedDebugAndroidTest -x testDebugUnitTest
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: composeApp/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
+ fail_ci_if_error: true
diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml
new file mode 100644
index 0000000..6c6c09f
--- /dev/null
+++ b/.github/workflows/instrumented-tests.yml
@@ -0,0 +1,44 @@
+name: Instrumented Tests
+
+on:
+ workflow_call:
+
+jobs:
+ instrumented-tests:
+ name: Instrumented Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Run instrumented tests
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 29
+ arch: x86_64
+ script: ./gradlew composeApp:connectedDebugAndroidTest
+
+ - name: Upload test report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: instrumented-test-report
+ path: composeApp/build/reports/androidTests/connected/
+
+ - name: Upload instrumented coverage data
+ uses: actions/upload-artifact@v4
+ with:
+ name: jacoco-instrumented-ec
+ path: composeApp/build/outputs/code_coverage/
diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
new file mode 100644
index 0000000..bd17edf
--- /dev/null
+++ b/.github/workflows/pr-checks.yml
@@ -0,0 +1,34 @@
+name: PR Checks
+
+on:
+ pull_request:
+ branches: [ master ]
+ push:
+ branches: [ master ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ uses: ./.github/workflows/build.yml
+
+ spotless:
+ uses: ./.github/workflows/spotless.yml
+ permissions:
+ contents: write
+
+ unit-tests:
+ needs: build
+ uses: ./.github/workflows/unit-tests.yml
+
+ instrumented-tests:
+ needs: build
+ uses: ./.github/workflows/instrumented-tests.yml
+
+ coverage:
+ needs: [ unit-tests, instrumented-tests ]
+ uses: ./.github/workflows/coverage.yml
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/spotless.yml b/.github/workflows/spotless.yml
new file mode 100644
index 0000000..58eb3d9
--- /dev/null
+++ b/.github/workflows/spotless.yml
@@ -0,0 +1,32 @@
+name: Spotless
+
+on:
+ workflow_call:
+
+jobs:
+ spotless:
+ name: Spotless
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Apply Spotless formatting
+ run: ./gradlew spotlessApply
+
+ - name: Commit formatting changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: 'style: apply spotless formatting'
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 0000000..6d4dadd
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,34 @@
+name: Unit Tests
+
+on:
+ workflow_call:
+
+jobs:
+ unit-tests:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Run unit tests
+ run: ./gradlew composeApp:testDebugUnitTest
+
+ - name: Upload test report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: unit-test-report
+ path: composeApp/build/reports/tests/testDebugUnitTest/
+
+ - name: Upload Jacoco execution data
+ uses: actions/upload-artifact@v4
+ with:
+ name: jacoco-unit-exec
+ path: composeApp/build/jacoco/testDebugUnitTest.exec
diff --git a/README.md b/README.md
index a673dd6..269e5fc 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,12 @@
-
+
+
+
+
+
+
RandomBoxd is a **Compose Multiplatform** project designed to fetch a random movie from a Letterboxd user's **watchlists** or **custom lists**. This app is built for **Android** and **iOS** devices. 📱🎬
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 03ec453..b336cff 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
@@ -1,7 +1,6 @@
package com.randomboxd.feature.random_film.presentation
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
@@ -26,29 +25,29 @@ class RandomFilmScreenTest {
@Test
fun all_random_film_screen_initial_components_should_be_displayed() {
composeTestRule.setContent {
- RandomFilmScreenRoot { }
+ RandomFilmScreenRoot(onFilmClicked = {})
}
composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").assertIsDisplayed()
composeTestRule.onNodeWithTag("test-random-film-submit-button").assertIsDisplayed()
- composeTestRule.onNodeWithTag("test-loading-indicator").assertIsNotDisplayed()
+ composeTestRule.onNodeWithTag("test-loading-indicator").assertDoesNotExist()
}
@Test
fun press_on_disabled_button_should_not_show_loading_indicator() {
composeTestRule.setContent {
- RandomFilmScreenRoot { }
+ RandomFilmScreenRoot(onFilmClicked = {})
}
composeTestRule.onNodeWithTag("test-random-film-submit-button").performClick()
composeTestRule.onNodeWithTag("test-random-film-submit-button").assertIsNotEnabled()
- composeTestRule.onNodeWithTag("test-loading-indicator").assertIsNotDisplayed()
+ composeTestRule.onNodeWithTag("test-loading-indicator").assertDoesNotExist()
}
@Test
fun enter_text_and_submit_button_should_show_loading_indicator() {
composeTestRule.setContent {
- RandomFilmScreenRoot { }
+ RandomFilmScreenRoot(onFilmClicked = {})
}
composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").performTextInput("user")
@@ -59,7 +58,7 @@ class RandomFilmScreenTest {
@Test
fun enter_text_and_on_clear_text_field_should_remove_text_from_field() {
composeTestRule.setContent {
- RandomFilmScreenRoot { }
+ RandomFilmScreenRoot(onFilmClicked = {})
}
composeTestRule.onNodeWithTag("test-random-film-user-name-text-field").performTextInput("user")
@@ -86,7 +85,7 @@ class RandomFilmScreenTest {
) { }
}
- composeTestRule.onNodeWithTag("test-film-display").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("test-film-display").assertExists()
composeTestRule.onNodeWithTag("test-film-display").performClick()
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt
deleted file mode 100644
index b18b7b2..0000000
--- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmRepositoryImpl.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.nacchofer31.randomboxd.random_film.data.repository_impl
-
-import com.nacchofer31.randomboxd.core.data.RandomBoxdEndpoints
-import com.nacchofer31.randomboxd.core.domain.DataError
-import com.nacchofer31.randomboxd.core.domain.ResultData
-import com.nacchofer31.randomboxd.random_film.data.dto.FilmDto
-import com.nacchofer31.randomboxd.random_film.data.mapper.toFilm
-import com.nacchofer31.randomboxd.random_film.domain.model.Film
-import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode
-import com.nacchofer31.randomboxd.random_film.domain.repository.RandomFilmRepository
-import io.ktor.client.HttpClient
-import io.ktor.client.request.get
-import io.ktor.client.statement.bodyAsText
-import kotlinx.serialization.json.Json
-
-class RandomFilmRepositoryImpl(
- private val httpClient: HttpClient,
-) : RandomFilmRepository {
- override suspend fun getRandomMovie(userName: String): ResultData {
- try {
- val filmResponse =
- httpClient
- .get(
- urlString = RandomBoxdEndpoints.getUserRandomFilm(userName),
- ).bodyAsText()
-
- val json = Json { ignoreUnknownKeys = true }
-
- val filmDto = json.decodeFromString(filmResponse)
-
- return ResultData.Success(filmDto.toFilm())
- } catch (e: Exception) {
- return ResultData.Error(DataError.Remote.SERIALIZATION)
- }
- }
-
- override suspend fun getRandomMoviesFromSearchList(
- searchList: Set,
- filmSearchMode: FilmSearchMode,
- ): ResultData {
- TODO("Not yet implemented")
- }
-}
diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt
index 819fe82..3120302 100644
--- a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/RandomBoxdColorsTest.kt
@@ -10,10 +10,10 @@ class RandomBoxdColorsTest {
fun testColors() {
val colors =
listOf(
- Pair(RandomBoxdColors.BackgroundColor, Color(0xff2C343F)),
- Pair(RandomBoxdColors.BackgroundLightColor, Color(0xff556678)),
- Pair(RandomBoxdColors.BackgroundDarkColor, Color(0xff14171C)),
- Pair(RandomBoxdColors.GreenAccent, Color(0xff00B021)),
+ Pair(RandomBoxdColors.BackgroundColor, Color(0xff1c2228)),
+ Pair(RandomBoxdColors.BackgroundLightColor, Color(0xff99aabb)),
+ Pair(RandomBoxdColors.BackgroundDarkColor, Color(0xff14181c)),
+ Pair(RandomBoxdColors.GreenAccent, Color(0xff00e054)),
Pair(RandomBoxdColors.OrangeAccent, Color(0xfff27405)),
Pair(RandomBoxdColors.BlueAccent, Color(0xff40bcf4)),
Pair(RandomBoxdColors.White, Color.White),
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 ddd4137..444e8ba 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
@@ -1,26 +1,24 @@
package com.nacchofer31.randomboxd.feature.random_film.presentation
import app.cash.turbine.test
-import com.nacchofer31.randomboxd.core.data.RandomBoxdHttpClientFactory
-import com.nacchofer31.randomboxd.random_film.data.dto.FilmDto
-import com.nacchofer31.randomboxd.random_film.data.repository_impl.RandomFilmRepositoryImpl
+import com.nacchofer31.randomboxd.core.domain.DataError
+import com.nacchofer31.randomboxd.core.domain.ResultData
+import com.nacchofer31.randomboxd.random_film.domain.model.Film
import com.nacchofer31.randomboxd.random_film.domain.repository.RandomFilmRepository
import com.nacchofer31.randomboxd.random_film.domain.repository.UserNameRepository
import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction
import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmViewModel
import com.nacchofer31.randomboxd.utils.dispatchers.TestDispatchers
-import com.nacchofer31.randomboxd.utils.http.HttpResponseData
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.HttpClientEngine
-import io.ktor.client.engine.mock.MockEngine
-import io.ktor.client.engine.mock.respond
-import io.ktor.http.HttpStatusCode
-import io.ktor.http.headers
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
import org.kodein.mock.Mock
import org.kodein.mock.generated.mock
import org.kodein.mock.tests.TestsWithMocks
+import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -30,172 +28,141 @@ import kotlin.test.assertSame
class RandomFilmViewModelTest : TestsWithMocks() {
private lateinit var viewModel: RandomFilmViewModel
- private lateinit var repository: RandomFilmRepository
- private lateinit var httpClient: HttpClient
- private lateinit var mockEngine: HttpClientEngine
private lateinit var testDispatchers: TestDispatchers
+ @Mock lateinit var repository: RandomFilmRepository
+
@Mock lateinit var userNameRepository: UserNameRepository
- private var defaultResponseData =
- HttpResponseData(
- content = """{"slug":"test-slug","image_url":"test-image_url","release_year":"2000","film_name":"test-film_name","film_length":""}""",
- statusCode = HttpStatusCode.OK,
+ private val testFilm =
+ Film(
+ slug = "test-film",
+ imageUrl = "https://example.com/poster.jpg",
+ releaseYear = 2020,
+ name = "Test Film",
)
override fun setUpMocks() {
+ repository = mocker.mock()
userNameRepository = mocker.mock()
mocker.every {
userNameRepository.getAllUserNames()
- } returns
- flow {
- emit(emptyList())
- }
- }
-
- private suspend fun setUpAllMocks() {
- mocker.everySuspending {
- userNameRepository.addUserName(isAny())
- } returns Unit
+ } returns flow { emit(emptyList()) }
}
+ @OptIn(ExperimentalCoroutinesApi::class)
@BeforeTest
fun setUp() {
testDispatchers = TestDispatchers()
+ Dispatchers.setMain(testDispatchers.testDispatcher)
}
- private fun setUpWithResponse(responseData: HttpResponseData) {
- mockEngine =
- MockEngine.create {
- addHandler { request ->
- val relativeUrl = request.url.encodedPathAndQuery
- when (relativeUrl) {
- "/api?users=user" -> {
- respond(
- content = responseData.content,
- status = responseData.statusCode,
- headers =
- headers {
- set("Content-Type", "application/json")
- },
- )
- }
-
- else -> {
- respond("Not mocked", HttpStatusCode.NotFound)
- }
- }
- }
- }
- httpClient = RandomBoxdHttpClientFactory.create(engine = mockEngine)
- repository = RandomFilmRepositoryImpl(httpClient)
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel() {
viewModel = RandomFilmViewModel(repository, userNameRepository, testDispatchers)
}
@Test
fun `given successful response when submit button clicked then update state with film`() =
runTest(testDispatchers.testDispatcher) {
- setUpAllMocks()
- mocker.everySuspending {
- userNameRepository.addUserName(isAny())
- } returns Unit
-
- setUpWithResponse(defaultResponseData)
- val expectedFilm =
- FilmDto(
- slug = "test-slug",
- name = "test-film_name",
- releaseYear = "2000",
- imageUrl = "test-image_url",
- )
+ 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.OnUserNameChanged("user"))
viewModel.onAction(RandomFilmAction.OnSubmitButtonClick())
val idleState = awaitItem()
assertSame(false, idleState.isLoading)
- val loadingState = awaitItem()
- assertSame(true, loadingState.isLoading)
+ // Loading state may or may not be emitted as a separate item depending on dispatcher timing
+ var state = awaitItem()
+ if (state.isLoading) state = awaitItem()
- val successState = awaitItem()
- assertSame(false, successState.isLoading)
- assertEquals(expectedFilm.name, successState.resultFilm?.name)
- assertEquals(expectedFilm.releaseYear.toInt(), successState.resultFilm?.releaseYear)
+ assertSame(false, state.isLoading)
+ assertEquals(testFilm.name, state.resultFilm?.name)
+ assertEquals(testFilm.releaseYear, state.resultFilm?.releaseYear)
}
}
@Test
fun `given error response when submit button clicked then update state with null film`() =
runTest(testDispatchers.testDispatcher) {
- setUpAllMocks()
- setUpWithResponse(
- HttpResponseData(
- content = """{}""",
- statusCode = HttpStatusCode.InternalServerError,
- ),
- )
+ mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit
+ mocker.everySuspending {
+ repository.getRandomMovie(isAny())
+ } returns ResultData.Error(DataError.Remote.SERIALIZATION)
+ createViewModel()
+ viewModel.onAction(RandomFilmAction.OnUserNameChanged("user"))
+
viewModel.state.test {
- viewModel.onAction(RandomFilmAction.OnUserNameChanged("user"))
viewModel.onAction(RandomFilmAction.OnSubmitButtonClick())
val idleState = awaitItem()
assertSame(false, idleState.isLoading)
- val loadingState = awaitItem()
- assertSame(true, loadingState.isLoading)
+ // Loading state may or may not be emitted as a separate item depending on dispatcher timing
+ var state = awaitItem()
+ if (state.isLoading) state = awaitItem()
- val errorState = awaitItem()
- assertSame(false, errorState.isLoading)
- assertNull(errorState.resultFilm)
+ assertSame(false, state.isLoading)
+ assertNull(state.resultFilm)
+ assertNotNull(state.resultError)
}
}
@Test
- fun `when clear button clicked then resultFilm is null`() =
+ fun `when clear button clicked then result error is cleared`() =
runTest(testDispatchers.testDispatcher) {
- setUpAllMocks()
- setUpWithResponse(defaultResponseData)
+ mocker.everySuspending { userNameRepository.addUserName(isAny()) } returns Unit
+ mocker.everySuspending {
+ repository.getRandomMovie(isAny())
+ } returns ResultData.Error(DataError.Remote.SERIALIZATION)
+ createViewModel()
+ viewModel.onAction(RandomFilmAction.OnUserNameChanged("user"))
+
viewModel.state.test {
- viewModel.onAction(RandomFilmAction.OnUserNameChanged("user"))
viewModel.onAction(RandomFilmAction.OnSubmitButtonClick())
- awaitItem()
- awaitItem()
- val successState = awaitItem()
- assertNotNull(successState.resultFilm)
+ awaitItem() // idle
+
+ // Loading state may or may not be emitted as a separate item depending on dispatcher timing
+ var state = awaitItem()
+ if (state.isLoading) state = awaitItem()
+ assertNotNull(state.resultError)
viewModel.onAction(RandomFilmAction.OnClearButtonClick)
val clearedState = awaitItem()
- assertNull(clearedState.resultFilm)
+ assertNull(clearedState.resultError)
}
}
@Test
- fun `when onFilmClicked success`() =
+ fun `when submit button clicked twice then state has film both times`() =
runTest(testDispatchers.testDispatcher) {
- setUpAllMocks()
- setUpWithResponse(defaultResponseData)
+ 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.OnUserNameChanged("user"))
viewModel.onAction(RandomFilmAction.OnSubmitButtonClick())
- val idleState = awaitItem()
- assertSame(false, idleState.isLoading)
-
- val loadingState = awaitItem()
- assertSame(true, loadingState.isLoading)
-
- val successState = awaitItem()
- assertNotNull(successState.resultFilm)
+ awaitItem() // idle
+ var firstState = awaitItem()
+ if (firstState.isLoading) firstState = awaitItem()
+ assertNotNull(firstState.resultFilm)
viewModel.onAction(RandomFilmAction.OnSubmitButtonClick())
-
- val newLoadingState = awaitItem()
- assertSame(true, newLoadingState.isLoading)
-
- val newSuccessState = awaitItem()
- assertNotNull(newSuccessState.resultFilm)
+ var secondState = awaitItem()
+ if (secondState.isLoading) secondState = awaitItem()
+ assertNotNull(secondState.resultFilm)
}
}
}
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
new file mode 100644
index 0000000..d4043e5
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/random_film/data/repository_impl/RandomFilmScrappingRepositoryTest.kt
@@ -0,0 +1,233 @@
+package com.nacchofer31.randomboxd.random_film.data.repository_impl
+
+import com.nacchofer31.randomboxd.core.domain.DataError
+import com.nacchofer31.randomboxd.core.domain.ResultData
+import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+
+class RandomFilmScrappingRepositoryTest {
+ private val paginationHtml =
+ """"""
+
+ private val filmListHtml =
+ """
+
+ """.trimIndent()
+
+ private val alternateFilmListHtml =
+ """
+
+ """.trimIndent()
+
+ private val filmDetailHtml =
+ """
+
+
+
+ """.trimIndent()
+
+ private val emptyFilmListHtml = ""
+
+ private fun createRepository(mockEngine: MockEngine) = RandomFilmScrappingRepository(HttpClient(mockEngine))
+
+ @Test
+ fun `getRandomMovie returns film on successful watchlist scraping`() =
+ runTest {
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content =
+ when {
+ path.contains("/page/") -> filmListHtml
+ path.startsWith("/film/") -> filmDetailHtml
+ else -> paginationHtml
+ },
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result = repository.getRandomMovie("user")
+
+ assertIs>(result)
+ val film = (result as ResultData.Success).data
+ assertEquals("Test Film", film.name)
+ assertEquals(2020, film.releaseYear)
+ assertEquals("https://example.com/poster.jpg", film.imageUrl)
+ }
+
+ @Test
+ fun `getRandomMovie returns NO_RESULTS error when watchlist is empty`() =
+ runTest {
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content = if (path.contains("/page/")) emptyFilmListHtml else paginationHtml,
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result = repository.getRandomMovie("user")
+
+ assertIs>(result)
+ assertEquals(DataError.Remote.NO_RESULTS, (result as ResultData.Error).error)
+ }
+
+ @Test
+ fun `getRandomMovie with list slash notation returns film`() =
+ runTest {
+ val listFilmHtml =
+ """
+
+ """.trimIndent()
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content =
+ when {
+ path.contains("/page/") -> listFilmHtml
+ path.startsWith("/film/") -> filmDetailHtml
+ else -> paginationHtml
+ },
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result = repository.getRandomMovie("user/my-list")
+
+ assertIs>(result)
+ assertNotNull((result as ResultData.Success).data)
+ }
+
+ @Test
+ fun `getRandomMoviesFromSearchList UNION returns film from any user`() =
+ runTest {
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content =
+ when {
+ path.contains("/page/") -> filmListHtml
+ path.startsWith("/film/") -> filmDetailHtml
+ else -> paginationHtml
+ },
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result =
+ repository.getRandomMoviesFromSearchList(
+ searchList = setOf("user1", "user2"),
+ filmSearchMode = FilmSearchMode.UNION,
+ )
+
+ assertIs>(result)
+ assertNotNull((result as ResultData.Success).data)
+ }
+
+ @Test
+ fun `getRandomMoviesFromSearchList INTERSECTION with common films returns film`() =
+ runTest {
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content =
+ when {
+ path.contains("/page/") -> filmListHtml
+ path.startsWith("/film/") -> filmDetailHtml
+ else -> paginationHtml
+ },
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result =
+ repository.getRandomMoviesFromSearchList(
+ searchList = setOf("user1", "user2"),
+ filmSearchMode = FilmSearchMode.INTERSECTION,
+ )
+
+ assertIs>(result)
+ assertNotNull((result as ResultData.Success).data)
+ }
+
+ @Test
+ fun `getRandomMoviesFromSearchList INTERSECTION with no common films returns NO_RESULTS error`() =
+ runTest {
+ val mockEngine =
+ MockEngine { request ->
+ val path = request.url.encodedPath
+ respond(
+ content =
+ when {
+ path.contains("/page/") && path.contains("user1") -> filmListHtml
+ path.contains("/page/") && path.contains("user2") -> alternateFilmListHtml
+ else -> paginationHtml
+ },
+ status = HttpStatusCode.OK,
+ headers = headersOf("Content-Type", "text/html"),
+ )
+ }
+ val repository = createRepository(mockEngine)
+
+ val result =
+ repository.getRandomMoviesFromSearchList(
+ searchList = setOf("user1", "user2"),
+ filmSearchMode = FilmSearchMode.INTERSECTION,
+ )
+
+ assertIs>(result)
+ assertEquals(DataError.Remote.NO_RESULTS, (result as ResultData.Error).error)
+ }
+
+ @Test
+ fun `getRandomMoviesFromSearchList with empty search list returns error`() =
+ runTest {
+ val mockEngine = MockEngine { _ -> respond("", HttpStatusCode.OK) }
+ val repository = createRepository(mockEngine)
+
+ val result =
+ repository.getRandomMoviesFromSearchList(
+ searchList = emptySet(),
+ filmSearchMode = FilmSearchMode.UNION,
+ )
+
+ assertIs>(result)
+ assertEquals(DataError.Remote.SERIALIZATION, (result as ResultData.Error).error)
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 397c95d..ca8f7bf 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,13 +16,10 @@ kotlin.native.disableCompilerDaemon=true
#Room
ksp.useKSP2=true
-android.defaults.buildfeatures.resvalues=true
-android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
-android.enableAppCompileTimeRClass=false
-android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
+android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
android.r8.strictFullModeForKeepRules=false
-android.r8.optimizedResourceShrinking=false
+# Required for KMP + com.android.application compatibility until AGP 10.0 migration
android.builtInKotlin=false
android.newDsl=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c03e15e..b97270e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -44,7 +44,7 @@ turbine = "1.2.1"
coroutines-test = "1.10.2"
uiTestAndroid = "1.9.0"
uiTestJunit4Android = "1.9.0"
-androidx-test-runner = "1.7.0"
+androidx-test-runner = "1.5.0"
mockmp = "2.0.2"
[libraries]