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:
2026-09-22 10:23:54 +02:00
parent 2aaae5dde8
commit 7b727e0d40
11 changed files with 1272 additions and 26 deletions
@@ -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)
}
}