diff --git a/android/app/src/test/java/com/chronomind/app/engine/TimerEngineTest.kt b/android/app/src/test/java/com/chronomind/app/engine/TimerEngineTest.kt new file mode 100644 index 0000000..b0d904e --- /dev/null +++ b/android/app/src/test/java/com/chronomind/app/engine/TimerEngineTest.kt @@ -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()) + } + } + } +}