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:
@@ -0,0 +1,216 @@
|
||||
package de.jeanlucmakiola.clockula.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* What only a device can answer about the alarms tab: that the row's switch,
|
||||
* the FAB, the M3 time picker, the day toggles, the ringtone picker and the
|
||||
* delete confirmation really compose and really write.
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (M5 §5.17).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AlarmsInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun alarms(): AlarmRepository
|
||||
fun states(): AlarmStateRepository
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createEmptyComposeRule()
|
||||
|
||||
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val seams: Seams =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
runBlocking {
|
||||
seams.states().clearAll()
|
||||
seams.alarms().alarms().first().forEach { seams.alarms().delete(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
/** The alarm's time as the device itself renders it, 12- or 24-hour. */
|
||||
private fun timeText(hour: Int, minute: Int): String {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
}
|
||||
return DateFormat.getTimeFormat(context).format(calendar.time)
|
||||
}
|
||||
|
||||
private fun givenAlarm(label: String = "Work"): Long = runBlocking {
|
||||
seams.alarms().create(AlarmDraft(time = TimeOfDay(7, 0), label = label))
|
||||
}
|
||||
|
||||
private fun stored(id: Long) = runBlocking { seams.alarms().find(id) }
|
||||
|
||||
private fun launch(): ActivityScenario<MainActivity> =
|
||||
ActivityScenario.launch<MainActivity>(
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
).also { scenario = it }
|
||||
|
||||
private fun openEditorForNewAlarm() {
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.alarms_add)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingTheSwitchDisablesTheAlarm() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
|
||||
).assertIsOff()
|
||||
assertThat(stored(id)!!.enabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFabCreatesAnAlarmAndOpensItsEditor() {
|
||||
openEditorForNewAlarm()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_title)).assertIsDisplayed()
|
||||
assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTimePickerWritesTheTypedTime() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(timeText(7, 0), substring = true).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription("hour", substring = true)
|
||||
.performTextReplacement("06")
|
||||
composeTestRule.onNodeWithContentDescription("minute", substring = true)
|
||||
.performTextReplacement("45")
|
||||
composeTestRule.onNodeWithText(string(R.string.action_ok)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(stored(id)!!.time).isEqualTo(TimeOfDay(6, 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglingADayReplacesTheOnceSummary() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
DayOfWeek.TUESDAY.getDisplayName(TextStyle.FULL, Locale.getDefault()),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.alarm_repeat_once)).assertDoesNotExist()
|
||||
assertThat(DayOfWeek.TUESDAY in stored(id)!!.repeatDays).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingTheRingtonePickerChangesNothing() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_ringtone)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
Espresso.pressBack()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.ringtone_picker_title)).assertDoesNotExist()
|
||||
assertThat(stored(id)!!.ringtoneUri).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backFromTheEditorReturnsToTheAlarmsTab() {
|
||||
givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
string(de.jeanlucmakiola.floret.components.R.string.back),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_alarms)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingFromTheEditorRemovesTheRow() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
|
||||
assertThat(stored(id)).isNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user