225 lines
9.7 KiB
Kotlin
225 lines
9.7 KiB
Kotlin
package dev.privacyllc.period.notifications
|
|
|
|
import androidx.test.core.app.ApplicationProvider
|
|
import dev.privacyllc.period.core.data.CycleData
|
|
import dev.privacyllc.period.core.data.CycleRepository
|
|
import dev.privacyllc.period.core.data.PeriodWriteResult
|
|
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
|
|
import dev.privacyllc.period.core.notifications.NotificationCopy
|
|
import dev.privacyllc.period.core.notifications.ReminderAction
|
|
import dev.privacyllc.period.core.notifications.ReminderActionRequest
|
|
import dev.privacyllc.period.core.notifications.ReminderKind
|
|
import dev.privacyllc.period.core.datastore.NotificationPrivacy
|
|
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
|
|
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
|
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.flow.first
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.coroutines.test.runTest
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertFalse
|
|
import org.junit.Assert.assertNull
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Before
|
|
import org.junit.Rule
|
|
import org.junit.Test
|
|
import org.junit.rules.TemporaryFolder
|
|
import org.junit.runner.RunWith
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.annotation.Config
|
|
import java.time.Clock
|
|
import java.time.LocalDate
|
|
import java.time.ZoneOffset
|
|
|
|
/**
|
|
* The two writes reachable from a locked phone.
|
|
*
|
|
* These had no test at all, which is how the period-end check-in came to record
|
|
* the opposite of what its buttons said: "Ended" inserted a new period starting
|
|
* today, in the middle of the period it was asking about. The button labels are
|
|
* chosen in one file and the writes happened in another, and nothing held them
|
|
* to each other.
|
|
*
|
|
* So the first test here is not about a write at all — it is about the two
|
|
* halves agreeing.
|
|
*/
|
|
@RunWith(RobolectricTestRunner::class)
|
|
@Config(sdk = [34])
|
|
class NotificationActionHandlerTest {
|
|
|
|
@get:Rule val temp = TemporaryFolder()
|
|
|
|
private lateinit var repo: CycleRepository
|
|
private lateinit var prefs: UserPreferencesRepository
|
|
private lateinit var handler: NotificationActionHandler
|
|
|
|
private val today = LocalDate.of(2026, 8, 20)
|
|
|
|
@Before fun open() {
|
|
val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
|
|
// Through CycleData, like every other app test: this module deliberately
|
|
// cannot name PeriodDatabase, which is the boundary architecture/README
|
|
// describes and the compiler enforces.
|
|
repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock)
|
|
runBlocking { repo.deleteAllHealthData() }
|
|
prefs = UserPreferencesRepository(
|
|
PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.Unconfined)) {
|
|
temp.newFile("prefs.preferences_pb")
|
|
},
|
|
)
|
|
handler = NotificationActionHandler(repo, prefs, clock)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// The pairing itself
|
|
// -----------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `a question about the end never offers an answer about the start`() {
|
|
// The defect, stated as a property. Whatever the labels say in whatever
|
|
// privacy mode, the period-end check-in must not be able to produce a
|
|
// start, and the did-it-start family must not be able to produce an end.
|
|
NotificationPrivacy.entries.forEach { privacy ->
|
|
val end = NotificationCopy.buttons(ReminderKind.PERIOD_END_CHECK_IN, privacy)
|
|
assertEquals(listOf(ReminderAction.ENDED, ReminderAction.STILL_GOING), end.map { it.action })
|
|
|
|
listOf(ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY).forEach { kind ->
|
|
val start = NotificationCopy.buttons(kind, privacy)
|
|
assertEquals(listOf(ReminderAction.STARTED, ReminderAction.NOT_YET), start.map { it.action })
|
|
}
|
|
|
|
(end + NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)).forEach {
|
|
assertTrue("a button in $privacy had a blank label", it.label.isNotBlank())
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// What each button writes
|
|
// -----------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `Started records a period, sourced from the notification`() = runTest {
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today)))
|
|
|
|
val period = repo.confirmedPeriods.first().single()
|
|
assertEquals(today, period.startDate)
|
|
// Not MANUAL: how a record arrived is part of the record (§14).
|
|
assertEquals(PeriodRecordSource.NOTIFICATION_CONFIRMATION, period.source)
|
|
}
|
|
|
|
@Test
|
|
fun `Not yet records a censoring observation and no period`() = runTest {
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today)))
|
|
|
|
assertEquals(today, repo.notYetObservations.first().single().date)
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `Ended closes the period that is running rather than starting another`() = runTest {
|
|
repo.confirmPeriodStart(LocalDate.of(2026, 8, 16))
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
|
|
|
// Regression: this used to insert a SECOND period starting today, in the
|
|
// middle of the one the notification was asking about.
|
|
val period = repo.confirmedPeriods.first().single()
|
|
assertEquals(LocalDate.of(2026, 8, 16), period.startDate)
|
|
assertEquals(today, period.endDate)
|
|
}
|
|
|
|
@Test
|
|
fun `Ended with nothing running writes nothing`() = runTest {
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `Ended leaves a period that was already closed alone`() = runTest {
|
|
val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) as PeriodWriteResult.Added).id
|
|
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 19))
|
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
|
|
|
// She answered in the app first. The honest response to an answered
|
|
// question is silence, not a correction.
|
|
assertEquals(LocalDate.of(2026, 8, 19), repo.confirmedPeriods.first().single().endDate)
|
|
}
|
|
|
|
@Test
|
|
fun `Still going writes nothing at all`() = runTest {
|
|
repo.confirmPeriodStart(LocalDate.of(2026, 8, 16))
|
|
val before = repo.confirmedPeriods.first().single()
|
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STILL_GOING, today)))
|
|
|
|
val after = repo.confirmedPeriods.first().single()
|
|
assertEquals(before, after)
|
|
assertNull(after.endDate)
|
|
assertTrue(repo.notYetObservations.first().isEmpty())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Anything else
|
|
// -----------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `an action this app did not write is not guessed at`() = runTest {
|
|
// Nothing to apply at all.
|
|
assertFalse(handler.handle(null))
|
|
|
|
// And a notification posted before the day travelled with the answer —
|
|
// one sitting in somebody's shade across an upgrade. It opens the app
|
|
// and writes nothing, because there is no day it could honestly use.
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, date = null)))
|
|
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
|
assertTrue(repo.notYetObservations.first().isEmpty())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// The day it was asking about
|
|
// -----------------------------------------------------------------------
|
|
|
|
@Test
|
|
fun `an answer given the next morning is recorded against the day it was asked`() = runTest {
|
|
// Posted yesterday evening, tapped this morning. The handler used
|
|
// LocalDate.now(), so this recorded today — and a start date is the one
|
|
// input the whole prediction engine is built on.
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(1))))
|
|
|
|
assertEquals(today.minusDays(1), repo.confirmedPeriods.first().single().startDate)
|
|
}
|
|
|
|
@Test
|
|
fun `an answer left for days writes nothing`() = runTest {
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(5))))
|
|
|
|
// She has lived days the app knows nothing about. Opening the app, where
|
|
// she can see and fix what is recorded, beats a confident wrong write.
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `a question already answered in the app is not answered twice`() = runTest {
|
|
repo.confirmPeriodStart(today)
|
|
|
|
// The notification from yesterday is still in the shade.
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today.minusDays(1))))
|
|
|
|
// A "not yet" for a day before a start she has since logged would
|
|
// censor a forecast that has already arrived.
|
|
assertTrue(repo.notYetObservations.first().isEmpty())
|
|
assertEquals(1, repo.confirmedPeriods.first().size)
|
|
}
|
|
|
|
@Test
|
|
fun `an answer dated in the future writes nothing`() = runTest {
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.plusDays(1))))
|
|
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
|
}
|
|
}
|