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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user