feat(engines): dismiss and snooze from outside, under the same lock

The verbs an assistant can ask for go on the engines, not on a handler that
orchestrates repositories behind their backs. `dismissUpcoming` and
`snooze(id, minutesOverride)` move the ring slot and the backstop;
`dismissExpired` and `dismissAllExpired` move the timer's expiry registration.
Each of those is why they belong under the engine's mutex.

A snooze duration a caller sends is clamped to one hour, because that one is
the user's own request happening now. An alarm or timer marked to delete
itself is deleted as its cycle closes rather than left disabled.
This commit is contained in:
2026-09-23 11:01:54 +02:00
parent 05fc6e437f
commit 72b788b589
5 changed files with 738 additions and 8 deletions
@@ -9,12 +9,14 @@ import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolution
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
import de.jeanlucmakiola.clockula.domain.interop.InteropLimits
import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -83,7 +85,45 @@ class AlarmEngine @Inject constructor(
closeCycleLocked(alarmId, state)
}
suspend fun snooze(alarmId: Long): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId) }
/** [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15). */
suspend fun snooze(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome =
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
/**
* The `AlarmClock` contract's dismissal, and the chooser dialog's (M9 D14).
*
* One lock for all three branches, because each moves state the engine owns:
* closing a ring touches the global ring slot and the auto-silence backstop,
* and both arming a skip and disabling move the next-alarm registration. A
* caller doing `find` → `setEnabled` → `reschedule` would race a fire
* broadcast between the read and the write.
*/
suspend fun dismissUpcoming(alarmId: Long): DismissOutcome = lock.withLock {
val alarm = alarms.find(alarmId) ?: return@withLock DismissOutcome.NotFound
val state = states.state(alarmId)
// Ringing or snoozed: the cycle closes, whatever the alarm repeats.
if (state.ringingSince != null || state.snoozedUntil != null) {
closeCycleLocked(alarmId, state)
return@withLock DismissOutcome.Dismissed(alarmId)
}
// A switched-off alarm has no upcoming instance to dismiss, so nothing
// is written and the caller is told so.
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
// "Dismissing a repeating alarm only prevents the upcoming instance from
// ringing"; dismissing a single occurrence "effectively disables" it.
if (alarm.repeatDays.isRepeating) {
alarms.setSkipNextOccurrence(alarmId, true)
rescheduleLocked()
return@withLock DismissOutcome.SkippedNext(alarmId)
}
disableOrDeleteLocked(alarm)
rescheduleLocked()
DismissOutcome.Disabled(alarmId)
}
suspend fun dismiss(alarmId: Long): Unit =
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
@@ -213,7 +253,7 @@ class AlarmEngine @Inject constructor(
return FireOutcome.Ringing(alarmId)
}
private suspend fun snoozeLocked(alarmId: Long): SnoozeOutcome {
private suspend fun snoozeLocked(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome {
val state = states.state(alarmId)
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
@@ -226,7 +266,10 @@ class AlarmEngine @Inject constructor(
return SnoozeOutcome.LimitReached(alarmId)
}
val until = wallClock.now() + resolved.snoozeMinutes.minutes
// The override applies to *this* ring only and is written nowhere —
// neither to the alarm's own column nor to the default (M9 D15).
val minutes = minutesOverride?.coerceIn(InteropLimits.SNOOZE_MINUTES) ?: resolved.snoozeMinutes
val until = wallClock.now() + minutes.minutes
val snoozed = state.copy(
ringingSince = null,
snoozedUntil = until,
@@ -258,7 +301,18 @@ class AlarmEngine @Inject constructor(
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
if (resolution.state != before) states.save(resolution.state)
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
if (resolution.disableAlarm) alarms.setEnabled(alarm.id, false)
// A transient alarm is deleted wherever a permanent one would be
// disabled: the cycle has closed either way (M9 D10).
if (resolution.disableAlarm) disableOrDeleteLocked(alarm)
}
/**
* The contract's "remove this alarm after it has been dismissed", in the one
* place a non-repeating alarm's cycle closes. The `alarm_states` foreign key
* cascades, so the ring state goes with the row (M9 D10).
*/
private suspend fun disableOrDeleteLocked(alarm: Alarm) {
if (alarm.deleteAfterUse) alarms.delete(alarm.id) else alarms.setEnabled(alarm.id, false)
}
/**
@@ -284,7 +338,7 @@ class AlarmEngine @Inject constructor(
if (closed != state) states.save(closed)
val alarm = alarms.find(alarmId)
if (alarm != null && !alarm.repeatDays.isRepeating) alarms.setEnabled(alarmId, false)
if (alarm != null && !alarm.repeatDays.isRepeating) disableOrDeleteLocked(alarm)
rescheduleLocked()
}
}
@@ -130,12 +130,39 @@ class TimerEngine @Inject constructor(
/** Back to IDLE at the configured duration; closes the ring session. No-op when already IDLE. */
suspend fun reset(timerId: Long): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
if (timer.state == TimerState.IDLE) return@withLock
timers.reset(timerId)
if (!resetLocked(timerId)) return@withLock
resyncLocked()
}
/**
* Resets (or deletes, when transient) every timer that reads as EXPIRED.
* Ids in list order (M9 D16).
*
* A "stop all" the *UI* deliberately does not offer: that rule was about a
* thumb on a ring, where silently resetting both destroys the information
* "which one finished". An app saying "dismiss all expired timers" has
* stated exactly which information it is discarding (M9 D17).
*/
suspend fun dismissAllExpired(): List<Long> = lock.withLock {
val expired = expiredIdsLocked()
if (expired.isEmpty()) return@withLock expired
for (id in expired) resetLocked(id)
resyncLocked()
expired
}
/**
* Resets [timerId] iff it reads as EXPIRED. A no-op `false` otherwise (M9 D16):
* dismissing a running countdown because a caller referenced it would
* destroy a measurement.
*/
suspend fun dismissExpired(timerId: Long): Boolean = lock.withLock {
if (timerId !in expiredIdsLocked()) return@withLock false
resetLocked(timerId)
resyncLocked()
true
}
/** D12. No-op when IDLE. */
suspend fun addTime(timerId: Long, extra: Duration = TimerRing.ADD_TIME): Unit = lock.withLock {
val timer = timers.find(timerId) ?: return@withLock
@@ -154,6 +181,26 @@ class TimerEngine @Inject constructor(
resyncLocked()
}
/**
* One timer back to IDLE — or **deleted**, when the contract asked for a
* transient one: stop/reset *is* a timer's dismissal in Clockula's
* vocabulary, so there is no second concept to invent (M9 D10). False when
* there was nothing to do, so the caller can skip the resync.
*/
private suspend fun resetLocked(timerId: Long): Boolean {
val timer = timers.find(timerId) ?: return false
if (timer.state == TimerState.IDLE) return false
if (timer.deleteAfterUse) timers.delete(timerId) else timers.reset(timerId)
return true
}
/** Every timer that *reads* EXPIRED, in the app's one subject order. */
private suspend fun expiredIdsLocked(): List<Long> {
val rows = timers.timers().first()
val readings = TimerReadings.active(rows, elapsed.elapsedRealtime(), wall.now())
return TimerReadings.expired(readings).map { it.timer.id }
}
/**
* The one pass every verb ends with: mark what is due, hand the single slot
* the next earliest deadline, and match the service to "anything active".
@@ -0,0 +1,290 @@
package de.jeanlucmakiola.clockula.alarm
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.RingEvent
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.offsetInstant
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.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The two engine verbs the `AlarmClock` contract needs, and the transient-alarm
* branch that keeps a voice user's list from becoming a graveyard of dead
* 06:30 rows. Both live under the engine's lock because all of it moves the
* ring slot, the auto-silence backstop and the next-alarm registration — state
* a handler calling three repositories in a row would race (M9 D10, D14, D15).
*/
class AlarmEngineInteropTest {
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
@Test
fun `dismissing a ringing alarm stops the ring and closes the cycle`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
val outcome = harness.engine.dismissUpcoming(1L)
assertThat(
listOf(
harness.ring.events.last(),
harness.scheduler.autoSilence,
harness.states.state(1L).ringingSince,
outcome,
),
).containsExactly(
RingEvent.Stopped(1L),
null,
null,
DismissOutcome.Dismissed(1L),
).inOrder()
}
@Test
fun `dismissing a repeating alarm skips only its upcoming instance`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
harness.engine.reschedule()
val outcome = harness.engine.dismissUpcoming(1L)
val alarm = harness.alarms.find(1L)!!
assertThat(
listOf(
alarm.skipNextOccurrence,
alarm.enabled,
harness.scheduler.next?.fireAt,
outcome,
),
).containsExactly(true, true, sevenTomorrow, DismissOutcome.SkippedNext(1L)).inOrder()
}
@Test
fun `dismissing a one-shot alarm disables it and clears the slot`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
val outcome = harness.engine.dismissUpcoming(1L)
assertThat(
listOf(harness.alarms.find(1L)!!.enabled, harness.scheduler.next, outcome),
).containsExactly(false, null, DismissOutcome.Disabled(1L)).inOrder()
}
@Test
fun `a disabled alarm has nothing to dismiss`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
harness.engine.reschedule()
val before = harness.alarmDao.stored
val outcome = harness.engine.dismissUpcoming(1L)
assertThat(listOf(outcome, harness.alarmDao.stored))
.containsExactly(DismissOutcome.NotFound, before).inOrder()
}
@Test
fun `an id no alarm has writes nothing and throws nothing`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
val before = harness.alarmDao.stored
val outcome = harness.engine.dismissUpcoming(404L)
assertThat(listOf(outcome, harness.alarmDao.stored))
.containsExactly(DismissOutcome.NotFound, before).inOrder()
}
@Test
fun `dismissing a snoozed alarm drops the snooze`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.snooze(1L)
val outcome = harness.engine.dismissUpcoming(1L)
assertThat(listOf(harness.states.state(1L).snoozedUntil, outcome))
.containsExactly(null, DismissOutcome.Dismissed(1L)).inOrder()
}
@Test
fun `a snooze override moves the snooze to the minutes the intent asked for`(
@TempDir tempDir: Path,
) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, snoozeMinutes = 10)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.snooze(1L, minutesOverride = 5)
assertThat(harness.states.state(1L).snoozedUntil).isEqualTo(sevenToday + 5.minutes)
}
@Test
fun `no override snoozes by the alarm's own resolved length`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, snoozeMinutes = 10)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.snooze(1L, minutesOverride = null)
assertThat(harness.states.state(1L).snoozedUntil).isEqualTo(sevenToday + 10.minutes)
}
@Test
fun `a snooze override is never persisted, in the row or in the preferences`(
@TempDir tempDir: Path,
) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.settings.setSnoozeMinutes(10)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
val before = harness.alarmDao.stored
harness.engine.snooze(1L, minutesOverride = 5)
assertThat(listOf(harness.alarmDao.stored, harness.settings.currentDefaults().snoozeMinutes))
.containsExactly(before, 10).inOrder()
}
@Test
fun `an override buys no extra snooze past the limit`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, snoozeMinutes = 10, snoozeLimit = 0)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
val outcome = harness.engine.snooze(1L, minutesOverride = 5)
assertThat(
listOf(outcome, harness.states.state(1L).ringingSince, harness.ring.events.last()),
).containsExactly(SnoozeOutcome.LimitReached(1L), null, RingEvent.Stopped(1L)).inOrder()
}
@Test
fun `the old one-argument snooze still means the alarm's own length`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, snoozeMinutes = 10)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.snooze(1L)
assertThat(harness.states.state(1L).snoozedUntil).isEqualTo(sevenToday + 10.minutes)
}
@Test
fun `a transient one-shot alarm is deleted when its ring is dismissed`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.dismiss(1L)
assertThat(harness.alarmDao.stored).isEmpty()
assertThat(harness.stateDao.stored).isEmpty()
}
@Test
fun `a transient repeating alarm survives its dismissal`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY, deleteAfterUse = true)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.dismiss(1L)
assertThat(harness.alarms.find(1L)?.enabled).isTrue()
}
@Test
fun `an ordinary one-shot alarm is still disabled rather than deleted`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.engine.dismiss(1L)
assertThat(harness.alarms.find(1L)?.enabled).isFalse()
}
@Test
fun `a transient alarm nobody answered is deleted by the auto-silence backstop`(
@TempDir tempDir: Path,
) = runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
harness.wallClock.instant = sevenToday + AlarmRing.AUTO_SILENCE_AFTER
harness.engine.onAutoSilence(1L)
assertThat(harness.alarmDao.stored).isEmpty()
}
@Test
fun `a transient alarm the resolver would disable is deleted instead`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(
tempDir,
now = instantAt(BERLIN, "2026-06-10T05:00"),
)
harness.givenAlarm(id = 1L, hour = 7, skipNextOccurrence = true, deleteAfterUse = true)
harness.engine.reschedule()
assertThat(harness.alarmDao.stored).isEmpty()
}
@Test
fun `reading the upcoming list never deletes a transient alarm`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true)
harness.engine.reschedule()
val writesBefore = harness.stateDao.writes
harness.engine.upcoming(harness.ticker.ticks(30.seconds)).test {
awaitItem()
harness.ticker.tick()
awaitItem()
harness.ticker.tick()
awaitItem()
cancelAndIgnoreRemainingEvents()
}
assertThat(harness.alarmDao.stored).hasSize(1)
assertThat(harness.stateDao.writes).isEqualTo(writesBefore)
}
}
@@ -0,0 +1,168 @@
package de.jeanlucmakiola.clockula.testing
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerMapper
import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.interop.AlarmClockHandler
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import java.nio.file.Path
import java.time.ZoneId
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The handler over **both** real engines, the real repositories, the fake DAOs
* and one DataStore.
*
* It is deliberately **not** a composition of [AlarmEngineHarness] and
* [TimerEngineHarness]: each of those builds its own DataStore over the same
* file name under the test's `@TempDir`, and DataStore refuses a second
* instance over a live file — so composing them throws (M9 D29). One store,
* one settings object, both engines.
*/
class InteropHarness(
tempDir: Path,
storeScope: CoroutineScope,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
uptime: Duration = 1_000.seconds,
) {
val alarmDao: FakeAlarmDao = FakeAlarmDao()
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao(alarms = alarmDao)
val timerDao: FakeTimerDao = FakeTimerDao()
val wallClock: FakeWallClock = FakeWallClock(now)
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
val zones: FakeZoneProvider = FakeZoneProvider(zone)
val alarmScheduler: FakeAlarmScheduler = FakeAlarmScheduler()
val ring: FakeRingCoordinator = FakeRingCoordinator()
val notifier: FakeAlarmNotifier = FakeAlarmNotifier()
val timerScheduler: FakeTimerScheduler = FakeTimerScheduler()
val timerService: FakeTimerServiceHandle = FakeTimerServiceHandle()
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
val store: PrefStore = PrefStore(
PreferenceDataStoreFactory.create(
scope = storeScope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
),
)
val settings: SettingsPrefs = SettingsPrefs(store)
val ringState: TimerRingStateStore = TimerRingStateStore(store)
val alarms: AlarmRepositoryImpl = AlarmRepositoryImpl(alarmDao, wallClock)
val states: AlarmStateRepositoryImpl = AlarmStateRepositoryImpl(stateDao)
val timers: TimerRepositoryImpl = TimerRepositoryImpl(timerDao, elapsed, wallClock)
val alarmEngine: AlarmEngine = AlarmEngine(
alarms = alarms,
states = states,
settings = settings,
scheduler = alarmScheduler,
ring = ring,
notifier = notifier,
rebootRepair = rebootRepair,
wallClock = wallClock,
zones = zones,
)
val timerEngine: TimerEngine = TimerEngine(
timers = timers,
settings = settings,
ringState = ringState,
scheduler = timerScheduler,
service = timerService,
alarmRing = alarmRing,
rebootRepair = rebootRepair,
elapsed = elapsed,
wall = wallClock,
)
val handler: AlarmClockHandler = AlarmClockHandler(
alarms = alarms,
timers = timers,
alarmEngine = alarmEngine,
timerEngine = timerEngine,
wallClock = wallClock,
zones = zones,
)
init {
alarmDao.onDeleted = { stateDao.deleteByAlarmId(it) }
}
/** Inserts an alarm straight into storage, bypassing every engine. */
suspend fun givenAlarm(alarm: Alarm): Alarm {
alarmDao.insert(AlarmMapper.toEntity(alarm))
return alarm
}
/** Inserts a timer straight into storage, bypassing every engine. */
suspend fun givenTimer(timer: Timer): Timer {
timerDao.insert(TimerMapper.toEntity(timer))
return timer
}
/** Rings [alarmId] through the engine, exactly as a fire broadcast would. */
suspend fun givenRinging(alarmId: Long, at: Instant) {
wallClock.instant = at
alarmEngine.onFire(alarmId, at)
}
suspend fun storedAlarms(): List<Alarm> = alarmDao.stored.map(AlarmMapper::toDomain)
suspend fun storedTimers(): List<Timer> = timerDao.stored.map(TimerMapper::toDomain)
/** An alarm identical to what a `SET_ALARM` spec would create, for the reuse cases. */
fun alarmFor(
id: Long,
hour: Int = 7,
minute: Int = 30,
label: String = "Gym",
repeatDays: RepeatDays = RepeatDays.NONE,
ringtoneUri: String? = "content://media/42",
vibrate: Boolean? = true,
enabled: Boolean = false,
skipNextOccurrence: Boolean = false,
deleteAfterUse: Boolean = false,
): Alarm = alarmAt(
id = id,
hour = hour,
minute = minute,
label = label,
repeatDays = repeatDays,
enabled = enabled,
skipNextOccurrence = skipNextOccurrence,
deleteAfterUse = deleteAfterUse,
).copy(ringtoneUri = ringtoneUri, vibrate = vibrate)
}
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
fun TestScope.interopHarness(
tempDir: Path,
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
zone: ZoneId = BERLIN,
): InteropHarness = InteropHarness(
tempDir = tempDir,
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
now = now,
zone = zone,
)
@@ -0,0 +1,171 @@
package de.jeanlucmakiola.clockula.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
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.minutes
import kotlin.time.Duration.Companion.seconds
/**
* `ACTION_DISMISS_TIMER`'s two engine verbs. The stop-all the UI deliberately
* does not offer is reachable here because an app that says "dismiss all
* expired timers" has stated exactly which information it is discarding, which
* a thumb on a ring has not (M9 D16, D17). Both narrow to EXPIRED: dismissing a
* running countdown because a caller referenced it would destroy a measurement.
*/
class TimerEngineInteropTest {
private val uptime: Duration = 1_000.seconds
@Test
fun `dismissing all expired timers resets exactly those`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, sortOrder = 0))
harness.given(expiredTimer(id = 2L, sortOrder = 1))
harness.given(runningTimerWith(id = 3L, left = 4.minutes, elapsedNow = uptime, wallNow = T0, sortOrder = 2))
val dismissed = harness.engine.dismissAllExpired()
assertThat(dismissed).containsExactly(1L, 2L).inOrder()
assertThat(listOf(harness.stored(1L)!!.state, harness.stored(2L)!!.state))
.containsExactly(TimerState.IDLE, TimerState.IDLE)
}
@Test
fun `a running timer is untouched by the stop-all`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, sortOrder = 0))
val running = harness.given(
runningTimerWith(id = 2L, left = 4.minutes, elapsedNow = uptime, wallNow = T0, sortOrder = 1),
)
harness.engine.dismissAllExpired()
assertThat(harness.stored(2L)).isEqualTo(running)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
@Test
fun `nothing expired is nothing to dismiss`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val running = harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
val dismissed = harness.engine.dismissAllExpired()
assertThat(dismissed).isEmpty()
assertThat(harness.stored(1L)).isEqualTo(running)
}
@Test
fun `the stop-all closes a sounding ring session and takes the service down`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.engine.serviceState(alreadyAlerted = false)
harness.engine.dismissAllExpired()
assertThat(harness.ringState.current()).isNull()
assertThat(harness.service.active).isFalse()
}
@Test
fun `dismissing one expired timer resets it`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
val dismissed = harness.engine.dismissExpired(1L)
assertThat(dismissed).isTrue()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.IDLE)
}
@Test
fun `dismissing a running timer is a silent no-op`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
val running = harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
harness.engine.resync()
val dismissed = harness.engine.dismissExpired(1L)
assertThat(dismissed).isFalse()
assertThat(harness.stored(1L)).isEqualTo(running)
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 4.minutes)
}
@Test
fun `dismissing an id no timer has is false and throws nothing`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val dismissed = harness.engine.dismissExpired(404L)
assertThat(dismissed).isFalse()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
}
@Test
fun `resetting a transient timer deletes it rather than returning it to idle`(
@TempDir tempDir: Path,
) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, deleteAfterUse = true))
harness.engine.reset(1L)
assertThat(harness.stored(1L)).isNull()
}
@Test
fun `the stop-all deletes a transient timer too`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, deleteAfterUse = true))
harness.engine.dismissAllExpired()
assertThat(harness.stored(1L)).isNull()
}
@Test
fun `an ordinary expired timer stays in the list once it is reset`(@TempDir tempDir: Path) =
runTest {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, duration = 5.minutes))
harness.engine.reset(1L)
assertThat(harness.stored(1L)?.state).isEqualTo(TimerState.IDLE)
}
@Test
fun `deleting a transient timer hands the slot to the survivor`(@TempDir tempDir: Path) = runTest {
val harness = timerEngineHarness(tempDir)
harness.given(
runningTimerWith(
id = 1L,
left = 2.minutes,
elapsedNow = uptime,
wallNow = T0,
sortOrder = 0,
deleteAfterUse = true,
),
)
harness.given(runningTimerWith(id = 2L, left = 9.minutes, elapsedNow = uptime, wallNow = T0, sortOrder = 1))
harness.engine.resync()
harness.engine.reset(1L)
assertThat(harness.stored(1L)).isNull()
assertThat(harness.scheduler.expiryAt).isEqualTo(uptime + 9.minutes)
assertThat(harness.service.active).isTrue()
}
}