test(android): prove sleep intake lifecycle retention
This commit is contained in:
parent
fe79c44e51
commit
696dac82a3
2 changed files with 74 additions and 5 deletions
|
|
@ -92,16 +92,55 @@ class SyncGenerationServiceTest {
|
||||||
active = true,
|
active = true,
|
||||||
)
|
)
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val pendingRoot =
|
||||||
|
source.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
nightStartDate = "2026-09-23",
|
||||||
|
nightEndDate = "2026-09-24",
|
||||||
|
createdAt = "2026-09-23T22:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-23T22:00:00+02:00",
|
||||||
|
sleepQuality = null,
|
||||||
|
wakeQuality = null,
|
||||||
|
dayForm = null,
|
||||||
|
treatmentAndNotes = "",
|
||||||
|
events = emptyList(),
|
||||||
|
intakes = emptyList(),
|
||||||
|
)
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
val preBed =
|
val preBed =
|
||||||
source.quickSleepMedication(
|
source.quickSleepMedication(
|
||||||
medicationCreated.entryId,
|
medicationCreated.entryId,
|
||||||
"2026-09-23T22:20:00+02:00",
|
"2026-09-23T22:20:00+02:00",
|
||||||
quantity = 2,
|
quantity = 2,
|
||||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
|
||||||
|
source.quickSleepMedication(
|
||||||
|
medicationCreated.entryId,
|
||||||
|
"2026-09-23T22:21:00+02:00",
|
||||||
|
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
source.inSyncGenerationTransaction { db ->
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
|
||||||
|
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
|
||||||
|
arrayOf(preBed.receipt.entryId),
|
||||||
|
).use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.getInt(0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
|
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||||
val bed =
|
val bed =
|
||||||
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
val firstMeasurement =
|
val firstMeasurement =
|
||||||
ParsedHeartRateMeasurement(
|
ParsedHeartRateMeasurement(
|
||||||
bpm = 61,
|
bpm = 61,
|
||||||
|
|
@ -120,6 +159,9 @@ class SyncGenerationServiceTest {
|
||||||
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
)
|
)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
source.recordLiveHeartRateForActiveSleep(
|
source.recordLiveHeartRateForActiveSleep(
|
||||||
"Synthetic HR",
|
"Synthetic HR",
|
||||||
|
|
@ -131,6 +173,8 @@ class SyncGenerationServiceTest {
|
||||||
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
)
|
)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
assertTrue(source.listSessions().isEmpty())
|
assertTrue(source.listSessions().isEmpty())
|
||||||
|
|
||||||
val night = source.listSleepDiary().single()
|
val night = source.listSleepDiary().single()
|
||||||
|
|
@ -140,7 +184,7 @@ class SyncGenerationServiceTest {
|
||||||
.getJSONObject(0)
|
.getJSONObject(0)
|
||||||
assertEquals(night.entryId, capture.getString("context_id"))
|
assertEquals(night.entryId, capture.getString("context_id"))
|
||||||
assertEquals(2, capture.getJSONArray("samples").length())
|
assertEquals(2, capture.getJSONArray("samples").length())
|
||||||
assertEquals(2, night.intakes.single().quantity)
|
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
|
||||||
|
|
||||||
val service = SyncGenerationService(source)
|
val service = SyncGenerationService(source)
|
||||||
val captured = service.capture(root, desktopPeer)
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
|
@ -154,9 +198,12 @@ class SyncGenerationServiceTest {
|
||||||
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
||||||
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
||||||
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
||||||
|
assertEquals(2, publishedNight.getJSONArray("intakes").length())
|
||||||
assertEquals(
|
assertEquals(
|
||||||
2,
|
listOf(1, 2),
|
||||||
publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"),
|
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
|
||||||
|
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
|
||||||
|
}.sorted(),
|
||||||
)
|
)
|
||||||
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
||||||
|
|
||||||
|
|
@ -193,13 +240,13 @@ class SyncGenerationServiceTest {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
// The desktop fixture owns one reserved calibration session;
|
// The desktop fixture owns one reserved calibration session;
|
||||||
// the empty Android history must not add a workout.
|
// the empty Android history must not add a workout.
|
||||||
"1|1|2|2|2|1",
|
"1|2|2,1|2|2|1",
|
||||||
run(
|
run(
|
||||||
"sqlite3",
|
"sqlite3",
|
||||||
desktopDb.absolutePath,
|
desktopDb.absolutePath,
|
||||||
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
||||||
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
||||||
"(SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
|
||||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||||
"(SELECT count(*) FROM sessions);",
|
"(SELECT count(*) FROM sessions);",
|
||||||
|
|
@ -207,6 +254,19 @@ class SyncGenerationServiceTest {
|
||||||
.trim(),
|
.trim(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
|
||||||
|
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
|
||||||
|
staleEntry.put("revision_id", pendingRoot.revisionId)
|
||||||
|
staleEntry.put("parent_revision_id", JSONObject.NULL)
|
||||||
|
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
|
||||||
|
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
|
||||||
|
staleEntry.put("events", org.json.JSONArray())
|
||||||
|
staleEntry.put("intakes", org.json.JSONArray())
|
||||||
|
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
|
||||||
|
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
|
||||||
|
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
|
||||||
|
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||||
|
|
||||||
val revised =
|
val revised =
|
||||||
source.saveSleepDiary(
|
source.saveSleepDiary(
|
||||||
SleepDiaryDraft(
|
SleepDiaryDraft(
|
||||||
|
|
@ -255,6 +315,7 @@ class SyncGenerationServiceTest {
|
||||||
)
|
)
|
||||||
.trim(),
|
.trim(),
|
||||||
)
|
)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
assertTrue(source.listSessions().isEmpty())
|
assertTrue(source.listSessions().isEmpty())
|
||||||
} finally {
|
} finally {
|
||||||
source.close()
|
source.close()
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ successful intake; the per-unit dose and unit remain independent structured
|
||||||
facts. Pending entries persist through restart and can synchronize without
|
facts. Pending entries persist through restart and can synchronize without
|
||||||
inventing bedtime.
|
inventing bedtime.
|
||||||
|
|
||||||
|
The persistence regression begins from an empty pending revision, records two
|
||||||
|
pre-bed intakes (one with quantity two), and reopens the repository after
|
||||||
|
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
|
||||||
|
snapshots through generation, desktop import/ACK, exact replay, rejection of a
|
||||||
|
stale intake-free ancestor and a later current revision. This is the executable
|
||||||
|
contract for lifecycle and synchronization retention; it does not infer facts
|
||||||
|
that are absent from a device database.
|
||||||
|
|
||||||
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
||||||
no capture. After Couché, the first fresh real measurement creates or resumes
|
no capture. After Couché, the first fresh real measurement creates or resumes
|
||||||
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue