refactor: lift the ring machinery out of the alarm package

The audio player, the vibrator, the volume ramp and the audio-source policy
were never alarm-specific — timers need the same ones, and a second
MediaPlayer in the app would be a bug. Moved and renamed, no behaviour
changed: the moved tests differ by their package line and one constant name.

The ringtone picker moves to ui/common for the same reason.
This commit is contained in:
makiolaj committed 2026-09-12 16:48:07 +02:00
1 parent 5a01efb7bf
commit 5430e0c778
25 files changed
+860 -102

No files matched your search

@@ -25,6 +25,18 @@ class ArchitectureRulesTest {
private fun List<File>.containing(vararg needles: String): List<String> =
filter { file -> needles.any { it in file.readText() } }.map { it.invariantSeparatorsPath }
/**
* As [containing], but blind to comments: prose may name a platform class
* it explains — `AlarmEditorViewModel`'s KDoc says "no AlarmManager
* traffic" — while code that *uses* one is the offence.
*/
private fun List<File>.usingInCode(vararg needles: String): List<String> = filter { file ->
file.readLines()
.map { it.trim() }
.filterNot { it.startsWith("//") || it.startsWith("*") || it.startsWith("/*") }
.any { line -> needles.any { it in line } }
}.map { it.invariantSeparatorsPath }
@Test
fun `Room does not leak above the data layer`() {
val offenders = outsideDataLayer.containing("androidx.room")
@@ -123,6 +135,7 @@ class ArchitectureRulesTest {
val path = file.invariantSeparatorsPath
path.contains("de/jeanlucmakiola/clockula/domain/alarm/") ||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt") ||
path.endsWith("de/jeanlucmakiola/clockula/timer/TimerEngine.kt") ||
path.endsWith("de/jeanlucmakiola/clockula/alarm/AlarmResolver.kt") ||
path.contains("de/jeanlucmakiola/clockula/system/")
}
@@ -171,4 +184,33 @@ class ArchitectureRulesTest {
assertThat(offenders).isEmpty()
}
/**
* M6 D31: the screen schedules nothing and decides no ring — the mirror of
* "the screen re-derives none of the engine's time arithmetic".
*/
@Test
fun `the screen schedules no timer and decides no ring`() {
val ui = kotlinSources
.filter { it.invariantSeparatorsPath.contains("de/jeanlucmakiola/clockula/ui/") }
val offenders = ui.usingInCode("TimerScheduler", "TimerService", "TimerRingPolicy", "AlarmManager")
assertThat(offenders).isEmpty()
}
/**
* M6 D31: "reuses M3's audio path" made mechanical. One `MediaPlayer`
* wrapper for both ring paths and one for the picker's preview; a third
* audio path cannot be added without the build failing. The `/ring/` match
* covers the wrapper both before and after D8's package move.
*/
@Test
fun `MediaPlayer lives only in the ring path and the ringtone catalog`() {
val offenders = kotlinSources.containing("MediaPlayer").filterNot { path ->
path.contains("/ring/") || path.contains("de/jeanlucmakiola/clockula/data/ringtones/")
}
assertThat(offenders).isEmpty()
}
}
@@ -387,4 +387,151 @@ class TimerRepositoryTest {
assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds)
assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds)
}
// --- M6: "+1 min" on an expired timer, and the two named edits (§5.9, D3/D12) ---
/** §5.9 #1 */
@Test
fun `adding time to an expired timer resumes it with exactly the extra`() = runTest {
val id = startedTimer()
elapsed.value = 1_400.seconds
repository.markExpired(id)
elapsed.value = 2_000.seconds
repository.addTime(id, 1.minutes)
val timer = repository.find(id)!!
assertThat(
listOf(
timer.state,
timer.remaining,
timer.startedAtElapsedRealtime,
timer.endsAtElapsedRealtime,
timer.endsAtWallClock,
),
).containsExactly(
TimerState.RUNNING,
1.minutes,
2_000.seconds,
2_060.seconds,
T0 + 1.minutes,
).inOrder()
}
/** §5.9 #2 */
@Test
fun `adding time to an expired timer leaves its configured duration alone`() = runTest {
val id = startedTimer()
repository.markExpired(id)
repository.addTime(id, 1.minutes)
assertThat(repository.find(id)!!.duration).isEqualTo(5.minutes)
}
/** §5.9 #3 */
@Test
fun `setting an idle timer's duration moves its remaining with it`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.setDuration(id, 90.seconds)
val timer = repository.find(id)!!
assertThat(listOf(timer.duration, timer.remaining)).containsExactly(90.seconds, 90.seconds)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
}
/** §5.9 #4 */
@Test
fun `a running timer's duration is not the caller's to change`() = runTest {
startedTimer()
val before = dao.stored.single()
repository.setDuration(before.id, 90.seconds)
assertThat(dao.stored.single()).isEqualTo(before)
}
/** §5.9 #5 */
@Test
fun `a paused timer's duration is not the caller's to change either`() = runTest {
val id = startedTimer()
elapsed.value = 1_100.seconds
repository.pause(id)
val before = dao.stored.single()
repository.setDuration(id, 90.seconds)
assertThat(dao.stored.single()).isEqualTo(before)
}
/** §5.9 #6 */
@Test
fun `a negative duration is stored as zero, never as a negative`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.setDuration(id, -5.seconds)
assertThat(repository.find(id)!!.duration).isEqualTo(Duration.ZERO)
}
/** §5.9 #7 */
@Test
fun `a ringtone is trimmed, and a blank one is absence`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.setRingtoneUri(id, " content://x ")
val trimmed = repository.find(id)!!.ringtoneUri
repository.setRingtoneUri(id, " ")
assertThat(trimmed to repository.find(id)!!.ringtoneUri).isEqualTo("content://x" to null)
}
/** §5.9 #8 */
@Test
fun `both new edits touch no anchor and go through the DAO transaction`() = runTest {
val spy = TransactionSpyTimerDao()
val repository = TimerRepositoryImpl(spy, elapsed, wallClock)
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.start(id)
val anchors = repository.find(id)!!.let {
listOf(it.startedAtElapsedRealtime, it.endsAtElapsedRealtime, it.endsAtWallClock)
}
repository.setRingtoneUri(id, "content://x")
repository.setDuration(id, 90.seconds)
val timer = repository.find(id)!!
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).isEqualTo(anchors)
assertThat(spy.updatesOutsideATransaction).isEqualTo(0)
}
/** §5.9 #9 — added with the review fix for finding 1. */
@Test
fun `re-anchoring writes nothing when the wall clock has not moved`() = runTest {
val id = startedTimer()
val before = repository.find(id)!!
repository.reanchorWallClocks()
assertThat(repository.find(id)).isEqualTo(before)
}
/** §5.9 #10 — added with the review fix for finding 1. */
@Test
fun `re-anchoring leaves a paused timer alone`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.start(id)
repository.pause(id)
val before = repository.find(id)!!
wallClock.advance(1.hours)
repository.reanchorWallClocks()
assertThat(repository.find(id)).isEqualTo(before)
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import org.junit.jupiter.api.Test
/**
@@ -96,17 +97,4 @@ class RingPoliciesTest {
// There is no third branch to fall into — "do not schedule" is not a mode.
assertThat(ScheduleMode.entries).hasSize(2)
}
@Test
fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() {
val timings = AlarmVibrationPattern.TIMINGS
val amplitudes = AlarmVibrationPattern.AMPLITUDES
assertThat(timings.size).isEqualTo(amplitudes.size)
assertThat(timings.size).isGreaterThan(0)
assertThat(AlarmVibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList())
assertThat(timings.filter { it <= 0L }).isEmpty()
assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty()
assertThat(amplitudes.filter { it > 0 }).isNotEmpty()
}
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.domain.alarm
package de.jeanlucmakiola.clockula.domain.ring
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.clockula.domain.ring
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The looped waveform, now shared by both ring paths (slice plan D8). Moved
* here with its assertion unchanged: the pattern did not change, only where it
* lives.
*/
class VibrationPatternTest {
@Test
fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() {
val timings = VibrationPattern.TIMINGS
val amplitudes = VibrationPattern.AMPLITUDES
assertThat(timings.size).isEqualTo(amplitudes.size)
assertThat(timings.size).isGreaterThan(0)
assertThat(VibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList())
assertThat(timings.filter { it <= 0L }).isEmpty()
assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty()
assertThat(amplitudes.filter { it > 0 }).isNotEmpty()
}
}
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.domain.alarm
package de.jeanlucmakiola.clockula.domain.ring
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
@@ -17,7 +17,7 @@ class VolumeRampTest {
fun `the ramp starts at the floor rather than at silence`() {
val level = VolumeRamp.levelAt(Duration.ZERO, ramp)
assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME)
assertThat(level).isEqualTo(RingAudio.MIN_VOLUME)
}
@Test
@@ -44,7 +44,7 @@ class VolumeRampTest {
fun `a negative elapsed reading never falls below the floor`() {
val level = VolumeRamp.levelAt((-5).seconds, ramp)
assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME)
assertThat(level).isEqualTo(RingAudio.MIN_VOLUME)
}
@Test
@@ -60,6 +60,6 @@ class VolumeRampTest {
VolumeRamp.levelAt((-5).seconds + (35.seconds / 100) * step, ramp)
}
assertThat(samples.filterNot { it >= AlarmRing.MIN_VOLUME && it <= 1f }).isEmpty()
assertThat(samples.filterNot { it >= RingAudio.MIN_VOLUME && it <= 1f }).isEmpty()
}
}
@@ -169,7 +169,12 @@ class RecordingTimerRepository : TimerRepository {
override suspend fun reset(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun addTime(id: Long, extra: Duration): Unit = TODO("not used by the reboot gate")
override suspend fun markExpired(id: Long): Unit = TODO("not used by the reboot gate")
override suspend fun setDuration(id: Long, duration: Duration): Unit =
TODO("not used by the reboot gate")
override suspend fun setRingtoneUri(id: Long, uri: String?): Unit =
TODO("not used by the reboot gate")
override suspend fun reorder(idsInOrder: List<Long>): Unit = TODO("not used by the reboot gate")
override suspend fun reanchorWallClocks(): Unit = TODO("not used by the reboot gate")
}
/** A [StopwatchRepository] that records only the one call the reboot gate makes. */
@@ -8,6 +8,9 @@ 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 de.jeanlucmakiola.clockula.domain.snapshotAt
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
@@ -19,10 +22,16 @@ import kotlin.time.Duration
* 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.
* Its clocks are injected and default to standing still, so the anchors it
* writes are the ones the real repository would write from one reading of both
* — which is what lets a real `TimerEngine` register a deadline over it (M6
* D25). A test that wants a particular row still hands it over with [set].
*/
class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository {
class FakeTimerRepository(
initial: List<Timer> = emptyList(),
private val elapsed: ElapsedRealtimeClock = FakeElapsedRealtimeClock(Duration.ZERO),
private val wall: WallClock = FakeWallClock(T0),
) : TimerRepository {
private val rows = MutableStateFlow(initial)
private val log = mutableListOf<String>()
@@ -72,14 +81,32 @@ class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository
override suspend fun start(id: Long) {
log += "start($id)"
mutate(id) { it.copy(state = TimerState.RUNNING) }
val now = elapsed.elapsedRealtime()
val wallNow = wall.now()
mutate(id) { timer ->
if (timer.state == TimerState.RUNNING) {
timer
} else {
val left = if (timer.state == TimerState.PAUSED) timer.remaining else timer.duration
timer.copy(
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = now,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wallNow + left,
)
}
}
}
override suspend fun pause(id: Long) {
log += "pause($id)"
val now = elapsed.elapsedRealtime()
val wallNow = wall.now()
mutate(id) {
it.copy(
state = TimerState.PAUSED,
remaining = it.snapshotAt(now, wallNow).remaining,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
@@ -110,11 +137,36 @@ class FakeTimerRepository(initial: List<Timer> = emptyList()) : TimerRepository
mutate(id) { it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO) }
}
override suspend fun setDuration(id: Long, duration: Duration) {
log += "setDuration($id)"
mutate(id) { if (it.state == TimerState.IDLE) it.copy(duration = duration, remaining = duration) else it }
}
override suspend fun setRingtoneUri(id: Long, uri: String?) {
log += "setRingtoneUri($id)"
mutate(id) { it.copy(ringtoneUri = uri?.trim()?.takeIf(String::isNotEmpty)) }
}
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 reanchorWallClocks() {
log += "reanchorWallClocks()"
val now = elapsed.elapsedRealtime()
val wallNow = wall.now()
rows.update { all ->
all.map { timer ->
if (timer.state != TimerState.RUNNING) {
timer
} else {
timer.copy(endsAtWallClock = wallNow + timer.snapshotAt(now, wallNow).remaining)
}
}
}
}
override suspend fun repairAfterReboot() {
log += "repairAfterReboot()"
}
@@ -17,6 +17,7 @@ import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
@@ -216,3 +217,108 @@ fun upcomingAlarm(
nextFire = nextFire,
source = source,
)
// --- M6: timer builders, beside `runningTimer()`'s every-field-distinct row ---
/**
* One timer with only the fields M6 reasons about spelled out. The anchors are
* passed, never derived, so a test can hand over the corrupt and stale
* combinations a repository would never write.
*/
fun timerAt(
id: Long = 1L,
label: String = "Pasta",
duration: Duration = 5.minutes,
state: TimerState = TimerState.IDLE,
remaining: Duration = duration,
startedAtElapsedRealtime: Duration? = null,
endsAtElapsedRealtime: Duration? = null,
endsAtWallClock: Instant? = null,
ringtoneUri: String? = null,
sortOrder: Int = 0,
): Timer = Timer(
id = id,
label = label,
duration = duration,
state = state,
remaining = remaining,
startedAtElapsedRealtime = startedAtElapsedRealtime,
endsAtElapsedRealtime = endsAtElapsedRealtime,
endsAtWallClock = endsAtWallClock,
ringtoneUri = ringtoneUri,
sortOrder = sortOrder,
createdAt = T0,
updatedAt = T0,
)
fun idleTimer(
id: Long = 1L,
duration: Duration = 5.minutes,
label: String = "Pasta",
sortOrder: Int = 0,
ringtoneUri: String? = null,
): Timer = timerAt(
id = id,
label = label,
duration = duration,
state = TimerState.IDLE,
remaining = duration,
sortOrder = sortOrder,
ringtoneUri = ringtoneUri,
)
/** RUNNING with all three anchors derived from one reading of both clocks, [left] from now. */
fun runningTimerWith(
id: Long = 1L,
left: Duration,
elapsedNow: Duration = Duration.ZERO,
wallNow: Instant = T0,
duration: Duration = 5.minutes,
label: String = "Pasta",
sortOrder: Int = 0,
ringtoneUri: String? = null,
): Timer = timerAt(
id = id,
label = label,
duration = duration,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = elapsedNow,
endsAtElapsedRealtime = elapsedNow + left,
endsAtWallClock = wallNow + left,
sortOrder = sortOrder,
ringtoneUri = ringtoneUri,
)
fun pausedTimer(
id: Long = 1L,
remaining: Duration = 1.minutes,
duration: Duration = 5.minutes,
label: String = "Pasta",
sortOrder: Int = 0,
ringtoneUri: String? = null,
): Timer = timerAt(
id = id,
label = label,
duration = duration,
state = TimerState.PAUSED,
remaining = remaining,
sortOrder = sortOrder,
ringtoneUri = ringtoneUri,
)
fun expiredTimer(
id: Long = 1L,
duration: Duration = 5.minutes,
label: String = "Pasta",
sortOrder: Int = 0,
ringtoneUri: String? = null,
): Timer = timerAt(
id = id,
label = label,
duration = duration,
state = TimerState.EXPIRED,
remaining = Duration.ZERO,
sortOrder = sortOrder,
ringtoneUri = ringtoneUri,
)
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.PreviewEvent
import de.jeanlucmakiola.clockula.ui.common.RingtoneChoice
import de.jeanlucmakiola.clockula.testing.RingEvent
import de.jeanlucmakiola.clockula.testing.alarmAt
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
@@ -1,4 +1,4 @@
package de.jeanlucmakiola.clockula.ui.alarms
package de.jeanlucmakiola.clockula.ui.common
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.clockula.ui.shell
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
import org.junit.jupiter.api.Test
/**
@@ -138,4 +140,36 @@ class ShellNavigationTest {
fun `back from a nested destination is not the shell's to intercept`() {
assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit)
}
// --- M6 §5.15: the timer editor's route, and the notification's deep link ---
/** §5.15 #1 */
@Test
fun `the timer editor is no tab, so the timers tab stays selected`() {
assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull()
}
/** §5.15 #2 */
@Test
fun `the shell stands aside on back, leaving the timer editor to pop itself`() {
assertThat(ShellNavigation.onBack(TimerRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
}
/** §5.15 #3 */
@Test
fun `a timer notification asks for the timers tab`() {
assertThat(ShellNavigation.tabForAction(TimerIntents.ACTION_SHOW_TIMERS))
.isEqualTo(ClockulaDestination.TIMERS)
}
/** §5.15 #4 */
@Test
fun `an absent or unknown action asks for no tab at all`() {
val answers = listOf(
ShellNavigation.tabForAction(null),
ShellNavigation.tabForAction("android.intent.action.MAIN"),
)
assertThat(answers).containsExactly(null, null)
}
}
@@ -15,15 +15,18 @@ import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository
import de.jeanlucmakiola.clockula.testing.FakeTicker
import de.jeanlucmakiola.clockula.testing.FakeTimerRepository
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.TimerEngineSeams
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The pill's two buttons, and the promise behind them: the primary one pauses
@@ -39,10 +42,21 @@ class ShellViewModelTest {
private val now = 1.hours
private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00")
private val timers = FakeTimerRepository()
private val timers = FakeTimerRepository(
elapsed = FakeElapsedRealtimeClock(now),
wall = FakeWallClock(wallInstant),
)
private val stopwatch = FakeStopwatchRepository()
private val ticker = FakeTicker()
/**
* The pill's timer branch goes through the **real** engine over the same
* repository (M6 D25): without it, pausing from the pill would leave the
* expiry slot pointing at a dead deadline. The repository's call log is
* therefore still what the older cases assert on.
*/
private val seams = TimerEngineSeams(timers = timers, now = wallInstant, uptime = now)
private fun viewModel() = ShellViewModel(
source = LivePillSource(
timers = timers,
@@ -53,6 +67,7 @@ class ShellViewModelTest {
),
timers = timers,
stopwatch = stopwatch,
timerEngine = seams.engine,
)
private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState(
@@ -69,6 +84,31 @@ class ShellViewModelTest {
otherActiveTimers = 0,
)
private fun pausedTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
runningTimer().copy(
id = id,
state = TimerState.PAUSED,
remaining = left,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
private fun expiredTimerRow(id: Long = 7L): Timer = runningTimer().copy(
id = id,
state = TimerState.EXPIRED,
remaining = Duration.ZERO,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
private fun rowFor(mode: LivePillMode): Timer = when (mode) {
LivePillMode.RUNNING -> runningTimerRow()
LivePillMode.PAUSED -> pausedTimerRow()
LivePillMode.EXPIRED -> expiredTimerRow()
}
private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer =
runningTimer().copy(
id = id,
@@ -95,6 +135,8 @@ class ShellViewModelTest {
@Test
fun `the primary action pauses a running timer`() = runTest(main.dispatcher) {
timers.set(listOf(runningTimerRow()))
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
assertThat(timers.calls.last()).isEqualTo("pause(7)")
@@ -102,6 +144,8 @@ class ShellViewModelTest {
@Test
fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) {
timers.set(listOf(pausedTimerRow()))
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
assertThat(timers.calls.last()).isEqualTo("start(7)")
@@ -132,7 +176,12 @@ class ShellViewModelTest {
fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) {
val viewModel = viewModel()
LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) }
LivePillMode.entries.forEach { mode ->
// Re-seeded, not re-created: `reset` is a no-op on a timer that is
// already idle, and each mode is its own question.
timers.set(listOf(rowFor(mode)))
viewModel.onPillStop(timerState(mode = mode))
}
assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)")
}
@@ -160,4 +209,53 @@ class ShellViewModelTest {
cancel()
}
}
// --- M6 §5.14: the pill's timer branch goes through the engine (D25) ---
/** §5.14 #1 */
@Test
fun `pausing from the pill cancels the expiry slot with it`() = runTest(main.dispatcher) {
timers.set(listOf(runningTimerRow()))
seams.engine.resync()
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING))
assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt)
.isEqualTo(TimerState.PAUSED to null)
}
/** §5.14 #2 */
@Test
fun `resuming from the pill registers the deadline again`() = runTest(main.dispatcher) {
timers.set(listOf(pausedTimerRow(left = 4.minutes)))
viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED))
assertThat(timers.timers().first().single().state to seams.scheduler.expiryAt)
.isEqualTo(TimerState.RUNNING to now + 4.minutes)
}
/** §5.14 #3 */
@Test
fun `stopping a sounding timer from the pill closes the ring session`() = runTest(main.dispatcher) {
timers.set(listOf(expiredTimerRow()))
seams.ringState.set(wallInstant - 30.seconds)
viewModel().onPillStop(timerState(mode = LivePillMode.EXPIRED))
assertThat(timers.timers().first().single().state to seams.ringState.current())
.isEqualTo(TimerState.IDLE to null)
}
/** §5.14 #4 */
@Test
fun `the stopwatch's half of the pill does not reach the timer engine`() = runTest(main.dispatcher) {
val viewModel = viewModel()
viewModel.onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING))
viewModel.onPillStop(stopwatchState(mode = LivePillMode.RUNNING))
assertThat(seams.scheduler.scheduled).isEmpty()
assertThat(seams.service.transitions).isEmpty()
}
}