feat(alarms): ask which alarm, when the request names more than one
"Dismiss my alarm" with two alarms set is a question, not an instruction. When the search matches more than one the app asks, in a dialog listing the candidates; when it matches exactly one it acts without asking. The Timers tab's setup effect no longer re-keys on a list that changes with every readout tick.
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.AlarmClock
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
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.domain.interop.AlarmClockContract
|
||||||
|
import de.jeanlucmakiola.clockula.interop.AlarmClockActivity
|
||||||
|
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRowState
|
||||||
|
import de.jeanlucmakiola.clockula.ui.alarms.DismissAlarmDialog
|
||||||
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What only a device can answer about the `AlarmClock` contract: that our
|
||||||
|
* literals really are the platform's constants, that the manifest's two
|
||||||
|
* intent-filters really resolve — with and without a data URI — and that the
|
||||||
|
* door really opens no window.
|
||||||
|
*
|
||||||
|
* Compiled in the gate; **not run here** — no device is attached.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class InteropInstrumentedTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
|
private fun row(id: Long, hour: Int, label: String) = AlarmRowState(
|
||||||
|
id = id,
|
||||||
|
time = TimeOfDay(hour, 0),
|
||||||
|
label = label,
|
||||||
|
enabled = true,
|
||||||
|
repeatDays = RepeatDays.NONE,
|
||||||
|
nextFire = NextFireLabel.NotScheduled,
|
||||||
|
snoozed = false,
|
||||||
|
ringing = false,
|
||||||
|
skippingNext = false,
|
||||||
|
isNext = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ourLiteralsAreThePlatformsOwnConstants() {
|
||||||
|
val ours = listOf(
|
||||||
|
AlarmClockContract.ACTION_SET_ALARM,
|
||||||
|
AlarmClockContract.ACTION_SET_TIMER,
|
||||||
|
AlarmClockContract.ACTION_SHOW_ALARMS,
|
||||||
|
AlarmClockContract.ACTION_SHOW_TIMERS,
|
||||||
|
AlarmClockContract.ACTION_DISMISS_ALARM,
|
||||||
|
AlarmClockContract.ACTION_DISMISS_TIMER,
|
||||||
|
AlarmClockContract.ACTION_SNOOZE_ALARM,
|
||||||
|
AlarmClockContract.EXTRA_HOUR,
|
||||||
|
AlarmClockContract.EXTRA_MINUTES,
|
||||||
|
AlarmClockContract.EXTRA_MESSAGE,
|
||||||
|
AlarmClockContract.EXTRA_DAYS,
|
||||||
|
AlarmClockContract.EXTRA_RINGTONE,
|
||||||
|
AlarmClockContract.EXTRA_VIBRATE,
|
||||||
|
AlarmClockContract.EXTRA_SKIP_UI,
|
||||||
|
AlarmClockContract.EXTRA_LENGTH,
|
||||||
|
AlarmClockContract.EXTRA_IS_PM,
|
||||||
|
AlarmClockContract.EXTRA_ALARM_SEARCH_MODE,
|
||||||
|
AlarmClockContract.EXTRA_ALARM_SNOOZE_DURATION,
|
||||||
|
AlarmClockContract.SEARCH_MODE_TIME,
|
||||||
|
AlarmClockContract.SEARCH_MODE_NEXT,
|
||||||
|
AlarmClockContract.SEARCH_MODE_ALL,
|
||||||
|
AlarmClockContract.SEARCH_MODE_LABEL,
|
||||||
|
AlarmClockContract.VALUE_RINGTONE_SILENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(ours).containsExactly(
|
||||||
|
AlarmClock.ACTION_SET_ALARM,
|
||||||
|
AlarmClock.ACTION_SET_TIMER,
|
||||||
|
AlarmClock.ACTION_SHOW_ALARMS,
|
||||||
|
AlarmClock.ACTION_SHOW_TIMERS,
|
||||||
|
AlarmClock.ACTION_DISMISS_ALARM,
|
||||||
|
AlarmClock.ACTION_DISMISS_TIMER,
|
||||||
|
AlarmClock.ACTION_SNOOZE_ALARM,
|
||||||
|
AlarmClock.EXTRA_HOUR,
|
||||||
|
AlarmClock.EXTRA_MINUTES,
|
||||||
|
AlarmClock.EXTRA_MESSAGE,
|
||||||
|
AlarmClock.EXTRA_DAYS,
|
||||||
|
AlarmClock.EXTRA_RINGTONE,
|
||||||
|
AlarmClock.EXTRA_VIBRATE,
|
||||||
|
AlarmClock.EXTRA_SKIP_UI,
|
||||||
|
AlarmClock.EXTRA_LENGTH,
|
||||||
|
AlarmClock.EXTRA_IS_PM,
|
||||||
|
AlarmClock.EXTRA_ALARM_SEARCH_MODE,
|
||||||
|
AlarmClock.EXTRA_ALARM_SNOOZE_DURATION,
|
||||||
|
AlarmClock.ALARM_SEARCH_MODE_TIME,
|
||||||
|
AlarmClock.ALARM_SEARCH_MODE_NEXT,
|
||||||
|
AlarmClock.ALARM_SEARCH_MODE_ALL,
|
||||||
|
AlarmClock.ALARM_SEARCH_MODE_LABEL,
|
||||||
|
AlarmClock.VALUE_RINGTONE_SILENT,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyContractActionResolvesToTheDoor() {
|
||||||
|
val unresolved = AlarmClockContract.ACTIONS.filterNot { action ->
|
||||||
|
context.packageManager
|
||||||
|
.queryIntentActivities(Intent(action).setPackage(context.packageName), 0)
|
||||||
|
.any { it.activityInfo.name == AlarmClockActivity::class.java.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(unresolved).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDeeplinkedDismissResolvesToTheDoor() {
|
||||||
|
val intent = Intent(AlarmClockContract.ACTION_DISMISS_ALARM)
|
||||||
|
.setPackage(context.packageName)
|
||||||
|
.setData(Uri.parse("clockula://alarm/1"))
|
||||||
|
|
||||||
|
val resolved = context.packageManager.queryIntentActivities(intent, 0)
|
||||||
|
|
||||||
|
assertThat(resolved.map { it.activityInfo.name })
|
||||||
|
.contains(AlarmClockActivity::class.java.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingARowInTheChooserDismissesThatAlarm() {
|
||||||
|
val dismissed = mutableListOf<Long>()
|
||||||
|
composeTestRule.setContent {
|
||||||
|
ClockulaTheme {
|
||||||
|
DismissAlarmDialog(
|
||||||
|
rows = listOf(row(1L, 6, "Morning gym"), row(2L, 7, "Yoga")),
|
||||||
|
onDismissAlarm = { dismissed += it },
|
||||||
|
onCancel = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Yoga").assertExists()
|
||||||
|
composeTestRule.onNodeWithText("Morning gym").performClick()
|
||||||
|
|
||||||
|
assertThat(dismissed).containsExactly(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDoorFinishesWithoutEverShowingAWindow() {
|
||||||
|
val intent = Intent(context, AlarmClockActivity::class.java)
|
||||||
|
.setAction(AlarmClockContract.ACTION_SHOW_ALARMS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
ActivityScenario.launch<AlarmClockActivity>(intent).use { scenario ->
|
||||||
|
assertThat(scenario.state).isEqualTo(Lifecycle.State.DESTROYED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,9 +43,12 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
fun AlarmsScreen(
|
fun AlarmsScreen(
|
||||||
onEditAlarm: (Long) -> Unit,
|
onEditAlarm: (Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
dismissCandidates: LongArray = LongArray(0),
|
||||||
|
onDismissCandidatesConsumed: () -> Unit = {},
|
||||||
viewModel: AlarmsViewModel = hiltViewModel(),
|
viewModel: AlarmsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val rows = (state as? AlarmsUiState.Loaded)?.rows
|
||||||
val pillInset = LocalLivePillInset.current
|
val pillInset = LocalLivePillInset.current
|
||||||
val addAlarm = stringResource(R.string.alarms_add)
|
val addAlarm = stringResource(R.string.alarms_add)
|
||||||
|
|
||||||
@@ -70,7 +73,6 @@ fun AlarmsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val rows = (state as? AlarmsUiState.Loaded)?.rows
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
@@ -94,6 +96,25 @@ fun AlarmsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Which alarm should I dismiss?" — asked only when a search named more
|
||||||
|
// than one, and answered out of the rows already on screen (M9 D13).
|
||||||
|
if (dismissCandidates.isNotEmpty() && rows != null) {
|
||||||
|
val chosen = DismissChooser.rowsFor(rows, dismissCandidates.toList())
|
||||||
|
if (chosen.isEmpty()) {
|
||||||
|
// Every candidate has since gone: there is nothing to ask about.
|
||||||
|
LaunchedEffect(dismissCandidates) { onDismissCandidatesConsumed() }
|
||||||
|
} else {
|
||||||
|
DismissAlarmDialog(
|
||||||
|
rows = chosen,
|
||||||
|
onDismissAlarm = { alarmId ->
|
||||||
|
viewModel.onDismissUpcoming(alarmId)
|
||||||
|
onDismissCandidatesConsumed()
|
||||||
|
},
|
||||||
|
onCancel = onDismissCandidatesConsumed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A real empty state, not a flash: the list has loaded and there is nothing in it. */
|
/** A real empty state, not a flash: the list has loaded and there is nothing in it. */
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class AlarmsViewModel @Inject internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `AlarmClock` contract's dismissal, and the chooser dialog's, through the engine (M9 D14). */
|
||||||
|
fun onDismissUpcoming(alarmId: Long) {
|
||||||
|
// One engine verb, so the contract's dismissal and the user's are the
|
||||||
|
// same code: it closes a ring, arms a repeating alarm's skip, or
|
||||||
|
// disables a one-shot — each of which moves state only the engine owns.
|
||||||
|
viewModelScope.launch { engine.dismissUpcoming(alarmId) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Dismisses a ring first when switching off (M5 D11), then re-resolves. */
|
/** Dismisses a ring first when switching off (M5 D11), then re-resolves. */
|
||||||
fun onToggleEnabled(alarmId: Long, enabled: Boolean) {
|
fun onToggleEnabled(alarmId: Long, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ui.alarms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import de.jeanlucmakiola.clockula.R
|
||||||
|
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Which alarm?" — an M3 basic dialog with a list. Tapping a row **is** the
|
||||||
|
* answer; the one action button is Cancel (M9 D13).
|
||||||
|
*
|
||||||
|
* The canonical component for "pick one of a short list, or cancel": not a
|
||||||
|
* full-screen dialog, which is for a whole task, and not a bottom sheet, which
|
||||||
|
* is a surface rather than a question. It takes `AlertDialog`'s M3 defaults —
|
||||||
|
* `surfaceContainerHigh`, the extra-large corner, tonal elevation 3 — with no
|
||||||
|
* overrides and no literal colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DismissAlarmDialog(
|
||||||
|
rows: List<AlarmRowState>,
|
||||||
|
onDismissAlarm: (Long) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val formatTime = rememberAlarmTimeFormatter()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(stringResource(R.string.interop_dismiss_title)) },
|
||||||
|
text = {
|
||||||
|
// A dialog's own body does not scroll, and a search can name more
|
||||||
|
// alarms than fit on a short screen.
|
||||||
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
rows.forEach { row ->
|
||||||
|
DismissAlarmRow(
|
||||||
|
row = row,
|
||||||
|
time = formatTime(row.time),
|
||||||
|
onClick = { onDismissAlarm(row.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// M3 puts a lone action in the confirm slot. There is no "select then
|
||||||
|
// confirm": a tap on a row is the answer, the same rule M4 applied to
|
||||||
|
// the dismiss challenge.
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DismissAlarmRow(
|
||||||
|
row: AlarmRowState,
|
||||||
|
time: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val repeat = repeatSummaryText(row.repeatDays)
|
||||||
|
val spoken = if (row.label.isBlank()) time else "$time, ${row.label}"
|
||||||
|
val description = stringResource(R.string.interop_dismiss_row_description, spoken)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(time) },
|
||||||
|
overlineContent = { Text(repeat) },
|
||||||
|
supportingContent = row.label.takeIf { it.isNotBlank() }?.let { label -> { Text(label) } },
|
||||||
|
// A `ListItem`'s default container is `surface`, which sits *lower* than
|
||||||
|
// the dialog's own `surfaceContainerHigh` and would draw a visible
|
||||||
|
// lighter block per row. `Color.Transparent` is the absence of a colour,
|
||||||
|
// not a literal one.
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
// One node per row, saying what it is and what tapping it does;
|
||||||
|
// the 48dp target comes free with `ListItem`.
|
||||||
|
.semantics(mergeDescendants = true) { contentDescription = description },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ui.alarms
|
||||||
|
|
||||||
|
/** Which rows the disambiguation dialog shows, out of the list already on screen (M9 D13). */
|
||||||
|
internal object DismissChooser {
|
||||||
|
/** The rows named by [ids], in [rows]' own order, de-duplicated. */
|
||||||
|
fun rowsFor(rows: List<AlarmRowState>, ids: List<Long>): List<AlarmRowState> {
|
||||||
|
// Filtered out of the list already on screen, in *its* order: the
|
||||||
|
// dialog and the list behind it then agree, with no second source and
|
||||||
|
// no second ticker. A duplicated candidate cannot duplicate a row.
|
||||||
|
val wanted = ids.toSet()
|
||||||
|
return rows.filter { it.id in wanted }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -46,6 +47,8 @@ import kotlin.time.Duration
|
|||||||
fun TimersScreen(
|
fun TimersScreen(
|
||||||
onEditTimer: (Long) -> Unit,
|
onEditTimer: (Long) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
requestSetup: Boolean = false,
|
||||||
|
onSetupRequestConsumed: () -> Unit = {},
|
||||||
viewModel: TimersViewModel = hiltViewModel(),
|
viewModel: TimersViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -64,6 +67,19 @@ fun TimersScreen(
|
|||||||
val rows = loaded?.rows
|
val rows = loaded?.rows
|
||||||
val presets = loaded?.presets.orEmpty()
|
val presets = loaded?.presets.orEmpty()
|
||||||
|
|
||||||
|
// `SET_TIMER` with no usable length asks for the setup panel (M9 D7). On an
|
||||||
|
// empty list that panel is already inline and *is* the empty state, so the
|
||||||
|
// sheet must not also open there.
|
||||||
|
// Keyed on whether the list has *arrived and is empty*, never on the list
|
||||||
|
// itself: `rows` is recomputed on every readout tick, so keying on it would
|
||||||
|
// cancel and relaunch this effect once a second for the life of the tab.
|
||||||
|
LaunchedEffect(requestSetup, rows?.isEmpty()) {
|
||||||
|
val loadedRows = rows ?: return@LaunchedEffect
|
||||||
|
if (!requestSetup) return@LaunchedEffect
|
||||||
|
if (loadedRows.isNotEmpty()) sheetOpen = true
|
||||||
|
onSetupRequestConsumed()
|
||||||
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
viewModel.onStartNew(entry.duration)
|
viewModel.onStartNew(entry.duration)
|
||||||
digits = ""
|
digits = ""
|
||||||
|
|||||||
@@ -230,4 +230,9 @@
|
|||||||
<item quantity="one">%d minute</item>
|
<item quantity="one">%d minute</item>
|
||||||
<item quantity="other">%d minutes</item>
|
<item quantity="other">%d minutes</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<!-- M9: the one question the AlarmClock contract has to ask -->
|
||||||
|
<string name="interop_dismiss_title">Which alarm?</string>
|
||||||
|
<!-- %1$s is the time, followed by the label when the alarm has one. -->
|
||||||
|
<string name="interop_dismiss_row_description">Dismiss the alarm at %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -345,4 +345,55 @@ class AlarmsViewModelTest {
|
|||||||
|
|
||||||
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
|
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- M9: the AlarmClock contract's dismissal, and the chooser dialog's ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dismissing a ringing alarm from the dialog closes the cycle`(@TempDir tempDir: Path) =
|
||||||
|
runTest(main.dispatcher) {
|
||||||
|
val harness = alarmEngineHarness(tempDir)
|
||||||
|
harness.givenAlarm(id = 1L, hour = 7)
|
||||||
|
harness.engine.reschedule()
|
||||||
|
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||||
|
val viewModel = viewModel(harness)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onDismissUpcoming(1L)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(harness.states.state(1L).ringingSince).isNull()
|
||||||
|
assertThat(harness.ring.events.last()).isEqualTo(RingEvent.Stopped(1L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dismissing a repeating alarm only skips its next occurrence`(@TempDir tempDir: Path) =
|
||||||
|
runTest(main.dispatcher) {
|
||||||
|
val harness = alarmEngineHarness(tempDir)
|
||||||
|
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||||
|
harness.engine.reschedule()
|
||||||
|
val viewModel = viewModel(harness)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onDismissUpcoming(1L)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val alarm = harness.alarms.find(1L)!!
|
||||||
|
assertThat(alarm.skipNextOccurrence to alarm.enabled).isEqualTo(true to true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dismissing an id no alarm has writes nothing`(@TempDir tempDir: Path) =
|
||||||
|
runTest(main.dispatcher) {
|
||||||
|
val harness = alarmEngineHarness(tempDir)
|
||||||
|
harness.givenAlarm(id = 1L, hour = 7)
|
||||||
|
harness.engine.reschedule()
|
||||||
|
val viewModel = viewModel(harness)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val before = harness.alarmDao.stored
|
||||||
|
|
||||||
|
viewModel.onDismissUpcoming(404L)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(harness.alarmDao.stored).isEqualTo(before)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ui.alarms
|
||||||
|
|
||||||
|
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 org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chooser dialog filters the list already on screen, so the question and
|
||||||
|
* the list behind it agree — one source, one order, no second ticker (M9 D13).
|
||||||
|
*/
|
||||||
|
class DismissChooserTest {
|
||||||
|
|
||||||
|
private fun row(id: Long, hour: Int) = AlarmRowState(
|
||||||
|
id = id,
|
||||||
|
time = TimeOfDay(hour, 0),
|
||||||
|
label = "",
|
||||||
|
enabled = true,
|
||||||
|
repeatDays = RepeatDays.NONE,
|
||||||
|
nextFire = NextFireLabel.NotScheduled,
|
||||||
|
snoozed = false,
|
||||||
|
ringing = false,
|
||||||
|
skippingNext = false,
|
||||||
|
isNext = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val rows = listOf(row(1L, 6), row(2L, 7), row(3L, 8))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the chosen rows keep the list's own order, not the candidates'`() {
|
||||||
|
assertThat(DismissChooser.rowsFor(rows, listOf(3L, 1L)).map { it.id })
|
||||||
|
.containsExactly(1L, 3L).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a candidate the list does not hold is skipped`() {
|
||||||
|
assertThat(DismissChooser.rowsFor(rows, listOf(2L, 99L)).map { it.id }).containsExactly(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no candidates means no rows`() {
|
||||||
|
assertThat(DismissChooser.rowsFor(rows, emptyList())).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a duplicated candidate appears once`() {
|
||||||
|
assertThat(DismissChooser.rowsFor(rows, listOf(2L, 2L, 2L)).map { it.id }).containsExactly(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list answers with nothing and throws nothing`() {
|
||||||
|
assertThat(DismissChooser.rowsFor(emptyList(), listOf(1L, 2L))).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user