Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Try using molecule #754

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ android {
storeFile file("release.keystore")
storePassword props['storePassword']
keyAlias props['keyAlias']
keyPassword props['keyPassword']
keyPassword props['keyPassword']
}
}
}
Expand All @@ -68,7 +68,8 @@ android {
}
}
compileOptions {
coreLibraryDesugaringEnabled true // https://github.com/DroidKaigi/conference-app-2021/issues/373
// https://github.com/DroidKaigi/conference-app-2021/issues/373
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
Expand Down Expand Up @@ -131,7 +132,12 @@ dependencies {
implementation Dep.Kotlin.bom
implementation Dep.Kotlin.stdlib

implementation (Dep.Coroutines.core) {
implementation(Dep.Coroutines.core) {
version {
strictly Versions.coroutines
}
}
testImplementation(Dep.Coroutines.core) {
version {
strictly Versions.coroutines
}
Expand Down Expand Up @@ -165,7 +171,7 @@ dependencies {
testImplementation 'io.kotest:kotest-assertions-core:4.3.2'
// https://github.com/cashapp/turbine/issues/10
testImplementation 'app.cash.turbine:turbine:0.2.1'

testImplementation Dep.turbine
androidTestImplementation Dep.Jetpack.Test.ext
androidTestImplementation Dep.Jetpack.Test.espresso
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,57 @@
package io.github.droidkaigi.feeder

import androidx.compose.runtime.BroadcastFrameClock
import androidx.compose.runtime.withFrameMillis
import androidx.lifecycle.overrideDefaultContext
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.yield
import org.junit.rules.TestWatcher
import org.junit.runner.Description

@ExperimentalCoroutinesApi
class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
TestWatcher() {
private lateinit var clock: BroadcastFrameClock

suspend fun awaitFrame() {
yield()
clock.awaitFrame()
}

override fun starting(description: Description?) {
super.starting(description)
clock = BroadcastFrameClock()
overrideDefaultContext = clock + testDispatcher
Dispatchers.setMain(testDispatcher)
}

override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
overrideDefaultContext = null
}
}

// from: molecule
private suspend fun BroadcastFrameClock.awaitFrame() {
// TODO Remove the need for two frames to happen!
// I think this is because of the diff-sender is a hot loop that immediately reschedules
// itself on the clock. This schedules it ahead of the coroutine which applies changes and
// so we need to trigger an additional frame to actually emit the change's diffs.
repeat(2) {
coroutineScope {
launch(start = CoroutineStart.UNDISPATCHED) {
withFrameMillis { }
}
sendFrame(0L)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.droidkaigi.feeder

import app.cash.turbine.test
import io.github.droidkaigi.feeder.data.FeedRepositoryImpl
import io.github.droidkaigi.feeder.data.fakeFeedApi
import io.github.droidkaigi.feeder.data.fakeFeedItemDao
Expand Down Expand Up @@ -32,63 +33,79 @@ class FeedViewModelTest(
val coroutineTestRule = CoroutineTestRule()

@Test
fun contents() = coroutineTestRule.testDispatcher.runBlockingTest {
// Replace when it fixed https://github.com/cashapp/turbine/issues/10
val feedViewModel = feedViewModelFactory.create()

val firstContent = feedViewModel.state.value.filteredFeedContents

firstContent.size shouldBeGreaterThan 1
fun contents() {
coroutineTestRule.testDispatcher.runBlockingTest {
val feedViewModel = feedViewModelFactory.create()
feedViewModel.state.test {
coroutineTestRule.awaitFrame()
expectMostRecentItem().filteredFeedContents.size shouldBeGreaterThan 1
}
}
}

@Test
fun favorite_Add() = coroutineTestRule.testDispatcher.runBlockingTest {
val feedViewModel = feedViewModelFactory.create()
val firstContent = feedViewModel.state.value.filteredFeedContents
firstContent.favorites shouldBe setOf()
feedViewModel.state.test {
coroutineTestRule.awaitFrame()
val firstContent = expectMostRecentItem().filteredFeedContents
firstContent.favorites shouldBe setOf()

feedViewModel.event(ToggleFavorite(firstContent.feedItemContents[0]))
feedViewModel.event(ToggleFavorite(firstContent.feedItemContents[0]))

val secondContent = feedViewModel.state.value.filteredFeedContents
secondContent.favorites shouldBe setOf(firstContent.feedItemContents[0].id)
coroutineTestRule.awaitFrame()
val secondContent = awaitItem().filteredFeedContents
secondContent.favorites shouldBe setOf(firstContent.feedItemContents[0].id)
}
}

@Test
fun favorite_Remove() = coroutineTestRule.testDispatcher.runBlockingTest {
val feedViewModel = feedViewModelFactory.create()
val firstContent = feedViewModel.state.value.filteredFeedContents
firstContent.favorites shouldBe setOf()

feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0]))
feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0]))

val secondContent = feedViewModel.state.value.filteredFeedContents
secondContent.favorites shouldBe setOf()
feedViewModel.state.test {
coroutineTestRule.awaitFrame()
val firstContent = expectMostRecentItem().filteredFeedContents
firstContent.favorites shouldBe setOf()

feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0]))
coroutineTestRule.awaitFrame()
feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0]))
coroutineTestRule.awaitFrame()

val secondContent = expectMostRecentItem().filteredFeedContents
secondContent.favorites shouldBe setOf()
}
}

@Test
fun favorite_Filter() = coroutineTestRule.testDispatcher.runBlockingTest {
val feedViewModel = feedViewModelFactory.create()
val firstContent = feedViewModel.state.value.filteredFeedContents
firstContent.favorites shouldBe setOf()
val favoriteContents = firstContent.feedItemContents[1]

feedViewModel.event(ToggleFavorite(feedItem = favoriteContents))
feedViewModel.event(ChangeFavoriteFilter(Filters(filterFavorite = true)))

val secondContent = feedViewModel.state.value.filteredFeedContents
secondContent.contents[0].first.id shouldBe favoriteContents.id
feedViewModel.state.test {
coroutineTestRule.awaitFrame()
val firstContent = expectMostRecentItem().filteredFeedContents
firstContent.favorites shouldBe setOf()
val favoriteContents = firstContent.feedItemContents[1]

feedViewModel.event(ToggleFavorite(feedItem = favoriteContents))
feedViewModel.event(ChangeFavoriteFilter(Filters(filterFavorite = true)))
coroutineTestRule.awaitFrame()

val secondContent = expectMostRecentItem().filteredFeedContents
secondContent.contents[0].first.id shouldBe favoriteContents.id
}
}

@Test
fun errorWhenFetch() = coroutineTestRule.testDispatcher.runBlockingTest {
val feedViewModel = feedViewModelFactory.create(errorFetchData = true)
val firstContent = feedViewModel.state.value.filteredFeedContents
firstContent.favorites shouldBe setOf()
feedViewModel.state.test {
val firstContent = expectMostRecentItem().filteredFeedContents
firstContent.favorites shouldBe setOf()

val firstEffect = feedViewModel.effect.first()
val firstEffect = feedViewModel.effect.first()

firstEffect.shouldBeInstanceOf<FeedViewModel.Effect.ErrorMessage>()
firstEffect.shouldBeInstanceOf<FeedViewModel.Effect.ErrorMessage>()
}
}

class FeedViewModelFactory(
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ buildscript {
classpath Dep.SQLDelight.plugin
classpath Dep.playServicesOssLicensesPlugin
classpath Dep.buildKonfig
classpath Dep.Molecule.plugin
}
}

Expand Down
7 changes: 7 additions & 0 deletions buildSrc/src/main/java/Dep.kt
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,11 @@ object Dep {
const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5"

const val napier = "io.github.aakira:napier:2.1.0"

object Molecule {
const val plugin = "app.cash.molecule:molecule-gradle-plugin:0.1.0"
const val runtime = "app.cash.molecule:molecule-runtime"
const val testing = "app.cash.molecule:molecule-testing:0.1.0"
}
val turbine = "app.cash.turbine:turbine:0.7.0"
}
6 changes: 6 additions & 0 deletions uicomponent-compose/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ plugins {
id 'app.cash.exhaustive'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id 'app.cash.molecule'
}

apply from: rootProject.file("gradle/android.gradle")
Expand Down Expand Up @@ -42,6 +43,11 @@ dependencies {
strictly Versions.coroutines
}
}
testImplementation (Dep.Coroutines.core) {
version {
strictly Versions.coroutines
}
}

// Write here to get from JetNews
// https://github.com/android/compose-samples/blob/master/JetNews/app/build.gradle#L66
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package androidx.lifecycle

import app.cash.molecule.AndroidUiDispatcher
import java.io.Closeable
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
takahirom marked this conversation as resolved.
Show resolved Hide resolved
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel

private const val JOB_KEY = "io.github.droidkaigi.feeder.ViewModelCoroutineScope.JOB_KEY"

val defaultDispatcher by lazy { AndroidUiDispatcher.Main }
var overrideDefaultContext: CoroutineContext? = null

public val ViewModel.viewModelScopeWithClock: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(
SupervisorJob() +
(overrideDefaultContext ?: defaultDispatcher)
)
)
}

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context

override fun close() {
coroutineContext.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package io.github.droidkaigi.feeder.core.util

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.github.droidkaigi.feeder.LoadState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect

@Suppress("ComposableNaming")
Expand All @@ -13,3 +17,17 @@ fun <T> Flow<T>.collectInLaunchedEffect(function: suspend (value: T) -> Unit) {
flow.collect(function)
}
}

@Composable
fun <T> Flow<T>.collectAsLoadState(): State<LoadState<T>> {
val flow = this
return produceState<LoadState<T>>(
initialValue = LoadState.Loading
) {
flow
.catch { value = LoadState.Error(it) }
.collect {
value = LoadState.Loaded(it)
}
}
}
11 changes: 9 additions & 2 deletions uicomponent-compose/main/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ plugins {
id 'kotlin-kapt'
id 'app.cash.exhaustive'
id 'dagger.hilt.android.plugin'
id 'app.cash.molecule'
}

apply from: rootProject.file("gradle/android.gradle")
apply from: rootProject.file("gradle/compose.gradle")

android {
compileOptions {
coreLibraryDesugaringEnabled true // need for test. https://github.com/DroidKaigi/conference-app-2021/issues/373
coreLibraryDesugaringEnabled true
// need for test. https://github.com/DroidKaigi/conference-app-2021/issues/373
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
Expand Down Expand Up @@ -50,7 +52,12 @@ dependencies {
implementation Dep.Accompanist.insets
implementation Dep.Accompanist.systemuicontroller

implementation (Dep.Coroutines.core) {
implementation(Dep.Coroutines.core) {
version {
strictly Versions.coroutines
}
}
testImplementation(Dep.Coroutines.core) {
version {
strictly Versions.coroutines
}
Expand Down
Loading