diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/AlarmDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/AlarmDefaults.kt new file mode 100644 index 0000000..2865c74 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/AlarmDefaults.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt new file mode 100644 index 0000000..8aea681 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt @@ -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) : 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 = + List(DayOfWeek.entries.size) { firstDayOfWeek.plus(it.toLong()) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Ringtone.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Ringtone.kt new file mode 100644 index 0000000..4346993 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Ringtone.kt @@ -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() } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt index 303c914..c891954 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt @@ -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 = 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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/NextFire.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/NextFire.kt new file mode 100644 index 0000000..9c065bc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/NextFire.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmDefaultsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmDefaultsTest.kt new file mode 100644 index 0000000..ae25dc8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmDefaultsTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysOrderTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysOrderTest.kt new file mode 100644 index 0000000..69ea97d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysOrderTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatSummaryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatSummaryTest.kt new file mode 100644 index 0000000..16fb914 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatSummaryTest.kt @@ -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) + } + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/RingtonesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RingtonesTest.kt new file mode 100644 index 0000000..59d2fde --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RingtonesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt new file mode 100644 index 0000000..f537d9b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AudioSourcePolicyTest.kt @@ -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(), listOf(0)) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/NextFireFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/NextFireFormatTest.kt new file mode 100644 index 0000000..4fd894f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/NextFireFormatTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt new file mode 100644 index 0000000..11761bc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt @@ -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, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index 76f94ce..a904bba 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -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 } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index 24d0a10..e99055c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -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 = emptyList(), nextId: Long = 1L, -) : AlarmDao { +) : AlarmDao() { private val rows = MutableStateFlow(initial) private var nextRowId = maxOf(nextId, (initial.maxOfOrNull { it.id } ?: 0L) + 1L) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt new file mode 100644 index 0000000..a7686ef --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRingtones.kt @@ -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 = listOf( + RingtoneOption("content://media/1", "Argon"), + RingtoneOption("content://media/2", "Bright morning"), + RingtoneOption("content://media/3", "Cesium"), + ), + var titles: Map = emptyMap(), + var unplayable: Set = 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? = null + + val persisted: MutableList = mutableListOf() + + override suspend fun alarmTones(): List { + 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 = mutableListOf() + + override suspend fun play(uri: String) { + events += PreviewEvent.Played(uri) + } + + override fun stop() { + events += PreviewEvent.Stopped + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt index 4073080..2c5e727 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt @@ -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, +)