fix(android): start mixed session preparations
This commit is contained in:
parent
f48be35f3b
commit
e2284be1e3
4 changed files with 162 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue