feat(stopwatch): the tab — the readout, the laps, and the fastest and slowest
A fixed readout over a lap list, newest first, the lap in progress counting as its own row once there is a lap to compare it against. The fastest and the slowest are marked in `primary` and `tertiary` — never `error`, and never by colour alone: each carries a spoken marker so the emphasis survives being read aloud. The state rebuilds only what actually moves: the recorded rows and their emphasis come off the lap table once, and the tick recomputes the hero figure and the row in progress. Laps stop at 999 and the readout ticks only while the stopwatch is running.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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.onNodeWithContentDescription
|
||||
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.stopwatch.StopwatchRepository
|
||||
import de.jeanlucmakiola.clockula.domain.Lap
|
||||
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
|
||||
|
||||
/**
|
||||
* §5.14 — 4 cases. What only a device can answer about the Stopwatch tab: that
|
||||
* a real clock really moves the hero readout, that Lap really appends rows,
|
||||
* that the tab survives a real activity recreation because its state is storage
|
||||
* and not a `rememberSaveable`, and that Back lands on Alarms.
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (§5.14).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StopwatchInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun stopwatch(): StopwatchRepository
|
||||
}
|
||||
|
||||
@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.stopwatch().reset() }
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
private fun storedLaps(): List<Lap> = runBlocking { seams.stopwatch().laps().first() }
|
||||
|
||||
private fun openStopwatchTab(): 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_stopwatch)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
return launched
|
||||
}
|
||||
|
||||
private fun startAndLap(times: Int) {
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
repeat(times) {
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** §5.14 #1 */
|
||||
@Test
|
||||
fun startingReallyAdvancesTheReadoutAndOffersPauseAndLap() {
|
||||
openStopwatchTab()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).assertIsDisplayed()
|
||||
// Polled, never slept on: the hero figure has to leave zero by itself.
|
||||
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeTestRule.onAllNodesWithText("0:00", substring = true)
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** §5.14 #2 */
|
||||
@Test
|
||||
fun twoTapsOnLapAppendTwoRowsAndLeaveAThirdInProgress() {
|
||||
openStopwatchTab()
|
||||
|
||||
startAndLap(times = 2)
|
||||
|
||||
assertThat(storedLaps().map { it.index }).containsExactly(1, 2).inOrder()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 3), substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** §5.14 #3 */
|
||||
@Test
|
||||
fun theReadoutAndTheLapListSurviveAnActivityRecreation() {
|
||||
val launched = openStopwatchTab()
|
||||
startAndLap(times = 2)
|
||||
|
||||
launched.recreate()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(storedLaps()).hasSize(2)
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_laps_header_total)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** §5.14 #4 */
|
||||
@Test
|
||||
fun backFromTheStopwatchTabLandsOnAlarms() {
|
||||
openStopwatchTab()
|
||||
|
||||
Espresso.pressBack()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertIsDisplayed()
|
||||
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user