diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt new file mode 100644 index 0000000..18f8839 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/StopwatchInstrumentedTest.kt @@ -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? = null + + @After + fun tearDown() { + scenario?.close() + runBlocking { seams.stopwatch().reset() } + } + + private fun string(id: Int): String = context.getString(id) + + private fun storedLaps(): List = runBlocking { seams.stopwatch().laps().first() } + + private fun openStopwatchTab(): ActivityScenario { + val launched = ActivityScenario.launch( + 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/LapRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/LapRow.kt new file mode 100644 index 0000000..51218fb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/LapRow.kt @@ -0,0 +1,133 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat +import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis +import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position + +/** The three columns the rows below line up under, in the grouped list's own inset. */ +@Composable +internal fun LapColumnHeader(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset + 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HeaderCell(R.string.stopwatch_laps_header_lap, INDEX_WEIGHT, TextAlign.Start) + HeaderCell(R.string.stopwatch_laps_header_split, FIGURE_WEIGHT, TextAlign.End) + HeaderCell(R.string.stopwatch_laps_header_total, FIGURE_WEIGHT, TextAlign.End) + } +} + +/** + * One lap as a **three-column table** — index · split · total — and therefore a + * bespoke `Row` rather than a `GroupedRow`: `ListItem` has one trailing slot + * and a table has two figures (D10). Every figure uses the tabular `Figure` + * role, so the columns do not jitter as the digits change. + * + * Best is `primary` and worst is `tertiary` — both scheme tokens, and `error` + * is deliberately not used, because a slow lap is not a failure. Colour is not + * the only channel: the row carries a description naming what it is, since a + * distinction a screen reader cannot hear is not a distinction (D7). + */ +@Composable +internal fun LapRow(row: LapRowState, position: Position, modifier: Modifier = Modifier) { + val split = StopwatchFormat.precise(row.split) + val total = StopwatchFormat.precise(row.cumulative) + val description = stringResource(descriptionFor(row), row.index, split, total) + + GroupedSurface( + position = position, + modifier = modifier.padding(horizontal = GroupedListInset), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp) + .semantics(mergeDescendants = true) { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val color = colorFor(row.emphasis) + FigureCell( + text = stringResource(R.string.stopwatch_lap_number, row.index), + weight = INDEX_WEIGHT, + align = TextAlign.Start, + color = color, + ) + FigureCell(text = split, weight = FIGURE_WEIGHT, align = TextAlign.End, color = color) + FigureCell(text = total, weight = FIGURE_WEIGHT, align = TextAlign.End, color = color) + } + } +} + +@Composable +private fun RowScope.HeaderCell( + label: Int, + weight: Float, + align: TextAlign, +) { + Text( + text = stringResource(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = align, + modifier = Modifier.weight(weight), + ) +} + +@Composable +private fun RowScope.FigureCell( + text: String, + weight: Float, + align: TextAlign, + color: Color, +) { + Text( + text = text, + style = ClockulaReadoutDefaults.Figure, + color = color, + textAlign = align, + modifier = Modifier.weight(weight), + ) +} + +/** A slow lap is not a failure, so `error` is not in this list (D7). */ +@Composable +private fun colorFor(emphasis: LapEmphasis): Color = when (emphasis) { + LapEmphasis.BEST -> MaterialTheme.colorScheme.primary + LapEmphasis.WORST -> MaterialTheme.colorScheme.tertiary + LapEmphasis.NONE -> MaterialTheme.colorScheme.onSurface +} + +private fun descriptionFor(row: LapRowState): Int = when { + row.kind == LapKind.IN_PROGRESS -> R.string.stopwatch_lap_row_in_progress + row.emphasis == LapEmphasis.BEST -> R.string.stopwatch_lap_row_best + row.emphasis == LapEmphasis.WORST -> R.string.stopwatch_lap_row_worst + else -> R.string.stopwatch_lap_row +} + +/** Three digits of index against two figures: the cap is a layout fact too (D14). */ +private const val INDEX_WEIGHT = 1f +private const val FIGURE_WEIGHT = 1.5f diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchDefaults.kt new file mode 100644 index 0000000..11df81d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchDefaults.kt @@ -0,0 +1,13 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal object StopwatchDefaults { + /** ~60 Hz — the display's own cadence, and it only runs while running (D9). */ + val ReadoutTick: Duration = 16.milliseconds + + val ListBottomPadding: Dp = 8.dp +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchReadoutPanel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchReadoutPanel.kt new file mode 100644 index 0000000..0464d1d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchReadoutPanel.kt @@ -0,0 +1,175 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat +import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.identity.animateContentSizeMotion +import kotlin.time.Duration + +/** + * The hero readout and the controls, fixed above the lap list: scrolling a long + * list must never put Lap out of reach mid-run (D10). + * + * The readout is **two strings** — the seconds at the hero scale and the + * hundredths two ranks down, baseline-aligned (D5). A readout whose hundredths + * are the same size as its seconds makes the fastest-changing glyphs the + * loudest ones; splitting it is `PLAN.md` §8's "refinement comes from shape, + * colour, space and motion, never from enlarging text" applied to the one + * surface §8 says the design lives on. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun StopwatchReadoutPanel( + mode: StopwatchState, + elapsed: Duration, + canLap: Boolean, + onStart: () -> Unit, + onPause: () -> Unit, + onLap: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val readout = StopwatchFormat.readout(elapsed) + // The whole figure in one phrase: two Texts would otherwise be announced as + // two unrelated numbers. + val description = stringResource(R.string.stopwatch_readout, readout.text) + + GroupedSurface( + position = Position.Alone, + modifier = modifier.padding(horizontal = GroupedListInset), + color = containerFor(mode), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + // So 9:59 becoming 10:00 eases rather than jumps — the pill's + // own treatment. + modifier = Modifier + .animateContentSizeMotion() + .semantics(mergeDescendants = true) { contentDescription = description }, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = readout.major, + style = ClockulaReadoutDefaults.Hero, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = readout.hundredths, + style = ClockulaReadoutDefaults.HeroFraction, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp), + ) + } + + StopwatchControls( + mode = mode, + canLap = canLap, + onStart = onStart, + onPause = onPause, + onLap = onLap, + onReset = onReset, + ) + } + } +} + +/** + * Start · Pause/Lap · Resume/Reset (D13). The word "Stop" is deliberately not + * used: in Clockula "Stop" already means "reset" — the timer's expired row and + * the live pill both say so — and a Stop that paused would be the one + * inconsistency in the app's verbs. + * + * An idle stopwatch offers **one** button: a Lap with nothing to lap and a + * Reset with nothing to reset are dead controls. Reset is not offered while + * running; the user pauses first, which is Google Clock's arrangement and the + * reference `PLAN.md` §11 names. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StopwatchControls( + mode: StopwatchState, + canLap: Boolean, + onStart: () -> Unit, + onPause: () -> Unit, + onLap: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val lapLimit = stringResource(R.string.stopwatch_lap_limit) + + ButtonGroup(modifier = modifier) { + val primaryInteraction = remember { MutableInteractionSource() } + val primary = when (mode) { + StopwatchState.RUNNING -> R.string.stopwatch_pause to onPause + StopwatchState.PAUSED -> R.string.stopwatch_resume to onStart + StopwatchState.IDLE -> R.string.stopwatch_start to onStart + } + Button( + onClick = primary.second, + interactionSource = primaryInteraction, + // `animateWidth` is the expressive press-widening — the reason the + // pair is a ButtonGroup rather than two buttons in a Row. + modifier = Modifier.animateWidth(primaryInteraction), + ) { + Text(stringResource(primary.first)) + } + + if (mode != StopwatchState.IDLE) { + val secondaryInteraction = remember { MutableInteractionSource() } + val lapping = mode == StopwatchState.RUNNING + FilledTonalButton( + onClick = if (lapping) onLap else onReset, + enabled = !lapping || canLap, + interactionSource = secondaryInteraction, + modifier = Modifier + .animateWidth(secondaryInteraction) + // A disabled button says nothing about why: the cap is the + // reason, so the button carries it (D14). + .semantics { if (lapping && !canLap) contentDescription = lapLimit }, + ) { + Text(stringResource(if (lapping) R.string.stopwatch_lap else R.string.stopwatch_reset)) + } + } + } +} + +/** + * The same tonal step `TimerRow` uses to say "this one is live". No + * `tertiaryContainer` anywhere: in this app that token means "finished and + * asking for you", and a stopwatch never finishes (D24). + */ +@Composable +private fun containerFor(mode: StopwatchState): Color = when (mode) { + StopwatchState.RUNNING -> MaterialTheme.colorScheme.surfaceContainerHigh + StopwatchState.PAUSED, StopwatchState.IDLE -> MaterialTheme.colorScheme.surfaceContainer +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowState.kt new file mode 100644 index 0000000..e57e517 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowState.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis +import de.jeanlucmakiola.clockula.domain.stopwatch.LapStats +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings +import kotlin.time.Duration + +enum class LapKind { RECORDED, IN_PROGRESS } + +data class LapRowState( + /** 1-based. The in-progress row's index is `recorded.size + 1`. */ + val index: Int, + val kind: LapKind, + /** Never negative (D11). */ + val split: Duration, + val cumulative: Duration, + /** Always NONE for [LapKind.IN_PROGRESS] (D7). */ + val emphasis: LapEmphasis, +) + +sealed interface StopwatchUiState { + /** Before the first repository emission. Not "idle". */ + data object Loading : StopwatchUiState + + data class Loaded( + /** The reading's mode, not the stored state (D6). */ + val mode: StopwatchState, + val elapsed: Duration, + /** Newest first; empty when no lap has been recorded (D11). */ + val laps: List, + val canLap: Boolean, + ) : StopwatchUiState +} + +internal object StopwatchRows { + /** + * The recorded rows, newest first, with their best/worst emphasis. Pure, and + * derived from the lap table **alone** — nothing here moves with the clock, + * so the source computes it once per lap change rather than once per 60 Hz + * tick (a 999-row sort and a thousand allocations sixty times a second, for + * data that only changes when Lap is tapped). + */ + fun recorded(laps: List): List { + val stats = LapStats.of(laps) + return laps.sortedByDescending { it.index }.map { lap -> + LapRowState( + index = lap.index, + kind = LapKind.RECORDED, + split = lap.split, + cumulative = lap.cumulative, + emphasis = stats.emphasisFor(lap.index), + ) + } + } + + /** Pure; never throws. */ + fun from( + run: StopwatchRun, + laps: List, + elapsedRealtime: Duration, + ): StopwatchUiState.Loaded = fromRecorded(run, recorded(laps), elapsedRealtime) + + /** + * As [from], over rows [recorded] has already derived. This is the per-tick + * path: only the hero figure and the in-progress row can move. (A separate + * name rather than an overload: both signatures erase to the same `List`.) + */ + fun fromRecorded( + run: StopwatchRun, + recorded: List, + elapsedRealtime: Duration, + ): StopwatchUiState.Loaded { + val reading = StopwatchReadings.of(run, elapsedRealtime) + if (reading.mode == StopwatchState.IDLE) { + // An idle stopwatch shows no lap list, whatever the table still + // holds: the next start clears it anyway. + return StopwatchUiState.Loaded( + mode = StopwatchState.IDLE, + elapsed = Duration.ZERO, + laps = emptyList(), + canLap = false, + ) + } + + // The in-progress row only exists once a lap has been recorded: with + // none, its split *and* its total equal the hero figure, and the tab + // would print the same number three times (D11). + val laps = if (recorded.isEmpty()) { + emptyList() + } else { + buildList(recorded.size + 1) { + add( + LapRowState( + index = recorded.size + 1, + kind = LapKind.IN_PROGRESS, + split = reading.currentLapSplit, + cumulative = reading.elapsed, + emphasis = LapEmphasis.NONE, + ), + ) + addAll(recorded) + } + } + + return StopwatchUiState.Loaded( + mode = reading.mode, + elapsed = reading.elapsed, + laps = laps, + canLap = reading.mode == StopwatchState.RUNNING && recorded.size < StopwatchLaps.MAX, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt index b5eccbc..40d5e08 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt @@ -2,50 +2,91 @@ package de.jeanlucmakiola.clockula.ui.stopwatch import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.domain.format.ClockFormat +import de.jeanlucmakiola.clockula.domain.StopwatchState import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset import de.jeanlucmakiola.floret.components.CollapsingScaffold -import de.jeanlucmakiola.floret.components.GroupedListInset -import de.jeanlucmakiola.floret.components.GroupedSurface -import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.animateItemMotion import kotlin.time.Duration /** - * A top-level tab: its title bar and the readout, standing at zero. Starting, - * lapping and the readout's final typography are M7's — which is why the - * figure here is the same [ClockFormat] a running stopwatch will print. + * The Stopwatch tab: a fixed readout with its controls, over a lap list that + * runs **newest first** (D10). The interesting lap is the one that just + * happened, and a growing list must not push it off screen — which is Google + * Clock's order, the reference `PLAN.md` §11 names. + * + * Nothing on this screen is remembered: the run lives in DataStore and the laps + * in Room, so a recreation costs the tab nothing at all. */ @Composable -fun StopwatchScreen(modifier: Modifier = Modifier) { - CollapsingScaffold(title = stringResource(R.string.tab_stopwatch), modifier = modifier) { - GroupedSurface( - position = Position.Alone, - modifier = Modifier.padding(horizontal = GroupedListInset), +fun StopwatchScreen( + modifier: Modifier = Modifier, + viewModel: StopwatchViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val pillInset = LocalLivePillInset.current + val loaded = state as? StopwatchUiState.Loaded + + CollapsingScaffold( + title = stringResource(R.string.tab_stopwatch), + modifier = modifier, + scrollable = false, + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = ClockFormat.elapsed(Duration.ZERO), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurface, - ) + StopwatchReadoutPanel( + // Before the first emission there is nothing to show but a + // stopwatch standing at zero — which is what an idle one is. + mode = loaded?.mode ?: StopwatchState.IDLE, + elapsed = loaded?.elapsed ?: Duration.ZERO, + canLap = loaded?.canLap == true, + onStart = viewModel::onStart, + onPause = viewModel::onPause, + onLap = viewModel::onLap, + onReset = viewModel::onReset, + modifier = Modifier.padding(top = 8.dp), + ) + + val laps = loaded?.laps.orEmpty() + // No lap recorded ⇒ no list at all: the in-progress row's split and + // its total would both equal the hero figure (D11). + if (laps.isEmpty()) { + Spacer(Modifier.height(pillInset)) + } else { + LapColumnHeader() + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + bottom = StopwatchDefaults.ListBottomPadding + pillInset, + ), + ) { + itemsIndexed(laps, key = { _, row -> row.index }) { index, row -> + LapRow( + row = row, + position = positionOf(index, laps.size), + modifier = animateItemMotion(), + ) + } + } } } - Spacer(Modifier.height(LocalLivePillInset.current)) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchSource.kt new file mode 100644 index 0000000..bcd71b1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchSource.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.Ticker +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** The repository, the clock and the ticker into one state (D9). */ +@Singleton +internal class StopwatchSource @Inject constructor( + private val stopwatch: StopwatchRepository, + private val elapsed: ElapsedRealtimeClock, + private val ticker: Ticker, +) { + /** + * Distinct-until-changed. Subscribes the ticker at + * [StopwatchDefaults.ReadoutTick] **only while the stored state is RUNNING**; + * otherwise exactly one emission (D9). + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun state(): Flow = + combine(stopwatch.run(), stopwatch.laps(), ::Pair) + .flatMapLatest { (run, laps) -> + // The recorded rows and their best/worst emphasis depend on the + // lap table alone, so they are derived once here and not sixty + // times a second under the ticker. + val recorded = StopwatchRows.recorded(laps) + // A paused or idle stopwatch costs nothing: there is no ticker + // under it at all, only the one emission its record earned. + val ticks = if (run.state == StopwatchState.RUNNING) { + ticker.ticks(StopwatchDefaults.ReadoutTick) + } else { + flowOf(Unit) + } + // The clock is read once per emission and the arithmetic is the + // domain's: the screen re-derives none of it. + ticks.map { StopwatchRows.fromRecorded(run, recorded, elapsed.elapsedRealtime()) } + } + // A record that says running while the reading does not move — a + // stale anchor (D6) — collapses to one state rather than sixty a second. + .distinctUntilChanged() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModel.kt new file mode 100644 index 0000000..1f7d14b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModel.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The tab's state and its four verbs; every verb goes through the engine (D2). */ +@HiltViewModel +class StopwatchViewModel @Inject internal constructor( + private val engine: StopwatchEngine, + private val source: StopwatchSource, +) : ViewModel() { + + /** `WhileSubscribed(5_000)`, seeded [StopwatchUiState.Loading]. */ + val state: StateFlow = source.state() + // `WhileSubscribed`, not `Eagerly`: the upstream is a 60 Hz ticker, and + // eager sharing would keep it running with nobody looking. The timeout + // is what gives rotation its resilience, exactly as the Timers tab. + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), StopwatchUiState.Loading) + + /** Start from idle, Resume from paused — one control, because the engine knows which (D13). */ + fun onStart() = launchOnEngine { engine.start() } + + fun onPause() = launchOnEngine { engine.pause() } + + fun onLap() = launchOnEngine { engine.lap() } + + fun onReset() = launchOnEngine { engine.reset() } + + private fun launchOnEngine(block: suspend () -> Unit) { + viewModelScope.launch { block() } + } + + private companion object { + /** The window a rotation has to re-subscribe in before the ticker is dropped. */ + const val SUBSCRIPTION_GRACE_MS = 5_000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d211d41..5f751c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,4 +164,30 @@ Unavailable \u2014 the default alarm sound will be used Unknown sound Clockula may lose access to this file later + + Start + Pause + Resume + Reset + Lap + Lap limit reached + Elapsed %1$s + Lap + Split + Total + Lap %1$d + Lap %1$d, split %2$s, total %3$s + Lap %1$d, fastest, split %2$s, total %3$s + Lap %1$d, slowest, split %2$s, total %3$s + Lap %1$d in progress, split %2$s, total %3$s + + + Stopwatch + The running stopwatch and its controls. It makes no sound. + Stopwatch + Paused \u00b7 %1$s + + %1$d lap + %1$d laps + diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowsTest.kt new file mode 100644 index 0000000..2641111 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchRowsTest.kt @@ -0,0 +1,271 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * §5.7 — 16 cases. The tab as pure data: the hero reading, the lap list newest + * first, and the in-progress row that only exists once a lap has been recorded + * (D10, D11). + */ +class StopwatchRowsTest { + + private val now: Duration = 1_000.seconds + + private fun running( + accumulated: Duration = Duration.ZERO, + anchor: Duration? = now, + lastLapCumulative: Duration = Duration.ZERO, + ) = StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = accumulated, + startedAtElapsedRealtime = anchor, + lastLapCumulative = lastLapCumulative, + ) + + private fun paused( + accumulated: Duration = 30.seconds, + lastLapCumulative: Duration = Duration.ZERO, + ) = StopwatchRun( + state = StopwatchState.PAUSED, + accumulated = accumulated, + startedAtElapsedRealtime = null, + lastLapCumulative = lastLapCumulative, + ) + + private fun lapsOf(vararg splits: Long): List { + var running = Duration.ZERO + return splits.map { it.seconds }.mapIndexed { position, split -> + running += split + Lap(index = position + 1, split = split, cumulative = running) + } + } + + private fun rows( + run: StopwatchRun, + laps: List = emptyList(), + elapsedRealtime: Duration = now, + ) = StopwatchRows.from(run, laps, elapsedRealtime) + + /** §5.7 #1 */ + @Test + fun `an idle stopwatch is a zero readout with no list and no lap button`() { + val state = rows(StopwatchRun()) + + assertThat(state).isEqualTo( + StopwatchUiState.Loaded( + mode = StopwatchState.IDLE, + elapsed = Duration.ZERO, + laps = emptyList(), + canLap = false, + ), + ) + } + + /** §5.7 #2 */ + @Test + fun `a running stopwatch with no recorded lap shows no lap list at all`() { + val state = rows(running(accumulated = 30.seconds)) + + assertThat(state.laps).isEmpty() + } + + /** §5.7 #3 */ + @Test + fun `the first row is the lap in progress`() { + val state = rows( + running(accumulated = 30.seconds, lastLapCumulative = 12.seconds), + lapsOf(12), + ) + + assertThat(state.laps.first().index to state.laps.first().kind) + .isEqualTo(2 to LapKind.IN_PROGRESS) + } + + /** §5.7 #4 */ + @Test + fun `the lap list runs newest first`() { + val state = rows( + running(accumulated = 40.seconds, lastLapCumulative = 30.seconds), + lapsOf(10, 10, 10), + ) + + assertThat(state.laps.map { it.index }).containsExactly(4, 3, 2, 1).inOrder() + } + + /** §5.7 #5 */ + @Test + fun `a paused stopwatch keeps its in-progress row, frozen`() { + val state = rows( + paused(accumulated = 30.seconds, lastLapCumulative = 20.seconds), + lapsOf(10, 10), + elapsedRealtime = now + 5.seconds, + ) + + assertThat(state.laps.first()).isEqualTo( + LapRowState( + index = 3, + kind = LapKind.IN_PROGRESS, + split = 10.seconds, + cumulative = 30.seconds, + emphasis = LapEmphasis.NONE, + ), + ) + } + + /** §5.7 #6 */ + @Test + fun `the in-progress row splits from the last lap's total`() { + val state = rows( + running(accumulated = 30.seconds, lastLapCumulative = 12.seconds), + lapsOf(12), + ) + + assertThat(state.laps.first().cumulative to state.laps.first().split) + .isEqualTo(30.seconds to 18.seconds) + } + + /** §5.7 #7 */ + @Test + fun `a lap total above the reading floors the in-progress split at zero`() { + val state = rows( + running(accumulated = 10.seconds, lastLapCumulative = 40.seconds), + lapsOf(40), + ) + + assertThat(state.laps.first().split).isEqualTo(Duration.ZERO) + } + + /** §5.7 #8 */ + @Test + fun `three recorded laps mark one best and one worst, and never the one in progress`() { + val state = rows( + running(accumulated = 30.seconds, lastLapCumulative = 21.seconds), + lapsOf(10, 4, 7), + ) + + val byKind = state.laps.groupBy { it.kind } + assertThat(byKind.getValue(LapKind.RECORDED).map { it.emphasis }) + .containsExactly(LapEmphasis.NONE, LapEmphasis.BEST, LapEmphasis.WORST) + assertThat(byKind.getValue(LapKind.IN_PROGRESS).map { it.emphasis }) + .containsExactly(LapEmphasis.NONE) + } + + /** §5.7 #9 */ + @Test + fun `two recorded laps emphasise nothing`() { + val state = rows( + running(accumulated = 30.seconds, lastLapCumulative = 14.seconds), + lapsOf(10, 4), + ) + + assertThat(state.laps.map { it.emphasis }.toSet()).containsExactly(LapEmphasis.NONE) + } + + /** §5.7 #10 */ + @Test + fun `a running stopwatch below the cap can still lap`() { + val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 10.seconds), lapsOf(10)) + + assertThat(state.canLap).isTrue() + } + + /** §5.7 #11 */ + @Test + fun `a running stopwatch at the cap cannot lap again`() { + val laps = (1..StopwatchLaps.MAX).map { + Lap(index = it, split = 1.seconds, cumulative = it.seconds) + } + + val state = rows( + running(accumulated = 2_000.seconds, lastLapCumulative = StopwatchLaps.MAX.seconds), + laps, + ) + + assertThat(state.canLap).isFalse() + } + + /** §5.7 #12 */ + @Test + fun `a paused stopwatch has nothing to lap`() { + val state = rows(paused(lastLapCumulative = 10.seconds), lapsOf(10)) + + assertThat(state.canLap).isFalse() + } + + /** §5.7 #13 */ + @Test + fun `a run anchored in a previous boot is presented as paused at what it banked`() { + val stale = running(accumulated = 25.seconds, anchor = now + 500.seconds, lastLapCumulative = 10.seconds) + + val state = rows(stale, lapsOf(10)) + + assertThat(Triple(state.mode, state.elapsed, state.canLap)) + .isEqualTo(Triple(StopwatchState.PAUSED, 25.seconds, false)) + } + + /** §5.7 #14 */ + @Test + fun `laps handed over in any order come back in descending index order`() { + val laps = lapsOf(10, 4, 7).shuffled() + + val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 21.seconds), laps) + + val indices = state.laps.map { it.index } + assertThat(indices).isEqualTo(indices.sortedDescending()) + assertThat(indices).containsExactly(4, 3, 2, 1) + } + + /** §5.7 #15 */ + @Test + fun `an idle stopwatch shows no lap list even when the table still holds one`() { + val state = rows(StopwatchRun(), lapsOf(10, 4, 7)) + + assertThat(state.laps).isEmpty() + } + + /** §5.7 #16 */ + @Test + fun `a second of monotonic clock moves only the reading and the lap in progress`() { + val run = running(accumulated = 30.seconds, lastLapCumulative = 21.seconds) + val laps = lapsOf(10, 4, 7) + val before = rows(run, laps) + + val after = rows(run, laps, elapsedRealtime = now + 1.seconds) + + assertThat(after.elapsed).isEqualTo(before.elapsed + 1.seconds) + assertThat(after.laps.filter { it.kind == LapKind.RECORDED }) + .isEqualTo(before.laps.filter { it.kind == LapKind.RECORDED }) + } + + // --- M7 review finding 4: the per-tick path must equal the whole-model one --- + + /** + * `StopwatchSource` derives the recorded rows once per lap change and re-uses + * them on every 60 Hz tick, so [StopwatchRows.fromRecorded] has to produce + * exactly what [StopwatchRows.from] does — including the idle case, where the + * rows are dropped whatever the table holds. + */ + @Test + fun `the per-tick path produces the same state as the whole model`() { + val laps = lapsOf(10, 4, 7) + val runs = listOf( + StopwatchRun(), + running(accumulated = 30.seconds, lastLapCumulative = 21.seconds), + paused(accumulated = 30.seconds, lastLapCumulative = 21.seconds), + running(accumulated = 30.seconds, anchor = now + 500.seconds, lastLapCumulative = 21.seconds), + ) + + runs.forEach { run -> + assertThat(StopwatchRows.fromRecorded(run, StopwatchRows.recorded(laps), now)) + .isEqualTo(StopwatchRows.from(run, laps, now)) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModelTest.kt new file mode 100644 index 0000000..feebfe0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchViewModelTest.kt @@ -0,0 +1,232 @@ +package de.jeanlucmakiola.clockula.ui.stopwatch + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps +import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension +import de.jeanlucmakiola.clockula.testing.StopwatchEngineHarness +import de.jeanlucmakiola.clockula.testing.stopwatchEngineHarness +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * §5.8 — 10 cases over the **real** [de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine], + * the real repository, a real DataStore under a `@TempDir` and the fake ticker. + * The four verbs go through the engine, not the repository, which is what keeps + * the foreground service moving with the state (D2) — the service handle's + * transitions are how a test proves the call took that route. + */ +class StopwatchViewModelTest { + + @JvmField + @RegisterExtension + val main = MainDispatcherExtension() + + private fun TestScope.viewModel(harness: StopwatchEngineHarness) = StopwatchViewModel( + engine = harness.engine, + source = StopwatchSource(harness.stopwatch, harness.elapsed, harness.ticker), + ).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } } + + private fun StopwatchUiState.loaded(): StopwatchUiState.Loaded = this as StopwatchUiState.Loaded + + /** §5.8 #1 */ + @Test + fun `the tab starts out loading, not idle`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val harness = stopwatchEngineHarness(tempDir) + + val viewModel = viewModel(harness) + + assertThat(viewModel.state.value).isEqualTo(StopwatchUiState.Loading) + advanceUntilIdle() + assertThat(viewModel.state.value).isInstanceOf(StopwatchUiState.Loaded::class.java) + } + + /** §5.8 #2 */ + @Test + fun `Start runs the stopwatch and brings the service up`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + + viewModel.onStart() + advanceUntilIdle() + + assertThat(harness.storedRun().state).isEqualTo(StopwatchState.RUNNING) + assertThat(harness.service.active).isTrue() + } + + /** §5.8 #3 */ + @Test + fun `Pause banks the run and leaves the service up`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + + viewModel.onPause() + advanceUntilIdle() + + assertThat(harness.storedRun().state).isEqualTo(StopwatchState.PAUSED) + assertThat(harness.service.active).isTrue() + } + + /** §5.8 #4 */ + @Test + fun `Lap records a lap and the list grows by one`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + harness.elapsed.advance(10.seconds) + viewModel.onLap() + advanceUntilIdle() + val before = viewModel.state.value.loaded().laps.size + + harness.elapsed.advance(10.seconds) + viewModel.onLap() + advanceUntilIdle() + + assertThat(harness.storedLaps().map { it.index }).containsExactly(1, 2).inOrder() + assertThat(viewModel.state.value.loaded().laps).hasSize(before + 1) + } + + /** §5.8 #5 */ + @Test + fun `Reset empties the run, the laps and the shade`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + harness.elapsed.advance(10.seconds) + viewModel.onLap() + advanceUntilIdle() + + viewModel.onReset() + advanceUntilIdle() + + assertThat(harness.storedRun().state).isEqualTo(StopwatchState.IDLE) + assertThat(harness.storedLaps()).isEmpty() + assertThat(harness.service.active).isFalse() + } + + /** §5.8 #6 */ + @Test + fun `Lap at the cap records nothing and the button stays disabled`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + (1..StopwatchLaps.MAX).forEach { index -> + harness.lapDao.insert( + LapEntity(lapIndex = index, splitMillis = 1_000L, cumulativeMillis = index * 1_000L), + ) + } + harness.elapsed.advance(2_000.seconds) + advanceUntilIdle() + + viewModel.onLap() + advanceUntilIdle() + + assertThat(harness.storedLaps()).hasSize(StopwatchLaps.MAX) + assertThat(viewModel.state.value.loaded().canLap).isFalse() + } + + /** §5.8 #7 */ + @Test + fun `Lap on a paused stopwatch records nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + viewModel.onPause() + advanceUntilIdle() + + viewModel.onLap() + advanceUntilIdle() + + assertThat(harness.storedLaps()).isEmpty() + } + + /** §5.8 #8 — the checklist's "how the paused result is presented" (D26). */ + @Test + fun `a run whose anchor a reboot invalidated is presented paused at the banked value`( + @TempDir tempDir: Path, + ) = runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + harness.elapsed.advance(20.seconds) + viewModel.onLap() + advanceUntilIdle() + harness.elapsed.advance(20.seconds) + viewModel.onLap() + advanceUntilIdle() + + harness.elapsed.reboot(uptime = 5.seconds) + harness.ticker.tick() + advanceUntilIdle() + + val state = viewModel.state.value.loaded() + assertThat(state.mode to state.elapsed).isEqualTo(StopwatchState.PAUSED to 40.seconds) + assertThat(state.canLap).isFalse() + assertThat(state.laps.map { it.index to it.kind }).containsExactly( + 3 to LapKind.IN_PROGRESS, + 2 to LapKind.RECORDED, + 1 to LapKind.RECORDED, + ).inOrder() + assertThat(state.laps.first().split).isEqualTo(Duration.ZERO) + } + + /** §5.8 #9 */ + @Test + fun `a readout writes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + viewModel.onStart() + advanceUntilIdle() + val writes = harness.dataStore.writes + val before = viewModel.state.value.loaded().elapsed + + repeat(3) { + harness.elapsed.advance(1.seconds) + harness.ticker.tick() + advanceUntilIdle() + } + + assertThat(viewModel.state.value.loaded().elapsed).isEqualTo(before + 3.seconds) + assertThat(harness.dataStore.writes).isEqualTo(writes) + } + + /** §5.8 #10 */ + @Test + fun `two Starts in one frame start one run`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val harness = stopwatchEngineHarness(tempDir) + val viewModel = viewModel(harness) + + viewModel.onStart() + viewModel.onStart() + advanceUntilIdle() + + assertThat(harness.storedRun().startedAtElapsedRealtime).isEqualTo(harness.elapsed.value) + assertThat(harness.service.transitions).containsExactly(true) + } +}