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