Schedule engine: pure occurrence/supply/renewal math with 22 unit tests
Milestone 3. Single authority for DoseTime semantics: weekly mask (bit 0 = Sunday) vs every-N-days from anchor (mask ignored when intervalDays > 1). nextOccurrence is strictly-after by contract so the alarm layer can pass the occurrence it just fired and never re-arm the same minute. Wall-clock LocalDateTime throughout — instant conversion happens at the alarm edge, so an 08:00 dose stays 08:00 across DST. Consumption rate is prospective (from schedule) because days-of-supply predicts forward; historical consumption stays in the DAO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b884885e40
commit
63f1ecd9fd
2 changed files with 326 additions and 0 deletions
118
app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt
Normal file
118
app/src/main/java/no/naiv/meddetsamme/domain/ScheduleEngine.kt
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package no.naiv.meddetsamme.domain
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
|
||||
/**
|
||||
* All schedule and supply math, pure and side-effect free. The alarm layer,
|
||||
* UI, and supply checks all *re-ask* this engine instead of storing derived
|
||||
* state — reboot, app update, and "Taken" therefore can't drift.
|
||||
*
|
||||
* Everything works in local wall-clock time (LocalDate/LocalDateTime); the
|
||||
* caller converts to an instant at the edge. A dose "at 08:00" means 08:00
|
||||
* on the wall clock regardless of DST shifts — which is what a human taking
|
||||
* medication expects.
|
||||
*
|
||||
* Schedule semantics (the single authority for [DoseTime]'s fields):
|
||||
* - intervalDays > 1 → every N days counted from anchorEpochDay; mask ignored.
|
||||
* - otherwise → weekly daysOfWeekMask, bit 0 = Sunday … bit 6 = Saturday.
|
||||
* Mask 0 selects nothing: no occurrences, never an error.
|
||||
*/
|
||||
object ScheduleEngine {
|
||||
|
||||
/** Bit position for a day-of-week: Sunday=0 … Saturday=6 (brief's convention). */
|
||||
fun bitFor(day: DayOfWeek): Int = day.value % 7 // java.time: MON=1..SUN=7
|
||||
|
||||
/** Does this dose-time occur on [date]? */
|
||||
fun occursOn(doseTime: DoseTime, date: LocalDate): Boolean {
|
||||
return if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return false
|
||||
val delta = date.toEpochDay() - anchor
|
||||
delta >= 0 && delta % doseTime.intervalDays == 0L
|
||||
} else {
|
||||
doseTime.daysOfWeekMask and (1 shl bitFor(date.dayOfWeek)) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Earliest occurrence strictly after [after], or null if the schedule never
|
||||
* fires (empty mask / missing anchor). Strict-after is load-bearing: the
|
||||
* alarm receiver passes the occurrence it just fired, and must get the
|
||||
* *next* one — never the same minute again.
|
||||
*/
|
||||
fun nextOccurrence(doseTime: DoseTime, after: LocalDateTime): LocalDateTime? {
|
||||
val time = LocalTime.of(doseTime.minuteOfDay / 60, doseTime.minuteOfDay % 60)
|
||||
// First candidate day: today if the time is still ahead of us, else tomorrow.
|
||||
var date = if (time > after.toLocalTime()) after.toLocalDate() else after.toLocalDate().plusDays(1)
|
||||
|
||||
if (doseTime.intervalDays > 1) {
|
||||
val anchor = doseTime.anchorEpochDay ?: return null
|
||||
val interval = doseTime.intervalDays.toLong()
|
||||
val delta = date.toEpochDay() - anchor
|
||||
date = when {
|
||||
delta <= 0 -> LocalDate.ofEpochDay(anchor) // cycle starts in the future
|
||||
else -> {
|
||||
val intoCycle = delta % interval
|
||||
if (intoCycle == 0L) date else date.plusDays(interval - intoCycle)
|
||||
}
|
||||
}
|
||||
return LocalDateTime.of(date, time)
|
||||
}
|
||||
|
||||
if (doseTime.daysOfWeekMask and 0x7F == 0) return null
|
||||
repeat(7) {
|
||||
if (occursOn(doseTime, date)) return LocalDateTime.of(date, time)
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return null // unreachable with a non-empty mask; defensive
|
||||
}
|
||||
|
||||
/** Earliest next occurrence across all of a med's dose-times (what to arm). */
|
||||
fun nextOccurrence(doseTimes: List<DoseTime>, after: LocalDateTime): Pair<DoseTime, LocalDateTime>? =
|
||||
doseTimes
|
||||
.mapNotNull { dt -> nextOccurrence(dt, after)?.let { dt to it } }
|
||||
.minByOrNull { it.second }
|
||||
|
||||
/** All occurrences on [date], in time order — the "today" screen's source. */
|
||||
fun occurrencesOn(doseTimes: List<DoseTime>, date: LocalDate): List<Pair<DoseTime, LocalDateTime>> =
|
||||
doseTimes
|
||||
.filter { occursOn(it, date) }
|
||||
.map { it to LocalDateTime.of(date, LocalTime.of(it.minuteOfDay / 60, it.minuteOfDay % 60)) }
|
||||
.sortedBy { it.second }
|
||||
|
||||
/**
|
||||
* Scheduled (prospective) consumption in units/day. Weekly: amount × selected
|
||||
* days ÷ 7; every-N-days: amount ÷ N. Prospective, not historical, because
|
||||
* days-of-supply is a forward prediction.
|
||||
*/
|
||||
fun dailyConsumption(doseTimes: List<DoseTime>): Double =
|
||||
doseTimes.sumOf { dt ->
|
||||
if (dt.intervalDays > 1) {
|
||||
if (dt.anchorEpochDay == null) 0.0 else dt.amount / dt.intervalDays
|
||||
} else {
|
||||
dt.amount * Integer.bitCount(dt.daysOfWeekMask and 0x7F) / 7.0
|
||||
}
|
||||
}
|
||||
|
||||
/** Days until stock runs out at the scheduled rate; null if nothing is consumed. */
|
||||
fun daysOfSupply(inventoryUnits: Double, dailyConsumption: Double): Double? =
|
||||
if (dailyConsumption <= 0.0) null else inventoryUnits / dailyConsumption
|
||||
|
||||
/** Refill warning: supply (at scheduled rate) won't outlast the lead time. */
|
||||
fun needsRefill(inventoryUnits: Double, dailyConsumption: Double, lowStockLeadDays: Int): Boolean {
|
||||
val supply = daysOfSupply(inventoryUnits, dailyConsumption) ?: return false
|
||||
return supply <= lowStockLeadDays
|
||||
}
|
||||
|
||||
/**
|
||||
* Renewal warning: e-resept expires within [leadDays] (or already has).
|
||||
* Independent of stock by design — a full box doesn't renew a prescription.
|
||||
*/
|
||||
fun needsRenewal(rxExpiryEpochDay: Long?, today: LocalDate, leadDays: Int = 14): Boolean {
|
||||
if (rxExpiryEpochDay == null) return false
|
||||
return rxExpiryEpochDay - today.toEpochDay() <= leadDays
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
package no.naiv.meddetsamme.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import no.naiv.meddetsamme.data.DoseTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The schedule engine is the safety core of a medication app — these tests are
|
||||
* the contract. Fixed reference dates (no clock access) keep every case
|
||||
* deterministic.
|
||||
*
|
||||
* Calendar anchor used throughout: 2026-06-10 is a WEDNESDAY.
|
||||
*/
|
||||
class ScheduleEngineTest {
|
||||
|
||||
private val wednesday: LocalDate = LocalDate.of(2026, 6, 10)
|
||||
private val saturday: LocalDate = LocalDate.of(2026, 6, 13)
|
||||
private val sunday: LocalDate = LocalDate.of(2026, 6, 14)
|
||||
|
||||
private fun doseTime(
|
||||
minuteOfDay: Int = 8 * 60, // 08:00
|
||||
amount: Double = 1.0,
|
||||
mask: Int = 0x7F,
|
||||
intervalDays: Int = 0,
|
||||
anchorEpochDay: Long? = null,
|
||||
id: Long = 1,
|
||||
) = DoseTime(
|
||||
id = id,
|
||||
medId = 1,
|
||||
minuteOfDay = minuteOfDay,
|
||||
amount = amount,
|
||||
daysOfWeekMask = mask,
|
||||
intervalDays = intervalDays,
|
||||
anchorEpochDay = anchorEpochDay,
|
||||
)
|
||||
|
||||
private fun at(date: LocalDate, hour: Int, minute: Int = 0): LocalDateTime =
|
||||
LocalDateTime.of(date, java.time.LocalTime.of(hour, minute))
|
||||
|
||||
// ---- weekly mask -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `daily dose later today fires today`() {
|
||||
val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 6, 0))
|
||||
assertEquals(at(wednesday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily dose already past fires tomorrow`() {
|
||||
val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 9, 0))
|
||||
assertEquals(at(wednesday.plusDays(1), 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrence is strictly after - same minute rolls to next day`() {
|
||||
// The alarm just fired at 08:00; asking from 08:00 must give tomorrow.
|
||||
val next = ScheduleEngine.nextOccurrence(doseTime(), at(wednesday, 8, 0))
|
||||
assertEquals(at(wednesday.plusDays(1), 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekend-only mask from wednesday fires saturday`() {
|
||||
// bit 0 = Sunday, bit 6 = Saturday → weekend = 0b1000001 = 0x41
|
||||
val next = ScheduleEngine.nextOccurrence(doseTime(mask = 0x41), at(wednesday, 12, 0))
|
||||
assertEquals(at(saturday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sunday-only mask wraps the week correctly`() {
|
||||
val next = ScheduleEngine.nextOccurrence(doseTime(mask = 0x01), at(sunday, 9, 0))
|
||||
assertEquals(at(sunday.plusDays(7), 8), next) // past Sunday's time → next Sunday
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty mask never fires`() {
|
||||
assertNull(ScheduleEngine.nextOccurrence(doseTime(mask = 0), at(wednesday, 6, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occursOn respects mask`() {
|
||||
val weekendDose = doseTime(mask = 0x41)
|
||||
assertTrue(ScheduleEngine.occursOn(weekendDose, saturday))
|
||||
assertTrue(ScheduleEngine.occursOn(weekendDose, sunday))
|
||||
assertFalse(ScheduleEngine.occursOn(weekendDose, wednesday))
|
||||
}
|
||||
|
||||
// ---- every-N-days ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `every 2 days on a cycle day before dose time fires same day`() {
|
||||
val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay())
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0))
|
||||
assertEquals(at(wednesday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every 2 days past dose time skips to next cycle day`() {
|
||||
val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay())
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 9, 0))
|
||||
assertEquals(at(wednesday.plusDays(2), 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every 3 days from mid-cycle lands on cycle day not just any day`() {
|
||||
val dt = doseTime(intervalDays = 3, anchorEpochDay = wednesday.toEpochDay())
|
||||
// Thursday is 1 day into a 3-day cycle → next fire is Saturday (+3 from anchor).
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday.plusDays(1), 12, 0))
|
||||
assertEquals(at(saturday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor in the future means first occurrence is the anchor day`() {
|
||||
val dt = doseTime(intervalDays = 2, anchorEpochDay = saturday.toEpochDay())
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 12, 0))
|
||||
assertEquals(at(saturday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval schedule ignores the weekly mask`() {
|
||||
// Mask says "never", interval says every day — interval wins when > 1.
|
||||
val dt = doseTime(mask = 0, intervalDays = 2, anchorEpochDay = wednesday.toEpochDay())
|
||||
val next = ScheduleEngine.nextOccurrence(dt, at(wednesday, 6, 0))
|
||||
assertEquals(at(wednesday, 8), next)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval without anchor never fires`() {
|
||||
assertNull(ScheduleEngine.nextOccurrence(doseTime(intervalDays = 2), at(wednesday, 6, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occursOn for interval requires day on cycle and not before anchor`() {
|
||||
val dt = doseTime(intervalDays = 2, anchorEpochDay = wednesday.toEpochDay())
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday))
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.plusDays(1)))
|
||||
assertTrue(ScheduleEngine.occursOn(dt, wednesday.plusDays(2)))
|
||||
assertFalse(ScheduleEngine.occursOn(dt, wednesday.minusDays(2))) // before anchor
|
||||
}
|
||||
|
||||
// ---- multiple dose-times --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `nextOccurrence over a list picks the earliest across rows`() {
|
||||
val morning = doseTime(minuteOfDay = 8 * 60, id = 1)
|
||||
val evening = doseTime(minuteOfDay = 20 * 60, id = 2)
|
||||
val next = ScheduleEngine.nextOccurrence(listOf(evening, morning), at(wednesday, 9, 0))
|
||||
assertEquals(evening to at(wednesday, 20), next) // morning already past
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrencesOn lists a day's doses in time order`() {
|
||||
val morning = doseTime(minuteOfDay = 8 * 60, id = 1)
|
||||
val evening = doseTime(minuteOfDay = 20 * 60, id = 2)
|
||||
val weekendOnly = doseTime(minuteOfDay = 12 * 60, mask = 0x41, id = 3)
|
||||
val today = ScheduleEngine.occurrencesOn(listOf(evening, weekendOnly, morning), wednesday)
|
||||
assertEquals(listOf(1L, 2L), today.map { it.first.id }) // weekend row absent, sorted
|
||||
}
|
||||
|
||||
// ---- supply / refill / renewal ---------------------------------------
|
||||
|
||||
@Test
|
||||
fun `daily consumption sums weekly rows`() {
|
||||
// 1.0 every day + 0.5 Mon/Wed/Fri (bits 1,3,5 = 0x2A)
|
||||
val rows = listOf(doseTime(amount = 1.0), doseTime(amount = 0.5, mask = 0x2A, id = 2))
|
||||
assertEquals(1.0 + 0.5 * 3 / 7.0, ScheduleEngine.dailyConsumption(rows), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily consumption for every-N-days divides by N`() {
|
||||
val rows = listOf(doseTime(amount = 1.5, intervalDays = 3, anchorEpochDay = 0))
|
||||
assertEquals(0.5, ScheduleEngine.dailyConsumption(rows), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days of supply divides stock by rate`() {
|
||||
assertEquals(7.0, ScheduleEngine.daysOfSupply(14.0, 2.0)!!, 1e-9)
|
||||
assertNull(ScheduleEngine.daysOfSupply(14.0, 0.0)) // nothing scheduled → no horizon
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsRefill at exactly the lead boundary warns`() {
|
||||
assertTrue(ScheduleEngine.needsRefill(14.0, 2.0, lowStockLeadDays = 7))
|
||||
assertFalse(ScheduleEngine.needsRefill(14.1, 2.0, lowStockLeadDays = 7))
|
||||
assertFalse(ScheduleEngine.needsRefill(14.0, 0.0, lowStockLeadDays = 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsRenewal is independent of stock and handles null and expired`() {
|
||||
assertFalse(ScheduleEngine.needsRenewal(null, wednesday))
|
||||
assertTrue(ScheduleEngine.needsRenewal(wednesday.toEpochDay() + 14, wednesday)) // boundary
|
||||
assertFalse(ScheduleEngine.needsRenewal(wednesday.toEpochDay() + 15, wednesday))
|
||||
assertTrue(ScheduleEngine.needsRenewal(wednesday.toEpochDay() - 1, wednesday)) // already dead
|
||||
}
|
||||
|
||||
// ---- mask bit convention --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bit convention is Sunday=0 through Saturday=6`() {
|
||||
assertEquals(0, ScheduleEngine.bitFor(java.time.DayOfWeek.SUNDAY))
|
||||
assertEquals(1, ScheduleEngine.bitFor(java.time.DayOfWeek.MONDAY))
|
||||
assertEquals(6, ScheduleEngine.bitFor(java.time.DayOfWeek.SATURDAY))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue