diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt new file mode 100644 index 0000000..375b11d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt @@ -0,0 +1,175 @@ +package de.jeanlucmakiola.clockula.testing + +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.TimerDraft +import de.jeanlucmakiola.clockula.domain.TimerState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlin.time.Duration + +/** + * A working [TimerRepository] over a [MutableStateFlow]: a write re-emits on the + * observing flow exactly as Room's would, and every mutating call is logged so + * "the pill stopped it rather than deleting it" is assertable. + * + * It has no clock, so the anchors it writes are deliberately absent — a test + * that cares about them hands the rows over with [set] instead. + */ +class FakeTimerRepository(initial: List = emptyList()) : TimerRepository { + + private val rows = MutableStateFlow(initial) + private val log = mutableListOf() + + /** Every mutating call, in order: "pause(3)", "start(3)", "reset(3)", … */ + val calls: List get() = log.toList() + + fun set(timers: List) { + rows.value = timers + } + + override fun timers(): Flow> = rows + + override fun timer(id: Long): Flow = rows.map { all -> all.firstOrNull { it.id == id } } + + override suspend fun find(id: Long): Timer? = rows.value.firstOrNull { it.id == id } + + override suspend fun create(draft: TimerDraft): Long { + val id = (rows.value.maxOfOrNull { it.id } ?: 0L) + 1L + log += "create($id)" + rows.update { + it + runningTimer().copy( + id = id, + label = draft.label, + duration = draft.duration, + state = TimerState.IDLE, + remaining = draft.duration, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ringtoneUri = draft.ringtoneUri, + sortOrder = it.size, + ) + } + return id + } + + override suspend fun rename(id: Long, label: String) { + log += "rename($id)" + mutate(id) { it.copy(label = label) } + } + + override suspend fun delete(id: Long) { + log += "delete($id)" + rows.update { all -> all.filterNot { it.id == id } } + } + + override suspend fun start(id: Long) { + log += "start($id)" + mutate(id) { it.copy(state = TimerState.RUNNING) } + } + + override suspend fun pause(id: Long) { + log += "pause($id)" + mutate(id) { + it.copy( + state = TimerState.PAUSED, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ) + } + } + + override suspend fun reset(id: Long) { + log += "reset($id)" + mutate(id) { + it.copy( + state = TimerState.IDLE, + remaining = it.duration, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ) + } + } + + override suspend fun addTime(id: Long, extra: Duration) { + log += "addTime($id)" + mutate(id) { it.copy(remaining = it.remaining + extra) } + } + + override suspend fun markExpired(id: Long) { + log += "markExpired($id)" + mutate(id) { it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO) } + } + + override suspend fun reorder(idsInOrder: List) { + log += "reorder(${idsInOrder.joinToString(",")})" + rows.update { all -> all.map { it.copy(sortOrder = idsInOrder.indexOf(it.id)) } } + } + + override suspend fun repairAfterReboot() { + log += "repairAfterReboot()" + } + + private fun mutate(id: Long, change: (Timer) -> Timer) { + rows.update { all -> all.map { if (it.id == id) change(it) else it } } + } +} + +/** A working [StopwatchRepository] over a [MutableStateFlow], with the same call log. */ +class FakeStopwatchRepository(initial: StopwatchRun = StopwatchRun()) : StopwatchRepository { + + private val state = MutableStateFlow(initial) + private val lapRows = MutableStateFlow(emptyList()) + private val log = mutableListOf() + + val calls: List get() = log.toList() + + fun set(run: StopwatchRun) { + state.value = run + } + + override fun run(): Flow = state + + override fun laps(): Flow> = lapRows + + override suspend fun currentRun(): StopwatchRun = state.value + + override suspend fun start() { + log += "start()" + state.update { it.copy(state = StopwatchState.RUNNING) } + } + + override suspend fun pause() { + log += "pause()" + state.update { + it.copy(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null) + } + } + + override suspend fun reset() { + log += "reset()" + state.value = StopwatchRun() + lapRows.value = emptyList() + } + + override suspend fun lap(): Lap? { + log += "lap()" + if (state.value.state != StopwatchState.RUNNING) return null + val lap = Lap(lapRows.value.size + 1, Duration.ZERO, state.value.accumulated) + lapRows.update { it + lap } + return lap + } + + override suspend fun pauseAfterReboot() { + log += "pauseAfterReboot()" + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt new file mode 100644 index 0000000..cbc475b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.clockula.testing + +import de.jeanlucmakiola.clockula.domain.time.Ticker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlin.time.Duration + +/** + * A [Ticker] the test drives by hand. Like `RealTicker` it replays one emission + * to a fresh collector, so a subscriber is never blank for a cadence. + */ +class FakeTicker : Ticker { + + private val streams = mutableListOf>() + private val periods = mutableListOf() + + /** Every period asked for, in order, so a test can pin the cadence. */ + val requestedPeriods: List get() = periods.toList() + + override fun ticks(period: Duration): Flow { + periods += period + val stream = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + check(stream.tryEmit(Unit)) + streams += stream + return stream + } + + /** One tick to every live collector. */ + suspend fun tick() { + streams.toList().forEach { it.emit(Unit) } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/MainDispatcherExtension.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/MainDispatcherExtension.kt new file mode 100644 index 0000000..c1cd594 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/MainDispatcherExtension.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.clockula.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +/** + * `Dispatchers.setMain` for JUnit 5 — twenty lines instead of a test framework + * (slice plan D17). A ViewModel's `viewModelScope` lives on Main, so the tests + * that exercise one hand it a [TestDispatcher] and share its scheduler with + * `runTest`. + */ +class MainDispatcherExtension( + val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext) { + Dispatchers.resetMain() + } +}