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:
parent
4b1e969039
commit
9c34a92b9e
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user