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
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
import dev.privacyllc.period.core.notifications.ReminderActionRequest
|
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
|
|
|
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 {
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today)))
|
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
|
|
|
|
|
|
|
|
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 {
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today)))
|
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
|
|
|
|
|
|
|
|
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))
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
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
|
|
|
|
|
|
|
|
// 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 {
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
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
|
|
|
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))
|
|
|
|
|
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today)))
|
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
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STILL_GOING, today)))
|
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
|
|
|
|
|
|
|
|
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 {
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
// 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.
|
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
|
|
|
assertTrue(repo.confirmedPeriods.first().isEmpty())
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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.
|
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
|
|
|
assertTrue(repo.notYetObservations.first().isEmpty())
|
fix: apply a reminder's answer to the day it asked about
A notification waits in the shade until somebody deals with it. The
handler used LocalDate.now(), so a reminder posted on Friday and tapped on
Monday recorded Monday -- and a period start is the single input the whole
prediction engine is built on. Being wrong by a weekend there is worse
than never asking.
The day the question was about now travels in the PendingIntent, written
when the notification is built rather than read when it is tapped, and the
parked action carries it through the app lock too.
ReminderActionRules then decides whether the answer is still worth
writing: nothing dated in the future, nothing older than a day, nothing
already settled by a start she has logged since, and ENDED only where
something is actually open to close. The bias is towards writing nothing
-- a stale tap still opens the app, which is where she can see what is
recorded and change it, and that beats a confident write against the wrong
day.
MainActivity consumes the extras after parking, and only parks when
savedInstanceState is null. Android redelivers the original Intent after
process death with its extras intact, so a restore would otherwise apply
a days-old answer a second time; a rotation would too. The writes are
idempotent today, which is the only reason that was survivable.
Anything unrecognised -- including the action strings from before buttons
carried their own meaning -- writes nothing. A notification sitting in a
shade across an upgrade opens the app and records nothing, rather than
guessing.
Handler tests go from 8 to 12: the next-morning case, the days-late case,
the already-answered-in-the-app case, and a legacy notification with no
date at all.
Also gives the app-lock test's await a diagnosis. It went red once in a
full-module run and passed alone, and "timed out" said nothing about
whether the write never happened, the callback never fired, or the state
had simply not arrived. It reports busy, message and hasPin now.
closes #69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:20:29 -05:00
|
|
|
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())
|
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
|
|
|
}
|
|
|
|
|
}
|