feat(backup): JSON export/import through SAF, whole-state replace
clockula-backup-v1.json, schema documented in docs/BACKUP.md per PLAN.md §7: alarms, timers and world clocks as configuration only — ring state and a timer's running anchors never leave the device, since neither means anything on another one. Unknown fields are ignored at any level; a malformed known field rejects the whole file rather than importing it half-way. Import is whole-state replacement, not a merge, and the backup screen says so before the user confirms. Before replacing: a ringing alarm is dismissed and every running timer is deleted, so the import can never strand a live ring session; after it, AlarmEngine and TimerEngine are told to re-resolve so the imported state is live immediately. BackupDao adds delete-all/insert-many queries under one transaction — no schema or version change, queries only.
This commit is contained in:
1 parent
7253445c4e
commit
616bf7f67f
22 files changed
+1657
-5
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package de.jeanlucmakiola.clockula.backup
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.WorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.backupHarness
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.coroutines.flow.first
|
||||
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.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* [BackupEngine] over the real engines and repositories: proves export then
|
||||
* import round-trips configuration, that ring state is never part of it, that
|
||||
* a ringing alarm and running timers are cleared before the replace, and that
|
||||
* both engines are told to re-resolve afterwards.
|
||||
*/
|
||||
class BackupEngineTest {
|
||||
|
||||
@Test
|
||||
fun `export then import reproduces the same alarms, timers, world clocks and settings`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("source").also { it.toFile().mkdirs() })
|
||||
source.givenAlarm(
|
||||
alarmAt(id = 1L, hour = 7, minute = 30, repeatDays = RepeatDays.WEEKDAYS, label = "Gym"),
|
||||
)
|
||||
source.givenTimer(idleTimer(id = 1L, duration = 10.minutes, label = "Pasta"))
|
||||
source.givenWorldClock(WorldClock(id = 1L, zoneId = "Europe/Berlin", label = "Home", sortOrder = 0))
|
||||
source.settings.setThemeMode(ThemeMode.DARK)
|
||||
source.settings.setSnoozeMinutes(7)
|
||||
source.settings.setHomeZoneId("America/New_York")
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
|
||||
// A fresh install: a different harness, empty of everything the export carries.
|
||||
val target = backupHarness(tempDir.resolve("target").also { it.toFile().mkdirs() })
|
||||
target.backupEngine.import(exported)
|
||||
|
||||
val alarm = target.storedAlarms().single()
|
||||
assertThat(alarm.time).isEqualTo(TimeOfDay(7, 30))
|
||||
assertThat(alarm.repeatDays).isEqualTo(RepeatDays.WEEKDAYS)
|
||||
assertThat(alarm.label).isEqualTo("Gym")
|
||||
|
||||
val timer = target.storedTimers().single()
|
||||
assertThat(timer.label).isEqualTo("Pasta")
|
||||
assertThat(timer.duration).isEqualTo(10.minutes)
|
||||
assertThat(timer.state).isEqualTo(TimerState.IDLE)
|
||||
assertThat(timer.remaining).isEqualTo(10.minutes)
|
||||
|
||||
val worldClock = target.storedWorldClocks().single()
|
||||
assertThat(worldClock.zoneId).isEqualTo("Europe/Berlin")
|
||||
assertThat(worldClock.label).isEqualTo("Home")
|
||||
|
||||
assertThat(target.settings.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||
assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7)
|
||||
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import never carries ring state`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("source2").also { it.toFile().mkdirs() })
|
||||
source.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY))
|
||||
source.givenRinging(1L, instantAt(BERLIN, "2026-06-10T07:00"))
|
||||
assertThat(source.alarmEngine.ringSession()).isNotNull()
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
assertThat(exported.alarms.single()).isInstanceOf(BackupAlarm::class.java)
|
||||
|
||||
val target = backupHarness(tempDir.resolve("target2").also { it.toFile().mkdirs() })
|
||||
target.backupEngine.import(exported)
|
||||
assertThat(target.alarmEngine.ringSession()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import dismisses a ringing alarm before replacing it`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = backupHarness(tempDir.resolve("ringing").also { it.toFile().mkdirs() })
|
||||
harness.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY))
|
||||
harness.givenRinging(1L, instantAt(BERLIN, "2026-06-10T07:00"))
|
||||
assertThat(harness.alarmEngine.ringSession()?.alarm?.id).isEqualTo(1L)
|
||||
|
||||
val replacement = harness.backupEngine.export().copy(alarms = emptyList())
|
||||
harness.backupEngine.import(replacement)
|
||||
|
||||
assertThat(harness.ring.events).contains(RingEvent.Stopped(1L))
|
||||
assertThat(harness.alarmEngine.ringSession()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import deletes running timers before replacing them`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = backupHarness(tempDir.resolve("runningTimer").also { it.toFile().mkdirs() })
|
||||
harness.givenTimer(runningTimerWith(id = 1L, left = 2.minutes, elapsedNow = 1_000.seconds))
|
||||
assertThat(harness.storedTimers().single().state).isEqualTo(TimerState.RUNNING)
|
||||
|
||||
val replacement = harness.backupEngine.export().copy(timers = emptyList())
|
||||
harness.backupEngine.import(replacement)
|
||||
|
||||
assertThat(harness.storedTimers()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import reschedules alarms with the system`(@TempDir tempDir: Path) = runTest {
|
||||
val harness = backupHarness(
|
||||
tempDir.resolve("reschedule").also { it.toFile().mkdirs() },
|
||||
now = instantAt(BERLIN, "2026-06-10T05:00"),
|
||||
)
|
||||
val content = harness.backupEngine.export().copy(
|
||||
alarms = listOf(
|
||||
BackupAlarm(
|
||||
time = TimeOfDay(7, 0),
|
||||
label = "Wake up",
|
||||
enabled = true,
|
||||
repeatDays = RepeatDays.NONE,
|
||||
skipNextOccurrence = false,
|
||||
ringtoneUri = null,
|
||||
vibrate = null,
|
||||
snoozeMinutes = null,
|
||||
snoozeLimit = null,
|
||||
volumeRampSeconds = null,
|
||||
dismissChallenge = null,
|
||||
deleteAfterUse = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
harness.backupEngine.import(content)
|
||||
|
||||
assertThat(harness.alarmScheduler.next).isNotNull()
|
||||
assertThat(harness.alarmScheduler.next?.fireAt).isEqualTo(instantAt(BERLIN, "2026-06-10T07:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings timer presets and dynamic colour round trip`(@TempDir tempDir: Path) = runTest {
|
||||
val source = backupHarness(tempDir.resolve("presetsSource").also { it.toFile().mkdirs() })
|
||||
source.settings.setDynamicColor(false)
|
||||
source.settings.setTimerPresets(listOf(1.minutes, 3.minutes, 9.minutes))
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
assertThat(exported.settings.dynamicColor).isFalse()
|
||||
assertThat(exported.settings.timerPresets).containsExactly(1.minutes, 3.minutes, 9.minutes).inOrder()
|
||||
|
||||
val target = backupHarness(tempDir.resolve("presetsTarget").also { it.toFile().mkdirs() })
|
||||
target.backupEngine.import(exported)
|
||||
|
||||
assertThat(target.settings.dynamicColor.first()).isFalse()
|
||||
assertThat(target.settings.timerPresets.first())
|
||||
.containsExactly(1.minutes, 3.minutes, 9.minutes).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package de.jeanlucmakiola.clockula.data.backup
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
class BackupCodecTest {
|
||||
|
||||
private fun sampleContent(): BackupContent = BackupContent(
|
||||
exportedAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
|
||||
alarms = listOf(
|
||||
BackupAlarm(
|
||||
time = TimeOfDay(7, 30),
|
||||
label = "Gym",
|
||||
enabled = true,
|
||||
repeatDays = RepeatDays.WEEKDAYS,
|
||||
skipNextOccurrence = true,
|
||||
ringtoneUri = "content://media/42",
|
||||
vibrate = false,
|
||||
snoozeMinutes = 7,
|
||||
snoozeLimit = 2,
|
||||
volumeRampSeconds = 30,
|
||||
dismissChallenge = DismissChallenge.MATH,
|
||||
deleteAfterUse = false,
|
||||
),
|
||||
),
|
||||
timers = listOf(
|
||||
BackupTimer(
|
||||
label = "Pasta",
|
||||
duration = 5.minutes,
|
||||
ringtoneUri = "content://media/7",
|
||||
sortOrder = 3,
|
||||
deleteAfterUse = false,
|
||||
),
|
||||
),
|
||||
worldClocks = listOf(
|
||||
BackupWorldClock(zoneId = "Europe/Berlin", label = "Home", sortOrder = 2),
|
||||
),
|
||||
settings = BackupSettings(
|
||||
themeMode = ThemeMode.DARK,
|
||||
dynamicColor = false,
|
||||
defaults = ClockDefaults(
|
||||
snoozeMinutes = 9,
|
||||
snoozeLimit = 4,
|
||||
vibrate = false,
|
||||
volumeRampSeconds = 20,
|
||||
alarmRingtoneUri = "content://media/1",
|
||||
timerRingtoneUri = "content://media/2",
|
||||
dismissChallenge = DismissChallenge.HOLD,
|
||||
timerDuration = 10.minutes,
|
||||
homeZoneId = "America/New_York",
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `round trip is identical`() {
|
||||
val original = sampleContent()
|
||||
val decoded = BackupCodec.decode(BackupCodec.encode(original))
|
||||
assertThat(decoded).isEqualTo(BackupResult.Success(original))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round trip of an empty backup is identical`() {
|
||||
val original = BackupContent(
|
||||
exportedAt = Instant.fromEpochMilliseconds(0L),
|
||||
alarms = emptyList(),
|
||||
timers = emptyList(),
|
||||
worldClocks = emptyList(),
|
||||
settings = BackupSettings(
|
||||
themeMode = ThemeMode.SYSTEM,
|
||||
dynamicColor = true,
|
||||
defaults = ClockDefaults(),
|
||||
timerPresets = emptyList(),
|
||||
),
|
||||
)
|
||||
val decoded = BackupCodec.decode(BackupCodec.encode(original))
|
||||
assertThat(decoded).isEqualTo(BackupResult.Success(original))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown top-level field is ignored`() {
|
||||
val decoded = BackupCodec.decode(minimalDocument(""" "unknownTopLevelField": 123,"""))
|
||||
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown nested field is ignored`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [ { "hour": 7, "minute": 0, "somethingElse": true } ],
|
||||
"timers": [],
|
||||
"worldClocks": [],
|
||||
"settings": { "someFutureSetting": "x" }
|
||||
}
|
||||
""".trimIndent()
|
||||
val decoded = BackupCodec.decode(json)
|
||||
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong format rejects the whole file`() {
|
||||
val decoded = BackupCodec.decode(minimalDocument(format = "something-else"))
|
||||
assertThat(decoded).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a future version rejects the whole file`() {
|
||||
val decoded = BackupCodec.decode(minimalDocument(version = 2))
|
||||
assertThat(decoded).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range hour rejects the whole file`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [ { "hour": 25, "minute": 0 } ],
|
||||
"timers": [],
|
||||
"worldClocks": [],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range minute rejects the whole file`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [ { "hour": 7, "minute": 60 } ],
|
||||
"timers": [],
|
||||
"worldClocks": [],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-IANA zone id rejects the whole file`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [],
|
||||
"timers": [],
|
||||
"worldClocks": [ { "zone_id": "Not/AZone" } ],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative timer duration rejects the whole file`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [],
|
||||
"timers": [ { "duration_millis": -1 } ],
|
||||
"worldClocks": [],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised dismiss challenge degrades rather than rejecting`() {
|
||||
val json = """
|
||||
{
|
||||
"format": "clockula-backup",
|
||||
"version": 1,
|
||||
"exportedAt": 0,
|
||||
"alarms": [ { "hour": 7, "minute": 0, "dismiss_challenge": "SOME_FUTURE_CHALLENGE" } ],
|
||||
"timers": [],
|
||||
"worldClocks": [],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
val result = BackupCodec.decode(json)
|
||||
assertThat(result).isInstanceOf(BackupResult.Success::class.java)
|
||||
val content = (result as BackupResult.Success).content
|
||||
assertThat(content.alarms.single().dismissChallenge).isEqualTo(DismissChallenge.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage text fails rather than throwing`() {
|
||||
assertThat(BackupCodec.decode("not json at all")).isInstanceOf(BackupResult.Failure::class.java)
|
||||
}
|
||||
|
||||
private fun minimalDocument(
|
||||
extraTopLevel: String = "",
|
||||
format: String = "clockula-backup",
|
||||
version: Int = 1,
|
||||
): String = """
|
||||
{
|
||||
"format": "$format",
|
||||
"version": $version,
|
||||
$extraTopLevel
|
||||
"exportedAt": 0,
|
||||
"alarms": [],
|
||||
"timers": [],
|
||||
"worldClocks": [],
|
||||
"settings": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.backup.BackupEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.backup.BackupRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerMapper
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockMapper
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.WorldClock
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import java.nio.file.Path
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* [BackupEngine] over both real engines, the real repositories, the fake DAOs
|
||||
* and one DataStore — the same shape [InteropHarness] builds for M9, extended
|
||||
* with world clocks for M10's backup.
|
||||
*/
|
||||
class BackupHarness(
|
||||
tempDir: Path,
|
||||
storeScope: CoroutineScope,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
|
||||
zone: ZoneId = BERLIN,
|
||||
uptime: Duration = 1_000.seconds,
|
||||
) {
|
||||
val alarmDao: FakeAlarmDao = FakeAlarmDao()
|
||||
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao(alarms = alarmDao)
|
||||
val timerDao: FakeTimerDao = FakeTimerDao()
|
||||
val worldClockDao: FakeWorldClockDao = FakeWorldClockDao()
|
||||
val backupDao: FakeBackupDao = FakeBackupDao(alarmDao, timerDao, worldClockDao)
|
||||
|
||||
val wallClock: FakeWallClock = FakeWallClock(now)
|
||||
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
|
||||
val zones: FakeZoneProvider = FakeZoneProvider(zone)
|
||||
|
||||
val alarmScheduler: FakeAlarmScheduler = FakeAlarmScheduler()
|
||||
val ring: FakeRingCoordinator = FakeRingCoordinator()
|
||||
val notifier: FakeAlarmNotifier = FakeAlarmNotifier()
|
||||
val timerScheduler: FakeTimerScheduler = FakeTimerScheduler()
|
||||
val timerService: FakeTimerServiceHandle = FakeTimerServiceHandle()
|
||||
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
|
||||
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
|
||||
|
||||
val store: PrefStore = PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = storeScope,
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
val settings: SettingsPrefs = SettingsPrefs(store)
|
||||
val ringState: TimerRingStateStore = TimerRingStateStore(store)
|
||||
|
||||
val alarms: AlarmRepositoryImpl = AlarmRepositoryImpl(alarmDao, wallClock)
|
||||
val states: AlarmStateRepositoryImpl = AlarmStateRepositoryImpl(stateDao)
|
||||
val timers: TimerRepositoryImpl = TimerRepositoryImpl(timerDao, elapsed, wallClock)
|
||||
val worldClocks: WorldClockRepositoryImpl = WorldClockRepositoryImpl(worldClockDao)
|
||||
val backupRepository: BackupRepositoryImpl =
|
||||
BackupRepositoryImpl(alarms, timers, worldClocks, backupDao, wallClock)
|
||||
|
||||
val alarmEngine: AlarmEngine = AlarmEngine(
|
||||
alarms = alarms,
|
||||
states = states,
|
||||
settings = settings,
|
||||
scheduler = alarmScheduler,
|
||||
ring = ring,
|
||||
notifier = notifier,
|
||||
rebootRepair = rebootRepair,
|
||||
wallClock = wallClock,
|
||||
zones = zones,
|
||||
)
|
||||
|
||||
val timerEngine: TimerEngine = TimerEngine(
|
||||
timers = timers,
|
||||
settings = settings,
|
||||
ringState = ringState,
|
||||
scheduler = timerScheduler,
|
||||
service = timerService,
|
||||
alarmRing = alarmRing,
|
||||
rebootRepair = rebootRepair,
|
||||
elapsed = elapsed,
|
||||
wall = wallClock,
|
||||
)
|
||||
|
||||
val backupEngine: BackupEngine = BackupEngine(
|
||||
repository = backupRepository,
|
||||
settings = settings,
|
||||
timers = timers,
|
||||
alarmEngine = alarmEngine,
|
||||
timerEngine = timerEngine,
|
||||
wallClock = wallClock,
|
||||
)
|
||||
|
||||
init {
|
||||
alarmDao.onDeleted = { stateDao.deleteByAlarmId(it) }
|
||||
}
|
||||
|
||||
suspend fun givenAlarm(alarm: Alarm): Alarm {
|
||||
alarmDao.insert(AlarmMapper.toEntity(alarm))
|
||||
return alarm
|
||||
}
|
||||
|
||||
suspend fun givenTimer(timer: Timer): Timer {
|
||||
timerDao.insert(TimerMapper.toEntity(timer))
|
||||
return timer
|
||||
}
|
||||
|
||||
suspend fun givenWorldClock(clock: WorldClock): WorldClock {
|
||||
worldClockDao.insert(WorldClockMapper.toEntity(clock))
|
||||
return clock
|
||||
}
|
||||
|
||||
/** Rings [alarmId] through the engine, exactly as a fire broadcast would. */
|
||||
suspend fun givenRinging(alarmId: Long, at: Instant) {
|
||||
wallClock.instant = at
|
||||
alarmEngine.onFire(alarmId, at)
|
||||
}
|
||||
|
||||
suspend fun storedAlarms(): List<Alarm> = alarmDao.stored.map(AlarmMapper::toDomain)
|
||||
|
||||
suspend fun storedTimers(): List<Timer> = timerDao.stored.map(TimerMapper::toDomain)
|
||||
|
||||
suspend fun storedWorldClocks(): List<WorldClock> = worldClockDao.stored.map(WorldClockMapper::toDomain)
|
||||
}
|
||||
|
||||
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
|
||||
fun TestScope.backupHarness(
|
||||
tempDir: Path,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
|
||||
zone: ZoneId = BERLIN,
|
||||
): BackupHarness = BackupHarness(
|
||||
tempDir = tempDir,
|
||||
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
now = now,
|
||||
zone = zone,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity
|
||||
import de.jeanlucmakiola.clockula.data.backup.BackupDao
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
|
||||
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerDao
|
||||
@@ -236,3 +237,42 @@ class FakeAlarmStateDao(
|
||||
|
||||
private fun sorted(rows: List<AlarmStateEntity>): List<AlarmStateEntity> = rows.sortedBy { it.alarmId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates to the real [FakeAlarmDao]/[FakeTimerDao]/[FakeWorldClockDao]
|
||||
* instances it is given, rather than holding its own storage — so a backup
|
||||
* test can read the same tables through [FakeAlarmDao.stored] etc. Deletes go
|
||||
* through each dao's own `deleteById`, so [FakeAlarmDao]'s `onDeleted` cascade
|
||||
* hook (wired to the state dao, exactly as [InteropHarness] wires it) still
|
||||
* fires for every alarm a replace removes.
|
||||
*/
|
||||
class FakeBackupDao(
|
||||
private val alarmDao: FakeAlarmDao,
|
||||
private val timerDao: FakeTimerDao,
|
||||
private val worldClockDao: FakeWorldClockDao,
|
||||
) : BackupDao() {
|
||||
|
||||
override suspend fun deleteAllAlarms() {
|
||||
for (row in alarmDao.stored) alarmDao.deleteById(row.id)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllTimers() {
|
||||
for (row in timerDao.stored) timerDao.deleteById(row.id)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllWorldClocks() {
|
||||
for (row in worldClockDao.stored) worldClockDao.deleteById(row.id)
|
||||
}
|
||||
|
||||
override suspend fun insertAlarms(alarms: List<AlarmEntity>) {
|
||||
for (alarm in alarms) alarmDao.insert(alarm)
|
||||
}
|
||||
|
||||
override suspend fun insertTimers(timers: List<TimerEntity>) {
|
||||
for (timer in timers) timerDao.insert(timer)
|
||||
}
|
||||
|
||||
override suspend fun insertWorldClocks(worldClocks: List<WorldClockEntity>) {
|
||||
for (clock in worldClocks) worldClockDao.insert(clock)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user