fix(android): start mixed session preparations

This commit is contained in:
fy59 2026-09-21 13:34:43 +02:00
parent f48be35f3b
commit e2284be1e3
4 changed files with 162 additions and 45 deletions

View file

@ -2,6 +2,10 @@
## Unreleased — 0.1.4 ## Unreleased — 0.1.4
- Fixed Android activation of mixed session preparations containing continuous
duration targets and set-based exercises. Continuous target metadata now
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
performed continuous activity or set is created before user capture.
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse, - Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
full-generation synchronization and a deterministic local vector PDF agenda. full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value Entries retain causal revisions, absolute timestamped events, the five-value

View file

@ -7838,6 +7838,8 @@ class TrainlogRepository(
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
/* CONTRACT: a target-only continuous occurrence has no
* performed row until the user records actual activity. */
val continuous = val continuous =
db.query( db.query(
"draft_continuous_activity", "draft_continuous_activity",
@ -7852,30 +7854,21 @@ class TrainlogRepository(
null, null,
null, null,
).use { item -> ).use { item ->
check(item.moveToFirst()) { val base = SessionExerciseDraft(
"Activité continue du brouillon manquante."
}
SessionExerciseDraft(
entryId = entryId, entryId = entryId,
exercise = exercise, exercise = exercise,
equipmentId = equipmentId, equipmentId = equipmentId,
plan = plan, plan = plan,
continuousDurationSeconds =
item.getInt(0),
speedKmh =
if (item.isNull(1)) {
null
} else {
item.getDouble(1)
},
distanceKm =
if (item.isNull(2)) {
null
} else {
item.getDouble(2)
},
) )
if (!item.moveToFirst()) {
base
} else {
base.copy(
continuousDurationSeconds = item.getInt(0),
speedKmh = if (item.isNull(1)) null else item.getDouble(1),
distanceKm = if (item.isNull(2)) null else item.getDouble(2),
)
}
} }
exercises += continuous exercises += continuous
} else { } else {
@ -8107,28 +8100,35 @@ class TrainlogRepository(
exerciseDraft.exercise.recordingMode == exerciseDraft.exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
) { ) {
val continuousValues = /*
ContentValues().apply { * A continuous target is planning data, not performed data.
put( * Do not manufacture a performed activity when a planned draft
"draft_exercise_row_id", * is first activated.
draftExerciseRowId, */
) if (exerciseDraft.continuousDurationSeconds > 0) {
put( val continuousValues =
"duration_seconds", ContentValues().apply {
exerciseDraft.continuousDurationSeconds, put(
) "draft_exercise_row_id",
exerciseDraft.speedKmh?.let { draftExerciseRowId,
put("speed_kmh", it) )
put(
"duration_seconds",
exerciseDraft.continuousDurationSeconds,
)
exerciseDraft.speedKmh?.let {
put("speed_kmh", it)
}
exerciseDraft.distanceKm?.let {
put("distance_km", it)
}
} }
exerciseDraft.distanceKm?.let { db.insertOrThrow(
put("distance_km", it) "draft_continuous_activity",
} null,
} continuousValues,
db.insertOrThrow( )
"draft_continuous_activity", }
null,
continuousValues,
)
} else { } else {
exerciseDraft.sets.forEachIndexed { exerciseDraft.sets.forEachIndexed {
setIndex, setIndex,
@ -9168,7 +9168,10 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
putNull("target_duration_seconds") putNull("target_duration_seconds")
putNull("target_weight_kg") putNull("target_weight_kg")
} else { } else {
put("target_sets", plan.sets) /* CONTRACT: continuous plans have no set count. The in-memory model
* uses zero for that absence, while the durable schema uses NULL and
* reserves positive target_sets for set-based occurrences. */
if (plan.sets == 0) putNull("target_sets") else put("target_sets", plan.sets)
if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps) if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps)
if (plan.durationSeconds == null) putNull("target_duration_seconds") if (plan.durationSeconds == null) putNull("target_duration_seconds")
else put("target_duration_seconds", plan.durationSeconds) else put("target_duration_seconds", plan.durationSeconds)
@ -9179,15 +9182,21 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? { private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? {
val loadMode = SessionLoadMode.fromWire(cursor.getString(start)) val loadMode = SessionLoadMode.fromWire(cursor.getString(start))
val restSeconds = cursor.getInt(start + 1) val restSeconds = cursor.getInt(start + 1)
if (cursor.isNull(start + 2)) { val hasTarget =
check(loadMode == SessionLoadMode.NONE && restSeconds == 0 && !cursor.isNull(start + 2) ||
cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) { !cursor.isNull(start + 3) ||
!cursor.isNull(start + 4) ||
!cursor.isNull(start + 5)
if (!hasTarget) {
check(loadMode == SessionLoadMode.NONE && restSeconds == 0) {
"Métadonnées de plan cible incohérentes" "Métadonnées de plan cible incohérentes"
} }
return null return null
} }
/* INVARIANT: SQL NULL target_sets is preserved as the model's zero
* marker only when other target data proves that a plan exists. */
return SessionExercisePlan( return SessionExercisePlan(
sets = cursor.getInt(start + 2), sets = if (cursor.isNull(start + 2)) 0 else cursor.getInt(start + 2),
reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3), reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3),
durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4), durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4),
weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5), weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5),

View file

@ -105,6 +105,106 @@ class SessionPreparationExchangeTest {
} }
} }
@Test
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
val name = "prepared-mixed-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared walk",
recordingMode = RecordingMode.CONTINUOUS,
trackingMode = TrackingMode.DURATION,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared press",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
val sets = repository.listExercises().single { it.name == "Test prepared press" }
val payload = JSONObject(artifact(repository))
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
delivery.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", continuous.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "continuous")
.put("tracking_mode", "duration")
.put("data_fields", continuous.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("target_sets", JSONObject.NULL)
.put("target_reps", JSONObject.NULL)
.put("target_duration_seconds", 600)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", "Target only"),
)
.put(
JSONObject()
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
.put("position", 1)
.put("exercise_id", sets.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", sets.dataFields)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 52.0)
.put("notes", JSONObject.NULL),
),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(payload.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val loadResult = repository.loadActiveSessionDraft()
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
val active = loadResult as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(3, active.draft.exercises[1].plan?.sets)
assertEquals(10, active.draft.exercises[1].plan?.reps)
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
assertTrue(active.draft.exercises[1].sets.isEmpty())
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test @Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() { fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db" val name = "prepared-${System.nanoTime()}.db"

View file

@ -45,6 +45,10 @@ and an accessible send action. Draft/local advances the same preparation to a
ready revision before delivery; ready/local delivers the current revision. ready revision before delivery; ready/local delivers the current revision.
Completed history hides the active preparation exclusively through the Completed history hides the active preparation exclusively through the
delivery's stable execution-session identity and retains all provenance. delivery's stable execution-session identity and retains all provenance.
Starting a delivered mixed preparation preserves continuous duration targets
and set/repetition/load/rest targets in the active singleton without creating
performed continuous activity, performed sets, MAX results, history, or
feedback before explicit user capture.
The opt-in full-generation path is operational on the private daily The opt-in full-generation path is operational on the private daily
installation. Android explicit synchronization uses a dedicated coordination installation. Android explicit synchronization uses a dedicated coordination