From 8fbbab30e2261182a383aa1bdd5391fce686a9a4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 8 Jun 2026 17:52:50 +0200 Subject: [PATCH] ui: add DebugViewModel combining calendars + next 30d instances --- .../calendula/ui/debug/DebugUiState.kt | 14 +++ .../calendula/ui/debug/DebugViewModel.kt | 49 ++++++++ .../calendula/ui/debug/DebugViewModelTest.kt | 114 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt new file mode 100644 index 0000000..a05ee0a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt @@ -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, + val nextEvents: List, + ) : DebugUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt new file mode 100644 index 0000000..ccd691b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt @@ -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 = 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, + ) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt new file mode 100644 index 0000000..0098977 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt @@ -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> = MutableStateFlow(emptyList()), + val instancesFlow: MutableStateFlow> = MutableStateFlow(emptyList()), + ) : CalendarRepository { + override fun calendars(): Flow> = calendarsFlow + override fun instances(range: ClosedRange): Flow> = 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() + } + } +}