Privacy-Period-Tracker/app/src/test/kotlin/dev/privacyllc/period/notifications/NotificationActionHandlerTe...

178 lines
7.6 KiB
Kotlin
Raw Normal View History

fix: stop "Ended" from recording the start of a period The period-end check-in asks "Is your period over?" and offers Ended and Still going. Tapping Ended inserted a NEW period record starting today, in the middle of the period it was asking about. Still going filed a censoring observation against a forecast that had already arrived. The labels were chosen in NotificationCopy and the writes were attached in ReminderWorker by position -- index 0 to "started", index 1 to "not yet", for every kind of reminder. That holds while every reminder asks the same question. It stopped holding the moment one did not. It corrupted the health record and every forecast built on it, and the user had no way to see it happen. A button is now one thing carrying both halves: NotificationCopy.buttons returns the label and the action together, and nothing downstream is allowed to pair them up again. ENDED closes the period that is running through setPeriodEnd -- the same call the Today screen makes -- and never opens one. STILL_GOING deliberately writes nothing: it is the state the record is already in, and the in-app equivalent is a no-op that would still move updatedAt and read, in the history, as an edit she never made. A start confirmed from a notification is now sourced NOTIFICATION_CONFIRMATION rather than MANUAL. How a record arrived is part of the record. Actions travel as their enum name, and anything unrecognised -- including the strings used before this change -- writes nothing. A notification sitting in somebody's shade across the upgrade still opens the app; it just does not guess what she meant. The extra key is now declared once in core/notifications and read by MainActivity rather than repeated as a literal on both sides. The handler had no test at all, which is how this survived: it owns the only two writes reachable from a locked phone. It has eight now, and the first is not about a write -- it asserts the two halves agree, in every privacy mode, as a property. Proved: mutating the already-closed guard out reddens exactly one test (scripts/prove-guard.sh). Reverting ENDED to its old write reddens three, which is the whole ENDED semantics and not a coincidence. closes #68 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:28:47 -05:00
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.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(ReminderAction.STARTED.name))
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(ReminderAction.NOT_YET.name))
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(ReminderAction.ENDED.name))
// 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(ReminderAction.ENDED.name))
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(ReminderAction.ENDED.name))
// 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(ReminderAction.STILL_GOING.name))
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 {
// Including the strings used before the buttons carried their own
// meaning — a notification sitting in the shade across an upgrade.
listOf(null, "", "dev.privacyllc.period.action.STARTED", "started", "ENDED_MAYBE").forEach {
assertFalse("$it was treated as an action", handler.handle(it))
}
assertTrue(repo.confirmedPeriods.first().isEmpty())
assertTrue(repo.notYetObservations.first().isEmpty())
}
}