diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index 2c8528f..e1634d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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() } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt index 869ba90..6f16a50 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt @@ -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 = 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 { + 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". diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt new file mode 100644 index 0000000..9a5a2ab --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/InteropHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/InteropHarness.kt new file mode 100644 index 0000000..50d3b08 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/InteropHarness.kt @@ -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 = alarmDao.stored.map(AlarmMapper::toDomain) + + suspend fun storedTimers(): List = 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, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineInteropTest.kt new file mode 100644 index 0000000..88c920d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineInteropTest.kt @@ -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() + } +}