Files
clockula/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt
T
makiolajandClaude Sonnet 5 d47220947b feat(timers): redo the add-timer flow as a full-screen calculator keypad
The Timers tab gets a full rewrite of how a timer is created, after Google
Clock as the interaction reference (PLAN.md §11):

- A dedicated TimerSetupScreen replaces the bottom-sheet/inline setup panel:
  a per-unit "00h 00m 00s" readout, a "00" key, and a big centred play
  button that never shifts position when Clear fades in beside it.
- The keypad grows into the thumb zone on its own screen (92dp keys, pushed
  toward the bottom), and both it and the readout read bold.
- Presets are dropped from the flow; TimerDurationEntry gains the matching
  domain support (hoursSegment/minutesSegment/secondsSegment,
  plusDoubleZero()).
- An empty timer list shows the same readout/keypad/Start inline, in place
  of a "tap + to add" card — the keypad is still the empty state, just the
  redesigned one.
- Timer cards are disconnected like the Alarms tab's own list, each with an
  instant, unconfirmed delete via a small corner button (re-creating a
  timer is faster than reading a confirmation dialog would be).
- Hero readouts go bold app-wide to match: stopwatch, alarm rows, world
  clock, the ring screen, and both editors' length/time displays
  (ClockulaReadoutDefaults.Hero and matching call sites).

Alongside: Android 16 Live Update support for the running-timer notification
(POST_PROMOTED_NOTIFICATIONS, ProgressStyle), the live pill restyled to a
plain tonal circle, the alarm dismiss-chooser dialog moved onto Floret's
OptionCard, and small fixes to addTime's zero/negative-result handling,
repeat-day selection and alarm routes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 17:06:54 +02:00

145 lines
5.1 KiB
Kotlin

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 newLapsStayVisibleAtTheTopOfTheList() {
openStopwatchTab()
startAndLap(times = 4)
assertThat(storedLaps().map { it.index }).containsExactly(1, 2, 3, 4).inOrder()
composeTestRule
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 5), 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)
}
}