feat(android): add Android app — Jetpack Compose, Hilt, timer engine Kotlin port, 4 screens, notifications, 3 Glance widgets, 30 JUnit5 tests

This commit is contained in:
saravanakumardb1 2026-02-27 23:09:12 -08:00
parent 4b1e969039
commit 9c34a92b9e

View File

@ -0,0 +1,388 @@
package com.chronomind.app.engine
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.Date
@DisplayName("TimerEngine")
class TimerEngineTest {
private fun futureDate(seconds: Double): Date = Date(Date().time + (seconds * 1000).toLong())
private fun pastDate(seconds: Double): Date = Date(Date().time - (seconds * 1000).toLong())
@Nested
@DisplayName("createCountdown")
inner class CreateCountdown {
@Test
fun `creates countdown with correct duration`() {
val timer = createCountdown("Test", 300.0)
assertEquals("Test", timer.label)
assertEquals(CMTimerType.COUNTDOWN, timer.type)
assertEquals(CMTimerState.ACTIVE, timer.state)
assertEquals(300.0, timer.duration, 1.0)
}
@Test
fun `creates countdown with urgency`() {
val timer = createCountdown("Urgent", 60.0, UrgencyLevel.CRITICAL)
assertEquals(UrgencyLevel.CRITICAL, timer.urgency)
}
@Test
fun `creates countdown with cascade warnings`() {
val timer = createCountdown("Test", 7200.0) // 2 hours
assertTrue(timer.warnings.isNotEmpty())
}
}
@Nested
@DisplayName("createAlarm")
inner class CreateAlarm {
@Test
fun `creates alarm with target time`() {
val target = futureDate(600.0)
val timer = createAlarm("Alarm", target)
assertEquals(CMTimerType.ALARM, timer.type)
assertEquals(target, timer.targetTime)
}
@Test
fun `creates alarm with category`() {
val timer = createAlarm("Meeting", futureDate(3600.0), category = "work")
assertEquals("work", timer.category)
}
}
@Nested
@DisplayName("createPomodoro")
inner class CreatePomodoro {
@Test
fun `creates pomodoro with default config`() {
val timer = createPomodoro()
assertEquals(CMTimerType.POMODORO, timer.type)
assertEquals("Focus Session", timer.label)
assertNotNull(timer.pomodoroConfig)
assertEquals(25, timer.pomodoroConfig!!.workMinutes)
assertEquals(4, timer.pomodoroConfig!!.rounds)
}
@Test
fun `creates pomodoro with custom config`() {
val config = PomodoroConfig(workMinutes = 50, breakMinutes = 10, rounds = 3)
val timer = createPomodoro("Deep Work", config)
assertEquals("Deep Work", timer.label)
assertEquals(50, timer.pomodoroConfig!!.workMinutes)
assertEquals(3, timer.pomodoroConfig!!.rounds)
}
}
@Nested
@DisplayName("State transitions")
inner class StateTransitions {
@Test
fun `pause active timer`() {
val timer = createCountdown("Test", 300.0)
val paused = pauseTimer(timer)
assertEquals(CMTimerState.PAUSED, paused.state)
assertNotNull(paused.pausedAt)
}
@Test
fun `pause non-active timer is no-op`() {
val timer = createCountdown("Test", 300.0).copy(state = CMTimerState.COMPLETED)
val result = pauseTimer(timer)
assertEquals(CMTimerState.COMPLETED, result.state)
}
@Test
fun `resume paused timer`() {
val timer = createCountdown("Test", 300.0)
val paused = pauseTimer(timer)
val resumed = resumeTimer(paused)
assertEquals(CMTimerState.ACTIVE, resumed.state)
assertNull(resumed.pausedAt)
}
@Test
fun `resume non-paused timer is no-op`() {
val timer = createCountdown("Test", 300.0)
val result = resumeTimer(timer)
assertEquals(CMTimerState.ACTIVE, result.state)
}
@Test
fun `fire timer changes state to firing`() {
val timer = createCountdown("Test", 300.0)
val fired = fireTimer(timer)
assertEquals(CMTimerState.FIRING, fired.state)
assertNotNull(fired.firedAt)
}
@Test
fun `fire completed timer is no-op`() {
val timer = createCountdown("Test", 300.0).copy(state = CMTimerState.COMPLETED)
val result = fireTimer(timer)
assertEquals(CMTimerState.COMPLETED, result.state)
}
@Test
fun `snooze firing timer`() {
val timer = createCountdown("Test", 300.0).copy(state = CMTimerState.FIRING)
val snoozed = snoozeTimer(timer, 5)
assertEquals(CMTimerState.SNOOZED, snoozed.state)
assertEquals(1, snoozed.snoozeCount)
assertNotNull(snoozed.snoozedUntil)
}
@Test
fun `snooze non-firing timer is no-op`() {
val timer = createCountdown("Test", 300.0)
val result = snoozeTimer(timer, 5)
assertEquals(CMTimerState.ACTIVE, result.state)
}
@Test
fun `dismiss timer`() {
val timer = createCountdown("Test", 300.0)
val dismissed = dismissTimer(timer)
assertEquals(CMTimerState.DISMISSED, dismissed.state)
assertNotNull(dismissed.dismissedAt)
}
@Test
fun `complete timer`() {
val timer = createCountdown("Test", 300.0)
val completed = completeTimer(timer)
assertEquals(CMTimerState.COMPLETED, completed.state)
assertNotNull(completed.completedAt)
}
}
@Nested
@DisplayName("Pomodoro transitions")
inner class PomodoroTransitions {
@Test
fun `advance from work to break`() {
val timer = createPomodoro()
val advanced = advancePomodoro(timer)
assertNotNull(advanced)
assertTrue(advanced!!.pomodoroState!!.isBreak)
assertEquals(1, advanced.pomodoroState!!.completedRounds)
}
@Test
fun `advance from break to next work round`() {
val timer = createPomodoro().copy(
pomodoroState = PomodoroState(currentRound = 1, isBreak = true, completedRounds = 1)
)
val advanced = advancePomodoro(timer)
assertNotNull(advanced)
assertFalse(advanced!!.pomodoroState!!.isBreak)
assertEquals(2, advanced.pomodoroState!!.currentRound)
}
@Test
fun `non-pomodoro timer returns null`() {
val timer = createCountdown("Test", 300.0)
assertNull(advancePomodoro(timer))
}
}
@Nested
@DisplayName("Utility functions")
inner class UtilityFunctions {
@Test
fun `getRemainingSeconds for active timer`() {
val timer = createCountdown("Test", 300.0)
val remaining = getRemainingSeconds(timer)
assertTrue(remaining > 298.0 && remaining <= 300.0)
}
@Test
fun `getRemainingSeconds for paused timer`() {
val timer = createCountdown("Test", 300.0).copy(
state = CMTimerState.PAUSED,
elapsedBeforePause = 100.0
)
val remaining = getRemainingSeconds(timer)
assertEquals(200.0, remaining, 0.1)
}
@Test
fun `isTimerActive for various states`() {
assertTrue(isTimerActive(createCountdown("T", 60.0)))
assertTrue(isTimerActive(createCountdown("T", 60.0).copy(state = CMTimerState.WARNING)))
assertTrue(isTimerActive(createCountdown("T", 60.0).copy(state = CMTimerState.SNOOZED)))
assertFalse(isTimerActive(createCountdown("T", 60.0).copy(state = CMTimerState.PAUSED)))
assertFalse(isTimerActive(createCountdown("T", 60.0).copy(state = CMTimerState.COMPLETED)))
assertFalse(isTimerActive(createCountdown("T", 60.0).copy(state = CMTimerState.DISMISSED)))
}
@Test
fun `shouldTimerFire when target time passed`() {
val timer = createCountdown("Test", 60.0).copy(
targetTime = pastDate(10.0)
)
assertTrue(shouldTimerFire(timer))
}
@Test
fun `shouldTimerFire when target time in future`() {
val timer = createCountdown("Test", 300.0)
assertFalse(shouldTimerFire(timer))
}
}
@Nested
@DisplayName("Cascade")
inner class CascadeTests {
@Test
fun `standard preset has 5 intervals`() {
val intervals = CascadePreset.STANDARD.defaultIntervals
assertEquals(listOf(120, 60, 30, 15, 5), intervals)
}
@Test
fun `aggressive preset has 9 intervals`() {
assertEquals(9, CascadePreset.AGGRESSIVE.defaultIntervals.size)
}
@Test
fun `none preset has 0 intervals`() {
assertTrue(CascadePreset.NONE.defaultIntervals.isEmpty())
}
@Test
fun `calculateCascadeWarnings creates correct warnings`() {
val target = futureDate(7200.0) // 2 hours
val warnings = calculateCascadeWarnings(target, listOf(60, 30, 15), Date())
assertEquals(3, warnings.size)
assertFalse(warnings[0].fired)
assertEquals(60, warnings[0].minutesBefore)
}
@Test
fun `getNextWarning returns earliest unfired`() {
val target = futureDate(7200.0)
val warnings = calculateCascadeWarnings(target, listOf(60, 30, 15), Date())
val next = getNextWarning(warnings)
assertNotNull(next)
assertEquals(60, next!!.minutesBefore)
}
@Test
fun `checkWarnings fires due warnings`() {
val target = futureDate(600.0) // 10 min from now
val warnings = calculateCascadeWarnings(target, listOf(60, 30, 15, 5), Date()).toMutableList()
// 60 and 30 min warnings should already be fired (target only 10m away)
val newlyFired = checkWarnings(warnings, Date())
// All warnings > 10 min should already be marked as fired during creation
assertTrue(warnings.filter { it.fired }.size >= 2)
}
}
@Nested
@DisplayName("Format")
inner class FormatTests {
@Test
fun `formatDuration hours`() {
assertEquals("01:30:00", formatDuration(5400.0))
}
@Test
fun `formatDuration minutes`() {
assertEquals("05:00", formatDuration(300.0))
}
@Test
fun `formatDuration seconds`() {
assertEquals("00:45", formatDuration(45.0))
}
@Test
fun `formatDuration zero`() {
assertEquals("00:00", formatDuration(0.0))
}
@Test
fun `formatDuration negative clamps to zero`() {
assertEquals("00:00", formatDuration(-10.0))
}
@Test
fun `formatDurationCompact hours`() {
assertEquals("1h 30m", formatDurationCompact(5400.0))
}
@Test
fun `formatDurationCompact minutes`() {
assertEquals("5m", formatDurationCompact(300.0))
}
@Test
fun `formatDurationCompact seconds`() {
assertEquals("45s", formatDurationCompact(45.0))
}
@Test
fun `formatDurationCompact zero`() {
assertEquals("0s", formatDurationCompact(0.0))
}
@Test
fun `formatMinutesBefore hours and minutes`() {
assertEquals("2h 30m", formatMinutesBefore(150))
}
@Test
fun `formatMinutesBefore minutes only`() {
assertEquals("30m", formatMinutesBefore(30))
}
@Test
fun `formatMinutesBefore exact hours`() {
assertEquals("1h", formatMinutesBefore(60))
}
}
@Nested
@DisplayName("Urgency")
inner class UrgencyTests {
@Test
fun `critical config has full screen overlay`() {
val config = getUrgencyConfig(UrgencyLevel.CRITICAL)
assertTrue(config.fullScreenOverlay)
assertTrue(config.requireConfirmToDismiss)
assertTrue(config.soundEnabled)
}
@Test
fun `passive config has no sound`() {
val config = getUrgencyConfig(UrgencyLevel.PASSIVE)
assertFalse(config.soundEnabled)
assertFalse(config.fullScreenOverlay)
assertEquals(10, config.autoSnoozeMinutes)
}
@Test
fun `standard config has sound but no overlay`() {
val config = getUrgencyConfig(UrgencyLevel.STANDARD)
assertTrue(config.soundEnabled)
assertFalse(config.fullScreenOverlay)
assertFalse(config.requireConfirmToDismiss)
}
@Test
fun `all urgency levels have configs`() {
UrgencyLevel.entries.forEach { level ->
val config = getUrgencyConfig(level)
assertEquals(level, config.level)
assertTrue(config.label.isNotEmpty())
assertTrue(config.colorHex.startsWith("#"))
assertTrue(config.vibrationPattern.isNotEmpty())
}
}
}
}