Skip to content
Closed
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
7 changes: 6 additions & 1 deletion android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
material = "1.12.0"
assertk = "0.28.1"
turbine = "1.2.0"

[plugins]
multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
Expand Down Expand Up @@ -94,6 +96,7 @@ androidx-datastore-preferences = { module = "androidx.datastore:datastore-prefer
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koinVersion" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinVersion" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinVersion" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koinVersion" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koinVersion" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" }
Expand Down Expand Up @@ -124,4 +127,6 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
assertk = {module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk"}
turbine = {module = "app.cash.turbine:turbine", version.ref = "turbine"}
4 changes: 4 additions & 0 deletions android/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ kotlin {
//XXX: Workaround for https://github.com/Kotlin/kotlinx-atomicfu/issues/469
implementation(libs.jetbrains.kotlinx.atomicfu)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.uuid)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
Expand Down Expand Up @@ -83,6 +84,9 @@ kotlin {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.assertk)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.koin.test)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ fun initKoin(context: Context) {
dataStoreModule,
androidModule,
libpebbleModule,
dependenciesModule
dependenciesModule,
viewModelModule,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.rebble.cobble.shared.PlatformContext
import io.rebble.cobble.shared.errors.GlobalExceptionHandler
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import org.koin.core.qualifier.named
import org.koin.dsl.module

val dependenciesModule = module {
Expand All @@ -22,6 +26,10 @@ val dependenciesModule = module {
}

single { GlobalExceptionHandler() }

factory<CoroutineDispatcher>(qualifier = named("io_dispatcher")) {
Dispatchers.IO
}
}

expect fun makePlatformCacheStorage(platformContext: PlatformContext): CacheStorage
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.rebble.cobble.shared.di

import org.koin.dsl.module
import io.rebble.cobble.shared.ui.viewmodel.LockerViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named


val viewModelModule = module {
viewModel {
LockerViewModel(get(), get(qualifier = named("io_dispatcher")) )
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,18 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import io.rebble.cobble.shared.database.dao.LockerDao
import io.rebble.cobble.shared.ui.nav.Routes
import io.rebble.cobble.shared.ui.viewmodel.LockerViewModel
import org.koin.compose.getKoin

import org.koin.compose.viewmodel.koinViewModel

enum class LockerTabs(val label: String, val navRoute: String) {
Watchfaces("My watch faces", Routes.Home.LOCKER_WATCHFACES),
Apps("My apps", Routes.Home.LOCKER_APPS),
}

@Composable
fun Locker(page: LockerTabs, lockerDao: LockerDao = getKoin().get(), viewModel: LockerViewModel = viewModel { LockerViewModel(lockerDao) }, onTabChanged: (LockerTabs) -> Unit) {
fun Locker(page: LockerTabs, viewModel: LockerViewModel = koinViewModel(), onTabChanged: (LockerTabs) -> Unit) {
val entriesState: LockerViewModel.LockerEntriesState by viewModel.entriesState.collectAsState()
val modalSheetState by viewModel.modalSheetState.collectAsState()
val watchIsConnected by viewModel.watchIsConnected.collectAsState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class LockerViewModel(private val lockerDao: LockerDao): ViewModel() {
class LockerViewModel(
private val lockerDao: LockerDao,
private val dispatcher: CoroutineDispatcher,
): ViewModel() {
open class LockerEntriesState {
object Loading : LockerEntriesState()
object Error : LockerEntriesState()
Expand All @@ -26,7 +29,7 @@ class LockerViewModel(private val lockerDao: LockerDao): ViewModel() {

val entriesState = MutableStateFlow<LockerEntriesState>(LockerEntriesState.Loading)
init {
viewModelScope.launch(Dispatchers.IO + CoroutineName("LockerViewModelGet")) {
viewModelScope.launch(dispatcher + CoroutineName("LockerViewModelGet")) {
lockerDao.getAllEntriesFlow().catch {
Logging.e("Error loading locker entries", it)
entriesState.value = LockerEntriesState.Error
Expand All @@ -45,7 +48,7 @@ class LockerViewModel(private val lockerDao: LockerDao): ViewModel() {

suspend fun updateOrder(entries: List<SyncedLockerEntryWithPlatforms>) {
lastJob?.cancel()
lastJob = viewModelScope.launch(Dispatchers.IO) {
lastJob = viewModelScope.launch(dispatcher) {
mutex.withLock {
entries.forEachIndexed { i, e ->
if (e.entry.type == "watchapp") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package io.rebble.cobble.shared.ui.viewmodel

import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.request.HttpResponseData
import io.ktor.http.Headers
import io.ktor.http.HttpProtocolVersion
import io.ktor.http.HttpStatusCode
import io.ktor.util.date.GMTDate
import io.rebble.cobble.shared.database.NextSyncAction
import io.rebble.cobble.shared.database.dao.LockerDao
import io.rebble.cobble.shared.database.entity.SyncedLockerEntry
import io.rebble.cobble.shared.database.entity.SyncedLockerEntryPlatform
import io.rebble.cobble.shared.database.entity.SyncedLockerEntryWithPlatforms
import io.rebble.cobble.shared.domain.state.ConnectionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.Instant
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.test.KoinTest
import kotlin.test.AfterTest
import kotlin.test.Test

class LockerViewModelTest : KoinTest {

@AfterTest
fun tearDown() {
stopKoin()
}

@Test
fun `given there is an exception when attempting to load entries then a error state is returned`() = runTest {
setupKoin()
val viewModel = LockerViewModel(lockerDao = object : LockerDao by NoOpLockerDao() {
override fun getAllEntriesFlow(): Flow<List<SyncedLockerEntryWithPlatforms>> = flow {
error("oops!")
}
}, dispatcher = StandardTestDispatcher(this.testScheduler))

viewModel.entriesState.test {
// first state is loading
assertThat(awaitItem()).isEqualTo(LockerViewModel.LockerEntriesState.Loading)

// second state should be error
assertThat(awaitItem()).isEqualTo(LockerViewModel.LockerEntriesState.Error)

cancel()
}
}

@Test
fun `given there are entries when entries are requested the loaded entry state is returned`() = runTest {
setupKoin()
val entries = listOf(SyncedLockerEntryWithPlatforms(entry = SyncedLockerEntry(id = "", uuid = "", version = "", title = "", type = "", hearts = 0, developerName = "", developerId = null, configurable = false, timelineEnabled = false, removeLink = "", shareLink = "", pbwLink = "", pbwReleaseId = "", pbwIconResourceId = 0, nextSyncAction = NextSyncAction.Upload, order = 0, lastOpened = null, local = false), platforms = listOf()))

val viewModel = LockerViewModel(lockerDao = object : LockerDao by NoOpLockerDao() {
override fun getAllEntriesFlow(): Flow<List<SyncedLockerEntryWithPlatforms>> = flow {
emit(entries)
}
}, dispatcher = StandardTestDispatcher(this.testScheduler))

viewModel.entriesState.test {
// first state is loading
assertThat(awaitItem()).isEqualTo(LockerViewModel.LockerEntriesState.Loading)

// second state is our loaded state
assertThat(awaitItem()).isEqualTo(LockerViewModel.LockerEntriesState.Loaded(entries = entries))

cancel()
}
}

@Test
fun `given a model is provided when a model sheet is attempted to be opened then modal state returns open`() = runTest {
Dispatchers.setMain(StandardTestDispatcher(this.testScheduler))
setupKoin()

val viewModel = LockerViewModel(lockerDao = object : LockerDao by NoOpLockerDao() {
override fun getAllEntriesFlow(): Flow<List<SyncedLockerEntryWithPlatforms>> = flow {
emit(emptyList())
}
}, dispatcher = StandardTestDispatcher(this.testScheduler))

val model = LockerItemViewModel(
httpClient = HttpClient(MockEngine.create {
addHandler {
HttpResponseData(statusCode = HttpStatusCode(value = 0, description = ""), requestTime = GMTDate(timestamp = null), headers = Headers.Empty, version = HttpProtocolVersion(name = "", major = 0, minor = 0), body = "", callContext = this@runTest.coroutineContext)
}
}),
entry = SyncedLockerEntryWithPlatforms(entry = SyncedLockerEntry(id = "", uuid = "", version = "", title = "", type = "", hearts = 0, developerName = "", developerId = null, configurable = false, timelineEnabled = false, removeLink = "", shareLink = "", pbwLink = "", pbwReleaseId = "", pbwIconResourceId = 0, nextSyncAction = NextSyncAction.Upload, order = 0, lastOpened = null, local = false), platforms = listOf()),
)

viewModel.modalSheetState.test {
// first state
assertThat(awaitItem()).isEqualTo(LockerViewModel.ModalSheetState.Closed)
viewModel.openModalSheet(model)

assertThat(awaitItem()).isEqualTo(LockerViewModel.ModalSheetState.Open(model))
}
}

private fun setupKoin(connectState: ConnectionState = ConnectionState.Disconnected, isConnected: Boolean = false) {
startKoin {
modules(module {
single(named("connectionState")) {
MutableStateFlow(connectState)
} bind StateFlow::class

single(named("isConnected")) {
MutableStateFlow(isConnected)
} bind StateFlow::class
})
}
}
}


/**
* Exists so we can only stub out the LockerDao function we need for testing.
*
* ```
* val testDao = object: LockerDao by NoOpLockerDao() {
* // Only need to override the test method we're going to use.
* override suspend fun getSyncedEntries(): List<SyncedLockerEntry> {
* return emptyList()
* }
* }
* ```
*/
class NoOpLockerDao : LockerDao {
override suspend fun insertOrReplace(entry: SyncedLockerEntry) {
}

override suspend fun update(entry: SyncedLockerEntry) {
TODO("Not yet implemented")
}

override suspend fun insertOrReplacePlatform(platform: SyncedLockerEntryPlatform) {
TODO("Not yet implemented")
}

override suspend fun insertOrReplaceAll(entries: List<SyncedLockerEntry>) {
TODO("Not yet implemented")
}

override suspend fun insertOrReplaceAllPlatforms(platforms: List<SyncedLockerEntryPlatform>) {
TODO("Not yet implemented")
}

override suspend fun getEntry(id: String): SyncedLockerEntryWithPlatforms? {
TODO("Not yet implemented")
}

override suspend fun getAllEntries(): List<SyncedLockerEntryWithPlatforms> {
TODO("Not yet implemented")
}

override fun getAllEntriesFlow(): Flow<List<SyncedLockerEntryWithPlatforms>> {
TODO("Not yet implemented")
}

override suspend fun clearPlatformsFor(entryId: String) {
TODO("Not yet implemented")
}

override suspend fun setNextSyncAction(id: String, action: NextSyncAction) {
TODO("Not yet implemented")
}

override suspend fun setNextSyncAction(ids: Set<String>, action: NextSyncAction) {
TODO("Not yet implemented")
}

override suspend fun getEntriesForSync(): List<SyncedLockerEntryWithPlatforms> {
TODO("Not yet implemented")
}

override suspend fun getEntryByUuid(uuid: String): SyncedLockerEntryWithPlatforms? {
TODO("Not yet implemented")
}

override suspend fun updateOrder(id: String, order: Int) {
TODO("Not yet implemented")
}

override suspend fun clearAll() {
TODO("Not yet implemented")
}

override suspend fun countEntriesWithNextSyncAction(action: NextSyncAction): Int {
TODO("Not yet implemented")
}

override suspend fun getSyncedEntries(): List<SyncedLockerEntry> {
TODO("Not yet implemented")
}

override suspend fun updateLastOpened(uuid: String, time: Instant?) {
TODO("Not yet implemented")
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ fun initKoin() {
stateModule,
databaseModule,
dataStoreModule,
iosModule
iosModule,
viewModelModule,
)
}
}