feat(data): entity and domain mappers

The translation either way, including the repeat mask's bit order and the
nullable per-alarm overrides that mean "inherit the app default" rather
than a concrete value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:11 +02:00
co-authored by Claude Opus 5
parent 778d08af24
commit 1d85faa54e
9 changed files with 583 additions and 0 deletions
@@ -0,0 +1,70 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlin.time.Instant
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
object AlarmMapper {
fun toDomain(entity: AlarmEntity): Alarm = Alarm(
id = entity.id,
time = TimeOfDay.clamped(entity.hour, entity.minute),
label = entity.label,
enabled = entity.enabled,
repeatDays = RepeatDays.fromMask(entity.repeatDays),
skipNextOccurrence = entity.skipNextOccurrence,
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
vibrate = entity.vibrate,
snoozeMinutes = entity.snoozeMinutes,
snoozeLimit = entity.snoozeLimit,
volumeRampSeconds = entity.volumeRampSeconds,
// An absent challenge is an *override that isn't set*, so it stays null;
// a stored name the app no longer knows degrades to NONE.
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
)
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
fun toEntity(alarm: Alarm): AlarmEntity = AlarmEntity(
id = alarm.id,
hour = alarm.time.hour,
minute = alarm.time.minute,
label = alarm.label,
enabled = alarm.enabled,
repeatDays = alarm.repeatDays.mask,
skipNextOccurrence = alarm.skipNextOccurrence,
ringtoneUri = alarm.ringtoneUri,
vibrate = alarm.vibrate,
snoozeMinutes = alarm.snoozeMinutes,
snoozeLimit = alarm.snoozeLimit,
volumeRampSeconds = alarm.volumeRampSeconds,
dismissChallenge = alarm.dismissChallenge?.name,
createdAt = alarm.createdAt.toEpochMilliseconds(),
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
)
/** id = 0, createdAt = updatedAt = [now]. */
fun toEntity(draft: AlarmDraft, now: Instant): AlarmEntity = AlarmEntity(
id = 0L,
hour = draft.time.hour,
minute = draft.time.minute,
label = draft.label,
enabled = draft.enabled,
repeatDays = draft.repeatDays.mask,
skipNextOccurrence = false,
ringtoneUri = draft.ringtoneUri,
vibrate = draft.vibrate,
snoozeMinutes = draft.snoozeMinutes,
snoozeLimit = draft.snoozeLimit,
volumeRampSeconds = draft.volumeRampSeconds,
dismissChallenge = draft.dismissChallenge?.name,
createdAt = now.toEpochMilliseconds(),
updatedAt = now.toEpochMilliseconds(),
)
}
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
import kotlin.time.Duration.Companion.milliseconds
object LapMapper {
fun toDomain(entity: LapEntity): Lap = Lap(
index = entity.lapIndex,
split = entity.splitMillis.coerceAtLeast(0L).milliseconds,
cumulative = entity.cumulativeMillis.coerceAtLeast(0L).milliseconds,
)
fun toDomain(entities: List<LapEntity>): List<Lap> = entities.map(::toDomain)
}
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
object TimerMapper {
fun toDomain(entity: TimerEntity): Timer = Timer(
id = entity.id,
label = entity.label,
duration = entity.durationMillis.coerceAtLeast(0L).milliseconds,
state = entity.state.toEnum(TimerState.IDLE),
remaining = entity.remainingMillis.coerceAtLeast(0L).milliseconds,
startedAtElapsedRealtime = entity.startedAtElapsedRealtimeMillis?.milliseconds,
endsAtElapsedRealtime = entity.endsAtElapsedRealtimeMillis?.milliseconds,
endsAtWallClock = entity.endsAtWallClockMillis?.let(Instant::fromEpochMilliseconds),
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
sortOrder = entity.sortOrder,
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
)
fun toDomain(entities: List<TimerEntity>): List<Timer> = entities.map(::toDomain)
fun toEntity(timer: Timer): TimerEntity = TimerEntity(
id = timer.id,
label = timer.label,
durationMillis = timer.duration.inWholeMilliseconds,
state = timer.state.name,
remainingMillis = timer.remaining.inWholeMilliseconds,
startedAtElapsedRealtimeMillis = timer.startedAtElapsedRealtime?.inWholeMilliseconds,
endsAtElapsedRealtimeMillis = timer.endsAtElapsedRealtime?.inWholeMilliseconds,
endsAtWallClockMillis = timer.endsAtWallClock?.toEpochMilliseconds(),
ringtoneUri = timer.ringtoneUri,
sortOrder = timer.sortOrder,
createdAt = timer.createdAt.toEpochMilliseconds(),
updatedAt = timer.updatedAt.toEpochMilliseconds(),
)
fun toEntity(draft: TimerDraft, sortOrder: Int, now: Instant): TimerEntity {
val duration = draft.duration.coerceAtLeast(Duration.ZERO)
return TimerEntity(
id = 0L,
label = draft.label,
durationMillis = duration.inWholeMilliseconds,
state = TimerState.IDLE.name,
remainingMillis = duration.inWholeMilliseconds,
startedAtElapsedRealtimeMillis = null,
endsAtElapsedRealtimeMillis = null,
endsAtWallClockMillis = null,
ringtoneUri = draft.ringtoneUri,
sortOrder = sortOrder,
createdAt = now.toEpochMilliseconds(),
updatedAt = now.toEpochMilliseconds(),
)
}
}
@@ -0,0 +1,26 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import de.jeanlucmakiola.clockula.domain.WorldClock
object WorldClockMapper {
/**
* A zone id the device no longer knows is passed through untouched —
* `WorldClock.isKnownZone` says so and M8 shows the row as broken. Blanking
* it here would silently lose what the user chose.
*/
fun toDomain(entity: WorldClockEntity): WorldClock = WorldClock(
id = entity.id,
zoneId = entity.zoneId,
label = entity.label?.takeIf { it.isNotBlank() },
sortOrder = entity.sortOrder,
)
fun toDomain(entities: List<WorldClockEntity>): List<WorldClock> = entities.map(::toDomain)
fun toEntity(clock: WorldClock): WorldClockEntity = WorldClockEntity(
id = clock.id,
zoneId = clock.zoneId,
label = clock.label,
sortOrder = clock.sortOrder,
)
}
@@ -0,0 +1,127 @@
package de.jeanlucmakiola.clockula.data.alarms
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.fullAlarm
import de.jeanlucmakiola.clockula.testing.fullAlarmEntity
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* The mapper is the single place a corrupt row is made harmless: reads degrade,
* they never throw, because a throw inside a cursor read is a crashed list.
*/
class AlarmMapperTest {
@Test
fun `a fully populated row maps field by field`() {
val alarm = AlarmMapper.toDomain(fullAlarmEntity())
assertThat(alarm).isEqualTo(
Alarm(
id = 42L,
time = TimeOfDay(7, 5),
label = "Gym",
enabled = true,
repeatDays = RepeatDays.WEEKDAYS,
skipNextOccurrence = true,
ringtoneUri = "content://media/42",
vibrate = false,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = DismissChallenge.MATH,
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
),
)
}
@Test
fun `a row round-trips through the domain model`() {
val entity = fullAlarmEntity()
assertThat(AlarmMapper.toEntity(AlarmMapper.toDomain(entity))).isEqualTo(entity)
}
@Test
fun `an alarm round-trips through the entity`() {
val alarm = fullAlarm()
assertThat(AlarmMapper.toDomain(AlarmMapper.toEntity(alarm))).isEqualTo(alarm)
}
@Test
fun `an unknown dismiss challenge degrades to none`() {
val entity = fullAlarmEntity().copy(dismissChallenge = "TELEPORT")
assertThat(AlarmMapper.toDomain(entity).dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `an absent dismiss challenge stays absent`() {
val entity = fullAlarmEntity().copy(dismissChallenge = null)
assertThat(AlarmMapper.toDomain(entity).dismissChallenge).isNull()
}
@Test
fun `an empty dismiss challenge degrades to none`() {
val entity = fullAlarmEntity().copy(dismissChallenge = "")
assertThat(AlarmMapper.toDomain(entity).dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `a corrupt repeat mask is sanitised`() {
val entity = fullAlarmEntity().copy(repeatDays = 255)
assertThat(AlarmMapper.toDomain(entity).repeatDays.mask).isEqualTo(127)
}
@Test
fun `an out-of-range time is clamped rather than thrown`() {
val entity = fullAlarmEntity().copy(hour = 25, minute = 88)
assertThat(AlarmMapper.toDomain(entity).time).isEqualTo(TimeOfDay(23, 59))
}
@Test
fun `a blank ringtone uri reads as absent`() {
val entity = fullAlarmEntity().copy(ringtoneUri = "")
assertThat(AlarmMapper.toDomain(entity).ringtoneUri).isNull()
}
@Test
fun `a draft becomes a new row stamped with the given instant`() {
val draft = AlarmDraft(TimeOfDay(6, 0), label = "Work", repeatDays = RepeatDays.WEEKDAYS)
val entity = AlarmMapper.toEntity(draft, T0)
assertThat(entity).isEqualTo(
AlarmEntity(
id = 0L,
hour = 6,
minute = 0,
label = "Work",
enabled = true,
repeatDays = 31,
skipNextOccurrence = false,
ringtoneUri = null,
vibrate = null,
snoozeMinutes = null,
snoozeLimit = null,
volumeRampSeconds = null,
dismissChallenge = null,
createdAt = T0.toEpochMilliseconds(),
updatedAt = T0.toEpochMilliseconds(),
),
)
}
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.testing.lapEntity
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class LapMapperTest {
@Test
fun `a row maps field by field, without its row id`() {
val lap = LapMapper.toDomain(lapEntity())
assertThat(lap).isEqualTo(Lap(index = 3, split = 1_234.milliseconds, cumulative = 5_678.milliseconds))
}
@Test
fun `a list maps in the order given`() {
val entities = listOf(
LapEntity(id = 1L, lapIndex = 1, splitMillis = 1_000L, cumulativeMillis = 1_000L),
LapEntity(id = 2L, lapIndex = 2, splitMillis = 2_000L, cumulativeMillis = 3_000L),
LapEntity(id = 3L, lapIndex = 3, splitMillis = 3_000L, cumulativeMillis = 6_000L),
)
val laps = LapMapper.toDomain(entities)
assertThat(laps.map { it.index }).containsExactly(1, 2, 3).inOrder()
}
@Test
fun `negative millis are clamped to zero`() {
val lap = LapMapper.toDomain(lapEntity().copy(splitMillis = -1L, cumulativeMillis = -1L))
assertThat(lap.split).isEqualTo(Duration.ZERO)
assertThat(lap.cumulative).isEqualTo(Duration.ZERO)
}
@Test
fun `an empty list maps to an empty list`() {
assertThat(LapMapper.toDomain(emptyList())).isEmpty()
}
}
@@ -0,0 +1,97 @@
package de.jeanlucmakiola.clockula.data.timers
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.runningTimer
import de.jeanlucmakiola.clockula.testing.runningTimerEntity
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class TimerMapperTest {
@Test
fun `a running row maps field by field`() {
val timer = TimerMapper.toDomain(runningTimerEntity())
assertThat(timer).isEqualTo(runningTimer())
}
@Test
fun `a row round-trips through the domain model`() {
val entity = runningTimerEntity()
assertThat(TimerMapper.toEntity(TimerMapper.toDomain(entity))).isEqualTo(entity)
}
@Test
fun `a timer round-trips through the entity`() {
val timer = runningTimer()
assertThat(TimerMapper.toDomain(TimerMapper.toEntity(timer))).isEqualTo(timer)
}
@Test
fun `an unknown state degrades to idle`() {
val entity = runningTimerEntity().copy(state = "SPINNING")
val timer = TimerMapper.toDomain(entity)
assertThat(timer.state).isEqualTo(TimerState.IDLE)
assertThat(timer.remaining).isEqualTo(200.seconds)
}
@Test
fun `an idle row has no anchors and its full remaining time`() {
val entity = runningTimerEntity().copy(
state = "IDLE",
remainingMillis = 300_000L,
startedAtElapsedRealtimeMillis = null,
endsAtElapsedRealtimeMillis = null,
endsAtWallClockMillis = null,
)
val timer = TimerMapper.toDomain(entity)
assertThat(timer.startedAtElapsedRealtime).isNull()
assertThat(timer.endsAtElapsedRealtime).isNull()
assertThat(timer.endsAtWallClock).isNull()
assertThat(timer.remaining).isEqualTo(timer.duration)
}
@Test
fun `a negative duration is clamped to zero`() {
val entity = runningTimerEntity().copy(durationMillis = -5L, remainingMillis = -5L)
val timer = TimerMapper.toDomain(entity)
assertThat(timer.duration).isEqualTo(Duration.ZERO)
assertThat(timer.remaining).isEqualTo(Duration.ZERO)
}
@Test
fun `a draft becomes a new idle row`() {
val draft = TimerDraft(90.seconds, label = "Tea")
val entity = TimerMapper.toEntity(draft, sortOrder = 2, now = T0)
assertThat(entity).isEqualTo(
TimerEntity(
id = 0L,
label = "Tea",
durationMillis = 90_000L,
state = "IDLE",
remainingMillis = 90_000L,
startedAtElapsedRealtimeMillis = null,
endsAtElapsedRealtimeMillis = null,
endsAtWallClockMillis = null,
ringtoneUri = null,
sortOrder = 2,
createdAt = T0.toEpochMilliseconds(),
updatedAt = T0.toEpochMilliseconds(),
),
)
}
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.isKnownZone
import de.jeanlucmakiola.clockula.testing.worldClockEntity
import org.junit.jupiter.api.Test
class WorldClockMapperTest {
@Test
fun `a row maps field by field`() {
val clock = WorldClockMapper.toDomain(worldClockEntity())
assertThat(clock).isEqualTo(WorldClock(3L, "Europe/Berlin", "Home", 2))
}
@Test
fun `a row round-trips through the domain model`() {
val entity = worldClockEntity()
assertThat(WorldClockMapper.toEntity(WorldClockMapper.toDomain(entity))).isEqualTo(entity)
}
@Test
fun `a blank label reads as absent`() {
assertThat(WorldClockMapper.toDomain(worldClockEntity().copy(label = "")).label).isNull()
assertThat(WorldClockMapper.toDomain(worldClockEntity().copy(label = " ")).label).isNull()
}
@Test
fun `a zone id the device no longer knows is preserved, not blanked`() {
val entity = worldClockEntity().copy(zoneId = "Mars/Phobos")
assertThat(WorldClockMapper.toDomain(entity).zoneId).isEqualTo("Mars/Phobos")
}
@Test
fun `isKnownZone reflects the device tzdata`() {
assertThat(WorldClock(1L, "Europe/Berlin", null, 0).isKnownZone).isTrue()
assertThat(WorldClock(1L, "Mars/Phobos", null, 0).isKnownZone).isFalse()
}
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** The one wall-clock instant every fixture is anchored on. */
val T0: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L)
/**
* Every field a distinct, non-default value, so a mapper that crosses two
* fields over cannot pass by coincidence.
*/
fun fullAlarmEntity(): AlarmEntity = AlarmEntity(
id = 42L,
hour = 7,
minute = 5,
label = "Gym",
enabled = true,
repeatDays = 31,
skipNextOccurrence = true,
ringtoneUri = "content://media/42",
vibrate = false,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = "MATH",
createdAt = 1_700_000_000_000L,
updatedAt = 1_700_000_060_000L,
)
fun fullAlarm(): Alarm = Alarm(
id = 42L,
time = TimeOfDay(7, 5),
label = "Gym",
enabled = true,
repeatDays = RepeatDays.WEEKDAYS,
skipNextOccurrence = true,
ringtoneUri = "content://media/42",
vibrate = false,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = DismissChallenge.MATH,
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
)
fun runningTimerEntity(): TimerEntity = TimerEntity(
id = 7L,
label = "Pasta",
durationMillis = 300_000L,
state = "RUNNING",
remainingMillis = 200_000L,
startedAtElapsedRealtimeMillis = 1_000_000L,
endsAtElapsedRealtimeMillis = 1_300_000L,
endsAtWallClockMillis = 1_700_000_300_000L,
ringtoneUri = "content://media/7",
sortOrder = 3,
createdAt = 1_700_000_000_000L,
updatedAt = 1_700_000_060_000L,
)
fun runningTimer(): Timer = Timer(
id = 7L,
label = "Pasta",
duration = 5.minutes,
state = TimerState.RUNNING,
remaining = 200.seconds,
startedAtElapsedRealtime = 1_000.seconds,
endsAtElapsedRealtime = 1_300.seconds,
endsAtWallClock = Instant.fromEpochMilliseconds(1_700_000_300_000L),
ringtoneUri = "content://media/7",
sortOrder = 3,
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L),
updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L),
)
fun worldClockEntity(): WorldClockEntity = WorldClockEntity(
id = 3L,
zoneId = "Europe/Berlin",
label = "Home",
sortOrder = 2,
)
fun lapEntity(): LapEntity = LapEntity(
id = 9L,
lapIndex = 3,
splitMillis = 1_234L,
cumulativeMillis = 5_678L,
)