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(
|
||||
onEditAlarm: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissCandidates: LongArray = LongArray(0),
|
||||
onDismissCandidatesConsumed: () -> Unit = {},
|
||||
viewModel: AlarmsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val rows = (state as? AlarmsUiState.Loaded)?.rows
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addAlarm = stringResource(R.string.alarms_add)
|
||||
|
||||
@@ -70,7 +73,6 @@ fun AlarmsScreen(
|
||||
}
|
||||
},
|
||||
) {
|
||||
val rows = (state as? AlarmsUiState.Loaded)?.rows
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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. */
|
||||
|
||||
@@ -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. */
|
||||
fun onToggleEnabled(alarmId: Long, enabled: Boolean) {
|
||||
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.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -46,6 +47,8 @@ import kotlin.time.Duration
|
||||
fun TimersScreen(
|
||||
onEditTimer: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
requestSetup: Boolean = false,
|
||||
onSetupRequestConsumed: () -> Unit = {},
|
||||
viewModel: TimersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -64,6 +67,19 @@ fun TimersScreen(
|
||||
val rows = loaded?.rows
|
||||
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() {
|
||||
viewModel.onStartNew(entry.duration)
|
||||
digits = ""
|
||||
|
||||
@@ -230,4 +230,9 @@
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</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>
|
||||
|
||||
@@ -345,4 +345,55 @@ class AlarmsViewModelTest {
|
||||
|
||||
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