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
|
## 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
|
- 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
|
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
|
the previous entry immediately, adopts only an exact-date result and rejects
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,12 @@ internal class SyncGenerationCoordinator(
|
||||||
phase(currentRunId, "cancelled", result = "interrupted")
|
phase(currentRunId, "cancelled", result = "interrupted")
|
||||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||||
} catch (error: Exception) {
|
} 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") {
|
if (error.message == "generation retention capacity exhausted") {
|
||||||
try {
|
try {
|
||||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||||
|
|
|
||||||
|
|
@ -841,7 +841,11 @@ class TrainlogRepository(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
|
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")
|
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 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")
|
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)
|
if (local != null && parent != local.first)
|
||||||
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||||
if (local == null && parent != null)
|
/* The snapshot may start at a non-root current revision on a
|
||||||
return SleepDiaryImportResult.Rejected("unknown sleep diary parent")
|
* fresh peer. Preserve its causal parent identity even though
|
||||||
|
* the historical parent payload is intentionally absent. */
|
||||||
val deleted = item.getBoolean("deleted")
|
val deleted = item.getBoolean("deleted")
|
||||||
if (local?.second == 1 && !deleted)
|
if (local?.second == 1 && !deleted)
|
||||||
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
|
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
|
||||||
|
|
@ -2543,10 +2548,14 @@ class TrainlogRepository(
|
||||||
return try {
|
return try {
|
||||||
for (deliveryIndex in 0 until values.length()) {
|
for (deliveryIndex in 0 until values.length()) {
|
||||||
val delivery = values.getJSONObject(deliveryIndex)
|
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",
|
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
|
||||||
"source_payload_sha256", "occurrences")
|
"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 deliveryId = delivery.getString("delivery_id")
|
||||||
val preparationId = delivery.getString("preparation_id")
|
val preparationId = delivery.getString("preparation_id")
|
||||||
val revisionId = delivery.getString("revision_id")
|
val revisionId = delivery.getString("revision_id")
|
||||||
|
|
@ -2555,6 +2564,17 @@ class TrainlogRepository(
|
||||||
stableUuidV4Id(preparationId, "sp_") &&
|
stableUuidV4Id(preparationId, "sp_") &&
|
||||||
stableUuidV4Id(revisionId, "spr_") &&
|
stableUuidV4Id(revisionId, "spr_") &&
|
||||||
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
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." }
|
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
||||||
val occurrences = delivery.getJSONArray("occurrences")
|
val occurrences = delivery.getJSONArray("occurrences")
|
||||||
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
||||||
|
|
@ -2581,9 +2601,83 @@ class TrainlogRepository(
|
||||||
})
|
})
|
||||||
imported++
|
imported++
|
||||||
} else {
|
} 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."
|
"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++
|
skipped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2754,9 +2848,24 @@ class TrainlogRepository(
|
||||||
val payload = JSONObject(row.second)
|
val payload = JSONObject(row.second)
|
||||||
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
||||||
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
||||||
/* INVARIANT: the desktop-reserved execution identity survives
|
val sourceProgramId = if (!payload.has("source_program_id") ||
|
||||||
* start and replay; no performed value is manufactured. */
|
payload.isNull("source_program_id")) null else payload.getString("source_program_id")
|
||||||
persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
|
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'",
|
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))
|
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
|
||||||
db.setTransactionSuccessful()
|
db.setTransactionSuccessful()
|
||||||
|
|
@ -4774,7 +4883,27 @@ class TrainlogRepository(
|
||||||
val sessionExerciseRowId = exerciseCursor.getLong(0)
|
val sessionExerciseRowId = exerciseCursor.getLong(0)
|
||||||
val recording = exerciseCursor.getString(3)
|
val recording = exerciseCursor.getString(3)
|
||||||
val tracking = exerciseCursor.getString(4)
|
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
|
val hasPlan = exportPlan != null
|
||||||
if (version < 3 && exportPlan != null) {
|
if (version < 3 && exportPlan != null) {
|
||||||
error("Un plan de séance exige l'export mobile V3.")
|
error("Un plan de séance exige l'export mobile V3.")
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
|
||||||
.put("deletions", deletions)
|
.put("deletions", deletions)
|
||||||
.toString()
|
.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 =
|
private fun deletion(): JSONObject =
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||||
|
|
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
|
||||||
context.deleteDatabase(name)
|
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())
|
assertTrue(repository.listSleepDiary().isEmpty())
|
||||||
} finally { repository.close(); context.deleteDatabase(name) }
|
} 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) { }
|
} 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
|
@Test
|
||||||
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
||||||
val repo = openRepository()
|
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
|
from AI proposals and the exactly-one active-session singleton. A received
|
||||||
preparation is inert until explicit start; an occupied singleton is never
|
preparation is inert until explicit start; an occupied singleton is never
|
||||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
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
|
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
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
|
correlated exchange remained completed with no missing capability. The real
|
||||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
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
|
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||||
schema v23 to v24 after verified backups, then installed the signed
|
schema v23 to v24 after verified backups, then installed the signed
|
||||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
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
|
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||||
generation artifact. It carries immutable ready revision snapshots and stable
|
generation artifact. It carries immutable ready revision snapshots and stable
|
||||||
delivery, preparation, occurrence and reserved execution identities, plus
|
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
|
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||||
the complete artifact in the generation transaction and stores it as pending;
|
the complete artifact in the generation transaction and stores it as pending;
|
||||||
start remains an explicit foreground action. Desktop records the exact
|
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
|
descriptor is optional, peers predating this companion keep exchanging all
|
||||||
existing required domains without a capability failure.
|
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
|
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||||
removed through causal history deletion. A retained older
|
removed through causal history deletion. A retained older
|
||||||
`program-executions-v1` fact is skipped only when the matching session has a
|
`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`
|
path. A real Firefox run through the production C server and `trainlog.perf`
|
||||||
creates events, medications and intakes, reloads their durable state, checks a
|
creates events, medications and intakes, reloads their durable state, checks a
|
||||||
responsive viewport and captures console errors and unhandled rejections.
|
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,
|
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
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
|
or resurrection. The desktop populated migration test now continues through
|
||||||
schema v27 and proves the new execution ledger starts empty.
|
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,
|
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
||||||
session and execution identities byte-for-byte, admits the terminal deleted
|
session and execution identities byte-for-byte, admits the terminal deleted
|
||||||
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
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,
|
current_revision_id TEXT NOT NULL,
|
||||||
source_proposal_id TEXT,
|
source_proposal_id TEXT,
|
||||||
source_payload_sha256 TEXT,
|
source_payload_sha256 TEXT,
|
||||||
|
source_program_id TEXT,
|
||||||
|
source_program_session_id TEXT,
|
||||||
withdrawn_at TEXT
|
withdrawn_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE session_preparation_revisions(
|
CREATE TABLE session_preparation_revisions(
|
||||||
|
|
@ -56,6 +58,13 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
created_at 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(
|
CREATE TABLE session_preparation_withdrawals(
|
||||||
withdrawal_id TEXT PRIMARY KEY,
|
withdrawal_id TEXT PRIMARY KEY,
|
||||||
preparation_id TEXT NOT NULL,
|
preparation_id TEXT NOT NULL,
|
||||||
|
|
@ -77,12 +86,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
delivery = f"spd_{suffix}"
|
delivery = f"spd_{suffix}"
|
||||||
execution = f"se_{suffix}"
|
execution = f"se_{suffix}"
|
||||||
self.database.execute(
|
self.database.execute(
|
||||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
|
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
preparation,
|
preparation,
|
||||||
revision,
|
revision,
|
||||||
"aid_11111111-1111-4111-8111-111111111111",
|
"aid_11111111-1111-4111-8111-111111111111",
|
||||||
"a" * 64,
|
"a" * 64,
|
||||||
|
"pg_11111111-1111-4111-8111-111111111111",
|
||||||
|
"pgs_22222222-2222-4222-8222-222222222222",
|
||||||
"2026-09-18T12:00:00Z" if withdrawn else None,
|
"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("trainlog-session-preparations", payload["format"])
|
||||||
self.assertEqual(2, payload["version"])
|
self.assertEqual(2, payload["version"])
|
||||||
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
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"]))
|
self.assertEqual(1, len(payload["withdrawals"]))
|
||||||
removal = payload["withdrawals"][0]
|
removal = payload["withdrawals"][0]
|
||||||
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
||||||
|
|
@ -147,6 +166,35 @@ class SessionPreparationExportTest(unittest.TestCase):
|
||||||
self.assertEqual([], payload["deliveries"])
|
self.assertEqual([], payload["deliveries"])
|
||||||
self.assertEqual([], payload["withdrawals"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,60 @@ class GenerationTest(unittest.TestCase):
|
||||||
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
||||||
).fetchone()[0],
|
).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):
|
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
||||||
_,manifest,checksum=self.capture();published=self.publish()
|
_,manifest,checksum=self.capture();published=self.publish()
|
||||||
(published/"history.json").write_text("{}")
|
(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")
|
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,"
|
||||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
"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 "
|
"FROM session_preparation_deliveries d "
|
||||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_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 ?",
|
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||||
(MAX_DELIVERIES + 1,),
|
(MAX_DELIVERIES + 1,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
@ -48,6 +57,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
||||||
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
||||||
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
||||||
|
"source_program_id": row[11], "source_program_session_id": row[12],
|
||||||
"occurrences": [{
|
"occurrences": [{
|
||||||
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||||
"equipment_id": entry[3], "recording_mode": entry[4],
|
"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"]),
|
(generation, delivery["delivery_id"], delivery["revision_id"]),
|
||||||
)
|
)
|
||||||
if cursor.rowcount != 1:
|
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"]:
|
for withdrawal in captured_preparations["withdrawals"]:
|
||||||
cursor = db.execute(
|
cursor = db.execute(
|
||||||
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,26 @@ static bool import_archive_and_prepare(void) {
|
||||||
free(response);
|
free(response);
|
||||||
response = NULL;
|
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,
|
CHECK(trainlog_web_programs_archive_json(database,
|
||||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
revision,
|
revision,
|
||||||
|
|
|
||||||
|
|
@ -206,4 +206,56 @@ describe('Programs tab', () => {
|
||||||
expect(fetchMock.mock.calls.filter(([, options]) =>
|
expect(fetchMock.mock.calls.filter(([, options]) =>
|
||||||
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
(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,
|
waitFor,
|
||||||
within,
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
SleepEntry,
|
SleepEntry,
|
||||||
SleepEntryInput,
|
SleepEntryInput,
|
||||||
|
|
@ -170,6 +170,8 @@ const dayAEntry = (): SleepEntry => ({
|
||||||
|
|
||||||
describe("SleepDiaryWorkspace", () => {
|
describe("SleepDiaryWorkspace", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
api.nextId = 0;
|
api.nextId = 0;
|
||||||
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
||||||
|
|
@ -181,6 +183,10 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["bed_time", "Mise au lit", "22:30", null],
|
["bed_time", "Mise au lit", "22:30", null],
|
||||||
["sleep", "Sommeil", "23:00", "06:30"],
|
["sleep", "Sommeil", "23:00", "06:30"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue