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
- 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,
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value

View file

@ -7838,6 +7838,8 @@ class TrainlogRepository(
exercise.recordingMode ==
RecordingMode.CONTINUOUS
) {
/* CONTRACT: a target-only continuous occurrence has no
* performed row until the user records actual activity. */
val continuous =
db.query(
"draft_continuous_activity",
@ -7852,30 +7854,21 @@ class TrainlogRepository(
null,
null,
).use { item ->
check(item.moveToFirst()) {
"Activité continue du brouillon manquante."
}
SessionExerciseDraft(
val base = SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
equipmentId = equipmentId,
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
} else {
@ -8107,28 +8100,35 @@ class TrainlogRepository(
exerciseDraft.exercise.recordingMode ==
RecordingMode.CONTINUOUS
) {
val continuousValues =
ContentValues().apply {
put(
"draft_exercise_row_id",
draftExerciseRowId,
)
put(
"duration_seconds",
exerciseDraft.continuousDurationSeconds,
)
exerciseDraft.speedKmh?.let {
put("speed_kmh", it)
/*
* A continuous target is planning data, not performed data.
* Do not manufacture a performed activity when a planned draft
* is first activated.
*/
if (exerciseDraft.continuousDurationSeconds > 0) {
val continuousValues =
ContentValues().apply {
put(
"draft_exercise_row_id",
draftExerciseRowId,
)
put(
"duration_seconds",
exerciseDraft.continuousDurationSeconds,
)
exerciseDraft.speedKmh?.let {
put("speed_kmh", it)
}
exerciseDraft.distanceKm?.let {
put("distance_km", it)
}
}
exerciseDraft.distanceKm?.let {
put("distance_km", it)
}
}
db.insertOrThrow(
"draft_continuous_activity",
null,
continuousValues,
)
db.insertOrThrow(
"draft_continuous_activity",
null,
continuousValues,
)
}
} else {
exerciseDraft.sets.forEachIndexed {
setIndex,
@ -9168,7 +9168,10 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
putNull("target_duration_seconds")
putNull("target_weight_kg")
} 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.durationSeconds == null) putNull("target_duration_seconds")
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? {
val loadMode = SessionLoadMode.fromWire(cursor.getString(start))
val restSeconds = cursor.getInt(start + 1)
if (cursor.isNull(start + 2)) {
check(loadMode == SessionLoadMode.NONE && restSeconds == 0 &&
cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) {
val hasTarget =
!cursor.isNull(start + 2) ||
!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"
}
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(
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),
durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4),
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
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
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.
Completed history hides the active preparation exclusively through the
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
installation. Android explicit synchronization uses a dedicated coordination