ui: add DebugViewModel combining calendars + next 30d instances
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
sealed interface DebugUiState {
|
||||
data object Loading : DebugUiState
|
||||
data class Failure(val reason: FailureReason) : DebugUiState
|
||||
data class Success(
|
||||
val calendars: List<CalendarSource>,
|
||||
val nextEvents: List<EventInstance>,
|
||||
) : DebugUiState
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_DEBUG_EVENTS = 50
|
||||
private val DEBUG_WINDOW = 30.days
|
||||
|
||||
@HiltViewModel
|
||||
class DebugViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<DebugUiState> = run {
|
||||
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val range = now..(now + DEBUG_WINDOW)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
DebugUiState.Success(
|
||||
calendars = calendars,
|
||||
nextEvents = instances.take(MAX_DEBUG_EVENTS),
|
||||
) as DebugUiState
|
||||
}
|
||||
.catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DebugUiState.Loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.calendula.ui.debug
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DebugViewModelTest {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private class FakeRepo(
|
||||
val calendarsFlow: MutableStateFlow<List<CalendarSource>> = MutableStateFlow(emptyList()),
|
||||
val instancesFlow: MutableStateFlow<List<EventInstance>> = MutableStateFlow(emptyList()),
|
||||
) : CalendarRepository {
|
||||
override fun calendars(): Flow<List<CalendarSource>> = calendarsFlow
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = instancesFlow
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail =
|
||||
throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
private fun makeCal(id: Long, name: String = "C $id"): CalendarSource =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||
|
||||
private fun makeEvent(id: Long, title: String = "E $id") = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = 1L,
|
||||
title = title,
|
||||
start = Instant.fromEpochMilliseconds(0L),
|
||||
end = Instant.fromEpochMilliseconds(60_000L),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state value is Loading before any subscriber`() {
|
||||
val repo = FakeRepo()
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
assertThat(vm.state.value).isEqualTo(DebugUiState.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Success contains calendars and capped events after subscription`() = runTest {
|
||||
val repo = FakeRepo(
|
||||
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
|
||||
instancesFlow = MutableStateFlow(listOf(makeEvent(10L, "X"))),
|
||||
)
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
val success = awaitItem() as DebugUiState.Success
|
||||
assertThat(success.calendars.map { it.id }).containsExactly(1L)
|
||||
assertThat(success.nextEvents.map { it.title }).containsExactly("X")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances are capped at 50`() = runTest {
|
||||
val repo = FakeRepo(
|
||||
calendarsFlow = MutableStateFlow(listOf(makeCal(1L))),
|
||||
instancesFlow = MutableStateFlow((1L..100L).map { makeEvent(it, "E$it") }),
|
||||
)
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
val success = awaitItem() as DebugUiState.Success
|
||||
assertThat(success.nextEvents).hasSize(50)
|
||||
assertThat(success.nextEvents.first().instanceId).isEqualTo(1L)
|
||||
assertThat(success.nextEvents.last().instanceId).isEqualTo(50L)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state updates when repository emits new data`() = runTest {
|
||||
val repo = FakeRepo()
|
||||
val vm = DebugViewModel(repo, testDispatcher)
|
||||
vm.state.test {
|
||||
// Empty initial: combine fires once because both StateFlows have initial empty value
|
||||
val empty = awaitItem() as DebugUiState.Success
|
||||
assertThat(empty.calendars).isEmpty()
|
||||
assertThat(empty.nextEvents).isEmpty()
|
||||
|
||||
repo.calendarsFlow.value = listOf(makeCal(1L), makeCal(2L))
|
||||
val updated = awaitItem() as DebugUiState.Success
|
||||
assertThat(updated.calendars.map { it.id }).containsExactly(1L, 2L).inOrder()
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user