Compare commits
2 commits
e6e5e76f68
...
dd6d2a180d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd6d2a180d | ||
|
|
891c09179e |
17 changed files with 706 additions and 18 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
## Unreleased — 0.1.4
|
||||
|
||||
- Fixed Program progress for sessions started from a delivered manual
|
||||
preparation. The preparation companion now carries its stable Program and
|
||||
Program-session provenance through Android start, finalization, restart and
|
||||
the existing execution companion; legacy stored deliveries are safely
|
||||
enriched only when every pre-existing field and execution identity matches.
|
||||
Android and Web consequently present the completed session and progress from
|
||||
the same persisted identity without fabricating history.
|
||||
Historical acknowledged deliveries now complete that recovery on the
|
||||
original session even when the preparation contained planned continuous
|
||||
activities, and current sleep snapshots can initialize a fresh peer from a
|
||||
non-root revision without discarding causal identity.
|
||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||
the previous entry immediately, adopts only an exact-date result and rejects
|
||||
|
|
|
|||
|
|
@ -541,6 +541,12 @@ internal class SyncGenerationCoordinator(
|
|||
phase(currentRunId, "cancelled", result = "interrupted")
|
||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||
} catch (error: Exception) {
|
||||
Log.e(
|
||||
"TrainlogSyncGeneration",
|
||||
"run_id=${currentRunId ?: "pending"} phase=error " +
|
||||
"type=${error.javaClass.name} message=${error.message}",
|
||||
error,
|
||||
)
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
try {
|
||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||
|
|
|
|||
|
|
@ -841,7 +841,11 @@ class TrainlogRepository(
|
|||
continue
|
||||
}
|
||||
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
|
||||
if (local == null && parent != null) return SleepDiaryImportResult.Rejected("unknown medication parent")
|
||||
/* CONTRACT: this companion is a current-state snapshot, not a
|
||||
* revision log. A fresh peer may therefore seed the current
|
||||
* revision while retaining an unavailable parent identity;
|
||||
* subsequent advances must still name the locally current
|
||||
* revision exactly. */
|
||||
if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection")
|
||||
val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")
|
||||
val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")
|
||||
|
|
@ -929,8 +933,9 @@ class TrainlogRepository(
|
|||
}
|
||||
if (local != null && parent != local.first)
|
||||
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||
if (local == null && parent != null)
|
||||
return SleepDiaryImportResult.Rejected("unknown sleep diary parent")
|
||||
/* The snapshot may start at a non-root current revision on a
|
||||
* fresh peer. Preserve its causal parent identity even though
|
||||
* the historical parent payload is intentionally absent. */
|
||||
val deleted = item.getBoolean("deleted")
|
||||
if (local?.second == 1 && !deleted)
|
||||
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
|
||||
|
|
@ -2543,10 +2548,14 @@ class TrainlogRepository(
|
|||
return try {
|
||||
for (deliveryIndex in 0 until values.length()) {
|
||||
val delivery = values.getJSONObject(deliveryIndex)
|
||||
val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
||||
val legacyKeys = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
||||
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
|
||||
"source_payload_sha256", "occurrences")
|
||||
require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." }
|
||||
val provenanceKeys = setOf("source_program_id", "source_program_session_id")
|
||||
require(delivery.hasExactKeys(legacyKeys) ||
|
||||
delivery.hasExactKeys(legacyKeys + provenanceKeys)) {
|
||||
"Forme de préparation invalide."
|
||||
}
|
||||
val deliveryId = delivery.getString("delivery_id")
|
||||
val preparationId = delivery.getString("preparation_id")
|
||||
val revisionId = delivery.getString("revision_id")
|
||||
|
|
@ -2555,6 +2564,17 @@ class TrainlogRepository(
|
|||
stableUuidV4Id(preparationId, "sp_") &&
|
||||
stableUuidV4Id(revisionId, "spr_") &&
|
||||
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
||||
if (delivery.has("source_program_id")) {
|
||||
val programId = if (delivery.isNull("source_program_id")) null else
|
||||
delivery.getString("source_program_id")
|
||||
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||
delivery.getString("source_program_session_id")
|
||||
require((programId == null) == (programSessionId == null) &&
|
||||
(programId == null || (stableUuidV4Id(programId, "pg_") &&
|
||||
stableUuidV4Id(programSessionId!!, "pgs_")))) {
|
||||
"Provenance de programme invalide."
|
||||
}
|
||||
}
|
||||
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
||||
val occurrences = delivery.getJSONArray("occurrences")
|
||||
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
||||
|
|
@ -2581,9 +2601,83 @@ class TrainlogRepository(
|
|||
})
|
||||
imported++
|
||||
} else {
|
||||
require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) {
|
||||
val existingPayload = JSONObject(existing.second)
|
||||
/* WHY: the PC delivery lifecycle can advance from pending
|
||||
* to acknowledged before a legacy Android row receives
|
||||
* newly exported Program provenance. CONTRACT: recovery
|
||||
* ignores only that transport state and the two missing
|
||||
* provenance fields; every preparation fact and identity
|
||||
* must remain byte-for-byte equivalent. */
|
||||
val legacyEnrichment =
|
||||
existing.first == revisionId &&
|
||||
!existingPayload.has("source_program_id") &&
|
||||
delivery.has("source_program_id") &&
|
||||
JSONObject(delivery.toString()).apply {
|
||||
remove("source_program_id")
|
||||
remove("source_program_session_id")
|
||||
remove("state")
|
||||
}.toString() == JSONObject(existingPayload.toString()).apply {
|
||||
remove("state")
|
||||
}.toString()
|
||||
require(existing.first == revisionId &&
|
||||
(existingPayload.toString() == canonical || legacyEnrichment)) {
|
||||
"Identité de livraison réutilisée avec un contenu différent."
|
||||
}
|
||||
if (legacyEnrichment) {
|
||||
val programId = if (delivery.isNull("source_program_id")) null else
|
||||
delivery.getString("source_program_id")
|
||||
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||
delivery.getString("source_program_session_id")
|
||||
if (programId != null && programSessionId != null) {
|
||||
val currentProvenance = db.rawQuery(
|
||||
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
||||
"WHERE session_id=? UNION ALL SELECT source_program_id," +
|
||||
"source_program_session_id FROM active_session_draft WHERE session_id=?",
|
||||
arrayOf(executionId, executionId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
Pair(
|
||||
if (cursor.isNull(0)) null else cursor.getString(0),
|
||||
if (cursor.isNull(1)) null else cursor.getString(1),
|
||||
).also {
|
||||
require(!cursor.moveToNext()) {
|
||||
"Identité d'exécution dupliquée."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(currentProvenance == null ||
|
||||
(currentProvenance.first == null &&
|
||||
currentProvenance.second == null) ||
|
||||
currentProvenance == Pair(programId, programSessionId)) {
|
||||
"Conflit de provenance du programme."
|
||||
}
|
||||
/* CONTRACT: old deliveries omitted Program
|
||||
* provenance. A later canonical delivery may fill
|
||||
* only those missing identities on the exact
|
||||
* reserved execution session; performed facts and
|
||||
* lifecycle state remain untouched. */
|
||||
db.execSQL(
|
||||
"UPDATE sessions SET source_program_id=?," +
|
||||
"source_program_session_id=? WHERE session_id=? AND " +
|
||||
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||
arrayOf(programId, programSessionId, executionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE active_session_draft SET source_program_id=?," +
|
||||
"source_program_session_id=? WHERE session_id=? AND " +
|
||||
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||
arrayOf(programId, programSessionId, executionId),
|
||||
)
|
||||
}
|
||||
db.execSQL(
|
||||
"UPDATE session_preparation_deliveries SET payload_json=? " +
|
||||
"WHERE delivery_id=? AND revision_id=?",
|
||||
arrayOf(canonical, deliveryId, revisionId),
|
||||
)
|
||||
}
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
|
@ -2754,9 +2848,24 @@ class TrainlogRepository(
|
|||
val payload = JSONObject(row.second)
|
||||
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
||||
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
||||
/* INVARIANT: the desktop-reserved execution identity survives
|
||||
* start and replay; no performed value is manufactured. */
|
||||
persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
|
||||
val sourceProgramId = if (!payload.has("source_program_id") ||
|
||||
payload.isNull("source_program_id")) null else payload.getString("source_program_id")
|
||||
val sourceProgramSessionId = if (!payload.has("source_program_session_id") ||
|
||||
payload.isNull("source_program_session_id")) null else
|
||||
payload.getString("source_program_session_id")
|
||||
/* INVARIANT: the desktop-reserved execution and Program
|
||||
* identities survive start, edits, finalization and replay;
|
||||
* no performed value is manufactured by this transition. */
|
||||
persistActiveSessionDraft(
|
||||
db,
|
||||
ActiveSessionDraft(
|
||||
sessionId = row.first,
|
||||
exercises = entries,
|
||||
sessionType = type,
|
||||
sourceProgramId = sourceProgramId,
|
||||
sourceProgramSessionId = sourceProgramSessionId,
|
||||
),
|
||||
)
|
||||
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
|
||||
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
|
||||
db.setTransactionSuccessful()
|
||||
|
|
@ -4774,7 +4883,27 @@ class TrainlogRepository(
|
|||
val sessionExerciseRowId = exerciseCursor.getLong(0)
|
||||
val recording = exerciseCursor.getString(3)
|
||||
val tracking = exerciseCursor.getString(4)
|
||||
val exportPlan = readSessionPlan(exerciseCursor, 10)
|
||||
val storedPlan = readSessionPlan(exerciseCursor, 10)
|
||||
/* WHY: a delivered continuous occurrence can retain a
|
||||
* local duration target, but mobile history represents
|
||||
* its completed fact exclusively through `continuous`.
|
||||
* CONTRACT: validate that local-only plan without
|
||||
* inventing a set-based target the Desktop schema
|
||||
* forbids. INVARIANT: performed duration, speed and
|
||||
* distance are still exported unchanged below. */
|
||||
if (recording == "continuous" && storedPlan != null) {
|
||||
check(
|
||||
tracking == "duration" &&
|
||||
exerciseCursor.isNull(9) &&
|
||||
storedPlan.sets == 0 &&
|
||||
storedPlan.reps == null &&
|
||||
storedPlan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS &&
|
||||
storedPlan.weightKg == null &&
|
||||
storedPlan.loadMode == SessionLoadMode.NONE &&
|
||||
storedPlan.restSeconds == 0,
|
||||
) { "Plan continu SQLite incohérent" }
|
||||
}
|
||||
val exportPlan = if (recording == "continuous") null else storedPlan
|
||||
val hasPlan = exportPlan != null
|
||||
if (version < 3 && exportPlan != null) {
|
||||
error("Un plan de séance exige l'export mobile V3.")
|
||||
|
|
|
|||
|
|
@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
): String {
|
||||
val occurrence =
|
||||
JSONObject()
|
||||
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
|
||||
.put("position", 0)
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 90)
|
||||
.put("target_sets", 3)
|
||||
.put("target_reps", 8)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 40.0)
|
||||
.put("notes", JSONObject.NULL)
|
||||
val delivery =
|
||||
JSONObject()
|
||||
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
|
||||
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
|
||||
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
|
||||
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
|
||||
.put("state", deliveryState)
|
||||
.put("title", "Séance A")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", JSONObject.NULL)
|
||||
.put("notes", JSONObject.NULL)
|
||||
.put("source_proposal_id", JSONObject.NULL)
|
||||
.put("source_payload_sha256", JSONObject.NULL)
|
||||
.put("occurrences", JSONArray().put(occurrence))
|
||||
if (includeProgramProvenance) {
|
||||
delivery
|
||||
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.put(
|
||||
"source_program_session_id",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-session-preparations")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:05:00Z")
|
||||
.put("deliveries", JSONArray().put(delivery))
|
||||
.put("withdrawals", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun deletion(): JSONObject =
|
||||
JSONObject()
|
||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||
|
|
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
|
||||
val name = "program-delivery-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("name", "Exercise fixture")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(1, 0),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(includeProgramProvenance = false),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
StartAiSessionDraftResult.Started,
|
||||
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
|
||||
)
|
||||
val active =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(null, active.sourceProgramId)
|
||||
assertEquals(null, active.sourceProgramSessionId)
|
||||
val performed =
|
||||
active.copy(
|
||||
exercises =
|
||||
active.exercises.map { occurrence ->
|
||||
occurrence.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||
)
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(performed),
|
||||
)
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.TODO,
|
||||
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
.sessions.single().executionState,
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(0, 1),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(deliveryState = "remote_unknown"),
|
||||
),
|
||||
)
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
|
||||
val execution =
|
||||
JSONObject(repository.buildProgramExecutionsV1Json())
|
||||
.getJSONArray("executions")
|
||||
.getJSONObject(0)
|
||||
assertEquals("completed", execution.getString("state"))
|
||||
assertEquals(
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
execution.getString("program_session_id"),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(1, detail.sessions.count {
|
||||
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
})
|
||||
assertEquals(
|
||||
StartProgramSessionResult.AlreadyCompleted,
|
||||
repository.startSyncedProgramSession(
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,4 +72,93 @@ class SleepDiaryRepositoryTest {
|
|||
assertTrue(repository.listSleepDiary().isEmpty())
|
||||
} finally { repository.close(); context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
|
||||
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
|
||||
val destinationName = "sleep-destination-${UUID.randomUUID()}.db"
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
val destination = TrainlogRepository(context, destinationName)
|
||||
try {
|
||||
val time = "2026-09-21T20:00:00+02:00"
|
||||
val createdMedication =
|
||||
source.saveSleepMedication(
|
||||
SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val medication = source.listSleepMedications().single()
|
||||
source.saveSleepMedication(
|
||||
medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"),
|
||||
)
|
||||
val createdEntry =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-09-20",
|
||||
nightEndDate = "2026-09-21",
|
||||
createdAt = time,
|
||||
updatedAt = time,
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = listOf(
|
||||
SleepDiaryEvent(
|
||||
"",
|
||||
SleepEventType.SLEEP,
|
||||
"2026-09-20T23:00:00+02:00",
|
||||
"2026-09-21T07:00:00+02:00",
|
||||
),
|
||||
),
|
||||
intakes = listOf(
|
||||
MedicationIntake(
|
||||
"",
|
||||
createdMedication.entryId,
|
||||
"Medication",
|
||||
"2026-09-20T22:00:00+02:00",
|
||||
5.0,
|
||||
"mg",
|
||||
"",
|
||||
time,
|
||||
),
|
||||
),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val current = source.listSleepDiary().single()
|
||||
val edited =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = current.entryId,
|
||||
expectedRevision = createdEntry.revisionId,
|
||||
nightStartDate = current.nightStartDate,
|
||||
nightEndDate = current.nightEndDate,
|
||||
createdAt = current.createdAt,
|
||||
updatedAt = "2026-09-21T20:10:00+02:00",
|
||||
sleepQuality = current.sleepQuality,
|
||||
wakeQuality = current.wakeQuality,
|
||||
dayForm = SleepQuality.B,
|
||||
treatmentAndNotes = current.treatmentAndNotes,
|
||||
events = current.events,
|
||||
intakes = current.intakes,
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
current.entryId,
|
||||
edited.revisionId,
|
||||
"2026-09-21T20:15:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
||||
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
|
||||
)
|
||||
assertEquals("Medication revised", destination.listSleepMedications().single().name)
|
||||
assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm)
|
||||
} finally {
|
||||
source.close()
|
||||
destination.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
context.deleteDatabase(destinationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2695,6 +2695,46 @@ class TrainlogRepositoryDraftTest {
|
|||
} catch (_: IllegalStateException) { }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
|
||||
val repo = openRepository()
|
||||
val exercise = createExercise(
|
||||
repo,
|
||||
"Marche planifiée",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
)
|
||||
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repo.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
exercises = listOf(
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = plan,
|
||||
continuousDurationSeconds = 540,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
|
||||
val detail = repo.getSessionDetail(result.sessionId)!!
|
||||
assertEquals(plan, detail.exercises.single().plan)
|
||||
val exported =
|
||||
JSONObject(repo.buildMobileExportV4Json())
|
||||
.getJSONArray("sessions")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("exercises")
|
||||
.getJSONObject(0)
|
||||
assertTrue(exported.isNull("target"))
|
||||
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
|
||||
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
|
||||
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
||||
val repo = openRepository()
|
||||
|
|
|
|||
|
|
@ -41,7 +41,16 @@ Android schema v22 adds a pending manual-preparation store which is separate
|
|||
from AI proposals and the exactly-one active-session singleton. A received
|
||||
preparation is inert until explicit start; an occupied singleton is never
|
||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||
copies targets only and creates no performed facts.
|
||||
copies targets only and creates no performed facts. A Program-owned preparation
|
||||
also preserves `source_program_id` and `source_program_session_id` through the
|
||||
active draft and completed session. A legacy stored delivery may receive those
|
||||
two omitted fields later only when its revision, complete prior payload and
|
||||
reserved execution identity match exactly; this repairs provenance without
|
||||
altering performed workout facts.
|
||||
Acknowledged historical deliveries use the same exact-identity recovery. A
|
||||
local continuous duration target is validated during generation capture while
|
||||
the completed duration, speed and distance remain represented by the existing
|
||||
`continuous` history payload rather than an invented set target.
|
||||
|
||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||
|
|
|
|||
|
|
@ -430,6 +430,17 @@ ACK without duplicating the execution or resurrecting the Program. A final
|
|||
correlated exchange remained completed with no missing capability. The real
|
||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
||||
|
||||
Program-owned manual preparations now carry their stable Program provenance to
|
||||
Android. Starting and finalizing such a delivery retains both identities, and a
|
||||
strict same-delivery enrichment repairs completed sessions produced from the
|
||||
earlier omission without changing workout facts. Android and Web therefore
|
||||
derive the completed count and per-session state from the same execution
|
||||
identity after synchronization and restart.
|
||||
The historical S1 A delivery was recovered onto its original completed
|
||||
`session_id`; Android and Web now both report one completed session out of 24.
|
||||
Generation capture also accepts its valid local continuous duration plans
|
||||
without projecting them as unsupported set targets.
|
||||
|
||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||
schema v23 to v24 after verified backups, then installed the signed
|
||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ full run, generation, peer, hash and ACK envelope and never starts a workout.
|
|||
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||
generation artifact. It carries immutable ready revision snapshots and stable
|
||||
delivery, preparation, occurrence and reserved execution identities, plus
|
||||
immutable preparation-withdrawal operations. It never
|
||||
immutable preparation-withdrawal operations. Program-owned deliveries also
|
||||
carry the paired stable Program and Program-session identities. Android keeps
|
||||
reading legacy deliveries that omitted this pair and accepts a later enrichment
|
||||
only when all legacy fields and the reserved execution identity are identical.
|
||||
It never
|
||||
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||
the complete artifact in the generation transaction and stores it as pending;
|
||||
start remains an explicit foreground action. Desktop records the exact
|
||||
|
|
@ -246,6 +250,16 @@ is idempotent; identity reuse or regression is a hard conflict. Because the
|
|||
descriptor is optional, peers predating this companion keep exchanging all
|
||||
existing required domains without a capability failure.
|
||||
|
||||
For Program-owned manual preparations, an acknowledged delivery is republished
|
||||
while its matching canonical execution evidence is absent on the PC. This
|
||||
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
||||
and stops automatically when the execution returns through this companion.
|
||||
The recovery emission retains the delivery's original acknowledged generation
|
||||
relation and requires exact delivery, revision, execution and Program
|
||||
identities. A current-state Sleep Diary snapshot may initialize a fresh peer
|
||||
from a non-root current revision while retaining the unavailable parent
|
||||
identity for subsequent causal advances.
|
||||
|
||||
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||
removed through causal history deletion. A retained older
|
||||
`program-executions-v1` fact is skipped only when the matching session has a
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
|
|||
path. A real Firefox run through the production C server and `trainlog.perf`
|
||||
creates events, medications and intakes, reloads their durable state, checks a
|
||||
responsive viewport and captures console errors and unhandled rejections.
|
||||
Program recovery coverage starts from an acknowledged legacy preparation,
|
||||
repairs the same completed Android `session_id`, publishes one completed
|
||||
execution and verifies Web `1 / 24` presentation. Related regressions cover
|
||||
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||
non-root current revisions.
|
||||
|
||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||
|
|
@ -130,6 +135,13 @@ through Program deletion, restart and old-generation replay without duplication
|
|||
or resurrection. The desktop populated migration test now continues through
|
||||
schema v27 and proves the new execution ledger starts empty.
|
||||
|
||||
The delivered-Program regression covers the previously omitted provenance
|
||||
pair end to end: legacy preparation receipt, start, performed capture,
|
||||
finalization, exact-payload enrichment, completed Android projection, execution
|
||||
companion output and restart. Export tests assert both stable IDs, the C Web API
|
||||
asserts `completed`, and the Web component verifies `1/24`, `Effectuée` and the
|
||||
absence of a preparation action on the completed session.
|
||||
|
||||
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
||||
session and execution identities byte-for-byte, admits the terminal deleted
|
||||
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
current_revision_id TEXT NOT NULL,
|
||||
source_proposal_id TEXT,
|
||||
source_payload_sha256 TEXT,
|
||||
source_program_id TEXT,
|
||||
source_program_session_id TEXT,
|
||||
withdrawn_at TEXT
|
||||
);
|
||||
CREATE TABLE session_preparation_revisions(
|
||||
|
|
@ -56,6 +58,13 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE program_session_executions(
|
||||
program_session_id TEXT PRIMARY KEY,
|
||||
program_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE session_preparation_withdrawals(
|
||||
withdrawal_id TEXT PRIMARY KEY,
|
||||
preparation_id TEXT NOT NULL,
|
||||
|
|
@ -77,12 +86,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
delivery = f"spd_{suffix}"
|
||||
execution = f"se_{suffix}"
|
||||
self.database.execute(
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
preparation,
|
||||
revision,
|
||||
"aid_11111111-1111-4111-8111-111111111111",
|
||||
"a" * 64,
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"2026-09-18T12:00:00Z" if withdrawn else None,
|
||||
),
|
||||
)
|
||||
|
|
@ -120,6 +131,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual("trainlog-session-preparations", payload["format"])
|
||||
self.assertEqual(2, payload["version"])
|
||||
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual(
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
payload["deliveries"][0]["source_program_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
payload["deliveries"][0]["source_program_session_id"],
|
||||
)
|
||||
self.assertEqual(1, len(payload["withdrawals"]))
|
||||
removal = payload["withdrawals"][0]
|
||||
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
||||
|
|
@ -147,6 +166,35 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual([], payload["deliveries"])
|
||||
self.assertEqual([], payload["withdrawals"])
|
||||
|
||||
def test_acknowledged_program_delivery_is_republished_until_execution_returns(self):
|
||||
suffix = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||
self.add_preparation(suffix, withdrawn=False)
|
||||
self.database.execute(
|
||||
"UPDATE session_preparation_deliveries SET state='acknowledged'"
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([f"spd_{suffix}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual("remote_unknown", payload["deliveries"][0]["state"])
|
||||
|
||||
self.database.execute(
|
||||
"INSERT INTO program_session_executions VALUES(?,?,?,?,?)",
|
||||
(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
f"se_{suffix}",
|
||||
"completed",
|
||||
"2026-09-18T12:10:00Z",
|
||||
),
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([], payload["deliveries"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -168,6 +168,60 @@ class GenerationTest(unittest.TestCase):
|
|||
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
def test_acknowledged_program_recovery_keeps_original_generation_relation(self):
|
||||
delivery = "spd_55555555-5555-4555-8555-555555555555"
|
||||
preparation = "sp_66666666-6666-4666-8666-666666666666"
|
||||
revision = "spr_77777777-7777-4777-8777-777777777777"
|
||||
execution = "se_88888888-8888-4888-8888-888888888888"
|
||||
program = "pg_99999999-9999-4999-8999-999999999999"
|
||||
program_session = "pgs_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
original_generation = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT")
|
||||
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT")
|
||||
db.execute(
|
||||
"CREATE TABLE program_session_executions(program_session_id TEXT PRIMARY KEY,"
|
||||
"program_id TEXT,session_id TEXT UNIQUE,state TEXT,observed_at TEXT)"
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?)",
|
||||
(preparation, revision, "acknowledged", None, program, program_session),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?,?,?)",
|
||||
(delivery, preparation, revision, execution, "acknowledged",
|
||||
"2026-09-21T12:00:00Z", original_generation, "2026-09-21T12:05:00Z"),
|
||||
)
|
||||
db.commit()
|
||||
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
|
||||
"session-preparations-v2.json", False, "controlled.py", ()),)
|
||||
payload = {
|
||||
"format": "trainlog-session-preparations", "version": 2,
|
||||
"generated_at": "2026-09-21T12:10:00Z", "withdrawals": [],
|
||||
"deliveries": [{
|
||||
"delivery_id": delivery, "preparation_id": preparation,
|
||||
"revision_id": revision, "execution_session_id": execution,
|
||||
"state": "remote_unknown", "source_program_id": program,
|
||||
"source_program_session_id": program_session,
|
||||
}],
|
||||
}
|
||||
def exporter(_tool, _extra, output, _snapshot):
|
||||
output.write_text(json.dumps(payload))
|
||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
|
||||
mock.patch.object(generation, "run_export", exporter):
|
||||
generation.capture_desktop(
|
||||
self.database, self.root / "program-recovery", PEER_B, RUN, GEN,
|
||||
)
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
self.assertEqual(
|
||||
("acknowledged", original_generation),
|
||||
db.execute(
|
||||
"SELECT state,generation_id FROM session_preparation_deliveries "
|
||||
"WHERE delivery_id=?", (delivery,),
|
||||
).fetchone(),
|
||||
)
|
||||
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
||||
_,manifest,checksum=self.capture();published=self.publish()
|
||||
(published/"history.json").write_text("{}")
|
||||
|
|
|
|||
|
|
@ -22,12 +22,21 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
|||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,"
|
||||
"CASE WHEN d.state='acknowledged' THEN 'remote_unknown' ELSE d.state END,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256,"
|
||||
"p.source_program_id,p.source_program_session_id "
|
||||
"FROM session_preparation_deliveries d "
|
||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
||||
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
||||
"WHERE p.withdrawn_at IS NULL AND ("
|
||||
"d.state IN('pending','remote_unknown') OR ("
|
||||
"d.state='acknowledged' AND p.source_program_id IS NOT NULL AND "
|
||||
"p.source_program_session_id IS NOT NULL AND NOT EXISTS("
|
||||
"SELECT 1 FROM program_session_executions x "
|
||||
"WHERE x.program_session_id=p.source_program_session_id "
|
||||
"AND x.program_id=p.source_program_id AND x.session_id=d.execution_session_id"
|
||||
"))) "
|
||||
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||
(MAX_DELIVERIES + 1,),
|
||||
).fetchall()
|
||||
|
|
@ -48,6 +57,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
|||
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
||||
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
||||
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
||||
"source_program_id": row[11], "source_program_session_id": row[12],
|
||||
"occurrences": [{
|
||||
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||
"equipment_id": entry[3], "recording_mode": entry[4],
|
||||
|
|
|
|||
|
|
@ -430,7 +430,34 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
(generation, delivery["delivery_id"], delivery["revision_id"]),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise GenerationError("preparation delivery changed during capture")
|
||||
# WHY: Program-provenance recovery republishes an
|
||||
# already acknowledged legacy delivery as
|
||||
# remote_unknown until its canonical execution comes
|
||||
# back. CONTRACT: this exception is allowed only for
|
||||
# the exact immutable delivery/revision/execution and
|
||||
# Program identities captured in the artifact.
|
||||
# INVARIANT: no delivery lifecycle or generation
|
||||
# relation is rewritten by the recovery emission.
|
||||
recovery = db.execute(
|
||||
"SELECT 1 FROM session_preparation_deliveries d "
|
||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||
"WHERE d.delivery_id=? AND d.revision_id=? "
|
||||
"AND d.execution_session_id=? AND d.state='acknowledged' "
|
||||
"AND p.source_program_id=? AND p.source_program_session_id=? "
|
||||
"AND NOT EXISTS(SELECT 1 FROM program_session_executions x "
|
||||
"WHERE x.program_id=p.source_program_id "
|
||||
"AND x.program_session_id=p.source_program_session_id "
|
||||
"AND x.session_id=d.execution_session_id)",
|
||||
(
|
||||
delivery["delivery_id"],
|
||||
delivery["revision_id"],
|
||||
delivery["execution_session_id"],
|
||||
delivery.get("source_program_id"),
|
||||
delivery.get("source_program_session_id"),
|
||||
),
|
||||
).fetchone()
|
||||
if recovery is None:
|
||||
raise GenerationError("preparation delivery changed during capture")
|
||||
for withdrawal in captured_preparations["withdrawals"]:
|
||||
cursor = db.execute(
|
||||
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
||||
|
|
|
|||
|
|
@ -317,6 +317,26 @@ static bool import_archive_and_prepare(void) {
|
|||
free(response);
|
||||
response = NULL;
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions("
|
||||
"program_session_id,program_id,session_id,state,observed_at) VALUES("
|
||||
"'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',"
|
||||
"'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',"
|
||||
"'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee','completed',"
|
||||
"'2026-09-21T12:00:00Z')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_detail_json(
|
||||
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(response, "\"execution_state\":\"completed\"") != NULL);
|
||||
CHECK(strstr(response,
|
||||
"\"execution_session_id\":"
|
||||
"\"se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee\"") != NULL);
|
||||
free(response);
|
||||
response = NULL;
|
||||
|
||||
CHECK(trainlog_web_programs_archive_json(database,
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
revision,
|
||||
|
|
|
|||
|
|
@ -206,4 +206,56 @@ describe('Programs tab', () => {
|
|||
expect(fetchMock.mock.calls.filter(([, options]) =>
|
||||
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts a completed program session and no longer offers to prepare it', async () => {
|
||||
const sessions = Array.from({ length: 24 }, (_, index) => ({
|
||||
program_session_id: `pgs_${index}`,
|
||||
position: index,
|
||||
title: index === 0 ? 'S1 A — Ceinture abdominale + jambes' : `Séance ${index + 1}`,
|
||||
session_type: 'training',
|
||||
planned_for: null,
|
||||
note: null,
|
||||
execution_state: index === 0 ? 'completed' : 'todo',
|
||||
execution_session_id: index === 0 ? 'se_completed' : null,
|
||||
occurrences: [],
|
||||
}))
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/v1/sessions/program/pg_test') {
|
||||
return jsonResponse({
|
||||
api_version: 1,
|
||||
program_id: 'pg_test',
|
||||
title: 'Programme 24 séances',
|
||||
note: null,
|
||||
state: 'active',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
created_at: '2026-09-18T12:00:00Z',
|
||||
updated_at: '2026-09-21T12:00:00Z',
|
||||
revision_id: 'revision',
|
||||
source_format: 'trainlog-program',
|
||||
source_version: 1,
|
||||
source_payload_sha256: 'a'.repeat(64),
|
||||
sessions,
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected request ${url}`)
|
||||
}))
|
||||
|
||||
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('1/24')).toBeInTheDocument()
|
||||
expect(screen.getByText('4% effectué')).toBeInTheDocument()
|
||||
const completed = screen
|
||||
.getByText(/S1 A — Ceinture abdominale \+ jambes/)
|
||||
.closest('li')
|
||||
expect(completed).not.toBeNull()
|
||||
expect(within(completed as HTMLElement).getByText('Effectuée')).toBeInTheDocument()
|
||||
expect(
|
||||
within(completed as HTMLElement).queryByRole('button', {
|
||||
name: 'Créer une préparation',
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: 'Créer une préparation' })).toHaveLength(23)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
SleepEntry,
|
||||
SleepEntryInput,
|
||||
|
|
@ -170,6 +170,8 @@ const dayAEntry = (): SleepEntry => ({
|
|||
|
||||
describe("SleepDiaryWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
||||
vi.clearAllMocks();
|
||||
api.nextId = 0;
|
||||
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
||||
|
|
@ -181,6 +183,10 @@ describe("SleepDiaryWorkspace", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bed_time", "Mise au lit", "22:30", null],
|
||||
["sleep", "Sommeil", "23:00", "06:30"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue