feat(alarms): the list and the editor
The tab the app has been missing. Rows carry the time, the repeat summary and the next-fire countdown, ordered by time of day rather than by when they fire next — a row must not move under the thumb reaching for it, so the next alarm is marked by colour instead. The editor covers time, repeat days, label, ringtone, vibration and snooze, with the five per-alarm overrides as three-state pickers: a switch cannot say "I have not chosen". Two things that look like details and are not. A ringing alarm is dismissed through the engine before it is disabled, edited or deleted — otherwise its state is cleared while the service keeps sounding and the auto-silence backstop returns early on its own guard, leaving the alarm ringing until the wake-lock timeout. And clearing the last repeat day disarms a pending skip, because otherwise the resolver disables the alarm and the control that would have explained it is already hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
1 parent
4662aee90d
commit
97ca617e6a
25 files changed
+3530
-7
No files matched your search
@@ -131,6 +131,44 @@ class ArchitectureRulesTest {
|
||||
file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
/**
|
||||
* M5 D27: the decision surface — every `*ViewModel`, `*UiState` and
|
||||
* `*Source` — is JVM-constructible by construction. That is what earns the
|
||||
* no-Robolectric posture, so it is a build rule rather than a habit.
|
||||
*/
|
||||
private val decisionSurface: List<File> = kotlinSources.filter {
|
||||
it.name.endsWith("ViewModel.kt") ||
|
||||
it.name.endsWith("UiState.kt") ||
|
||||
it.name.endsWith("Source.kt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the decision surface cannot acquire a Composable`() {
|
||||
val offenders = decisionSurface.filter { file ->
|
||||
file.readLines().any { it.startsWith("import androidx.compose.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the decision surface names no Android type`() {
|
||||
val offenders = decisionSurface.filter { file ->
|
||||
file.readLines().any { it.startsWith("import android.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen re-derives none of the engine's time arithmetic`() {
|
||||
val ui = kotlinSources
|
||||
.filter { it.invariantSeparatorsPath.contains("de/jeanlucmakiola/clockula/ui/") }
|
||||
|
||||
val offenders = ui.containing("AlarmResolver", "AlarmOccurrences")
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.PreviewEvent
|
||||
import de.jeanlucmakiola.clockula.testing.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/**
|
||||
* The editor over the **real** alarm engine: every control writes at once
|
||||
* (M5 D22), a write is a transactional read-modify-write so a concurrent engine
|
||||
* write is not clobbered (D25), and a *ringing* alarm is silenced before it is
|
||||
* deleted (D11).
|
||||
*/
|
||||
class AlarmEditorViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val catalog = FakeRingtoneCatalog()
|
||||
private val previewer = FakeRingtonePreviewer()
|
||||
|
||||
private val fiveToday = instantAt(BERLIN, "2026-06-10T05:00")
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
|
||||
private fun TestScope.editor(harness: AlarmEngineHarness, alarmId: Long = 1L) =
|
||||
AlarmEditorViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to alarmId)),
|
||||
alarms = harness.alarms,
|
||||
settings = harness.settings,
|
||||
engine = harness.engine,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
private fun AlarmEditorViewModel.editing(): AlarmEditorUiState.Editing =
|
||||
state.value as AlarmEditorUiState.Editing
|
||||
|
||||
private suspend fun AlarmEngineHarness.givenAlarmWith(
|
||||
id: Long = 1L,
|
||||
hour: Int = 7,
|
||||
minute: Int = 0,
|
||||
repeatDays: RepeatDays = RepeatDays.NONE,
|
||||
label: String = "Gym",
|
||||
ringtoneUri: String? = null,
|
||||
vibrate: Boolean? = null,
|
||||
snoozeMinutes: Int? = null,
|
||||
) {
|
||||
val alarm = alarmAt(id = id, hour = hour, minute = minute, repeatDays = repeatDays, label = label)
|
||||
.copy(ringtoneUri = ringtoneUri, vibrate = vibrate, snoozeMinutes = snoozeMinutes)
|
||||
alarmDao.insert(AlarmMapper.toEntity(alarm))
|
||||
}
|
||||
|
||||
// --- loading ---
|
||||
|
||||
@Test
|
||||
fun `an id no alarm has is already gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
|
||||
val viewModel = editor(harness, alarmId = 404L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(AlarmEditorUiState.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored alarm arrives with its own fields`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, minute = 5, label = "Gym", repeatDays = RepeatDays.WEEKDAYS)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(listOf(editing.time, editing.label, editing.repeatDays, editing.skipNextOccurrence))
|
||||
.containsExactly(TimeOfDay(7, 5), "Gym", RepeatDays.WEEKDAYS, false)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm with no overrides inherits every one of them`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(
|
||||
listOf(
|
||||
editing.vibrate,
|
||||
editing.snoozeMinutes,
|
||||
editing.snoozeLimit,
|
||||
editing.volumeRampSeconds,
|
||||
editing.dismissChallenge,
|
||||
),
|
||||
).containsExactly(
|
||||
OverrideValue(true, inherited = true),
|
||||
OverrideValue(10, inherited = true),
|
||||
OverrideValue(3, inherited = true),
|
||||
OverrideValue(15, inherited = true),
|
||||
OverrideValue(DismissChallenge.NONE, inherited = true),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one stored override leaves the others inherited`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(snoozeMinutes = 7)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(editing.snoozeMinutes to editing.snoozeLimit)
|
||||
.isEqualTo(OverrideValue(7, inherited = false) to OverrideValue(3, inherited = true))
|
||||
}
|
||||
|
||||
// --- time and repeat ---
|
||||
|
||||
@Test
|
||||
fun `picking a time stores it and moves the registration`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(TimeOfDay(stored.hour, stored.minute) to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(TimeOfDay(6, 45) to offsetInstant("2026-06-10T06:45+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range time is clamped, never thrown`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onTimePicked(31, 99)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(TimeOfDay(stored.hour, stored.minute)).isEqualTo(TimeOfDay(23, 59))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling a day adds it and toggling again removes it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
val afterOn = viewModel.editing().repeatDays
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((DayOfWeek.TUESDAY in afterOn) to (DayOfWeek.TUESDAY in viewModel.editing().repeatDays))
|
||||
.isEqualTo(true to false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the last repeat day makes skipping impossible`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(repeatDays = RepeatDays.of(DayOfWeek.TUESDAY))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(editing.repeatDays to editing.canSkipNext).isEqualTo(RepeatDays.NONE to false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the last repeat day disarms the skip instead of disabling the alarm`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.of(DayOfWeek.TUESDAY))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
check(harness.alarmDao.stored.single().skipNextOccurrence)
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
// A skip left armed on a mask that no longer repeats makes the resolver
|
||||
// disable the alarm — silently, because `canSkipNext` has just hidden
|
||||
// the control that would explain it.
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(listOf(stored.enabled, stored.skipNextOccurrence, stored.repeatDays))
|
||||
.containsExactly(true, false, RepeatDays.NONE.mask)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a repeat day makes skipping possible`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(!viewModel.editing().canSkipNext)
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().canSkipNext).isTrue()
|
||||
}
|
||||
|
||||
// --- label ---
|
||||
|
||||
@Test
|
||||
fun `a typed label is visible at once and stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "Gym")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("Gy")
|
||||
val shown = viewModel.editing().label
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(shown to harness.alarmDao.stored.single().label).isEqualTo("Gy" to "Gy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid typing stores the last keystroke, in order`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("G")
|
||||
viewModel.onLabelTyped("Gy")
|
||||
viewModel.onLabelTyped("Gym")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().label).isEqualTo("Gym")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the editor does not lose the last keystroke`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "")
|
||||
val viewModel = editor(harness)
|
||||
|
||||
viewModel.onLabelTyped("Gym")
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().label).isEqualTo("Gym")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a keystroke waits on no provider round-trip`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "Gym", ringtoneUri = "content://media/1")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.editing().ringtone.title == "Argon")
|
||||
val queriesAfterLoad = catalog.metadataCalls
|
||||
|
||||
// The provider now blocks. The label field feeds the state straight
|
||||
// back into the text field, so a render that awaits the catalog per
|
||||
// keystroke makes the field lag the IME.
|
||||
catalog.titleGate = CompletableDeferred()
|
||||
viewModel.onLabelTyped("Gy")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().label to catalog.metadataCalls)
|
||||
.isEqualTo("Gy" to queriesAfterLoad)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label change moves no registration`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val registrations = harness.scheduler.scheduleNextCalls
|
||||
|
||||
viewModel.onLabelTyped("Gym")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.scheduleNextCalls to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(registrations to sevenToday)
|
||||
}
|
||||
|
||||
// --- overrides ---
|
||||
|
||||
@Test
|
||||
fun `a stored false override is a choice, not an absence`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVibrateChanged(false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().vibrate to viewModel.editing().vibrate)
|
||||
.isEqualTo(false to OverrideValue(false, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing app default clears the override`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(vibrate = false)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVibrateChanged(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().vibrate to viewModel.editing().vibrate)
|
||||
.isEqualTo(null to OverrideValue(true, inherited = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing silent stores the sentinel`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onRingtoneChosen(Ringtones.SILENT_URI)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().ringtoneUri to viewModel.editing().ringtone.silent)
|
||||
.isEqualTo(Ringtones.SILENT_URI to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringtone change moves no registration`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val registrations = harness.scheduler.scheduleNextCalls
|
||||
|
||||
viewModel.onRingtoneChosen("content://media/2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.scheduleNextCalls to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(registrations to sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable sound is reported, never rewritten`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.unplayable = setOf("content://media/9")
|
||||
harness.givenAlarmWith(ringtoneUri = "content://media/9")
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val ringtone = viewModel.editing().ringtone
|
||||
assertThat(listOf(ringtone.unavailable, ringtone.storedUri, harness.alarmDao.stored.single().ringtoneUri))
|
||||
.containsExactly(true, "content://media/9", "content://media/9")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
// --- the SAF pick ---
|
||||
|
||||
@Test
|
||||
fun `a picked document's grant is taken and its uri stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.canPersist = true
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDocumentPicked("content://docs/a")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
catalog.persisted,
|
||||
harness.alarmDao.stored.single().ringtoneUri,
|
||||
viewModel.editing().ringtone.accessMayLapse,
|
||||
),
|
||||
).containsExactly(listOf("content://docs/a"), "content://docs/a", false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a grant that cannot be made persistent is disclosed, not refused`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.canPersist = false
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDocumentPicked("content://docs/a")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
harness.alarmDao.stored.single().ringtoneUri to viewModel.editing().ringtone.accessMayLapse,
|
||||
).isEqualTo("content://docs/a" to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled pick changes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val before = viewModel.state.value
|
||||
|
||||
viewModel.onDocumentPicked(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().ringtoneUri to viewModel.state.value)
|
||||
.isEqualTo(null to before)
|
||||
}
|
||||
|
||||
// --- the picker and the preview ---
|
||||
|
||||
@Test
|
||||
fun `opening the picker loads the device's tones once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.ringtonePicker.value == null)
|
||||
|
||||
viewModel.onOpenRingtonePicker()
|
||||
val whileLoading = viewModel.ringtonePicker.value
|
||||
advanceUntilIdle()
|
||||
|
||||
val settled = viewModel.ringtonePicker.value
|
||||
assertThat(
|
||||
listOf(
|
||||
whileLoading?.loading,
|
||||
settled?.loading,
|
||||
settled?.options?.filterIsInstance<RingtoneChoice.Sound>()?.map { it.uri },
|
||||
catalog.alarmTonesCalls,
|
||||
),
|
||||
).containsExactly(
|
||||
true,
|
||||
false,
|
||||
catalog.tones.map { it.uri },
|
||||
1,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device with no sounds still offers the stored one`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.tones = emptyList()
|
||||
catalog.titles = mapOf("content://docs/a" to "Mine")
|
||||
harness.givenAlarmWith(ringtoneUri = "content://docs/a")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onOpenRingtonePicker()
|
||||
advanceUntilIdle()
|
||||
|
||||
val picker = viewModel.ringtonePicker.value
|
||||
assertThat(
|
||||
picker?.options?.filterIsInstance<RingtoneChoice.Sound>()?.map { it.uri } to
|
||||
picker?.deviceHasNoSounds,
|
||||
).isEqualTo(listOf("content://docs/a") to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing an option stops the preview and closes the picker`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onOpenRingtonePicker()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onRingtoneChosen("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last() to viewModel.ringtonePicker.value)
|
||||
.isEqualTo(PreviewEvent.Stopped to null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second preview stops the first`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events).containsExactly(
|
||||
PreviewEvent.Played("content://media/1"),
|
||||
PreviewEvent.Stopped,
|
||||
PreviewEvent.Played("content://media/2"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previewing nothing or silence only stops`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPreview(null)
|
||||
viewModel.onPreview(Ringtones.SILENT_URI)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.filterIsInstance<PreviewEvent.Played>() to previewer.events.isNotEmpty())
|
||||
.isEqualTo(emptyList<PreviewEvent.Played>() to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the editor stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
|
||||
}
|
||||
|
||||
// --- skip ---
|
||||
|
||||
@Test
|
||||
fun `arming a skip moves the registration to the occurrence after it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.alarmDao.stored.single().skipNextOccurrence,
|
||||
harness.scheduler.next?.fireAt,
|
||||
viewModel.editing().skipNextOccurrence,
|
||||
),
|
||||
).containsExactly(true, sevenTomorrow, true).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-shot alarm cannot skip its only occurrence`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.NONE)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().canSkipNext).isFalse()
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
@Test
|
||||
fun `deleting the alarm leaves the editor gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored to viewModel.state.value)
|
||||
.isEqualTo(emptyList<Any>() to AlarmEditorUiState.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the ringing alarm silences it first`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.ring.events.last() to harness.alarmDao.stored)
|
||||
.isEqualTo(RingEvent.Stopped(1L) to emptyList<Any>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the last alarm clears the slot`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(harness.scheduler.next != null)
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next).isNull()
|
||||
}
|
||||
|
||||
// --- concurrency ---
|
||||
|
||||
@Test
|
||||
fun `two changes in a row both survive`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSnoozeLimitChanged(1)
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(stored.snoozeLimit to TimeOfDay(stored.hour, stored.minute))
|
||||
.isEqualTo(1 to TimeOfDay(6, 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an engine disable underneath the editor is not clobbered`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
harness.alarms.setEnabled(1L, enabled = false)
|
||||
viewModel.onVolumeRampChanged(30)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(stored.volumeRampSeconds to stored.enabled).isEqualTo(30 to false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.clockula.testing.upcomingAlarm
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The list is ordered by **time of day**, not by the engine's next-fire order:
|
||||
* an 07:00 and a 22:00 alarm would otherwise swap places at 07:00, reordering
|
||||
* rows under the user's thumb (M5 D13).
|
||||
*/
|
||||
class AlarmListRowsTest {
|
||||
|
||||
private val now = offsetInstant("2026-06-10T20:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
private val tenTonight = offsetInstant("2026-06-10T22:00+02:00")
|
||||
|
||||
@Test
|
||||
fun `rows are ordered by time of day, not by next fire`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 2L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 1L, hour = 22, nextFire = tenTonight),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(2L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled alarm keeps its place in the middle`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 2L, hour = 12, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
upcomingAlarm(id = 3L, hour = 22, nextFire = tenTonight),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(1L, 2L, 3L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the earliest next fire is the next alarm`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 6, nextFire = now + 2.hours),
|
||||
upcomingAlarm(id = 2L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 3L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two alarms at the same next fire leave the mark on the lower id`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 9L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 4L, hour = 8, nextFire = now + 30.minutes),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing scheduled no row is the next one`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
upcomingAlarm(id = 2L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.none { it.isNext }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing alarm is happening, not coming`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = null, source = FireSource.RESUMED_RING),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(listOf(row.ringing, row.isNext, row.nextFire))
|
||||
.containsExactly(true, false, NextFireLabel.NotScheduled)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snoozed alarm counts down to its snooze instant`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = now + 7.minutes, source = FireSource.SNOOZE),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.snoozed to row.nextFire).isEqualTo(true to NextFireLabel.InMinutes(7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a repeating alarm can be skipping its next occurrence`() {
|
||||
val repeating = upcomingAlarm(
|
||||
id = 1L,
|
||||
hour = 7,
|
||||
repeatDays = RepeatDays.EVERY_DAY,
|
||||
skipNextOccurrence = true,
|
||||
nextFire = sevenTomorrow,
|
||||
)
|
||||
val oneShot = upcomingAlarm(id = 2L, hour = 8, skipNextOccurrence = true, nextFire = sevenTomorrow)
|
||||
|
||||
val rows = AlarmListRows.from(listOf(repeating, oneShot), now)
|
||||
|
||||
assertThat(rows.map { it.skippingNext }).containsExactly(true, false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled alarm reads as not scheduled`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.nextFire).isEqualTo(NextFireLabel.NotScheduled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank label is carried through blank`() {
|
||||
val upcoming = listOf(upcomingAlarm(id = 1L, hour = 7, label = "", nextFire = sevenTomorrow))
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.label).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no alarms is an empty list, not a throw`() {
|
||||
val rows = AlarmListRows.from(emptyList(), now)
|
||||
|
||||
assertThat(rows).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the repeat mask is carried through raw for the locale to summarise`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.WEEKDAYS, nextFire = sevenTomorrow),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.repeatDays).isEqualTo(RepeatDays.WEEKDAYS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarms at the same time of day are ordered by id`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 9L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 4L, hour = 7, nextFire = sevenTomorrow),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(4L, 9L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every countdown is measured against the one now it was given`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 2L, hour = 8, nextFire = now + 90.minutes),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.nextFire })
|
||||
.containsExactly(NextFireLabel.InMinutes(30), NextFireLabel.InHoursMinutes(1, 30))
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A per-alarm setting is ternary: absent, or a stored choice that happens to be
|
||||
* `false` or `0`. Nothing may collapse the two (M5 D16).
|
||||
*/
|
||||
class AlarmOverridesTest {
|
||||
|
||||
@Test
|
||||
fun `an absent override shows the default and says so`() {
|
||||
val override = AlarmOverrides.of(stored = null, default = 10)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(10, inherited = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored value wins over the default`() {
|
||||
val override = AlarmOverrides.of(stored = 7, default = 10)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(7, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored false is a choice, not an absence`() {
|
||||
val override = AlarmOverrides.of(stored = false, default = true)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(false, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored zero is a choice, not an absence`() {
|
||||
val override = AlarmOverrides.of(stored = 0, default = 3)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(0, inherited = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellBackAction
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The editor is a sibling route in the same flat `NavHost`, so M4's navigation
|
||||
* policy already answers what the tab bar shows and who owns back (M5 D1).
|
||||
*/
|
||||
class AlarmRoutesTest {
|
||||
|
||||
@Test
|
||||
fun `the editor route carries the alarm's id`() {
|
||||
assertThat(AlarmRoutes.editor(7L)).isEqualTo("alarms/edit/7")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pattern's placeholder is the declared argument name`() {
|
||||
assertThat(AlarmRoutes.EDITOR_PATTERN).contains("{" + AlarmRoutes.ARG_ALARM_ID + "}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the editor is no tab, so the alarms tab stays selected`() {
|
||||
assertThat(ShellNavigation.destinationOf(AlarmRoutes.editor(7L))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell stands aside on back, leaving the editor to pop itself`() {
|
||||
assertThat(ShellNavigation.onBack(AlarmRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The list over the **real** alarm engine: what the rows say, that the readout
|
||||
* advances on a tick without a repository write, and that switching off a
|
||||
* *ringing* alarm silences it first (M5 D11) instead of leaving it sounding
|
||||
* until the wake lock expires.
|
||||
*/
|
||||
class AlarmsViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
|
||||
/**
|
||||
* The list's state is shared `WhileSubscribed`, so that the 30-second ticker
|
||||
* upstream is not left waking with nobody looking. A collector on the
|
||||
* background scope stands in for the screen's own
|
||||
* `collectAsStateWithLifecycle`; without one there is no subscriber and
|
||||
* nothing upstream would run.
|
||||
*/
|
||||
private fun TestScope.viewModel(harness: AlarmEngineHarness) = AlarmsViewModel(
|
||||
alarms = harness.alarms,
|
||||
engine = harness.engine,
|
||||
wall = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
|
||||
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
|
||||
|
||||
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
|
||||
|
||||
@Test
|
||||
fun `the list starts out loading and settles on what was stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
|
||||
val viewModel = viewModel(harness)
|
||||
val before = viewModel.state.value
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(before to viewModel.state.value.rows().size)
|
||||
.isEqualTo(AlarmsUiState.Loading to 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty store settles on a real empty state, not on loading`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(AlarmsUiState.Loaded(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding an alarm stores an enabled one-shot at the next whole hour`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.openEditor.test {
|
||||
viewModel.onAddAlarm()
|
||||
val newId = awaitItem()
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(
|
||||
listOf(
|
||||
stored.id,
|
||||
newId,
|
||||
stored.enabled,
|
||||
stored.repeatDays,
|
||||
stored.label,
|
||||
TimeOfDay(stored.hour, stored.minute),
|
||||
listOf(
|
||||
stored.ringtoneUri,
|
||||
stored.vibrate,
|
||||
stored.snoozeMinutes,
|
||||
stored.snoozeLimit,
|
||||
stored.volumeRampSeconds,
|
||||
stored.dismissChallenge,
|
||||
),
|
||||
),
|
||||
).containsExactly(
|
||||
newId,
|
||||
newId,
|
||||
true,
|
||||
RepeatDays.NONE.mask,
|
||||
"",
|
||||
TimeOfDay(7, 0),
|
||||
listOf(null, null, null, null, null, null),
|
||||
).inOrder()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a double-tapped plus creates one alarm and opens one editor`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.openEditor.test {
|
||||
viewModel.onAddAlarm()
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Two ids would leave the stack `alarms -> editor(A) -> editor(B)`,
|
||||
// with the user on the second alarm and the first behind back.
|
||||
val opened = awaitItem()
|
||||
expectNoEvents()
|
||||
assertThat(harness.alarmDao.stored.map { it.id }).containsExactly(opened)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new alarm is registered with the system at once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching an alarm off stores it disabled and clears the slot`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().enabled to harness.scheduler.next)
|
||||
.isEqualTo(false to null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching an alarm on registers its next fire again`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off the ringing alarm silences it first`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.ring.events.last(),
|
||||
harness.ring.ringing.value,
|
||||
harness.scheduler.autoSilence,
|
||||
harness.alarmDao.stored.single().enabled,
|
||||
),
|
||||
).containsExactly(RingEvent.Stopped(1L), null, null, false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off another alarm leaves the ringing one ringing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val ringEvents = harness.ring.events.toList()
|
||||
|
||||
viewModel.onToggleEnabled(2L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.ring.events,
|
||||
harness.states.state(1L).ringingSince != null,
|
||||
harness.scheduler.autoSilence != null,
|
||||
),
|
||||
).containsExactly(ringEvents, true, true).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off a snoozed alarm drops the snooze without stopping a ring`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.engine.snooze(1L)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val ringEvents = harness.ring.events.toList()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.states.state(1L).snoozedUntil to harness.ring.events)
|
||||
.isEqualTo(null to ringEvents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the countdown advances on a tick, with no repository write at all`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.state.test {
|
||||
assertThat(awaitItem().rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
|
||||
|
||||
harness.wallClock.advance(10.minutes)
|
||||
harness.ticker.tick()
|
||||
|
||||
assertThat(awaitItem().rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InMinutes(50))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tick that changes nothing does not re-emit`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem()
|
||||
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a skipped occurrence counts down to the one after it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T05:00"))
|
||||
harness.givenAlarm(
|
||||
id = 1L,
|
||||
hour = 7,
|
||||
repeatDays = RepeatDays.EVERY_DAY,
|
||||
skipNextOccurrence = true,
|
||||
)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InDaysHours(1, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading the list, ticks included, writes no ring state`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val writesBefore = harness.stateDao.writes
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem()
|
||||
repeat(3) {
|
||||
harness.wallClock.advance(10.minutes)
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
assertThat(harness.stateDao.writes).isEqualTo(writesBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm deleted underneath the list disappears from it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
harness.alarms.delete(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The option list is pure: App default, Silent, the user's own sound when the
|
||||
* device list does not contain it, then the device's own tones (M5 D17). A
|
||||
* picker is never empty, and a SAF-picked file is never invisible.
|
||||
*/
|
||||
class RingtonePickerTest {
|
||||
|
||||
private val tones = listOf(
|
||||
RingtoneOption("content://media/1", "Argon"),
|
||||
RingtoneOption("content://media/2", "Bright morning"),
|
||||
RingtoneOption("content://media/3", "Cesium"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `app default and silent always lead the list`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = "content://media/2", storedTitle = "Bright morning")
|
||||
|
||||
assertThat(options.take(2))
|
||||
.containsExactly(RingtoneChoice.AppDefault, RingtoneChoice.Silent)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the device's tones follow in the catalog's order`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.drop(2)).containsExactly(
|
||||
RingtoneChoice.Sound("content://media/1", "Argon"),
|
||||
RingtoneChoice.Sound("content://media/2", "Bright morning"),
|
||||
RingtoneChoice.Sound("content://media/3", "Cesium"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sound the device does not list sits directly under silent`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://docs/mine.ogg",
|
||||
storedTitle = "Mine",
|
||||
)
|
||||
|
||||
assertThat(options[2]).isEqualTo(RingtoneChoice.Sound("content://docs/mine.ogg", "Mine"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored sound the device already lists is not duplicated`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://media/2",
|
||||
storedTitle = "Bright morning",
|
||||
)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>().map { it.uri })
|
||||
.containsExactly("content://media/1", "content://media/2", "content://media/3")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the silent sentinel adds no sound row of its own`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = Ringtones.SILENT_URI, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).hasSize(tones.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no stored override adds no sound row of its own`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).hasSize(tones.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device with no alarm sounds still offers two choices`() {
|
||||
val options = RingtonePicker.optionsFor(emptyList(), storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options)
|
||||
.containsExactly(RingtoneChoice.AppDefault, RingtoneChoice.Silent)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sound the device cannot name gets a blank title`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://docs/mine.ogg",
|
||||
storedTitle = null,
|
||||
)
|
||||
|
||||
assertThat(options[2]).isEqualTo(RingtoneChoice.Sound("content://docs/mine.ogg", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duplicated device tone appears once, the first occurrence winning`() {
|
||||
val duplicated = tones + RingtoneOption("content://media/1", "Argon (copy)")
|
||||
|
||||
val options = RingtonePicker.optionsFor(duplicated, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).containsExactly(
|
||||
RingtoneChoice.Sound("content://media/1", "Argon"),
|
||||
RingtoneChoice.Sound("content://media/2", "Bright morning"),
|
||||
RingtoneChoice.Sound("content://media/3", "Cesium"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one option is checked for each stored shape`() {
|
||||
val sound = RingtoneChoice.Sound("content://media/2", "Bright morning")
|
||||
val options = listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent, sound)
|
||||
val stored = listOf(null, Ringtones.SILENT_URI, "content://media/2")
|
||||
val expected = listOf(
|
||||
listOf(true, false, false),
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, true),
|
||||
)
|
||||
|
||||
stored.forEachIndexed { index, storedUri ->
|
||||
assertWithMessage("stored %s", storedUri)
|
||||
.that(options.map { RingtonePicker.isSelected(it, storedUri) })
|
||||
.isEqualTo(expected[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user