feat(domain): next-fire text, repeat summaries, ringtones and defaults
The countdown on a row is formatting, not scheduling: it turns the engine's resolved instant into "in 9h 12m", rounding whole minutes up so a row never reads a minute it has already passed. Repeat masks become their own short summaries with the week starting where the locale says it does. A ringtone is a URI, plus one explicit silent sentinel — "Silent" has to be a choice a user can make, and it resolves to no audio source at all, which is what forces vibration rather than a quiet alarm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
object AlarmDefaults {
|
||||
/** The next whole hour in [zone], as a local time of day. Never "now". */
|
||||
fun nextWholeHour(now: Instant, zone: ZoneId): TimeOfDay {
|
||||
// An hour of *real* time, then truncated in the zone: on a spring-forward
|
||||
// night 01:30 + 1h is 03:30 local, which truncates to the 03:00 that
|
||||
// actually arrives. Adding an hour to the local reading would have
|
||||
// offered 02:00, an hour that does not happen that night.
|
||||
val local = now.toJavaInstant()
|
||||
.atZone(zone)
|
||||
.plusHours(1)
|
||||
.truncatedTo(ChronoUnit.HOURS)
|
||||
return TimeOfDay(local.hour, local.minute)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import java.time.DayOfWeek
|
||||
|
||||
sealed interface RepeatSummary {
|
||||
data object Once : RepeatSummary
|
||||
data object EveryDay : RepeatSummary
|
||||
data object Weekdays : RepeatSummary
|
||||
data object Weekends : RepeatSummary
|
||||
|
||||
/** The mask's days, ordered from the locale's first day of week. Never empty. */
|
||||
data class SomeDays(val days: List<DayOfWeek>) : RepeatSummary
|
||||
}
|
||||
|
||||
object RepeatDaysSummary {
|
||||
fun of(repeatDays: RepeatDays, firstDayOfWeek: DayOfWeek): RepeatSummary = when (repeatDays.mask) {
|
||||
RepeatDays.NONE.mask -> RepeatSummary.Once
|
||||
RepeatDays.EVERY_DAY.mask -> RepeatSummary.EveryDay
|
||||
RepeatDays.WEEKDAYS.mask -> RepeatSummary.Weekdays
|
||||
RepeatDays.WEEKENDS.mask -> RepeatSummary.Weekends
|
||||
// Anything else is the days themselves, in the reader's own week order —
|
||||
// "Mon, Wed, Fri" says more than any invented shorthand would.
|
||||
else -> RepeatSummary.SomeDays(
|
||||
RepeatDaysOrder.startingAt(firstDayOfWeek).filter { it in repeatDays },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object RepeatDaysOrder {
|
||||
/** All seven days, starting at [firstDayOfWeek]. */
|
||||
fun startingAt(firstDayOfWeek: DayOfWeek): List<DayOfWeek> =
|
||||
List(DayOfWeek.entries.size) { firstDayOfWeek.plus(it.toLong()) }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
/** One selectable device sound: its content URI and the title to show. */
|
||||
data class RingtoneOption(val uri: String, val title: String)
|
||||
|
||||
object Ringtones {
|
||||
/** The reserved "no audio" choice. A private scheme, so no provider URI can collide. */
|
||||
const val SILENT_URI: String = "clockula://silent"
|
||||
|
||||
fun isSilent(uri: String?): Boolean = uri == SILENT_URI
|
||||
|
||||
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
|
||||
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
|
||||
/** What the platform will currently let the app do. */
|
||||
data class CapabilitySnapshot(
|
||||
val canScheduleExactAlarms: Boolean,
|
||||
@@ -50,3 +52,24 @@ object AlarmVibrationPattern {
|
||||
/** The index the pattern loops from: 0, so the whole pattern repeats. */
|
||||
const val REPEAT_INDEX: Int = 0
|
||||
}
|
||||
|
||||
enum class RingtoneSourceKind { ALARM_OWN, DEVICE_DEFAULT, BUILT_IN_DEFAULT }
|
||||
|
||||
object AudioSourcePolicy {
|
||||
/**
|
||||
* The audio sources to try, in order. **Empty** for the silent sentinel —
|
||||
* which is what turns forced vibration on, so the alarm still does something.
|
||||
*/
|
||||
fun sourcesFor(ringtoneUri: String?): List<RingtoneSourceKind> = when {
|
||||
Ringtones.isSilent(ringtoneUri) -> emptyList()
|
||||
Ringtones.normalise(ringtoneUri) == null -> listOf(
|
||||
RingtoneSourceKind.DEVICE_DEFAULT,
|
||||
RingtoneSourceKind.BUILT_IN_DEFAULT,
|
||||
)
|
||||
else -> listOf(
|
||||
RingtoneSourceKind.ALARM_OWN,
|
||||
RingtoneSourceKind.DEVICE_DEFAULT,
|
||||
RingtoneSourceKind.BUILT_IN_DEFAULT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.jeanlucmakiola.clockula.domain.format
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
sealed interface NextFireLabel {
|
||||
data object NotScheduled : NextFireLabel
|
||||
data object Imminent : NextFireLabel
|
||||
data class InMinutes(val minutes: Int) : NextFireLabel
|
||||
data class InHoursMinutes(val hours: Int, val minutes: Int) : NextFireLabel
|
||||
data class InDaysHours(val days: Int, val hours: Int) : NextFireLabel
|
||||
}
|
||||
|
||||
object NextFireFormat {
|
||||
/** Anything nearer than this reads as [NextFireLabel.Imminent]. */
|
||||
val IMMINENT_BELOW: Duration = 1.minutes
|
||||
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
private const val HOURS_PER_DAY = 24
|
||||
private const val MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
|
||||
fun labelFor(nextFire: Instant?, now: Instant): NextFireLabel =
|
||||
if (nextFire == null) NextFireLabel.NotScheduled else labelFor(nextFire - now)
|
||||
|
||||
/** Whole minutes, rounded **up**. Zero and negative read as Imminent. */
|
||||
fun labelFor(remaining: Duration): NextFireLabel {
|
||||
if (remaining < IMMINENT_BELOW) return NextFireLabel.Imminent
|
||||
|
||||
// Rounded up, the direction `ClockFormat.countdown` rounds: "in 9h 12m"
|
||||
// must mean *at least* nine hours and eleven-and-a-bit minutes.
|
||||
val wholeMinutes = ceilMinutes(remaining)
|
||||
return when {
|
||||
wholeMinutes < MINUTES_PER_HOUR -> NextFireLabel.InMinutes(wholeMinutes)
|
||||
wholeMinutes < MINUTES_PER_DAY -> NextFireLabel.InHoursMinutes(
|
||||
hours = wholeMinutes / MINUTES_PER_HOUR,
|
||||
minutes = wholeMinutes % MINUTES_PER_HOUR,
|
||||
)
|
||||
// Past a day the unit is an hour, so the rounding moves up with it:
|
||||
// 6d 23h 59m reads "in 7d", not "in 6d 23h".
|
||||
else -> ceilHours(remaining).let { hours ->
|
||||
NextFireLabel.InDaysHours(days = hours / HOURS_PER_DAY, hours = hours % HOURS_PER_DAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ceilMinutes(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE)
|
||||
|
||||
private fun ceilHours(remaining: Duration): Int = ceilUnits(remaining, MILLIS_PER_MINUTE * MINUTES_PER_HOUR)
|
||||
|
||||
private fun ceilUnits(remaining: Duration, unitMillis: Long): Int {
|
||||
val millis = remaining.inWholeMilliseconds
|
||||
return ((millis + unitMillis - 1) / unitMillis).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.NEW_YORK
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A new alarm's time is the next whole hour *that actually arrives* — resolved
|
||||
* in the device zone, so a spring-forward night cannot offer 02:00 (M5 D23).
|
||||
*/
|
||||
class AlarmDefaultsTest {
|
||||
|
||||
@Test
|
||||
fun `a time inside the hour rounds up to the next one`() {
|
||||
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T06:12"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(7, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly on the hour is never now`() {
|
||||
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T06:00"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(7, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late in the evening wraps to midnight`() {
|
||||
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T23:40"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(0, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly on the last hour of the day wraps to midnight`() {
|
||||
val time = AlarmDefaults.nextWholeHour(instantAt(BERLIN, "2026-06-10T23:00"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(0, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the spring-forward gap is skipped, because 02 00 never happens`() {
|
||||
val time = AlarmDefaults.nextWholeHour(offsetInstant("2026-03-29T01:30+01:00"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(3, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first pass of a fall-back night gets the hour it is in`() {
|
||||
val time = AlarmDefaults.nextWholeHour(offsetInstant("2026-10-25T01:30+02:00"), BERLIN)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(2, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shape is the same in any zone`() {
|
||||
val time = AlarmDefaults.nextWholeHour(instantAt(NEW_YORK, "2026-06-10T06:12"), NEW_YORK)
|
||||
|
||||
assertThat(time).isEqualTo(TimeOfDay(7, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/** The day selector's column order is the locale's, not a hardcoded Monday (M5 D28). */
|
||||
class RepeatDaysOrderTest {
|
||||
|
||||
@Test
|
||||
fun `a monday-first locale runs monday to sunday`() {
|
||||
val order = RepeatDaysOrder.startingAt(DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(order).containsExactlyElementsIn(DayOfWeek.entries).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sunday-first locale wraps saturday to the end`() {
|
||||
val order = RepeatDaysOrder.startingAt(DayOfWeek.SUNDAY)
|
||||
|
||||
assertThat(order).containsExactly(
|
||||
DayOfWeek.SUNDAY,
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY,
|
||||
DayOfWeek.SATURDAY,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a saturday-first locale wraps two days to the end`() {
|
||||
val order = RepeatDaysOrder.startingAt(DayOfWeek.SATURDAY)
|
||||
|
||||
assertThat(order).containsExactly(
|
||||
DayOfWeek.SATURDAY,
|
||||
DayOfWeek.SUNDAY,
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every locale gets all seven days, beginning with its own`() {
|
||||
for (first in DayOfWeek.entries) {
|
||||
val order = RepeatDaysOrder.startingAt(first)
|
||||
|
||||
assertWithMessage("starting at %s", first)
|
||||
.that(order.distinct().size to order.firstOrNull())
|
||||
.isEqualTo(7 to first)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/**
|
||||
* The four named masks get their own word; anything else is the days
|
||||
* themselves, ordered from the locale's first day of week (M5 D28).
|
||||
*/
|
||||
class RepeatSummaryTest {
|
||||
|
||||
@Test
|
||||
fun `no days at all is once`() {
|
||||
val summary = RepeatDaysSummary.of(RepeatDays.NONE, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(RepeatSummary.Once)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every day has its own word`() {
|
||||
val summary = RepeatDaysSummary.of(RepeatDays.EVERY_DAY, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(RepeatSummary.EveryDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monday to friday is weekdays`() {
|
||||
val summary = RepeatDaysSummary.of(RepeatDays.WEEKDAYS, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(RepeatSummary.Weekdays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saturday and sunday is weekends`() {
|
||||
val summary = RepeatDaysSummary.of(RepeatDays.WEEKENDS, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(RepeatSummary.Weekends)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an arbitrary mask lists its days from monday when monday is first`() {
|
||||
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)
|
||||
|
||||
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(
|
||||
RepeatSummary.SomeDays(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sunday-first locale puts sunday before monday`() {
|
||||
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)
|
||||
|
||||
val summary = RepeatDaysSummary.of(mask, DayOfWeek.SUNDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(
|
||||
RepeatSummary.SomeDays(listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same mask in a monday-first locale puts sunday last`() {
|
||||
val mask = RepeatDays.of(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)
|
||||
|
||||
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(
|
||||
RepeatSummary.SomeDays(listOf(DayOfWeek.MONDAY, DayOfWeek.SUNDAY)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single day is that day alone`() {
|
||||
val summary = RepeatDaysSummary.of(RepeatDays.of(DayOfWeek.THURSDAY), DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(RepeatSummary.SomeDays(listOf(DayOfWeek.THURSDAY)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `six of seven days is not shortened to a word`() {
|
||||
val mask = RepeatDays.of(DayOfWeek.entries.toSet() - DayOfWeek.SUNDAY)
|
||||
|
||||
val summary = RepeatDaysSummary.of(mask, DayOfWeek.MONDAY)
|
||||
|
||||
assertThat(summary).isEqualTo(
|
||||
RepeatSummary.SomeDays(
|
||||
listOf(
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY,
|
||||
DayOfWeek.SATURDAY,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every mask in every locale summarises its own days, in locale order`() {
|
||||
for (mask in 0..RepeatDays.ALL_MASK) {
|
||||
val repeatDays = RepeatDays.fromMask(mask)
|
||||
for (first in DayOfWeek.entries) {
|
||||
val summary = RepeatDaysSummary.of(repeatDays, first)
|
||||
val expectedOrder = RepeatDaysOrder.startingAt(first).filter { it in repeatDays }
|
||||
|
||||
assertWithMessage("mask %s from %s", mask, first)
|
||||
.that(if (summary is RepeatSummary.SomeDays) summary.days else expectedOrder)
|
||||
.isEqualTo(expectedOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* `null` already means "inherit the app default", so silence needs a value of
|
||||
* its own — and a private scheme, so no provider URI can ever collide (M5 D18).
|
||||
*/
|
||||
class RingtonesTest {
|
||||
|
||||
@Test
|
||||
fun `only the sentinel is silent`() {
|
||||
val answers = listOf(
|
||||
Ringtones.isSilent(Ringtones.SILENT_URI),
|
||||
Ringtones.isSilent(null),
|
||||
Ringtones.isSilent("content://media/42"),
|
||||
)
|
||||
|
||||
assertThat(answers).containsExactly(true, false, false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sentinel's scheme is the app's own`() {
|
||||
assertThat(Ringtones.SILENT_URI).startsWith("clockula://")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalise trims and turns blank into absent`() {
|
||||
val answers = listOf(
|
||||
Ringtones.normalise(" content://media/42 "),
|
||||
Ringtones.normalise(""),
|
||||
Ringtones.normalise(" "),
|
||||
Ringtones.normalise(null),
|
||||
)
|
||||
|
||||
assertThat(answers).containsExactly("content://media/42", null, null, null).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalise passes the sentinel through unchanged`() {
|
||||
assertThat(Ringtones.normalise(Ringtones.SILENT_URI)).isEqualTo(Ringtones.SILENT_URI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The fallback chain, as a value a JVM test can enumerate. Silence is an empty
|
||||
* list — which is exactly what forces vibration, so a "silent" alarm still does
|
||||
* something (M5 D18).
|
||||
*/
|
||||
class AudioSourcePolicyTest {
|
||||
|
||||
@Test
|
||||
fun `no override falls through to the device's own defaults`() {
|
||||
val sources = AudioSourcePolicy.sourcesFor(null)
|
||||
|
||||
assertThat(sources)
|
||||
.containsExactly(RingtoneSourceKind.DEVICE_DEFAULT, RingtoneSourceKind.BUILT_IN_DEFAULT)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank override is no override`() {
|
||||
val sources = AudioSourcePolicy.sourcesFor(" ")
|
||||
|
||||
assertThat(sources)
|
||||
.containsExactly(RingtoneSourceKind.DEVICE_DEFAULT, RingtoneSourceKind.BUILT_IN_DEFAULT)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chosen sound is tried first, with the defaults behind it`() {
|
||||
val sources = AudioSourcePolicy.sourcesFor("content://media/42")
|
||||
|
||||
assertThat(sources).containsExactly(
|
||||
RingtoneSourceKind.ALARM_OWN,
|
||||
RingtoneSourceKind.DEVICE_DEFAULT,
|
||||
RingtoneSourceKind.BUILT_IN_DEFAULT,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the silent sentinel offers no audio source at all`() {
|
||||
val sources = AudioSourcePolicy.sourcesFor(Ringtones.SILENT_URI)
|
||||
|
||||
assertThat(sources).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm with no audio source still vibrates`() {
|
||||
val sources = AudioSourcePolicy.sourcesFor(Ringtones.SILENT_URI)
|
||||
|
||||
val required = RingFallbackPolicy.vibrationRequired(
|
||||
audioAvailable = sources.isNotEmpty(),
|
||||
vibrateSetting = false,
|
||||
)
|
||||
|
||||
assertThat(required).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the alarm's own sound is never repeated and never behind a default`() {
|
||||
val inputs = listOf(null, "", "content://media/42", Ringtones.SILENT_URI)
|
||||
|
||||
for (input in inputs) {
|
||||
val positions = AudioSourcePolicy.sourcesFor(input)
|
||||
.withIndex()
|
||||
.filter { it.value == RingtoneSourceKind.ALARM_OWN }
|
||||
.map { it.index }
|
||||
|
||||
assertWithMessage("ALARM_OWN positions for %s", input)
|
||||
.that(positions)
|
||||
.isAnyOf(emptyList<Int>(), listOf(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.jeanlucmakiola.clockula.domain.format
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* "in 9h 12m" must mean *at least* nine hours and eleven-and-a-bit minutes, so
|
||||
* whole minutes round **up** — the direction `ClockFormat.countdown` already
|
||||
* rounds (M5 D10). The wording is the composable's; the decision is here.
|
||||
*/
|
||||
class NextFireFormatTest {
|
||||
|
||||
private fun labelIn(remaining: Duration): NextFireLabel =
|
||||
NextFireFormat.labelFor(nextFire = T0 + remaining, now = T0)
|
||||
|
||||
@Test
|
||||
fun `no next fire is not scheduled`() {
|
||||
val label = NextFireFormat.labelFor(nextFire = null, now = T0)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.NotScheduled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fire at this very moment is imminent`() {
|
||||
val label = labelIn(Duration.ZERO)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.Imminent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thirty seconds out is imminent`() {
|
||||
val label = labelIn(30.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.Imminent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fifty-nine seconds out is imminent`() {
|
||||
val label = labelIn(59.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.Imminent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fire inside the grace window is imminent, never negative`() {
|
||||
val label = NextFireFormat.labelFor(nextFire = T0 - 1.seconds, now = T0)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.Imminent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one minute out reads as one minute`() {
|
||||
val label = labelIn(60.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InMinutes(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sixty-one seconds rounds up to two minutes`() {
|
||||
val label = labelIn(61.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InMinutes(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fifty-nine whole minutes stays in minutes`() {
|
||||
val label = labelIn(59.minutes)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InMinutes(59))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rounding up crosses the hour boundary`() {
|
||||
val label = labelIn(59.minutes + 1.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one hour reads as one hour and no minutes`() {
|
||||
val label = labelIn(60.minutes)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nine hours and twelve minutes reads as itself`() {
|
||||
val label = labelIn(9.hours + 12.minutes)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(9, 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half minute inside the hour rounds up to the next minute`() {
|
||||
val label = labelIn(9.hours + 11.minutes + 30.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(9, 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last minute of the day stays in hours and minutes`() {
|
||||
val label = labelIn(23.hours + 59.minutes)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InHoursMinutes(23, 59))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rounding up crosses the day boundary`() {
|
||||
val label = labelIn(23.hours + 59.minutes + 30.seconds)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one day reads as one day and no hours`() {
|
||||
val label = labelIn(24.hours)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekly alarm's range reads in days and hours`() {
|
||||
val label = labelIn(6.days + 23.hours)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(6, 23))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rounding up crosses the week boundary`() {
|
||||
val label = labelIn(6.days + 23.hours + 59.minutes)
|
||||
|
||||
assertThat(label).isEqualTo(NextFireLabel.InDaysHours(7, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
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.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
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.Instant
|
||||
|
||||
/**
|
||||
* The **real** [AlarmEngine] over the fake DAOs, the fake Android seams and a
|
||||
* real DataStore under a `@TempDir` — the arrangement `AlarmRingViewModelTest`
|
||||
* established, lifted so M5's two ViewModel suites do not repeat forty lines.
|
||||
*/
|
||||
class AlarmEngineHarness(
|
||||
tempDir: Path,
|
||||
storeScope: CoroutineScope,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
|
||||
zone: ZoneId = BERLIN,
|
||||
) {
|
||||
val alarmDao: FakeAlarmDao = FakeAlarmDao()
|
||||
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao(alarms = alarmDao)
|
||||
val wallClock: FakeWallClock = FakeWallClock(now)
|
||||
val zones: FakeZoneProvider = FakeZoneProvider(zone)
|
||||
val scheduler: FakeAlarmScheduler = FakeAlarmScheduler()
|
||||
val ring: FakeRingCoordinator = FakeRingCoordinator()
|
||||
val notifier: FakeAlarmNotifier = FakeAlarmNotifier()
|
||||
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
|
||||
val ticker: FakeTicker = FakeTicker()
|
||||
|
||||
val alarms: AlarmRepositoryImpl = AlarmRepositoryImpl(alarmDao, wallClock)
|
||||
val states: AlarmStateRepositoryImpl = AlarmStateRepositoryImpl(stateDao)
|
||||
|
||||
val settings: SettingsPrefs = SettingsPrefs(
|
||||
PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = storeScope,
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val engine: AlarmEngine = AlarmEngine(
|
||||
alarms = alarms,
|
||||
states = states,
|
||||
settings = settings,
|
||||
scheduler = scheduler,
|
||||
ring = ring,
|
||||
notifier = notifier,
|
||||
rebootRepair = rebootRepair,
|
||||
wallClock = wallClock,
|
||||
zones = zones,
|
||||
)
|
||||
|
||||
/** Inserts an alarm straight into storage, bypassing the engine. */
|
||||
suspend fun givenAlarm(
|
||||
id: Long = 1L,
|
||||
hour: Int = 7,
|
||||
minute: Int = 0,
|
||||
repeatDays: RepeatDays = RepeatDays.NONE,
|
||||
enabled: Boolean = true,
|
||||
skipNextOccurrence: Boolean = false,
|
||||
label: String = "Wake up",
|
||||
snoozeMinutes: Int? = null,
|
||||
snoozeLimit: Int? = null,
|
||||
): Alarm {
|
||||
val alarm = alarmAt(
|
||||
id = id,
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
repeatDays = repeatDays,
|
||||
enabled = enabled,
|
||||
skipNextOccurrence = skipNextOccurrence,
|
||||
label = label,
|
||||
snoozeMinutes = snoozeMinutes,
|
||||
snoozeLimit = snoozeLimit,
|
||||
)
|
||||
alarmDao.insert(AlarmMapper.toEntity(alarm))
|
||||
return alarm
|
||||
}
|
||||
|
||||
/** Rings [alarmId] through the engine, exactly as a fire broadcast would. */
|
||||
suspend fun givenRinging(alarmId: Long, at: Instant) {
|
||||
wallClock.instant = at
|
||||
engine.onFire(alarmId, at)
|
||||
}
|
||||
}
|
||||
|
||||
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
|
||||
fun TestScope.alarmEngineHarness(
|
||||
tempDir: Path,
|
||||
now: Instant = instantAt(BERLIN, "2026-06-10T05:00"),
|
||||
zone: ZoneId = BERLIN,
|
||||
): AlarmEngineHarness = AlarmEngineHarness(
|
||||
tempDir = tempDir,
|
||||
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
now = now,
|
||||
zone = zone,
|
||||
)
|
||||
@@ -51,10 +51,15 @@ class FakeAlarmScheduler(
|
||||
var cancelNextCount: Int = 0
|
||||
private set
|
||||
|
||||
/** How much AlarmManager traffic the slot has seen — D12's cheap interactions cost none. */
|
||||
var scheduleNextCalls: Int = 0
|
||||
private set
|
||||
|
||||
var cancelAutoSilenceCount: Int = 0
|
||||
private set
|
||||
|
||||
override fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode {
|
||||
scheduleNextCalls++
|
||||
next = Registration(fireAt, alarmId)
|
||||
return mode
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ import kotlinx.coroutines.flow.update
|
||||
* abstract DAOs are *extended*, not reimplemented, so their `@Transaction`
|
||||
* default bodies are the ones under test.
|
||||
*/
|
||||
class FakeAlarmDao(
|
||||
open class FakeAlarmDao(
|
||||
initial: List<AlarmEntity> = emptyList(),
|
||||
nextId: Long = 1L,
|
||||
) : AlarmDao {
|
||||
) : AlarmDao() {
|
||||
|
||||
private val rows = MutableStateFlow(initial)
|
||||
private var nextRowId = maxOf(nextId, (initial.maxOfOrNull { it.id } ?: 0L) + 1L)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
|
||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
/**
|
||||
* The device's sound catalog, scripted: which tones it has, what it can name,
|
||||
* what it can open right now, and whether a SAF grant can be made persistent.
|
||||
*/
|
||||
class FakeRingtoneCatalog(
|
||||
var tones: List<RingtoneOption> = listOf(
|
||||
RingtoneOption("content://media/1", "Argon"),
|
||||
RingtoneOption("content://media/2", "Bright morning"),
|
||||
RingtoneOption("content://media/3", "Cesium"),
|
||||
),
|
||||
var titles: Map<String, String?> = emptyMap(),
|
||||
var unplayable: Set<String> = emptySet(),
|
||||
var canPersist: Boolean = true,
|
||||
) : RingtoneCatalog {
|
||||
|
||||
/** How many times the tone list was asked for, so "queried once" is assertable. */
|
||||
var alarmTonesCalls: Int = 0
|
||||
private set
|
||||
|
||||
/** How many provider round-trips the metadata cost, so "asked once" is assertable. */
|
||||
var metadataCalls: Int = 0
|
||||
private set
|
||||
|
||||
/**
|
||||
* While set, [titleOf] suspends on it — a blocking provider, scripted: an
|
||||
* unmounted card, or a document provider on a slow volume.
|
||||
*/
|
||||
var titleGate: CompletableDeferred<Unit>? = null
|
||||
|
||||
val persisted: MutableList<String> = mutableListOf()
|
||||
|
||||
override suspend fun alarmTones(): List<RingtoneOption> {
|
||||
alarmTonesCalls++
|
||||
return tones
|
||||
}
|
||||
|
||||
override suspend fun titleOf(uri: String): String? {
|
||||
metadataCalls++
|
||||
titleGate?.await()
|
||||
return if (uri in titles) titles[uri] else tones.firstOrNull { it.uri == uri }?.title
|
||||
}
|
||||
|
||||
override suspend fun isPlayable(uri: String): Boolean {
|
||||
metadataCalls++
|
||||
return uri !in unplayable
|
||||
}
|
||||
|
||||
override suspend fun persistPickedDocument(uri: String): Boolean {
|
||||
persisted += uri
|
||||
return canPersist
|
||||
}
|
||||
}
|
||||
|
||||
/** What the previewer was told to do, in order — the order is the contract (M5 D21). */
|
||||
sealed interface PreviewEvent {
|
||||
data class Played(val uri: String) : PreviewEvent
|
||||
data object Stopped : PreviewEvent
|
||||
}
|
||||
|
||||
class FakeRingtonePreviewer : RingtonePreviewer {
|
||||
|
||||
val events: MutableList<PreviewEvent> = mutableListOf()
|
||||
|
||||
override suspend fun play(uri: String) {
|
||||
events += PreviewEvent.Played(uri)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
events += PreviewEvent.Stopped
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
@@ -186,3 +188,31 @@ fun ringState(
|
||||
handledOccurrence = handledOccurrence,
|
||||
skippedOccurrence = skippedOccurrence,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of `AlarmEngine.upcoming()` as the list's pure builder sees it: the
|
||||
* alarm, what the resolver made of it, and where that came from.
|
||||
*/
|
||||
fun upcomingAlarm(
|
||||
id: Long = 1L,
|
||||
hour: Int = 7,
|
||||
minute: Int = 0,
|
||||
label: String = "Wake up",
|
||||
enabled: Boolean = true,
|
||||
repeatDays: RepeatDays = RepeatDays.NONE,
|
||||
skipNextOccurrence: Boolean = false,
|
||||
nextFire: Instant? = null,
|
||||
source: FireSource = FireSource.OCCURRENCE,
|
||||
): UpcomingAlarm = UpcomingAlarm(
|
||||
alarm = alarmAt(
|
||||
id = id,
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
repeatDays = repeatDays,
|
||||
enabled = enabled,
|
||||
skipNextOccurrence = skipNextOccurrence,
|
||||
label = label,
|
||||
),
|
||||
nextFire = nextFire,
|
||||
source = source,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user