feat(prefs): clock defaults and the stopwatch run record in DataStore
Alarm and timer defaults, world-clock preferences, and the stopwatch's running state — the things that are settings rather than rows. The run record is read and written as a whole in one DataStore transaction, so a reader never sees a half-applied record and a process killed mid-write cannot persist one. Values are clamped on read: a preferences file someone has edited by hand degrades to the default instead of propagating nonsense upwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
import de.jeanlucmakiola.floret.prefs.longPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The clock defaults' on-disk keys. Reads and writes both clamp through
|
||||
* [Pref.map], so a value the user could never choose — a hand-edited file, a
|
||||
* restored backup from a future version — cannot come back out of the store.
|
||||
*/
|
||||
object ClockPrefs {
|
||||
|
||||
private val MIN_TIMER_DURATION = 1.seconds
|
||||
private val MAX_TIMER_DURATION = 24.hours
|
||||
|
||||
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(1..60)
|
||||
|
||||
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(0..10)
|
||||
|
||||
val vibrate: Pref<Boolean> = booleanPref("default_vibrate", true)
|
||||
|
||||
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60)
|
||||
|
||||
val alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
|
||||
|
||||
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent()
|
||||
|
||||
val dismissChallenge: Pref<DismissChallenge> =
|
||||
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
|
||||
|
||||
val timerDurationMillis: Pref<Duration> = longPref(
|
||||
"default_timer_duration_millis",
|
||||
5.minutes.inWholeMilliseconds,
|
||||
).map(
|
||||
decode = { it.milliseconds.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION) },
|
||||
encode = { it.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION).inWholeMilliseconds },
|
||||
)
|
||||
|
||||
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
|
||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
||||
|
||||
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
|
||||
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
|
||||
|
||||
private fun Pref<String?>.blankAsAbsent(): Pref<String?> {
|
||||
val trim: (String?) -> String? = { it?.trim()?.takeIf(String::isNotEmpty) }
|
||||
return map(decode = trim, encode = trim)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.appearance
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.time.Duration
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Clockula's app preferences. M1 covers the appearance slice only; M2 adds the
|
||||
* clock defaults and the stopwatch running state to this same class.
|
||||
* Clockula's app preferences: the appearance choices (M1) and the clock
|
||||
* defaults every alarm and timer inherits (M2). The stopwatch's running state
|
||||
* lives next door in `StopwatchStateStore`.
|
||||
*/
|
||||
@Singleton
|
||||
class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
@@ -28,4 +35,61 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode)
|
||||
|
||||
suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled)
|
||||
|
||||
// --- clock defaults (M2) ---
|
||||
|
||||
/**
|
||||
* Distinct-until-changed; built once, for the same reason [appearance] is.
|
||||
* Composed from the individual keys rather than one snapshot read, so a
|
||||
* write to an unrelated preference cannot re-emit it.
|
||||
*/
|
||||
val defaults: Flow<ClockDefaults> = combine(
|
||||
combine(
|
||||
store.flow(ClockPrefs.snoozeMinutes),
|
||||
store.flow(ClockPrefs.snoozeLimit),
|
||||
store.flow(ClockPrefs.vibrate),
|
||||
store.flow(ClockPrefs.volumeRampSeconds),
|
||||
store.flow(ClockPrefs.dismissChallenge),
|
||||
) { snoozeMinutes, snoozeLimit, vibrate, volumeRampSeconds, dismissChallenge ->
|
||||
ClockDefaults(
|
||||
snoozeMinutes = snoozeMinutes,
|
||||
snoozeLimit = snoozeLimit,
|
||||
vibrate = vibrate,
|
||||
volumeRampSeconds = volumeRampSeconds,
|
||||
dismissChallenge = dismissChallenge,
|
||||
)
|
||||
},
|
||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
||||
store.flow(ClockPrefs.timerRingtoneUri),
|
||||
store.flow(ClockPrefs.timerDurationMillis),
|
||||
store.flow(ClockPrefs.homeZoneId),
|
||||
) { partial, alarmRingtoneUri, timerRingtoneUri, timerDuration, homeZoneId ->
|
||||
partial.copy(
|
||||
alarmRingtoneUri = alarmRingtoneUri,
|
||||
timerRingtoneUri = timerRingtoneUri,
|
||||
timerDuration = timerDuration,
|
||||
homeZoneId = homeZoneId,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun currentDefaults(): ClockDefaults = defaults.first()
|
||||
|
||||
suspend fun setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes)
|
||||
|
||||
suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit)
|
||||
|
||||
suspend fun setVibrate(enabled: Boolean) = store.set(ClockPrefs.vibrate, enabled)
|
||||
|
||||
suspend fun setVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.volumeRampSeconds, seconds)
|
||||
|
||||
suspend fun setAlarmRingtoneUri(uri: String?) = store.set(ClockPrefs.alarmRingtoneUri, uri)
|
||||
|
||||
suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri)
|
||||
|
||||
suspend fun setDismissChallenge(challenge: DismissChallenge) =
|
||||
store.set(ClockPrefs.dismissChallenge, challenge)
|
||||
|
||||
suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration)
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* The stopwatch's running state — a single record, in DataStore rather than
|
||||
* Room (docs/PLAN.md §5). Every value here is monotonic: no key holds a
|
||||
* wall-clock instant, because a stopwatch that spans a reboot has lost time
|
||||
* nobody can reconstruct and guessing at it would be a lie.
|
||||
*
|
||||
* The four keys are read and written as **one record**, not as four independent
|
||||
* `Pref`s: the state and its anchor only mean anything together, so a reader
|
||||
* must never see "RUNNING with no anchor" because a multi-key write was caught
|
||||
* half-done.
|
||||
*/
|
||||
object StopwatchPrefs {
|
||||
|
||||
private val state = stringPreferencesKey("stopwatch_state")
|
||||
private val startedAtElapsed = longPreferencesKey("stopwatch_started_elapsed_millis")
|
||||
private val accumulated = longPreferencesKey("stopwatch_accumulated_millis")
|
||||
private val lastLapCumulative = longPreferencesKey("stopwatch_last_lap_cumulative_millis")
|
||||
|
||||
/** One consistent snapshot to one record. Never throws: a bad value degrades. */
|
||||
fun read(prefs: Preferences): StopwatchRun = StopwatchRun(
|
||||
state = prefs[state].toEnum(StopwatchState.IDLE),
|
||||
accumulated = prefs.elapsed(accumulated),
|
||||
startedAtElapsedRealtime = prefs[startedAtElapsed]?.milliseconds,
|
||||
lastLapCumulative = prefs.elapsed(lastLapCumulative),
|
||||
)
|
||||
|
||||
/**
|
||||
* The whole record into one [MutablePreferences], so [read] sees all of it
|
||||
* or none of it. An absent start anchor removes its key rather than storing
|
||||
* a sentinel for it.
|
||||
*/
|
||||
fun write(prefs: MutablePreferences, run: StopwatchRun) {
|
||||
prefs[state] = run.state.name
|
||||
prefs[accumulated] = run.accumulated.clampedMillis()
|
||||
prefs[lastLapCumulative] = run.lastLapCumulative.clampedMillis()
|
||||
val startedAt = run.startedAtElapsedRealtime
|
||||
if (startedAt == null) prefs.remove(startedAtElapsed) else prefs[startedAtElapsed] = startedAt.inWholeMilliseconds
|
||||
}
|
||||
|
||||
/** A stored elapsed value, clamped: a negative one is corruption, not a duration. */
|
||||
private fun Preferences.elapsed(key: Preferences.Key<Long>): Duration =
|
||||
(this[key] ?: 0L).coerceAtLeast(0L).milliseconds
|
||||
|
||||
private fun Duration.clampedMillis(): Long = coerceAtLeast(Duration.ZERO).inWholeMilliseconds
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.clockula.data.stopwatch
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.StopwatchPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The DataStore half of the stopwatch. Nothing else touches [StopwatchPrefs]. */
|
||||
@Singleton
|
||||
class StopwatchStateStore @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
/**
|
||||
* Distinct-until-changed; built once, so a collector does not restart it.
|
||||
* Read from one snapshot rather than composed from four key flows, so an
|
||||
* emission is always a whole record.
|
||||
*/
|
||||
val run: Flow<StopwatchRun> = store.data.map(StopwatchPrefs::read).distinctUntilChanged()
|
||||
|
||||
suspend fun current(): StopwatchRun = run.first()
|
||||
|
||||
/** One transaction for all four keys: a torn record is never persisted or observed. */
|
||||
suspend fun set(run: StopwatchRun) = store.edit { StopwatchPrefs.write(it, run) }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The clock defaults every alarm and timer inherits. A value the user could
|
||||
* never choose must not come back out of a hand-edited or restored file, so
|
||||
* both writes and reads clamp.
|
||||
*/
|
||||
class ClockPrefsTest {
|
||||
|
||||
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a fresh install reads the documented defaults`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every default round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.setSnoozeMinutes(5)
|
||||
prefs.setSnoozeLimit(1)
|
||||
prefs.setVibrate(false)
|
||||
prefs.setVolumeRampSeconds(0)
|
||||
prefs.setAlarmRingtoneUri("content://a")
|
||||
prefs.setTimerRingtoneUri("content://t")
|
||||
prefs.setDismissChallenge(DismissChallenge.HOLD)
|
||||
prefs.setTimerDuration(90.seconds)
|
||||
prefs.setHomeZoneId("Europe/Berlin")
|
||||
|
||||
assertThat(prefs.defaults.first()).isEqualTo(
|
||||
ClockDefaults(
|
||||
snoozeMinutes = 5,
|
||||
snoozeLimit = 1,
|
||||
vibrate = false,
|
||||
volumeRampSeconds = 0,
|
||||
alarmRingtoneUri = "content://a",
|
||||
timerRingtoneUri = "content://t",
|
||||
dismissChallenge = DismissChallenge.HOLD,
|
||||
timerDuration = 90.seconds,
|
||||
homeZoneId = "Europe/Berlin",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the defaults do not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.defaults.test {
|
||||
assertThat(awaitItem()).isEqualTo(ClockDefaults())
|
||||
|
||||
prefs.setSnoozeMinutes(ClockDefaults().snoozeMinutes)
|
||||
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.setSnoozeMinutes(0)
|
||||
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1)
|
||||
|
||||
prefs.setSnoozeMinutes(999)
|
||||
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt stored snooze value clamps on read`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences().apply { this[intPreferencesKey("default_snooze_minutes")] = -5 }
|
||||
}
|
||||
|
||||
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt dismiss challenge degrades to none`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences()
|
||||
.apply { this[stringPreferencesKey("default_dismiss_challenge")] = "TELEPORT" }
|
||||
}
|
||||
|
||||
assertThat(prefs.defaults.first().dismissChallenge).isEqualTo(DismissChallenge.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home zone the device no longer knows reads back as absent`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences().apply { this[stringPreferencesKey("home_zone_id")] = "Mars/Phobos" }
|
||||
}
|
||||
assertThat(prefs.defaults.first().homeZoneId).isNull()
|
||||
|
||||
prefs.setHomeZoneId(" Europe/Berlin ")
|
||||
assertThat(prefs.defaults.first().homeZoneId).isEqualTo("Europe/Berlin")
|
||||
|
||||
prefs.setHomeZoneId(null)
|
||||
assertThat(prefs.defaults.first().homeZoneId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing a clock default does not disturb the appearance flows`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.themeMode.test {
|
||||
assertThat(awaitItem()).isEqualTo(ThemeMode.SYSTEM)
|
||||
|
||||
prefs.setSnoozeMinutes(5)
|
||||
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchStateStore
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchRun
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The stopwatch's run record. It is monotonic time and nothing else — no key
|
||||
* here may hold a wall-clock instant.
|
||||
*/
|
||||
class StopwatchPrefsTest {
|
||||
|
||||
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the stored run record holds no wall-clock value`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val store = StopwatchStateStore(PrefStore(dataStore))
|
||||
|
||||
store.set(
|
||||
StopwatchRun(
|
||||
state = StopwatchState.RUNNING,
|
||||
accumulated = Duration.ZERO,
|
||||
startedAtElapsedRealtime = 1_000.seconds,
|
||||
),
|
||||
)
|
||||
|
||||
val snapshot = dataStore.data.first().asMap()
|
||||
assertThat(snapshot.values.filterIsInstance<Long>().filter { it >= 1_000_000_000_000L }).isEmpty()
|
||||
assertThat(snapshot.keys.map { it.name }).containsExactly(
|
||||
"stopwatch_state",
|
||||
"stopwatch_started_elapsed_millis",
|
||||
"stopwatch_accumulated_millis",
|
||||
"stopwatch_last_lap_cumulative_millis",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install is the default run`(@TempDir tempDir: Path) = runTest {
|
||||
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
assertThat(store.current()).isEqualTo(StopwatchRun())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full record round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
|
||||
val run = StopwatchRun(
|
||||
state = StopwatchState.PAUSED,
|
||||
accumulated = 42.seconds,
|
||||
startedAtElapsedRealtime = null,
|
||||
lastLapCumulative = 30.seconds,
|
||||
)
|
||||
|
||||
store.set(run)
|
||||
|
||||
assertThat(store.current()).isEqualTo(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown stored state degrades to idle`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val store = StopwatchStateStore(PrefStore(dataStore))
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences().apply { this[stringPreferencesKey("stopwatch_state")] = "SPINNING" }
|
||||
}
|
||||
|
||||
assertThat(store.current().state).isEqualTo(StopwatchState.IDLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole record lands in a single emission`(@TempDir tempDir: Path) = runTest {
|
||||
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
|
||||
val seen = mutableListOf<StopwatchRun>()
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { store.run.toList(seen) }
|
||||
|
||||
store.set(
|
||||
StopwatchRun(
|
||||
state = StopwatchState.RUNNING,
|
||||
accumulated = 42.seconds,
|
||||
startedAtElapsedRealtime = 1_000.seconds,
|
||||
lastLapCumulative = 30.seconds,
|
||||
),
|
||||
)
|
||||
|
||||
// Four keys, one transaction: a reader never sees RUNNING without its
|
||||
// anchor, or the new state beside the old accumulated total.
|
||||
assertThat(seen).containsExactly(
|
||||
StopwatchRun(),
|
||||
StopwatchRun(
|
||||
state = StopwatchState.RUNNING,
|
||||
accumulated = 42.seconds,
|
||||
startedAtElapsedRealtime = 1_000.seconds,
|
||||
lastLapCumulative = 30.seconds,
|
||||
),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the start anchor removes its key`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val store = StopwatchStateStore(PrefStore(dataStore))
|
||||
store.set(
|
||||
StopwatchRun(state = StopwatchState.RUNNING, startedAtElapsedRealtime = 1_000.seconds),
|
||||
)
|
||||
|
||||
store.set(StopwatchRun(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null))
|
||||
|
||||
assertThat(store.current().startedAtElapsedRealtime).isNull()
|
||||
assertThat(dataStore.data.first().asMap().keys.map { it.name })
|
||||
.doesNotContain("stopwatch_started_elapsed_millis")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user