feat(timer): the engine, its expiry slot, the service and the receivers

One AlarmManager slot for every timer, registered on ELAPSED_REALTIME_WAKEUP
so the clock the domain anchors on is the clock the platform wakes on. Its
fire carries no id and is an idempotent sweep, backed up by a second trigger
inside the service, because neither has to be reliable on its own.

The foreground service posts per state change rather than per second — the
platform chronometer draws the countdown — and its actions are broadcasts, so
pause and reset still work with the process dead. Every verb is guarded
against a stale id.

Expiry reuses the alarm's audio path: the same player, vibrator, source policy
and fallback-to-vibration chain, with a zero ramp and its own channel. No
full-screen intent, no challenge, no snooze — a timer is not an alarm. Two
timers expiring together share one ring; a timer expiring after a previous
one's auto-silence window lapsed gets a sound of its own, which is the whole
point of having a timer.
This commit is contained in:
makiolaj committed 2026-09-12 16:48:36 +02:00
1 parent 19efb67740
commit 7f9219e929
18 files changed
+1971 -4

No files matched your search

@@ -0,0 +1,739 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.time.BootId
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerAt
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.8 — 38 cases over the **real** [TimerEngine], the real
* `TimerRepositoryImpl` across the fake DAO, a real DataStore under a
* `@TempDir` and the three fake seams. The engine owns every write and every
* call into the seams (D15), so this is where the milestone's guarantees live:
* one slot on the elapsed-realtime base (D4), an idempotent id-less sweep (D5),
* the deferred-not-lost arbitration (D9) and "+1 min" (D12).
*/
class TimerEngineTest {
private val uptime: Duration = 1_000.seconds
// --- start, pause, reset: the slot and the service ---
/** §5.8 #1 */
@Test
fun `starting an idle timer registers its deadline on the monotonic clock`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 5.minutes)
}
/** §5.8 #2 */
@Test
fun `starting a timer brings the service up`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #3 */
@Test
fun `pausing cancels the slot and keeps the service up`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 5.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.pause(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to true)
}
/** §5.8 #4 */
@Test
fun `pausing the earliest timer hands the slot to the next one`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(runningTimerWith(id = 2L, left = 3.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.pause(1L)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 3.minutes)
}
/** §5.8 #5 */
@Test
fun `resetting the last active timer cancels the slot and takes the service down`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
harness.engine.reset(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to false)
}
// --- the sweep ---
/** §5.8 #6 */
@Test
fun `the sweep expires only what is due`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.given(runningTimerWith(id = 2L, left = 5.minutes, elapsedNow = uptime))
val untouched = harness.timerDao.stored.single { it.id == 2L }
val expired = harness.engine.onExpiryDue()
assertThat(expired).containsExactly(1L)
assertThat(harness.timerDao.stored.single { it.id == 2L }).isEqualTo(untouched)
}
/** §5.8 #7 */
@Test
fun `a fire that arrives early writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime))
val before = harness.timerDao.stored
val expired = harness.engine.onExpiryDue()
assertThat(expired).isEmpty()
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #8 */
@Test
fun `a fire for a timer the user has since deleted re-registers what remains`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 2L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
val expired = harness.engine.onExpiryDue()
assertThat(expired).isEmpty()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #9 */
@Test
fun `the slot moves to the next deadline after an expiry`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.given(runningTimerWith(id = 2L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.onExpiryDue()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #10 */
@Test
fun `the last timer expiring cancels the slot but keeps the service up`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.engine.onExpiryDue()
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to true)
}
/** §5.8 #11 */
@Test
fun `two timers due in the same second are one pass`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 1.minutes))
harness.given(runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = uptime - 1.minutes))
harness.engine.onExpiryDue()
assertThat(harness.timerDao.stored.map { it.state }).containsExactly("EXPIRED", "EXPIRED")
}
// --- "+1 min" (D12) ---
/** §5.8 #12 */
@Test
fun `a running timer gains a minute and keeps its configured duration`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(
runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime, wallNow = T0, duration = 5.minutes),
)
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.duration, harness.scheduler.expiryAt))
.containsExactly(TimerState.RUNNING, 5.minutes, uptime + 120.seconds)
.inOrder()
}
/** §5.8 #13 */
@Test
fun `a paused timer gains a minute without being resumed`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.remaining, harness.scheduler.expiryAt))
.containsExactly(TimerState.PAUSED, 3.minutes, null)
.inOrder()
}
/** §5.8 #14 */
@Test
fun `an expired timer that gains a minute is running again with exactly that minute`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
harness.engine.addTime(1L, 1.minutes)
val timer = harness.stored(1L)!!
assertThat(
listOf(
timer.state,
timer.remaining,
timer.startedAtElapsedRealtime,
timer.endsAtElapsedRealtime,
timer.endsAtWallClock,
harness.scheduler.expiryAt,
),
).containsExactly(
TimerState.RUNNING,
1.minutes,
uptime,
uptime + 1.minutes,
T0 + 1.minutes,
uptime + 1.minutes,
).inOrder()
}
/** §5.8 #15 */
@Test
fun `resuming an expired timer with a minute closes the ring session`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
harness.engine.addTime(1L, 1.minutes)
assertThat(harness.ringState.current()).isNull()
}
/** §5.8 #16 */
@Test
fun `a stale plus-one-minute on an idle timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
val before = harness.timerDao.stored
harness.engine.addTime(1L, 1.minutes)
assertThat(harness.timerDao.stored).isEqualTo(before)
}
// --- every verb is guarded by the state it makes sense for (D16) ---
/** §5.8 #17 */
@Test
fun `pausing a paused timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(pausedTimer(id = 1L, remaining = 2.minutes))
val before = harness.timerDao.stored
harness.engine.pause(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #18 */
@Test
fun `starting a running timer writes nothing and leaves the slot alone`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val before = harness.timerDao.stored
harness.engine.start(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
/** §5.8 #19 */
@Test
fun `resetting an idle timer writes nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
val before = harness.timerDao.stored
harness.engine.reset(1L)
assertThat(harness.timerDao.stored).isEqualTo(before)
assertThat(harness.scheduler.expiryAt).isNull()
}
// --- delete (D2) ---
/** §5.8 #20 */
@Test
fun `deleting the only running timer cancels the slot and stops the service`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.engine.delete(1L)
assertThat(harness.scheduler.expiryAt to harness.service.active).isEqualTo(null to false)
}
/** §5.8 #21 */
@Test
fun `deleting a ringing timer cannot leave the ring sounding`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
harness.engine.delete(1L)
assertThat(harness.ringState.current()).isNull()
assertThat(harness.engine.serviceState(alreadyAlerted = false).ring)
.isEqualTo(TimerRingDecision.Silent)
}
// --- boot and reboot (D21) ---
/** §5.8 #22 */
@Test
fun `the boot pass repairs once per boot`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.onBootCompleted()
val afterFirst = harness.stopwatch.pauseCalls
harness.engine.onBootCompleted()
// The *ordering* — repair before the timers are read — is what #23
// pins, by the value the slot ends up holding.
assertThat(afterFirst to harness.stopwatch.pauseCalls).isEqualTo(1 to 1)
}
/** §5.8 #23 */
@Test
fun `the boot pass registers the repaired remainder, not the dead anchor`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir, uptime = 50.seconds)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0)
harness.given(
timerAt(
id = 1L,
state = TimerState.RUNNING,
duration = 5.minutes,
remaining = 5.minutes,
startedAtElapsedRealtime = 9_000.seconds,
endsAtElapsedRealtime = 9_300.seconds,
endsAtWallClock = T0 + 3.minutes,
),
)
harness.engine.onBootCompleted()
assertThat(harness.scheduler.expiryAt).isEqualTo(50.seconds + 3.minutes)
}
/** §5.8 #24 */
@Test
fun `a timer whose end passed during the reboot rings when the device comes back`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir, uptime = 50.seconds)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0)
harness.given(
timerAt(
id = 1L,
state = TimerState.RUNNING,
duration = 5.minutes,
remaining = 5.minutes,
startedAtElapsedRealtime = 9_000.seconds,
endsAtElapsedRealtime = 9_300.seconds,
endsAtWallClock = T0 - 2.minutes,
),
)
harness.engine.onBootCompleted()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
assertThat(harness.engine.serviceState(alreadyAlerted = false).ring)
.isInstanceOf(TimerRingDecision.Sound::class.java)
}
// --- the system clock cannot move a timer (D4, D19) ---
/** §5.8 #25 */
@Test
fun `three hours of clock jump each way rewrites no row`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val before = harness.timerDao.stored
harness.wallClock.advance(3.hours)
harness.engine.resync()
harness.wallClock.advance(-6.hours)
harness.engine.resync()
assertThat(harness.timerDao.stored).isEqualTo(before)
}
/** §5.8 #26 */
@Test
fun `three hours of clock jump each way leaves the slot where it was`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(3.hours)
harness.engine.resync()
harness.wallClock.advance(-6.hours)
harness.engine.resync()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
// --- what the service is told to do (D15, D20) ---
/** §5.8 #27 */
@Test
fun `nothing active is nothing to do`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(listOf(state.notification, state.ring, state.expiresIn, state.silenceIn))
.containsExactly(null, TimerRingDecision.Silent, null, null)
.inOrder()
}
/** §5.8 #28 */
@Test
fun `an expired timer sounds and offers stop and plus one minute`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat((state.ring as TimerRingDecision.Sound).subjectId).isEqualTo(1L)
assertThat(state.notification!!.actions)
.containsExactly(TimerNotificationAction.RESET, TimerNotificationAction.ADD_MINUTE)
.inOrder()
}
/** §5.8 #29 */
@Test
fun `the service is told how long until the earliest timer expires`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(runningTimerWith(id = 2L, left = 90.seconds, elapsedNow = uptime, wallNow = T0))
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.expiresIn).isEqualTo(90.seconds)
}
/** §5.8 #30 */
@Test
fun `the service is told how long is left of the ring session's window`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0 - 30.seconds)
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.silenceIn).isEqualTo(TimerRing.AUTO_SILENCE_AFTER - 30.seconds)
}
/** §5.8 #31 */
@Test
fun `the session anchor is persisted once, however often the service reads`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val before = harness.dataStore.writes
repeat(3) { harness.engine.serviceState(alreadyAlerted = it > 0) }
assertThat(harness.dataStore.writes - before).isEqualTo(1)
}
/** §5.8 #32 */
@Test
fun `acknowledging the last expired timer closes the session`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.engine.serviceState(alreadyAlerted = false)
harness.engine.reset(1L)
val state = harness.engine.serviceState(alreadyAlerted = true)
assertThat(harness.ringState.current()).isNull()
assertThat(state.ring).isEqualTo(TimerRingDecision.Silent)
}
/** §5.8 #33 */
@Test
fun `two minutes added at once both land`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 60.seconds, elapsedNow = uptime, wallNow = T0))
coroutineScope {
launch { harness.engine.addTime(1L, 1.minutes) }
launch { harness.engine.addTime(1L, 1.minutes) }
}
assertThat(harness.stored(1L)!!.remaining).isEqualTo(3.minutes)
}
/** §5.8 #34 */
@Test
fun `the timer engine never touches the alarm's ring state or its slots`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(idleTimer(id = 1L, duration = 5.minutes))
harness.engine.start(1L)
harness.engine.addTime(1L, 1.minutes)
harness.engine.pause(1L)
harness.engine.onExpiryDue()
harness.engine.reset(1L)
harness.engine.resync()
assertThat(harness.stateDao.writes).isEqualTo(0)
assertThat(
listOf(harness.alarmScheduler.next, harness.alarmScheduler.autoSilence),
).containsExactly(null, null)
assertThat(harness.ringCoordinator.events).isEmpty()
}
// --- the audio arbitration (D9) ---
/** §5.8 #35 */
@Test
fun `an alarm ringing suppresses the audio but not the notification`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta"))
harness.alarmRing.ringing = true
val state = harness.engine.serviceState(alreadyAlerted = false)
val notification = state.notification!!
assertThat(state.ring).isEqualTo(TimerRingDecision.Suppressed(1L))
assertThat(notification.subjectId to notification.label).isEqualTo(1L to "Pasta")
}
/** §5.8 #36 */
@Test
fun `a timer that comes due while an alarm rings is still marked expired`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime - 2.minutes))
harness.alarmRing.ringing = true
harness.engine.onExpiryDue()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
}
/** §5.8 #37 */
@Test
fun `a suppressed timer sounds when the alarm stops, with no second expiry write`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.alarmRing.ringing = true
harness.engine.serviceState(alreadyAlerted = false)
val afterSuppression = harness.timerDao.stored
harness.alarmRing.ringing = false
val state = harness.engine.serviceState(alreadyAlerted = false)
assertThat(state.ring).isInstanceOf(TimerRingDecision.Sound::class.java)
assertThat(harness.timerDao.stored).isEqualTo(afterSuppression)
}
/** §5.8 #38 */
@Test
fun `starting an expired timer takes its whole configured duration back`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
harness.ringState.set(T0 - 30.seconds)
harness.engine.start(1L)
val timer = harness.stored(1L)!!
assertThat(listOf(timer.state, timer.remaining, harness.ringState.current()))
.containsExactly(TimerState.RUNNING, 5.minutes, null)
.inOrder()
}
// --- what a clock change does to the wall-clock fallback (review finding 1) ---
/** §5.8 #39 — added with the review fix for finding 1. */
@Test
fun `a clock change re-anchors the wall-clock fallback and moves no monotonic anchor`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 30.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(1.hours)
harness.engine.onSystemTimeOrZoneChanged()
val timer = harness.stored(1L)!!
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 1.hours + 30.minutes)
assertThat(timer.startedAtElapsedRealtime to timer.endsAtElapsedRealtime)
.isEqualTo(uptime to uptime + 30.minutes)
}
/** §5.8 #40 — added with the review fix for finding 1. */
@Test
fun `a reboot after a clock change still has the whole remainder to run`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 30.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
harness.wallClock.advance(1.hours)
harness.engine.onSystemTimeOrZoneChanged()
// The reboot: the monotonic clock restarts and a minute of wall clock
// passes with the device off.
harness.elapsed.reboot(uptime = 20.seconds)
harness.wallClock.advance(1.minutes)
harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0 + 1.hours)
harness.engine.onBootCompleted()
val timer = harness.stored(1L)!!
assertThat(timer.state to timer.remaining).isEqualTo(TimerState.RUNNING to 29.minutes)
assertThat(harness.scheduler.expiryAt).isEqualTo(20.seconds + 29.minutes)
}
// --- a new expiry is its own ring session (review finding 2) ---
/** §5.8 #41 — added with the review fix for finding 2. */
@Test
fun `a timer expiring after the window lapsed gets a session of its own`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(idleTimer(id = 2L, duration = 5.minutes, label = "Eggs", sortOrder = 1))
harness.advance(1.minutes)
harness.engine.onExpiryDue()
// The first timer sounds, and is then left alone until the window lapses.
harness.engine.serviceState(alreadyAlerted = false)
harness.advance(TimerRing.AUTO_SILENCE_AFTER)
val lapsed = harness.engine.serviceState(alreadyAlerted = true)
harness.engine.start(2L)
harness.advance(5.minutes)
harness.engine.onExpiryDue()
val second = harness.engine.serviceState(alreadyAlerted = true)
assertThat(lapsed.ring).isInstanceOf(TimerRingDecision.AutoSilenced::class.java)
assertThat(second.ring).isInstanceOf(TimerRingDecision.Sound::class.java)
assertThat((second.ring as TimerRingDecision.Sound).silenceAt)
.isEqualTo(harness.wallClock.instant + TimerRing.AUTO_SILENCE_AFTER)
}
/** §5.8 #42 — added with the review fix for finding 2. */
@Test
fun `a second timer expiring inside the window still inherits it`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = uptime, wallNow = T0))
harness.given(
runningTimerWith(
id = 2L,
left = 2.minutes,
elapsedNow = uptime,
wallNow = T0,
label = "Eggs",
sortOrder = 1,
),
)
harness.advance(1.minutes)
harness.engine.onExpiryDue()
val opened = harness.engine.serviceState(alreadyAlerted = false).ring
harness.advance(1.minutes)
harness.engine.onExpiryDue()
val inherited = harness.engine.serviceState(alreadyAlerted = true).ring
assertThat((opened as TimerRingDecision.Sound).silenceAt)
.isEqualTo((inherited as TimerRingDecision.Sound).silenceAt)
}
/** Both clocks by the same amount: time passing, with nothing else changing. */
private fun TimerEngineHarness.advance(by: Duration) {
elapsed.advance(by)
wallClock.advance(by)
}
}
@@ -0,0 +1,84 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
import org.junit.jupiter.api.Test
/**
* §5.17 — 5 cases. Two `PendingIntent`s sharing a request code silently
* overwrite each other, and M6 adds a second family of them — so the assertion
* that matters is over the **union** of both objects' codes (D16).
*/
class TimerIntentsTest {
private val actions: List<String> get() = listOf(
TimerIntents.ACTION_EXPIRY,
TimerIntents.ACTION_PAUSE,
TimerIntents.ACTION_RESUME,
TimerIntents.ACTION_RESET,
TimerIntents.ACTION_ADD_TIME,
TimerIntents.ACTION_SHOW_TIMERS,
TimerIntents.ACTION_SERVICE_STOP,
)
private val alarmActions: List<String> get() = listOf(
AlarmIntents.ACTION_FIRE,
AlarmIntents.ACTION_AUTO_SILENCE,
AlarmIntents.ACTION_SNOOZE,
AlarmIntents.ACTION_DISMISS,
)
/** §5.17 #1 */
@Test
fun `no two timer request codes collide`() {
assertThat(TimerIntents.ALL_REQUEST_CODES).containsNoDuplicates()
}
/** §5.17 #2 */
@Test
fun `no timer request code collides with an alarm's`() {
val union = TimerIntents.ALL_REQUEST_CODES + AlarmIntents.ALL_REQUEST_CODES
assertThat(union).containsNoDuplicates()
}
/** §5.17 #3 */
@Test
fun `the per-timer stride separates both ids and bases`() {
val sameBase = listOf(
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 1L),
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 2L),
)
val sameId = listOf(
TimerIntents.requestCodeFor(TimerIntents.REQUEST_PAUSE, 1L),
TimerIntents.requestCodeFor(TimerIntents.REQUEST_RESET, 1L),
)
// `requestCodeFor(REQUEST_PAUSE, 1L)` is deliberately in both lists: the
// function is deterministic (a `PendingIntent`'s cancel has to recompute
// the code its schedule used), so the union has that one code twice by
// construction. What the stride has to guarantee is that varying either
// input alone moves the code — three distinct codes across four calls.
assertThat(sameBase).containsNoDuplicates()
assertThat(sameId).containsNoDuplicates()
assertThat((sameBase + sameId).distinct()).hasSize(3)
}
/** §5.17 #4 */
@Test
fun `a per-timer code can never be a raw slot code`() {
val raw = TimerIntents.ALL_REQUEST_CODES + AlarmIntents.ALL_REQUEST_CODES
val derived = TimerIntents.ALL_REQUEST_CODES.flatMap { base ->
listOf(0L, 1L, 7L, 99_999L, 100_000L).map { TimerIntents.requestCodeFor(base, it) }
}
assertThat(derived.filter { it in raw }).isEmpty()
}
/** §5.17 #5 */
@Test
fun `every action is namespaced to this application and distinct from the alarm's`() {
assertThat(actions.filterNot { it.startsWith("de.jeanlucmakiola.clockula.") }).isEmpty()
assertThat(actions + alarmActions).containsNoDuplicates()
}
}