diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff571ef0..65ddf6eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,4 +152,7 @@ dependencies { implementation(libs.bundles.room) ksp(libs.androidx.room.compiler) detektPlugins(libs.compose.detekt) + + // Testing dependencies + testImplementation(libs.junit) } diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt index eb887cf9..ebc5d194 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt @@ -34,7 +34,7 @@ class AlarmController( fun rescheduleEnabledAlarms() { db.getEnabledAlarms().forEach { // TODO: Skipped upcoming alarms are being *rescheduled* here. - if (!it.isToday() || it.timeInMinutes > getCurrentDayMinutes()) { + if (shouldRescheduleAlarm(it)) { scheduleNextOccurrence(it, false) } } @@ -240,5 +240,18 @@ class AlarmController( ).also { instance = it } } } + + /** + * Testable function that determines if an alarm should be rescheduled. + * This encapsulates the core logic from rescheduleEnabledAlarms(). + * + * @param alarm The alarm to check + * @param currentDayMinutes Optional current time for testing, null uses system time + * @return true if the alarm should be scheduled + */ + fun shouldRescheduleAlarm(alarm: Alarm, currentDayMinutes: Int? = null): Boolean { + val currentMinutes = currentDayMinutes ?: getCurrentDayMinutes() + return !alarm.isToday() || alarm.timeInMinutes > currentMinutes + } } } diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt index 2d812c03..55676eed 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt @@ -304,8 +304,17 @@ fun getTimeOfNextAlarm(alarmTimeInMinutes: Int, days: Int): Calendar? { } fun updateNonRecurringAlarmDay(alarm: Alarm) { + updateNonRecurringAlarmDay(alarm, null) +} + +/** + * Testable version that accepts an optional current time for testing purposes. + * @param currentDayMinutes If provided, uses this as the current time instead of the system time. + */ +fun updateNonRecurringAlarmDay(alarm: Alarm, currentDayMinutes: Int?) { if (alarm.isRecurring()) return - alarm.days = if (alarm.timeInMinutes > getCurrentDayMinutes()) { + val currentMinutes = currentDayMinutes ?: getCurrentDayMinutes() + alarm.days = if (alarm.timeInMinutes > currentMinutes) { TODAY_BIT } else { TOMORROW_BIT diff --git a/app/src/test/README.md b/app/src/test/README.md new file mode 100644 index 00000000..2c4e88bb --- /dev/null +++ b/app/src/test/README.md @@ -0,0 +1,25 @@ +# Unit Tests for Fossify Clock + +## Overview + +Unit tests for alarm scheduling logic. + +## Test Files + +### AlarmControllerTest.kt + +Tests alarm controller reschedule logic. + +## Running Tests + +```bash +# Run all tests +./gradlew test + +# Run specific test variant +./gradlew testFossDebugUnitTest +``` + +Test reports are generated at: +- `build/reports/tests/testFossDebugUnitTest/index.html` +- `build/reports/tests/testCoreDebugUnitTest/index.html` diff --git a/app/src/test/kotlin/org/fossify/clock/helpers/AlarmControllerTest.kt b/app/src/test/kotlin/org/fossify/clock/helpers/AlarmControllerTest.kt new file mode 100644 index 00000000..39fce2fa --- /dev/null +++ b/app/src/test/kotlin/org/fossify/clock/helpers/AlarmControllerTest.kt @@ -0,0 +1,86 @@ +package org.fossify.clock.helpers + +import org.fossify.clock.models.Alarm +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for AlarmController reschedule logic. + * Tests the actual functions used in AlarmController. + */ +class AlarmControllerTest { + + /** + * Test basic behavior: current time is 7 AM, alarm is 8 AM + * Should be marked as TODAY since alarm time hasn't passed yet + */ + @Test + fun testAlarmBeforeCurrentTime_ShouldBeToday() { + val alarm = createAlarm(timeInMinutes = 480) // 8:00 AM + val currentTime = 420 // 7:00 AM + + updateNonRecurringAlarmDay(alarm, currentTime) + + assertEquals(TODAY_BIT, alarm.days) + } + + /** + * Test basic behavior: current time is 9 AM, alarm is 8 AM + * Should be marked as TOMORROW since alarm time has passed + */ + @Test + fun testAlarmAfterCurrentTime_ShouldBeTomorrow() { + val alarm = createAlarm(timeInMinutes = 480) // 8:00 AM + val currentTime = 540 // 9:00 AM + + updateNonRecurringAlarmDay(alarm, currentTime) + + assertEquals(TOMORROW_BIT, alarm.days) + } + + /** + * BUG REPRODUCTION TEST using actual AlarmController.shouldRescheduleAlarm(): + * + * Scenario: User sets alarm for 8 AM tomorrow, saves to DB with TOMORROW_BIT. + * Next day arrives, user restarts phone at 9 AM (after alarm time). + * AlarmController.rescheduleEnabledAlarms() reads alarm from DB and calls shouldRescheduleAlarm(). + * + * Expected: Alarm should NOT be rescheduled (time has passed for today's intended alarm) + * Actual Bug: shouldRescheduleAlarm returns TRUE because alarm still has TOMORROW_BIT! + * + * This test asserts the CORRECT behavior, so it FAILS now (demonstrating the bug exists) + * and will PASS once we implement the fix. + */ + @Test + fun testBugScenario_StaleAlarmFromDBGetsRescheduledIncorrectly() { + // Alarm was set yesterday for "tomorrow" at 8:00 AM and saved to DB with TOMORROW_BIT + val alarm = createAlarm(timeInMinutes = 480, initialDays = TOMORROW_BIT) // 8:00 AM + + // Next day: it's now 9:00 AM (after the alarm time) + // The alarm in DB still has TOMORROW_BIT (stale data) + val currentTime = 540 // 9:00 AM + + // AlarmController.rescheduleEnabledAlarms() calls shouldRescheduleAlarm + val shouldSchedule = AlarmController.shouldRescheduleAlarm(alarm, currentTime) + + // Assert CORRECT behavior: should be FALSE (don't reschedule stale alarms) + // This will FAIL now because the bug makes it return TRUE + assertFalse("Stale alarm should NOT be rescheduled - time has passed", shouldSchedule) + } + + private fun createAlarm(timeInMinutes: Int, initialDays: Int = 0): Alarm { + return Alarm( + id = 1, + timeInMinutes = timeInMinutes, + days = initialDays, + isEnabled = true, + vibrate = true, + soundTitle = "Test", + soundUri = "", + label = "", + oneShot = false + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69c6d074..7801df75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,8 @@ kotlinx-coroutines = "1.10.2" numberpicker = "2.4.13" #Room room = "2.8.4" +#JUnit +junit = "4.13.2" #Fossify commons = "5.13.1" #Gradle @@ -55,6 +57,8 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } #Compose compose-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } +#JUnit +junit = { module = "junit:junit", version.ref = "junit" } #Fossify fossify-commons = { module = "org.fossify:commons", version.ref = "commons" } [bundles]