feat(data): ui preferences, the real ticker and its binding

The ticker is injected rather than read from the composition so the shell's
per-second text can be driven by a test scheduler instead of a wall clock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
makiolaj committed 2026-09-12 13:07:04 +02:00
1 parent 6dee033aba
commit 199005e795
6 files changed
+185 -2

No files matched your search

@@ -11,7 +11,9 @@ import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@@ -26,12 +28,24 @@ import java.nio.file.Path
*/
class SettingsPrefsTest {
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
/**
* One store's worth of lifetime. 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 = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
scope = scope,
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
)
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
newDataStore(tempDir, newScope())
@Test
fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
@@ -105,4 +119,41 @@ class SettingsPrefsTest {
cancel()
}
}
// --- M4: the one-shot notification permission request ---
@Test
fun `a fresh install has not been asked for the notification permission`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
assertThat(prefs.notificationPermissionAsked.first()).isFalse()
}
@Test
fun `having asked once survives process death`(@TempDir tempDir: Path) = runTest {
val firstProcess = newScope()
SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).markNotificationPermissionAsked()
// The process death: the store's scope dies with the process, and that
// is also what lets the file be opened again.
firstProcess.coroutineContext.job.cancelAndJoin()
val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope())))
assertThat(reopened.notificationPermissionAsked.first()).isTrue()
}
@Test
fun `the asked flag does not re-emit when an unrelated preference is written`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.notificationPermissionAsked.test {
assertThat(awaitItem()).isFalse()
prefs.setSnoozeMinutes(7)
expectNoEvents()
cancel()
}
}
}
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.clockula.data.time
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* The ticker is nothing but `delay`, which is why it is injected: under
* `runTest`'s virtual clock five seconds of it cost nothing. It emits once
* immediately so a subscriber is never blank for a cadence.
*/
class RealTickerTest {
private val ticker = RealTicker()
@Test
fun `the first tick arrives immediately`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
assertThat(ticks).isEqualTo(1)
}
@Test
fun `a second tick arrives one period later`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
advanceTimeBy(1.seconds)
runCurrent()
assertThat(ticks).isEqualTo(2)
}
@Test
fun `five seconds is one immediate tick and five periodic ones`() = runTest {
var ticks = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } }
runCurrent()
advanceTimeBy(5.seconds)
runCurrent()
assertThat(ticks).isEqualTo(6)
}
@Test
fun `each collector gets its own cadence`() = runTest {
var slow = 0
var fast = 0
backgroundScope.launch { ticker.ticks(1.seconds).collect { slow++ } }
backgroundScope.launch { ticker.ticks(200.milliseconds).collect { fast++ } }
runCurrent()
advanceTimeBy(1.seconds)
runCurrent()
assertThat(slow to fast).isEqualTo(2 to 6)
}
@Test
fun `a collector that has seen enough can walk away`() = runTest {
val ticks = ticker.ticks(1.seconds).take(3).toList()
assertThat(ticks).hasSize(3)
}
}