feat(data): timer writes that survive a clock change and a reboot
"+1 min" on a timer that has just rung resumes it with exactly a minute, in one transaction — the user asked for a minute more than zero, not a minute more than an anchor that has gone by. M2 left that branch paused with no test to pin it; this is the gesture a timer app is judged on. Every running row carries both anchors: the monotonic one it actually runs on, and a wall-clock fallback for a reboot. A system clock change re-derives the fallback from the monotonic remainder rather than leaving it stale, because a stale fallback is how a thirty-minute timer rings twenty-nine minutes early after a reboot. Presets are app-wide, sanitised on read as well as on write, and go through the store's update so two edits cannot swallow each other.
This commit is contained in:
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
@@ -93,6 +94,26 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
|
||||
// --- timer presets (M6) ---
|
||||
|
||||
/**
|
||||
* The app-wide quick-start durations, sanitised on read as well as on write
|
||||
* (D13). Absent means [de.jeanlucmakiola.clockula.domain.timer.TimerPresets.BUILT_IN];
|
||||
* an explicitly empty value means the user removed them all and is honoured.
|
||||
*/
|
||||
val timerPresets: Flow<List<Duration>> = store.flow(TimerPrefs.presets)
|
||||
|
||||
suspend fun setTimerPresets(values: List<Duration>) = store.set(TimerPrefs.presets, values)
|
||||
|
||||
/** Through [PrefStore.update], so two concurrent edits cannot swallow each other. */
|
||||
suspend fun addTimerPreset(value: Duration) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.plus(it, value) }
|
||||
}
|
||||
|
||||
suspend fun removeTimerPreset(value: Duration) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
||||
}
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The timer path's own on-disk keys. Both are single *records* rather than
|
||||
* lists of rows, which is why docs/PLAN.md §5 sends them here instead of to
|
||||
* Room — and it keeps the ring's volatile state out of M10's backup of a timer
|
||||
* (slice plan D13, D14).
|
||||
*/
|
||||
object TimerPrefs {
|
||||
|
||||
/**
|
||||
* The app-wide quick-start durations, sanitised through [Pref.map] on read
|
||||
* as well as on write — exactly as every bounded value in [ClockPrefs] is.
|
||||
* An **absent** key means [TimerPresets.BUILT_IN]; an explicitly **empty**
|
||||
* value means the user removed them all and is honoured.
|
||||
*/
|
||||
val presets: Pref<List<Duration>> = nullableStringPref("timer_presets").map(
|
||||
decode = { raw ->
|
||||
if (raw == null) TimerPresets.BUILT_IN else TimerPresets.sanitise(TimerPresets.decode(raw))
|
||||
},
|
||||
encode = { values -> TimerPresets.encode(TimerPresets.sanitise(values)) },
|
||||
)
|
||||
|
||||
/**
|
||||
* When the current ring session began *sounding*. Wall clock, deliberately:
|
||||
* "how long has this been making noise" is a question a reboot must not
|
||||
* reset, which is why `alarm_states.ringing_since` is wall clock too. §5's
|
||||
* elapsed-realtime rule governs the countdown, not the ring's age.
|
||||
*
|
||||
* Null means no session, and writing null removes the key rather than
|
||||
* storing a sentinel for it.
|
||||
*/
|
||||
val ringSoundingSince: Pref<Instant?> = nullableLongPref("timer_ring_sounding_since_millis").map(
|
||||
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
|
||||
encode = { instant -> instant?.toEpochMilliseconds() },
|
||||
)
|
||||
}
|
||||
@@ -20,14 +20,45 @@ interface TimerRepository {
|
||||
/** Back to IDLE with the full configured duration; clears every anchor. */
|
||||
suspend fun reset(id: Long)
|
||||
|
||||
/** "+1 min": extends the remaining time only — [Timer.duration] is untouched. */
|
||||
/**
|
||||
* "+1 min", measured from the tap: a RUNNING timer is rebased on what is
|
||||
* actually left, a PAUSED one keeps its pause, an **EXPIRED one resumes with
|
||||
* exactly [extra] left**, and an IDLE one is a silent no-op.
|
||||
* [Timer.duration] is untouched on every branch, so [reset] still returns
|
||||
* the timer to the length the user configured (M6 D12).
|
||||
*/
|
||||
suspend fun addTime(id: Long, extra: Duration)
|
||||
|
||||
/** The ringing service calls this when a timer reaches zero. */
|
||||
suspend fun markExpired(id: Long)
|
||||
|
||||
/**
|
||||
* No-op unless the timer is IDLE (D3): changing the configured length of a
|
||||
* countdown that is already running is ambiguous, and every answer is a
|
||||
* surprise. Sets `remaining` to match. Clamped at zero.
|
||||
*/
|
||||
suspend fun setDuration(id: Long, duration: Duration)
|
||||
|
||||
/** Normalised through `Ringtones`; blank becomes null. Touches no anchor. */
|
||||
suspend fun setRingtoneUri(id: Long, uri: String?)
|
||||
|
||||
suspend fun reorder(idsInOrder: List<Long>)
|
||||
|
||||
/**
|
||||
* Rewrites every RUNNING timer's wall-clock fallback from what its
|
||||
* monotonic anchor says is left. Call on a clock or zone change: the
|
||||
* elapsed-realtime anchors are authoritative and must not move, but
|
||||
* `endsAtWallClock` is a wall-clock instant the change has just
|
||||
* invalidated — and it is both the notification's chronometer base and the
|
||||
* reboot fallback, so leaving it stale makes a running timer read finished
|
||||
* and a reboot ring it at the wrong minute (M6 D4, D19).
|
||||
*
|
||||
* A row whose monotonic anchor is already stale is left alone:
|
||||
* [repairAfterReboot] owns that case, and its remainder is read *from* the
|
||||
* fallback, so re-deriving the fallback from it would be circular.
|
||||
*/
|
||||
suspend fun reanchorWallClocks()
|
||||
|
||||
/**
|
||||
* Rewrites every RUNNING timer's monotonic anchors from its wall-clock
|
||||
* fallback. Call once at the top of a new boot, before the new uptime can
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
@@ -71,21 +72,41 @@ class TimerRepositoryImpl @Inject constructor(
|
||||
it.copy(state = TimerState.IDLE, remaining = it.duration).cleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* One rule for all three live states: the extra is measured **from the
|
||||
* tap**, never from an anchor that has gone by. [Timer.duration] is never
|
||||
* moved, so `reset` still returns the timer to the length the user
|
||||
* configured (M6 D12).
|
||||
*
|
||||
* | Before | After |
|
||||
* |---|---|
|
||||
* | RUNNING | still running, rebased on `snapshot.remaining + extra` |
|
||||
* | PAUSED | still paused, `remaining + extra`. The user paused deliberately |
|
||||
* | EXPIRED | **RUNNING with exactly `extra` left**, all three anchors written |
|
||||
* | IDLE | nothing. A stale notification action is a silent no-op |
|
||||
*/
|
||||
override suspend fun addTime(id: Long, extra: Duration) {
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
val wall = wallClock.now()
|
||||
edit(id, wall) { timer ->
|
||||
if (timer.state == TimerState.RUNNING) {
|
||||
when (timer.state) {
|
||||
// Rebased on what is actually left, not on the old end anchor:
|
||||
// a timer whose anchor is already past has zero left, and the
|
||||
// user asked for `extra` more than zero — not `extra` more than
|
||||
// an anchor that has gone by.
|
||||
timer.anchored(timer.snapshotAt(now, wall).remaining + extra, now, wall)
|
||||
} else {
|
||||
// An expired timer that gains time is paused, not running: it has
|
||||
// no anchors, and the user still has to press start.
|
||||
val state = if (timer.state == TimerState.EXPIRED) TimerState.PAUSED else timer.state
|
||||
timer.copy(state = state, remaining = timer.remaining + extra)
|
||||
TimerState.RUNNING ->
|
||||
timer.anchored(timer.snapshotAt(now, wall).remaining + extra, now, wall)
|
||||
|
||||
// The same sentence, with the anchor that has gone by being the
|
||||
// expiry itself: "+1 min" on a timer that has just rung is the
|
||||
// commonest gesture in a timer app, and a second tap on Start is
|
||||
// a papercut. Resuming here is also the only way to avoid
|
||||
// emitting an intermediate PAUSED frame to the pill and the row.
|
||||
TimerState.EXPIRED -> timer.anchored(extra, now, wall)
|
||||
|
||||
TimerState.PAUSED -> timer.copy(remaining = timer.remaining + extra)
|
||||
|
||||
TimerState.IDLE -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,12 +115,52 @@ class TimerRepositoryImpl @Inject constructor(
|
||||
it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO).cleared()
|
||||
}
|
||||
|
||||
override suspend fun setDuration(id: Long, duration: Duration) = edit(id) { timer ->
|
||||
// Refused unless IDLE: changing the configured length of a countdown
|
||||
// that is already running is ambiguous, and every answer is a surprise
|
||||
// (M6 D3). The editor hides the keypad for a non-idle timer, so this is
|
||||
// here for a stale caller.
|
||||
if (timer.state != TimerState.IDLE) return@edit null
|
||||
val length = duration.coerceAtLeast(Duration.ZERO)
|
||||
timer.copy(duration = length, remaining = length)
|
||||
}
|
||||
|
||||
override suspend fun setRingtoneUri(id: Long, uri: String?) = edit(id) {
|
||||
it.copy(ringtoneUri = Ringtones.normalise(uri))
|
||||
}
|
||||
|
||||
/** Ids the table no longer holds are dropped, so the survivors number 0..n-1. */
|
||||
override suspend fun reorder(idsInOrder: List<Long>) {
|
||||
val known = dao.observeAll().first().mapTo(HashSet()) { it.id }
|
||||
dao.reorder(idsInOrder.filter { it in known })
|
||||
}
|
||||
|
||||
/**
|
||||
* A clock change invalidates the opposite anchor from a reboot: the
|
||||
* monotonic ones still hold, and the wall-clock fallback — the notification's
|
||||
* chronometer base and the reboot fallback — is the one that has moved. It
|
||||
* is re-derived from what the monotonic anchor says is left, so the two
|
||||
* agree again (M6 D4, D19).
|
||||
*/
|
||||
override suspend fun reanchorWallClocks() {
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
val wall = wallClock.now()
|
||||
for (entity in dao.running()) {
|
||||
edit(entity.id, wall) { timer ->
|
||||
val snapshot = timer.snapshotAt(now, wall)
|
||||
// A stale anchor is a reboot `repairAfterReboot` has not run for
|
||||
// yet, and its remainder is read *from* the wall-clock fallback:
|
||||
// re-deriving the fallback from it would be circular.
|
||||
if (snapshot.anchorIsStale || !snapshot.isRunning) return@edit null
|
||||
val endsAt = wall + snapshot.remaining
|
||||
// No clock change, no write — so a resync that changed nothing
|
||||
// emits nothing to the row and the pill.
|
||||
if (endsAt == timer.endsAtWallClock) return@edit null
|
||||
timer.copy(endsAtWallClock = endsAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reboot invalidates every monotonic anchor, and the new boot's uptime
|
||||
* eventually climbs past the stored one — at which point a dead anchor looks
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.TimerPrefs
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The DataStore half of the timer ring: one nullable wall-clock instant saying
|
||||
* when the current session began *sounding*. Null means no session. A single
|
||||
* record, so docs/PLAN.md §5 sends it here rather than to a Room table — and
|
||||
* volatile ring state then stays out of M10's backup of a timer (D14).
|
||||
*/
|
||||
@Singleton
|
||||
class TimerRingStateStore @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
/** Built once, distinct-until-changed (ARCHITECTURE.md §6). */
|
||||
val soundingSince: Flow<Instant?> = store.flow(TimerPrefs.ringSoundingSince)
|
||||
|
||||
suspend fun current(): Instant? = store.get(TimerPrefs.ringSoundingSince)
|
||||
|
||||
suspend fun set(instant: Instant?) = store.set(TimerPrefs.ringSoundingSince, instant)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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.timers.TimerRingStateStore
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.job
|
||||
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.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* §5.10 — 12 cases over a **real** DataStore on a `@TempDir`, as `ClockPrefsTest`
|
||||
* does. The presets are sanitised on read as well as on write (D13), and the
|
||||
* ring session's anchor is one nullable key whose absence *is* "no session"
|
||||
* (D14).
|
||||
*/
|
||||
class TimerPrefsTest {
|
||||
|
||||
private val presetsKey = stringPreferencesKey("timer_presets")
|
||||
private val soundingSinceKey = "timer_ring_sounding_since_millis"
|
||||
|
||||
/**
|
||||
* One store's worth of lifetime, as `SettingsPrefsTest` documents: DataStore
|
||||
* keeps a registry of the files it has open and refuses a second store over
|
||||
* a live one, so a test that wants to *reopen* a file has to end the first
|
||||
* scope first — the way a process ending is what really releases the file.
|
||||
*/
|
||||
private fun TestScope.newScope(): CoroutineScope =
|
||||
CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job())
|
||||
|
||||
private fun newDataStore(tempDir: Path, scope: CoroutineScope): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = scope,
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
newDataStore(tempDir, newScope())
|
||||
|
||||
private suspend fun DataStore<Preferences>.writeRawPresets(raw: String) {
|
||||
updateData { it.toMutablePreferences().apply { this[presetsKey] = raw } }
|
||||
}
|
||||
|
||||
/** §5.10 #1 */
|
||||
@Test
|
||||
fun `an absent key means the built-in set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
assertThat(prefs.timerPresets.first()).isEqualTo(TimerPresets.BUILT_IN)
|
||||
}
|
||||
|
||||
/** §5.10 #2 */
|
||||
@Test
|
||||
fun `an explicitly empty set is the user's choice and is honoured`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.setTimerPresets(emptyList())
|
||||
|
||||
assertThat(prefs.timerPresets.first()).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.10 #3 */
|
||||
@Test
|
||||
fun `a written set round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.setTimerPresets(listOf(90.seconds, 7.minutes))
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(90.seconds, 7.minutes).inOrder()
|
||||
}
|
||||
|
||||
/** §5.10 #4 */
|
||||
@Test
|
||||
fun `a hand-edited nonsense value reads as nothing and throws nothing`(@TempDir tempDir: Path) =
|
||||
runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.writeRawPresets("nonsense")
|
||||
|
||||
assertThat(prefs.timerPresets.first()).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.10 #5 */
|
||||
@Test
|
||||
fun `a stored entry below the minimum is dropped on read`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.writeRawPresets("${100.milliseconds.inWholeMilliseconds},${1.minutes.inWholeMilliseconds}")
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes)
|
||||
}
|
||||
|
||||
/** §5.10 #6 */
|
||||
@Test
|
||||
fun `a stored entry above the maximum is dropped, not clamped`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.writeRawPresets("${48.hours.inWholeMilliseconds},${1.minutes.inWholeMilliseconds}")
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes)
|
||||
}
|
||||
|
||||
/** §5.10 #7 */
|
||||
@Test
|
||||
fun `a stored duplicate reads back once`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.writeRawPresets("300000,300000")
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(5.minutes)
|
||||
}
|
||||
|
||||
/** §5.10 #8 */
|
||||
@Test
|
||||
fun `a descending stored list reads back ascending`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.writeRawPresets("600000,300000,60000")
|
||||
|
||||
assertThat(prefs.timerPresets.first())
|
||||
.containsExactly(1.minutes, 5.minutes, 10.minutes)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.10 #9 */
|
||||
@Test
|
||||
fun `two presets saved at once cannot swallow each other`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
prefs.setTimerPresets(emptyList())
|
||||
|
||||
coroutineScope {
|
||||
launch { prefs.addTimerPreset(3.minutes) }
|
||||
launch { prefs.addTimerPreset(7.minutes) }
|
||||
}
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(3.minutes, 7.minutes).inOrder()
|
||||
}
|
||||
|
||||
/** §5.10 #10 */
|
||||
@Test
|
||||
fun `removing one preset leaves the rest in order`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
prefs.setTimerPresets(listOf(1.minutes, 5.minutes, 10.minutes))
|
||||
|
||||
prefs.removeTimerPreset(5.minutes)
|
||||
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 10.minutes).inOrder()
|
||||
}
|
||||
|
||||
/** §5.10 #11 */
|
||||
@Test
|
||||
fun `the session anchor survives process death`(@TempDir tempDir: Path) = runTest {
|
||||
val firstProcess = newScope()
|
||||
val store = TimerRingStateStore(PrefStore(newDataStore(tempDir, firstProcess)))
|
||||
|
||||
store.set(T0)
|
||||
val beforeDeath = store.current()
|
||||
|
||||
// The process death: the store's scope dies with the process, and that
|
||||
// is also what lets the file be opened again. A dead store cannot be
|
||||
// read, so what it saw is captured while it is still alive.
|
||||
firstProcess.coroutineContext.job.cancelAndJoin()
|
||||
val reborn = TimerRingStateStore(PrefStore(newDataStore(tempDir, newScope())))
|
||||
|
||||
assertThat(beforeDeath to reborn.current()).isEqualTo(T0 to T0)
|
||||
}
|
||||
|
||||
/** §5.10 #12 */
|
||||
@Test
|
||||
fun `closing the session removes the key rather than storing a sentinel`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val store = TimerRingStateStore(PrefStore(dataStore))
|
||||
store.set(T0)
|
||||
|
||||
store.set(null)
|
||||
|
||||
assertThat(dataStore.data.first().asMap().keys.map { it.name })
|
||||
.doesNotContain(soundingSinceKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user