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:
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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user