feat(timers): the timers tab, and the pill that finally has something to show
Multiple concurrent timers with labels, presets, add, pause, reset and +1 min. The setup panel is inline on an empty tab, so the first timer costs no navigation. The live pill has been waiting since M4 for something that could start a timer. It now goes through the engine rather than the repository, because a scheduler registration and a foreground service have to move with the state — its own contract is unchanged. Three new architecture rules keep it that way: no screen may name the scheduler, the service or AlarmManager, and a second MediaPlayer anywhere outside the ring path fails the build.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.clockula.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
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 kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* §5.19 — 4 cases. What only a device can answer about the Timers tab: that the
|
||||
* keypad and the bottom sheet really compose and really write, that the typed
|
||||
* digits survive a real activity recreation (which is the only thing that
|
||||
* proves `rememberSaveable`, D23), and that a real system back press from the
|
||||
* editor lands on the tab with the tab still selected (D24).
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (§5.19).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimersInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun timers(): TimerRepository
|
||||
}
|
||||
|
||||
@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.timers().timers().first().forEach { seams.timers().delete(it.id) } }
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
private fun stored(): List<Timer> = runBlocking { seams.timers().timers().first() }
|
||||
|
||||
private fun givenTimer(label: String, running: Boolean = true): Long = runBlocking {
|
||||
val id = seams.timers().create(TimerDraft(5.minutes, label))
|
||||
if (running) seams.timers().start(id)
|
||||
id
|
||||
}
|
||||
|
||||
private fun openTimersTab(): ActivityScenario<MainActivity> {
|
||||
val launched = ActivityScenario.launch<MainActivity>(
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
).also { scenario = it }
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
return launched
|
||||
}
|
||||
|
||||
private fun typeDigits(vararg digits: Char) {
|
||||
digits.forEach {
|
||||
composeTestRule.onNodeWithText(it.toString()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** §5.19 #1 */
|
||||
@Test
|
||||
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
|
||||
openTimersTab()
|
||||
|
||||
typeDigits('1', '3', '0')
|
||||
composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val timers = stored()
|
||||
assertThat(timers.map { it.state to it.duration })
|
||||
.containsExactly(TimerState.RUNNING to 90.seconds)
|
||||
}
|
||||
|
||||
/** §5.19 #2 */
|
||||
@Test
|
||||
fun theTypedDigitsSurviveAnActivityRecreation() {
|
||||
val launched = openTimersTab()
|
||||
typeDigits('1', '3', '0')
|
||||
|
||||
launched.recreate()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("0:01:30").assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** §5.19 #3 */
|
||||
@Test
|
||||
fun pausingOneRowLeavesTheOtherRunning() {
|
||||
val paused = givenTimer("Pasta")
|
||||
val untouched = givenTimer("Eggs")
|
||||
openTimersTab()
|
||||
|
||||
// Storage order, so the first row is Pasta's (D22).
|
||||
composeTestRule.onAllNodesWithText(string(R.string.timer_pause))[0].performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val byId = stored().associateBy { it.id }
|
||||
assertThat(byId[paused]?.state to byId[untouched]?.state)
|
||||
.isEqualTo(TimerState.PAUSED to TimerState.RUNNING)
|
||||
}
|
||||
|
||||
/** §5.19 #4 */
|
||||
@Test
|
||||
fun backFromTheTimerEditorReturnsToTheTimersTab() {
|
||||
givenTimer("Pasta", running = false)
|
||||
openTimersTab()
|
||||
// The card announces its own content and labels the tap "Timer settings"
|
||||
// through `onClick`, so the row is found by the timer it is.
|
||||
composeTestRule.onNodeWithText("Pasta").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
Espresso.pressBack()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Pasta").assertIsDisplayed()
|
||||
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user