feat(domain): calculadora de ciclos de sono e modos de cálculo com testes
This commit is contained in:
parent
3980ddbf04
commit
bd1796228b
3 changed files with 303 additions and 1 deletions
|
|
@ -0,0 +1,160 @@
|
|||
package me.muriloscolari.ciclo.domain
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Configurações de ciclos de sono.
|
||||
*/
|
||||
data class SleepSettings(
|
||||
val cycleMinutes: Int = 90,
|
||||
val fallAsleepMinutes: Int = 15,
|
||||
val minCycles: Int = 3,
|
||||
val maxCycles: Int = 6,
|
||||
val recommendedCycles: Int = 5,
|
||||
val maxSleepMinutes: Int = 600 // 10 horas
|
||||
)
|
||||
|
||||
enum class SleepMode {
|
||||
NOW,
|
||||
BEDTIME,
|
||||
WAKE_AT
|
||||
}
|
||||
|
||||
/**
|
||||
* Opção individual de horário calculada.
|
||||
*/
|
||||
data class SleepOption(
|
||||
val cycles: Int,
|
||||
val bedTime: LocalDateTime,
|
||||
val wakeTime: LocalDateTime,
|
||||
val sleepDuration: Duration,
|
||||
val isRecommended: Boolean,
|
||||
val isAvailable: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Resultado completo do modo "Acordar às X".
|
||||
*/
|
||||
data class WakeAtResult(
|
||||
val wakeTime: LocalDateTime,
|
||||
val options: List<SleepOption>,
|
||||
val hasAnyAvailable: Boolean
|
||||
)
|
||||
|
||||
object SleepCycleCalculator {
|
||||
|
||||
/**
|
||||
* Calcula os horários de acordar partindo de uma hora de deitar [bedTime].
|
||||
* Utilizado para os modos "Dormir agora" e "Vou dormir às X".
|
||||
*/
|
||||
fun wakeTimesFor(bedTime: LocalDateTime, settings: SleepSettings): List<SleepOption> {
|
||||
val validCycles = (settings.minCycles..settings.maxCycles)
|
||||
.filter { it * settings.cycleMinutes <= settings.maxSleepMinutes }
|
||||
|
||||
if (validCycles.isEmpty()) return emptyList()
|
||||
|
||||
val targetRecommended = if (settings.recommendedCycles in validCycles) {
|
||||
settings.recommendedCycles
|
||||
} else {
|
||||
validCycles.maxOrNull() ?: settings.recommendedCycles
|
||||
}
|
||||
|
||||
return validCycles.map { n ->
|
||||
val sleepDuration = Duration.ofMinutes((n * settings.cycleMinutes).toLong())
|
||||
val wakeTime = bedTime
|
||||
.plusMinutes(settings.fallAsleepMinutes.toLong())
|
||||
.plus(sleepDuration)
|
||||
.withSecond(0)
|
||||
.withNano(0)
|
||||
|
||||
SleepOption(
|
||||
cycles = n,
|
||||
bedTime = bedTime.withSecond(0).withNano(0),
|
||||
wakeTime = wakeTime,
|
||||
sleepDuration = sleepDuration,
|
||||
isRecommended = (n == targetRecommended),
|
||||
isAvailable = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula os horários para deitar partindo de uma hora de despertar [wakeTime].
|
||||
* [now] é o momento atual para aferir se o horário de ir para a cama já passou.
|
||||
*/
|
||||
fun bedTimesFor(
|
||||
wakeTime: LocalDateTime,
|
||||
now: LocalDateTime,
|
||||
settings: SleepSettings
|
||||
): WakeAtResult {
|
||||
val standardCycles = (settings.minCycles..settings.maxCycles)
|
||||
.filter { it * settings.cycleMinutes <= settings.maxSleepMinutes }
|
||||
|
||||
val cleanWakeTime = wakeTime.withSecond(0).withNano(0)
|
||||
val cleanNow = now.withSecond(0).withNano(0)
|
||||
|
||||
fun createOption(n: Int, isRec: Boolean): SleepOption {
|
||||
val sleepDuration = Duration.ofMinutes((n * settings.cycleMinutes).toLong())
|
||||
val totalMinutes = (n * settings.cycleMinutes) + settings.fallAsleepMinutes
|
||||
val bedTime = cleanWakeTime.minusMinutes(totalMinutes.toLong())
|
||||
val isAvailable = bedTime.isAfter(cleanNow)
|
||||
|
||||
return SleepOption(
|
||||
cycles = n,
|
||||
bedTime = bedTime,
|
||||
wakeTime = cleanWakeTime,
|
||||
sleepDuration = sleepDuration,
|
||||
isRecommended = isRec,
|
||||
isAvailable = isAvailable
|
||||
)
|
||||
}
|
||||
|
||||
val standardOptions = standardCycles.map { n -> createOption(n, false) }
|
||||
val availableStandard = standardOptions.filter { it.isAvailable }
|
||||
|
||||
return if (availableStandard.isNotEmpty()) {
|
||||
// Entre as disponíveis, a recomendada é a com maior número de ciclos até recommendedCycles
|
||||
val recommendedCandidate = availableStandard
|
||||
.filter { it.cycles <= settings.recommendedCycles }
|
||||
.maxByOrNull { it.cycles } ?: availableStandard.maxByOrNull { it.cycles }
|
||||
|
||||
val finalOptions = standardOptions.map { opt ->
|
||||
opt.copy(isRecommended = (opt.cycles == recommendedCandidate?.cycles))
|
||||
}
|
||||
WakeAtResult(
|
||||
wakeTime = cleanWakeTime,
|
||||
options = finalOptions,
|
||||
hasAnyAvailable = true
|
||||
)
|
||||
} else {
|
||||
// Nenhuma opção padrão está no futuro. Tenta ciclos menores (2 e 1).
|
||||
val fallbackCycles = (2 downTo 1).filter { n ->
|
||||
val totalMinutes = (n * settings.cycleMinutes) + settings.fallAsleepMinutes
|
||||
val bedTime = cleanWakeTime.minusMinutes(totalMinutes.toLong())
|
||||
bedTime.isAfter(cleanNow)
|
||||
}
|
||||
|
||||
if (fallbackCycles.isNotEmpty()) {
|
||||
val fallbackOptions = fallbackCycles.sorted().map { n -> createOption(n, false) }
|
||||
val highestFallback = fallbackOptions.maxByOrNull { it.cycles }
|
||||
val finalFallback = fallbackOptions.map { opt ->
|
||||
opt.copy(isRecommended = (opt.cycles == highestFallback?.cycles))
|
||||
}
|
||||
WakeAtResult(
|
||||
wakeTime = cleanWakeTime,
|
||||
options = finalFallback,
|
||||
hasAnyAvailable = true
|
||||
)
|
||||
} else {
|
||||
// Nem 1 ciclo é viável (já é muito perto da hora de acordar)
|
||||
// Retorna as opções padrão marcadas como indisponíveis para fins de visualização informativa
|
||||
WakeAtResult(
|
||||
wakeTime = cleanWakeTime,
|
||||
options = standardOptions,
|
||||
hasAnyAvailable = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package me.muriloscolari.ciclo.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class SleepCycleCalculatorTest {
|
||||
|
||||
private val defaultSettings = SleepSettings(
|
||||
cycleMinutes = 90,
|
||||
fallAsleepMinutes = 15,
|
||||
minCycles = 3,
|
||||
maxCycles = 6,
|
||||
recommendedCycles = 5,
|
||||
maxSleepMinutes = 600
|
||||
)
|
||||
|
||||
@Test
|
||||
fun wakeTimesFor_standardInputs_calculatesCorrectTimes() {
|
||||
// Deitar às 23:00 de hoje
|
||||
val bedTime = LocalDateTime.of(2026, 10, 23, 23, 0)
|
||||
val options = SleepCycleCalculator.wakeTimesFor(bedTime, defaultSettings)
|
||||
|
||||
assertEquals(4, options.size) // 3, 4, 5, 6 ciclos
|
||||
|
||||
// 3 ciclos: 3 * 90m = 270m + 15m = 285m (4h45) -> 03:45 do dia seguinte
|
||||
val opt3 = options.first { it.cycles == 3 }
|
||||
assertEquals(LocalDateTime.of(2026, 10, 24, 3, 45), opt3.wakeTime)
|
||||
assertFalse(opt3.isRecommended)
|
||||
|
||||
// 4 ciclos: 4 * 90m = 360m + 15m = 375m (6h15) -> 05:15 do dia seguinte
|
||||
val opt4 = options.first { it.cycles == 4 }
|
||||
assertEquals(LocalDateTime.of(2026, 10, 24, 5, 15), opt4.wakeTime)
|
||||
assertFalse(opt4.isRecommended)
|
||||
|
||||
// 5 ciclos: 5 * 90m = 450m + 15m = 465m (7h45) -> 06:45 do dia seguinte
|
||||
val opt5 = options.first { it.cycles == 5 }
|
||||
assertEquals(LocalDateTime.of(2026, 10, 24, 6, 45), opt5.wakeTime)
|
||||
assertTrue("5 ciclos deve ser recomendado por padrão", opt5.isRecommended)
|
||||
|
||||
// 6 ciclos: 6 * 90m = 540m + 15m = 555m (9h15) -> 08:15 do dia seguinte
|
||||
val opt6 = options.first { it.cycles == 6 }
|
||||
assertEquals(LocalDateTime.of(2026, 10, 24, 8, 15), opt6.wakeTime)
|
||||
assertFalse(opt6.isRecommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wakeTimesFor_with120MinuteCycles_respectsMaxSleepCap() {
|
||||
val settings120 = defaultSettings.copy(cycleMinutes = 120, maxSleepMinutes = 600)
|
||||
val bedTime = LocalDateTime.of(2026, 10, 23, 22, 0)
|
||||
val options = SleepCycleCalculator.wakeTimesFor(bedTime, settings120)
|
||||
|
||||
// Ciclos avaliados:
|
||||
// 3 ciclos = 360m (6h) <= 600m OK
|
||||
// 4 ciclos = 480m (8h) <= 600m OK
|
||||
// 5 ciclos = 600m (10h) <= 600m OK
|
||||
// 6 ciclos = 720m (12h) > 600m -> FILTRADO!
|
||||
assertEquals(3, options.size)
|
||||
assertTrue(options.none { it.cycles == 6 })
|
||||
assertTrue(options.any { it.cycles == 5 && it.isRecommended })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bedTimesFor_earlyEvening_recommends5Cycles() {
|
||||
// Deseja acordar amanhã às 07:00. Agora são 20:00.
|
||||
val now = LocalDateTime.of(2026, 10, 23, 20, 0)
|
||||
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
|
||||
|
||||
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
|
||||
|
||||
assertTrue(result.hasAnyAvailable)
|
||||
assertEquals(4, result.options.size)
|
||||
|
||||
// 5 ciclos: 07:00 - 7h45 = 23:15
|
||||
val opt5 = result.options.first { it.cycles == 5 }
|
||||
assertEquals(LocalDateTime.of(2026, 10, 23, 23, 15), opt5.bedTime)
|
||||
assertTrue(opt5.isAvailable)
|
||||
assertTrue(opt5.isRecommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bedTimesFor_lateNight_recommendsHighestViableCycle() {
|
||||
// Deseja acordar às 07:00. Agora já são 01:00 da madrugada.
|
||||
// Horários calculados para deitar:
|
||||
// 6 ciclos: 21:45 (já passou -> indisponível)
|
||||
// 5 ciclos: 23:15 (já passou -> indisponível)
|
||||
// 4 ciclos: 00:45 (já passou -> indisponível)
|
||||
// 3 ciclos: 02:15 (no futuro -> disponível!)
|
||||
val now = LocalDateTime.of(2026, 10, 24, 1, 0)
|
||||
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
|
||||
|
||||
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
|
||||
|
||||
assertTrue(result.hasAnyAvailable)
|
||||
val opt6 = result.options.first { it.cycles == 6 }
|
||||
assertFalse(opt6.isAvailable)
|
||||
|
||||
val opt5 = result.options.first { it.cycles == 5 }
|
||||
assertFalse(opt5.isAvailable)
|
||||
|
||||
val opt4 = result.options.first { it.cycles == 4 }
|
||||
assertFalse(opt4.isAvailable)
|
||||
|
||||
val opt3 = result.options.first { it.cycles == 3 }
|
||||
assertTrue(opt3.isAvailable)
|
||||
assertTrue("Como é o único viável, 3 ciclos deve ser recomendado", opt3.isRecommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bedTimesFor_veryLateNight_fallsBackTo1Or2Cycles() {
|
||||
// Deseja acordar às 07:00. Agora são 04:30 da madrugada.
|
||||
// 3 ciclos (02:15) já passou.
|
||||
// 2 ciclos: 07:00 - (180+15)m = 03:45 (já passou).
|
||||
// 1 ciclo: 07:00 - (90+15)m = 05:15 (viável!).
|
||||
val now = LocalDateTime.of(2026, 10, 24, 4, 30)
|
||||
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
|
||||
|
||||
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
|
||||
|
||||
assertTrue(result.hasAnyAvailable)
|
||||
assertEquals(1, result.options.size)
|
||||
val opt1 = result.options.first()
|
||||
assertEquals(1, opt1.cycles)
|
||||
assertEquals(LocalDateTime.of(2026, 10, 24, 5, 15), opt1.bedTime)
|
||||
assertTrue(opt1.isAvailable)
|
||||
assertTrue(opt1.isRecommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bedTimesFor_tooCloseToWakeTime_returnsNoAvailableOptions() {
|
||||
// Deseja acordar às 07:00. Agora são 06:15 da manhã.
|
||||
// Nem 1 ciclo de 90m + 15m é viável.
|
||||
val now = LocalDateTime.of(2026, 10, 24, 6, 15)
|
||||
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
|
||||
|
||||
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
|
||||
|
||||
assertFalse("Não deve haver opções disponíveis", result.hasAnyAvailable)
|
||||
assertTrue(result.options.all { !it.isAvailable })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
|
|
|
|||
Loading…
Reference in a new issue