test: shared fakes, a fake ticker and the main-dispatcher extension

The view-model tests need repositories they can drive and a clock they can
advance. One set of fakes for the whole shell, not one per test class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:06:53 +02:00
parent d5e2d5045e
commit a1bf6170d2
3 changed files with 236 additions and 0 deletions
@@ -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<Timer> = emptyList()) : TimerRepository {
private val rows = MutableStateFlow(initial)
private val log = mutableListOf<String>()
/** Every mutating call, in order: "pause(3)", "start(3)", "reset(3)", … */
val calls: List<String> get() = log.toList()
fun set(timers: List<Timer>) {
rows.value = timers
}
override fun timers(): Flow<List<Timer>> = rows
override fun timer(id: Long): Flow<Timer?> = 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<Long>) {
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<Lap>())
private val log = mutableListOf<String>()
val calls: List<String> get() = log.toList()
fun set(run: StopwatchRun) {
state.value = run
}
override fun run(): Flow<StopwatchRun> = state
override fun laps(): Flow<List<Lap>> = 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()"
}
}
@@ -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<MutableSharedFlow<Unit>>()
private val periods = mutableListOf<Duration>()
/** Every period asked for, in order, so a test can pin the cadence. */
val requestedPeriods: List<Duration> get() = periods.toList()
override fun ticks(period: Duration): Flow<Unit> {
periods += period
val stream = MutableSharedFlow<Unit>(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) }
}
}
@@ -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()
}
}