Add variable sets and session exercise removal
This commit is contained in:
parent
28f6f8ea44
commit
b3734b8674
20 changed files with 2659 additions and 302 deletions
81
CHANGELOG.md
81
CHANGELOG.md
|
|
@ -199,3 +199,84 @@ Next:
|
||||||
- documented the next synchronization architecture: structured history,
|
- documented the next synchronization architecture: structured history,
|
||||||
detailed sync inspection, common sync engine and PC-side `trainlog-syncd`.
|
detailed sync inspection, common sync engine and PC-side `trainlog-syncd`.
|
||||||
<!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG _END -->
|
<!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
|
||||||
|
## Variable repetition sets
|
||||||
|
|
||||||
|
Trainlog preserves each performed set independently.
|
||||||
|
|
||||||
|
Accepted repetition input:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
`4..10..4` expands to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
|
||||||
|
observations. Synchronization therefore does not invent a uniform target when
|
||||||
|
performed sets are heterogeneous.
|
||||||
|
|
||||||
|
`performed_sets` remains the source of truth for actual per-set values.
|
||||||
|
|
||||||
|
Existing planned desktop sessions may still carry explicit target sets/reps or
|
||||||
|
target durations.
|
||||||
|
|
||||||
|
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
|
||||||
|
|
||||||
|
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
|
||||||
|
## Variable sets and session exercise removal checkpoint
|
||||||
|
|
||||||
|
Validated functionality in this checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VARIABLE_REPETITION_SETS=PASS
|
||||||
|
REPETITION_SHORTHAND_5x10=PASS
|
||||||
|
REPETITION_EXPLICIT_LIST=PASS
|
||||||
|
REPETITION_PYRAMID=PASS
|
||||||
|
|
||||||
|
DESKTOP_SCHEMA_V5=PASS
|
||||||
|
V4_TO_V5_MIGRATION_REGRESSION=PASS
|
||||||
|
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
|
||||||
|
MOBILE_IMPORT_IDEMPOTENCE=PASS
|
||||||
|
NO_FAKE_UNIFORM_TARGET=PASS
|
||||||
|
|
||||||
|
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
|
||||||
|
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted repetition examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
A heterogeneous mobile session is persisted as ordered `performed_sets`.
|
||||||
|
The desktop does not invent `target_sets`, `target_reps` or
|
||||||
|
`target_duration_seconds` for actual-only mobile observations.
|
||||||
|
|
||||||
|
On Android, an exercise already added to the current session can be removed
|
||||||
|
before saving the session.
|
||||||
|
|
||||||
|
On the desktop TUI, session editing already supports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d supprimer
|
||||||
|
```
|
||||||
|
|
||||||
|
for removing the selected exercise from a current or persisted session draft.
|
||||||
|
The database replacement remains transactional.
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package com.labfytools.trainlog.ui
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
/* TRAINLOG_ANDROID_SESSION_REMOVE */
|
||||||
|
|
||||||
|
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -113,6 +117,30 @@ fun SessionScreen(
|
||||||
color =
|
color =
|
||||||
colors.text,
|
colors.text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TrainlogAction(
|
||||||
|
label =
|
||||||
|
"Retirer ${draft.exercise.name}",
|
||||||
|
description =
|
||||||
|
"Supprimer cet exercice de la séance en cours.",
|
||||||
|
accent =
|
||||||
|
colors.error,
|
||||||
|
onClick = {
|
||||||
|
draftExercises =
|
||||||
|
draftExercises
|
||||||
|
.filterIndexed {
|
||||||
|
itemIndex,
|
||||||
|
_ ->
|
||||||
|
itemIndex !=
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionRevision += 1
|
||||||
|
|
||||||
|
message =
|
||||||
|
"Exercice retiré de la séance."
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +297,9 @@ fun SessionScreen(
|
||||||
message ==
|
message ==
|
||||||
"Séance enregistrée." ||
|
"Séance enregistrée." ||
|
||||||
message ==
|
message ==
|
||||||
"Exercice ajouté à la séance."
|
"Exercice ajouté à la séance." ||
|
||||||
|
message ==
|
||||||
|
"Exercice retiré de la séance."
|
||||||
) {
|
) {
|
||||||
colors.success
|
colors.success
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -382,7 +412,7 @@ private fun SessionExerciseForm(
|
||||||
|
|
||||||
var repsText by
|
var repsText by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
mutableStateOf("10")
|
mutableStateOf("3x10")
|
||||||
}
|
}
|
||||||
|
|
||||||
var durationText by
|
var durationText by
|
||||||
|
|
@ -423,6 +453,28 @@ private fun SessionExerciseForm(
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
RecordingMode.SETS
|
RecordingMode.SETS
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
exercise.trackingMode ==
|
||||||
|
TrackingMode.REPS
|
||||||
|
) {
|
||||||
|
SessionNumberField(
|
||||||
|
label =
|
||||||
|
"Séries / répétitions",
|
||||||
|
value =
|
||||||
|
repsText,
|
||||||
|
onValueChange = {
|
||||||
|
repsText = it
|
||||||
|
error = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainlogInfo(
|
||||||
|
text =
|
||||||
|
"Formats : 5x10 · 4,5,6,7 · 4..10..4",
|
||||||
|
color =
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
"Nombre de séries",
|
"Nombre de séries",
|
||||||
|
|
@ -434,21 +486,6 @@ private fun SessionExerciseForm(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
|
||||||
exercise.trackingMode ==
|
|
||||||
TrackingMode.REPS
|
|
||||||
) {
|
|
||||||
SessionNumberField(
|
|
||||||
label =
|
|
||||||
"Répétitions par série",
|
|
||||||
value =
|
|
||||||
repsText,
|
|
||||||
onValueChange = {
|
|
||||||
repsText = it
|
|
||||||
error = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
"Durée par série (secondes)",
|
"Durée par série (secondes)",
|
||||||
|
|
@ -624,6 +661,160 @@ private fun SessionNumberField(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val MAX_SESSION_SETS = 64
|
||||||
|
private const val MAX_REPS_PER_SET = 10000
|
||||||
|
|
||||||
|
private fun parseRepSequence(
|
||||||
|
text: String,
|
||||||
|
): List<Int>? {
|
||||||
|
val normalized =
|
||||||
|
text.trim()
|
||||||
|
.lowercase()
|
||||||
|
.replace(
|
||||||
|
'×',
|
||||||
|
'x'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (normalized.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val repeated =
|
||||||
|
Regex(
|
||||||
|
"""^(\d+)\s*x\s*(\d+)$"""
|
||||||
|
).matchEntire(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
if (repeated != null) {
|
||||||
|
val count =
|
||||||
|
repeated.groupValues[1]
|
||||||
|
.toIntOrNull()
|
||||||
|
|
||||||
|
val reps =
|
||||||
|
repeated.groupValues[2]
|
||||||
|
.toIntOrNull()
|
||||||
|
|
||||||
|
if (
|
||||||
|
count == null ||
|
||||||
|
reps == null ||
|
||||||
|
count !in 1..MAX_SESSION_SETS ||
|
||||||
|
reps !in 0..MAX_REPS_PER_SET
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return List(count) {
|
||||||
|
reps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pyramid =
|
||||||
|
Regex(
|
||||||
|
"""^(\d+)\s*\.\.\s*(\d+)\s*\.\.\s*(\d+)$"""
|
||||||
|
).matchEntire(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pyramid != null) {
|
||||||
|
val start =
|
||||||
|
pyramid.groupValues[1]
|
||||||
|
.toIntOrNull()
|
||||||
|
|
||||||
|
val peak =
|
||||||
|
pyramid.groupValues[2]
|
||||||
|
.toIntOrNull()
|
||||||
|
|
||||||
|
val end =
|
||||||
|
pyramid.groupValues[3]
|
||||||
|
.toIntOrNull()
|
||||||
|
|
||||||
|
if (
|
||||||
|
start == null ||
|
||||||
|
peak == null ||
|
||||||
|
end == null ||
|
||||||
|
start !in 0..MAX_REPS_PER_SET ||
|
||||||
|
peak !in 0..MAX_REPS_PER_SET ||
|
||||||
|
end !in 0..MAX_REPS_PER_SET ||
|
||||||
|
start > peak ||
|
||||||
|
end > peak
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val values =
|
||||||
|
mutableListOf<Int>()
|
||||||
|
|
||||||
|
for (value in start..peak) {
|
||||||
|
values += value
|
||||||
|
|
||||||
|
if (
|
||||||
|
values.size >
|
||||||
|
MAX_SESSION_SETS
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peak > end) {
|
||||||
|
for (
|
||||||
|
value in
|
||||||
|
(peak - 1) downTo end
|
||||||
|
) {
|
||||||
|
values += value
|
||||||
|
|
||||||
|
if (
|
||||||
|
values.size >
|
||||||
|
MAX_SESSION_SETS
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts =
|
||||||
|
normalized
|
||||||
|
.split(
|
||||||
|
Regex(
|
||||||
|
"""[\s,;]+"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter {
|
||||||
|
it.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parts.isEmpty() ||
|
||||||
|
parts.size >
|
||||||
|
MAX_SESSION_SETS
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val values =
|
||||||
|
mutableListOf<Int>()
|
||||||
|
|
||||||
|
for (part in parts) {
|
||||||
|
val reps =
|
||||||
|
part.toIntOrNull()
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
if (
|
||||||
|
reps !in
|
||||||
|
0..MAX_REPS_PER_SET
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
values += reps
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildSessionExerciseDraft(
|
private fun buildSessionExerciseDraft(
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
setCountText: String,
|
setCountText: String,
|
||||||
|
|
@ -637,8 +828,7 @@ private fun buildSessionExerciseDraft(
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
) {
|
) {
|
||||||
val minutes =
|
val minutes =
|
||||||
durationText
|
durationText.toIntOrNull()
|
||||||
.toIntOrNull()
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
minutes == null ||
|
minutes == null ||
|
||||||
|
|
@ -649,35 +839,25 @@ private fun buildSessionExerciseDraft(
|
||||||
} else {
|
} else {
|
||||||
val wantsSpeed =
|
val wantsSpeed =
|
||||||
exercise.dataFields and
|
exercise.dataFields and
|
||||||
ExerciseDataFields
|
ExerciseDataFields.SPEED_KMH != 0
|
||||||
.SPEED_KMH != 0
|
|
||||||
|
|
||||||
val wantsDistance =
|
val wantsDistance =
|
||||||
exercise.dataFields and
|
exercise.dataFields and
|
||||||
ExerciseDataFields
|
ExerciseDataFields.DISTANCE_KM != 0
|
||||||
.DISTANCE_KM != 0
|
|
||||||
|
|
||||||
val speed =
|
val speed =
|
||||||
if (wantsSpeed) {
|
if (wantsSpeed) {
|
||||||
speedText
|
speedText
|
||||||
.replace(
|
.replace(',', '.')
|
||||||
',',
|
|
||||||
'.'
|
|
||||||
)
|
|
||||||
.toDoubleOrNull()
|
.toDoubleOrNull()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val distance =
|
val distance =
|
||||||
if (
|
if (wantsDistance) {
|
||||||
wantsDistance
|
|
||||||
) {
|
|
||||||
distanceText
|
distanceText
|
||||||
.replace(
|
.replace(',', '.')
|
||||||
',',
|
|
||||||
'.'
|
|
||||||
)
|
|
||||||
.toDoubleOrNull()
|
.toDoubleOrNull()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -702,60 +882,43 @@ private fun buildSessionExerciseDraft(
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
exercise =
|
exercise = exercise,
|
||||||
exercise,
|
|
||||||
continuousDurationSeconds =
|
continuousDurationSeconds =
|
||||||
minutes * 60,
|
minutes * 60,
|
||||||
speedKmh =
|
speedKmh = speed,
|
||||||
speed,
|
distanceKm = distance,
|
||||||
distanceKm =
|
|
||||||
distance,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
val count =
|
|
||||||
setCountText
|
|
||||||
.toIntOrNull()
|
|
||||||
|
|
||||||
if (
|
|
||||||
count == null ||
|
|
||||||
count <= 0 ||
|
|
||||||
count > 64
|
|
||||||
) {
|
|
||||||
null
|
|
||||||
} else if (
|
} else if (
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
val reps =
|
val reps =
|
||||||
|
parseRepSequence(
|
||||||
repsText
|
repsText
|
||||||
.toIntOrNull()
|
) ?: return null
|
||||||
|
|
||||||
if (
|
|
||||||
reps == null ||
|
|
||||||
reps < 0 ||
|
|
||||||
reps > 10000
|
|
||||||
) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
exercise =
|
exercise = exercise,
|
||||||
exercise,
|
|
||||||
sets =
|
sets =
|
||||||
List(count) {
|
reps.map {
|
||||||
SessionSetDraft(
|
SessionSetDraft(
|
||||||
reps = reps
|
reps = it
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
val count =
|
||||||
|
setCountText.toIntOrNull()
|
||||||
|
|
||||||
val seconds =
|
val seconds =
|
||||||
durationText
|
durationText.toIntOrNull()
|
||||||
.toIntOrNull()
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
count == null ||
|
||||||
|
count <= 0 ||
|
||||||
|
count > MAX_SESSION_SETS ||
|
||||||
seconds == null ||
|
seconds == null ||
|
||||||
seconds <= 0 ||
|
seconds <= 0 ||
|
||||||
seconds > 86400
|
seconds > 86400
|
||||||
|
|
@ -763,8 +926,7 @@ private fun buildSessionExerciseDraft(
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
exercise =
|
exercise = exercise,
|
||||||
exercise,
|
|
||||||
sets =
|
sets =
|
||||||
List(count) {
|
List(count) {
|
||||||
SessionSetDraft(
|
SessionSetDraft(
|
||||||
|
|
@ -776,62 +938,76 @@ private fun buildSessionExerciseDraft(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun draftSummary(
|
private fun draftSummary(
|
||||||
draft:
|
draft: SessionExerciseDraft,
|
||||||
SessionExerciseDraft,
|
|
||||||
): String {
|
): String {
|
||||||
val exercise =
|
|
||||||
draft.exercise
|
|
||||||
|
|
||||||
return if (
|
return if (
|
||||||
exercise.recordingMode ==
|
draft.exercise.recordingMode ==
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
) {
|
) {
|
||||||
buildString {
|
buildString {
|
||||||
append(
|
append(
|
||||||
exercise.name
|
draft.exercise.name
|
||||||
)
|
)
|
||||||
|
|
||||||
append(" · ")
|
|
||||||
|
|
||||||
append(
|
append(
|
||||||
draft
|
" · ${draft.continuousDurationSeconds / 60} min"
|
||||||
.continuousDurationSeconds /
|
|
||||||
60
|
|
||||||
)
|
)
|
||||||
|
|
||||||
append(" min")
|
draft.speedKmh?.let {
|
||||||
|
|
||||||
draft.speedKmh
|
|
||||||
?.let {
|
|
||||||
append(
|
append(
|
||||||
" · %.1f km/h"
|
" · %.1f km/h"
|
||||||
.format(it)
|
.format(it)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
draft.distanceKm
|
draft.distanceKm?.let {
|
||||||
?.let {
|
|
||||||
append(
|
append(
|
||||||
" · %.2f km"
|
" · %.2f km"
|
||||||
.format(it)
|
.format(it)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (
|
||||||
val metric =
|
draft.exercise.trackingMode ==
|
||||||
if (
|
|
||||||
exercise.trackingMode ==
|
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
"${draft.sets.firstOrNull()?.reps ?: 0} reps"
|
val reps =
|
||||||
} else {
|
draft.sets.map {
|
||||||
"${draft.sets.firstOrNull()?.durationSeconds ?: 0} s"
|
it.reps
|
||||||
}
|
}
|
||||||
|
|
||||||
"${exercise.name} · ${draft.sets.size} × $metric"
|
if (
|
||||||
|
reps.isNotEmpty() &&
|
||||||
|
reps.all {
|
||||||
|
it == reps.first()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
"${draft.exercise.name} · " +
|
||||||
|
"${reps.size} × " +
|
||||||
|
"${reps.first()} reps"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"${draft.exercise.name} · " +
|
||||||
|
"${reps.size} séries · " +
|
||||||
|
reps.joinToString(
|
||||||
|
separator = ","
|
||||||
|
) +
|
||||||
|
" reps"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val first =
|
||||||
|
draft.sets.firstOrNull()
|
||||||
|
|
||||||
|
(
|
||||||
|
"${draft.exercise.name} · " +
|
||||||
|
"${draft.sets.size} × " +
|
||||||
|
"${first?.durationSeconds ?: 0} s"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -708,3 +708,51 @@ The final Android synchronization workflow must evolve toward a single
|
||||||
`Synchroniser maintenant` action backed by a PC-side synchronization agent,
|
`Synchroniser maintenant` action backed by a PC-side synchronization agent,
|
||||||
rather than manual export/import steps.
|
rather than manual export/import steps.
|
||||||
<!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT _END -->
|
<!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
|
||||||
|
## Variable sets and session exercise removal checkpoint
|
||||||
|
|
||||||
|
Validated functionality in this checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VARIABLE_REPETITION_SETS=PASS
|
||||||
|
REPETITION_SHORTHAND_5x10=PASS
|
||||||
|
REPETITION_EXPLICIT_LIST=PASS
|
||||||
|
REPETITION_PYRAMID=PASS
|
||||||
|
|
||||||
|
DESKTOP_SCHEMA_V5=PASS
|
||||||
|
V4_TO_V5_MIGRATION_REGRESSION=PASS
|
||||||
|
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
|
||||||
|
MOBILE_IMPORT_IDEMPOTENCE=PASS
|
||||||
|
NO_FAKE_UNIFORM_TARGET=PASS
|
||||||
|
|
||||||
|
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
|
||||||
|
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted repetition examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
A heterogeneous mobile session is persisted as ordered `performed_sets`.
|
||||||
|
The desktop does not invent `target_sets`, `target_reps` or
|
||||||
|
`target_duration_seconds` for actual-only mobile observations.
|
||||||
|
|
||||||
|
On Android, an exercise already added to the current session can be removed
|
||||||
|
before saving the session.
|
||||||
|
|
||||||
|
On the desktop TUI, session editing already supports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d supprimer
|
||||||
|
```
|
||||||
|
|
||||||
|
for removing the selected exercise from a current or persisted session draft.
|
||||||
|
The database replacement remains transactional.
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
|
||||||
|
|
|
||||||
|
|
@ -503,3 +503,84 @@ manual fake sets for continuous activities
|
||||||
overloading frozen Trainlog JSON v1
|
overloading frozen Trainlog JSON v1
|
||||||
```
|
```
|
||||||
<!-- TRAINLOG_SYNC_VALIDATED_ROADMAP _END -->
|
<!-- TRAINLOG_SYNC_VALIDATED_ROADMAP _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
|
||||||
|
## Variable repetition sets
|
||||||
|
|
||||||
|
Trainlog preserves each performed set independently.
|
||||||
|
|
||||||
|
Accepted repetition input:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
`4..10..4` expands to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
|
||||||
|
observations. Synchronization therefore does not invent a uniform target when
|
||||||
|
performed sets are heterogeneous.
|
||||||
|
|
||||||
|
`performed_sets` remains the source of truth for actual per-set values.
|
||||||
|
|
||||||
|
Existing planned desktop sessions may still carry explicit target sets/reps or
|
||||||
|
target durations.
|
||||||
|
|
||||||
|
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
|
||||||
|
|
||||||
|
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
|
||||||
|
## Variable sets and session exercise removal checkpoint
|
||||||
|
|
||||||
|
Validated functionality in this checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VARIABLE_REPETITION_SETS=PASS
|
||||||
|
REPETITION_SHORTHAND_5x10=PASS
|
||||||
|
REPETITION_EXPLICIT_LIST=PASS
|
||||||
|
REPETITION_PYRAMID=PASS
|
||||||
|
|
||||||
|
DESKTOP_SCHEMA_V5=PASS
|
||||||
|
V4_TO_V5_MIGRATION_REGRESSION=PASS
|
||||||
|
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
|
||||||
|
MOBILE_IMPORT_IDEMPOTENCE=PASS
|
||||||
|
NO_FAKE_UNIFORM_TARGET=PASS
|
||||||
|
|
||||||
|
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
|
||||||
|
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted repetition examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
A heterogeneous mobile session is persisted as ordered `performed_sets`.
|
||||||
|
The desktop does not invent `target_sets`, `target_reps` or
|
||||||
|
`target_duration_seconds` for actual-only mobile observations.
|
||||||
|
|
||||||
|
On Android, an exercise already added to the current session can be removed
|
||||||
|
before saving the session.
|
||||||
|
|
||||||
|
On the desktop TUI, session editing already supports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d supprimer
|
||||||
|
```
|
||||||
|
|
||||||
|
for removing the selected exercise from a current or persisted session draft.
|
||||||
|
The database replacement remains transactional.
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
|
||||||
|
|
|
||||||
|
|
@ -282,3 +282,84 @@ Android-triggered request/receipt workflow
|
||||||
automatic mobile snapshot maintenance
|
automatic mobile snapshot maintenance
|
||||||
```
|
```
|
||||||
<!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT _END -->
|
<!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 -->
|
||||||
|
## Variable repetition sets
|
||||||
|
|
||||||
|
Trainlog preserves each performed set independently.
|
||||||
|
|
||||||
|
Accepted repetition input:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
`4..10..4` expands to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop schema v5 permits targetless `SETS` rows for actual-only mobile
|
||||||
|
observations. Synchronization therefore does not invent a uniform target when
|
||||||
|
performed sets are heterogeneous.
|
||||||
|
|
||||||
|
`performed_sets` remains the source of truth for actual per-set values.
|
||||||
|
|
||||||
|
Existing planned desktop sessions may still carry explicit target sets/reps or
|
||||||
|
target durations.
|
||||||
|
|
||||||
|
`trainlog-mobile-export` v1 keeps ordered heterogeneous `sets[]`.
|
||||||
|
|
||||||
|
Frozen `TRAINLOG_FORMAT_V1` is unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SET_REPS_V1 _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
|
||||||
|
## Variable sets and session exercise removal checkpoint
|
||||||
|
|
||||||
|
Validated functionality in this checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VARIABLE_REPETITION_SETS=PASS
|
||||||
|
REPETITION_SHORTHAND_5x10=PASS
|
||||||
|
REPETITION_EXPLICIT_LIST=PASS
|
||||||
|
REPETITION_PYRAMID=PASS
|
||||||
|
|
||||||
|
DESKTOP_SCHEMA_V5=PASS
|
||||||
|
V4_TO_V5_MIGRATION_REGRESSION=PASS
|
||||||
|
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
|
||||||
|
MOBILE_IMPORT_IDEMPOTENCE=PASS
|
||||||
|
NO_FAKE_UNIFORM_TARGET=PASS
|
||||||
|
|
||||||
|
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
|
||||||
|
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted repetition examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
A heterogeneous mobile session is persisted as ordered `performed_sets`.
|
||||||
|
The desktop does not invent `target_sets`, `target_reps` or
|
||||||
|
`target_duration_seconds` for actual-only mobile observations.
|
||||||
|
|
||||||
|
On Android, an exercise already added to the current session can be removed
|
||||||
|
before saving the session.
|
||||||
|
|
||||||
|
On the desktop TUI, session editing already supports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d supprimer
|
||||||
|
```
|
||||||
|
|
||||||
|
for removing the selected exercise from a current or persisted session draft.
|
||||||
|
The database replacement remains transactional.
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
|
||||||
|
|
|
||||||
48
docs/tui.md
48
docs/tui.md
|
|
@ -836,3 +836,51 @@ DD/MM/YYYY HH:MM
|
||||||
|
|
||||||
while stored timestamps remain RFC3339.
|
while stored timestamps remain RFC3339.
|
||||||
<!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT _END -->
|
<!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT _END -->
|
||||||
|
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL -->
|
||||||
|
## Variable sets and session exercise removal checkpoint
|
||||||
|
|
||||||
|
Validated functionality in this checkpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VARIABLE_REPETITION_SETS=PASS
|
||||||
|
REPETITION_SHORTHAND_5x10=PASS
|
||||||
|
REPETITION_EXPLICIT_LIST=PASS
|
||||||
|
REPETITION_PYRAMID=PASS
|
||||||
|
|
||||||
|
DESKTOP_SCHEMA_V5=PASS
|
||||||
|
V4_TO_V5_MIGRATION_REGRESSION=PASS
|
||||||
|
MOBILE_HETEROGENEOUS_SET_IMPORT=PASS
|
||||||
|
MOBILE_IMPORT_IDEMPOTENCE=PASS
|
||||||
|
NO_FAKE_UNIFORM_TARGET=PASS
|
||||||
|
|
||||||
|
ANDROID_SESSION_DRAFT_EXERCISE_REMOVE=PASS
|
||||||
|
DESKTOP_SESSION_EXERCISE_REMOVE=PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted repetition examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
5x10
|
||||||
|
4,5,6,7,8,9,10,9,8,7,6,5,4
|
||||||
|
4..10..4
|
||||||
|
```
|
||||||
|
|
||||||
|
A heterogeneous mobile session is persisted as ordered `performed_sets`.
|
||||||
|
The desktop does not invent `target_sets`, `target_reps` or
|
||||||
|
`target_duration_seconds` for actual-only mobile observations.
|
||||||
|
|
||||||
|
On Android, an exercise already added to the current session can be removed
|
||||||
|
before saving the session.
|
||||||
|
|
||||||
|
On the desktop TUI, session editing already supports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
d supprimer
|
||||||
|
```
|
||||||
|
|
||||||
|
for removing the selected exercise from a current or persisted session draft.
|
||||||
|
The database replacement remains transactional.
|
||||||
|
|
||||||
|
`TRAINLOG_FORMAT_V1` remains frozen and unchanged.
|
||||||
|
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
|
||||||
|
|
|
||||||
331
tests/test_mobile_import_variable_sets.py
Executable file
331
tests/test_mobile_import_variable_sets.py
Executable file
|
|
@ -0,0 +1,331 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression test for heterogeneous mobile set import."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
IMPORTER = ROOT / "tools" / "import_mobile_export.py"
|
||||||
|
|
||||||
|
EXPECTED_REPS = [
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
9,
|
||||||
|
8,
|
||||||
|
7,
|
||||||
|
6,
|
||||||
|
5,
|
||||||
|
4,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
||||||
|
CREATE TABLE exercises (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
exercise_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
normalized_name TEXT NOT NULL UNIQUE,
|
||||||
|
tracking_mode TEXT NOT NULL,
|
||||||
|
recording_mode TEXT NOT NULL,
|
||||||
|
data_fields INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL UNIQUE,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
session_type TEXT NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_exercises (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_row_id INTEGER NOT NULL
|
||||||
|
REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
exercise_row_id INTEGER NOT NULL
|
||||||
|
REFERENCES exercises(id),
|
||||||
|
recording_mode TEXT NOT NULL,
|
||||||
|
data_fields INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
load_mode TEXT NOT NULL,
|
||||||
|
rest_seconds INTEGER NOT NULL,
|
||||||
|
target_sets INTEGER,
|
||||||
|
target_reps INTEGER,
|
||||||
|
target_duration_seconds INTEGER,
|
||||||
|
target_weight_kg REAL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE performed_sets (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_exercise_row_id INTEGER NOT NULL
|
||||||
|
REFERENCES session_exercises(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
reps INTEGER,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
weight_kg REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE continuous_activity (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_exercise_row_id INTEGER NOT NULL UNIQUE
|
||||||
|
REFERENCES session_exercises(id) ON DELETE CASCADE,
|
||||||
|
duration_seconds INTEGER NOT NULL,
|
||||||
|
speed_kmh REAL,
|
||||||
|
distance_km REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE body_observations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
observation_id TEXT NOT NULL UNIQUE,
|
||||||
|
observed_at TEXT NOT NULL,
|
||||||
|
session_row_id INTEGER,
|
||||||
|
body_weight_kg REAL,
|
||||||
|
neck_cm REAL,
|
||||||
|
shoulders_cm REAL,
|
||||||
|
chest_cm REAL,
|
||||||
|
waist_cm REAL,
|
||||||
|
hips_cm REAL,
|
||||||
|
left_arm_cm REAL,
|
||||||
|
right_arm_cm REAL,
|
||||||
|
left_forearm_cm REAL,
|
||||||
|
right_forearm_cm REAL,
|
||||||
|
left_thigh_cm REAL,
|
||||||
|
right_thigh_cm REAL,
|
||||||
|
left_calf_cm REAL,
|
||||||
|
right_calf_cm REAL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAGMA user_version=5;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def payload() -> dict:
|
||||||
|
return {
|
||||||
|
"format":
|
||||||
|
"trainlog-mobile-export",
|
||||||
|
"version":
|
||||||
|
1,
|
||||||
|
"generated_at":
|
||||||
|
"2026-09-06T16:00:00+02:00",
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"exercise_id":
|
||||||
|
"ex_mobile_pyramid",
|
||||||
|
"name":
|
||||||
|
"Pompes pyramide",
|
||||||
|
"recording_mode":
|
||||||
|
"sets",
|
||||||
|
"tracking_mode":
|
||||||
|
"reps",
|
||||||
|
"data_fields":
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id":
|
||||||
|
"se_mobile_pyramid",
|
||||||
|
"started_at":
|
||||||
|
"2026-09-06T16:01:00+02:00",
|
||||||
|
"session_type":
|
||||||
|
"training",
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"exercise_id":
|
||||||
|
"ex_mobile_pyramid",
|
||||||
|
"name":
|
||||||
|
"Pompes pyramide",
|
||||||
|
"recording_mode":
|
||||||
|
"sets",
|
||||||
|
"tracking_mode":
|
||||||
|
"reps",
|
||||||
|
"data_fields":
|
||||||
|
0,
|
||||||
|
"load_mode":
|
||||||
|
"none",
|
||||||
|
"rest_seconds":
|
||||||
|
0,
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"reps": reps
|
||||||
|
}
|
||||||
|
for reps
|
||||||
|
in EXPECTED_REPS
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body_observations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_import(
|
||||||
|
json_path: Path,
|
||||||
|
database_path: Path,
|
||||||
|
) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(IMPORTER),
|
||||||
|
str(json_path),
|
||||||
|
"--database",
|
||||||
|
str(database_path),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
"import failed:\n"
|
||||||
|
+ result.stdout
|
||||||
|
+ result.stderr
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="trainlog-mobile-variable-"
|
||||||
|
) as directory:
|
||||||
|
base = Path(directory)
|
||||||
|
database_path = base / "trainlog.db"
|
||||||
|
json_path = base / "mobile.json"
|
||||||
|
|
||||||
|
connection = sqlite3.connect(
|
||||||
|
database_path
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection.executescript(
|
||||||
|
SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
payload(),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
first = run_import(
|
||||||
|
json_path,
|
||||||
|
database_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"MOBILE_IMPORT=PASS"
|
||||||
|
not in first
|
||||||
|
or "sessions_imported=1"
|
||||||
|
not in first
|
||||||
|
):
|
||||||
|
raise AssertionError(
|
||||||
|
"first import report invalid:\n"
|
||||||
|
+ first
|
||||||
|
)
|
||||||
|
|
||||||
|
second = run_import(
|
||||||
|
json_path,
|
||||||
|
database_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"MOBILE_IMPORT=PASS"
|
||||||
|
not in second
|
||||||
|
or "sessions_skipped=1"
|
||||||
|
not in second
|
||||||
|
):
|
||||||
|
raise AssertionError(
|
||||||
|
"second import is not idempotent:\n"
|
||||||
|
+ second
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = sqlite3.connect(
|
||||||
|
database_path
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT ps.reps
|
||||||
|
FROM performed_sets ps
|
||||||
|
JOIN session_exercises se
|
||||||
|
ON se.id =
|
||||||
|
ps.session_exercise_row_id
|
||||||
|
JOIN sessions s
|
||||||
|
ON s.id = se.session_row_id
|
||||||
|
WHERE s.session_id =
|
||||||
|
'se_mobile_pyramid'
|
||||||
|
ORDER BY ps.position;
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
reps = [
|
||||||
|
row[0]
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
if reps != EXPECTED_REPS:
|
||||||
|
raise AssertionError(
|
||||||
|
f"reps mismatch: {reps!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
target = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
target_sets,
|
||||||
|
target_reps,
|
||||||
|
target_duration_seconds
|
||||||
|
FROM session_exercises se
|
||||||
|
JOIN sessions s
|
||||||
|
ON s.id = se.session_row_id
|
||||||
|
WHERE s.session_id =
|
||||||
|
'se_mobile_pyramid';
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if target != (
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
raise AssertionError(
|
||||||
|
f"fake target persisted: {target!r}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
print(
|
||||||
|
"PASS mobile_import_variable_sets"
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -61,7 +61,7 @@ def main():
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
if version != 4:
|
if version != 5:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -491,30 +491,13 @@ def validate_session_exercise(
|
||||||
f"{label}.sets: tableau non vide attendu"
|
f"{label}.sets: tableau non vide attendu"
|
||||||
)
|
)
|
||||||
|
|
||||||
metric_values = []
|
|
||||||
|
|
||||||
for set_index, set_item in enumerate(sets):
|
for set_index, set_item in enumerate(sets):
|
||||||
_, metric = validate_set_item(
|
validate_set_item(
|
||||||
set_item,
|
set_item,
|
||||||
tracking_mode,
|
tracking_mode,
|
||||||
f"{label}.sets[{set_index}]",
|
f"{label}.sets[{set_index}]",
|
||||||
)
|
)
|
||||||
|
|
||||||
metric_values.append(metric)
|
|
||||||
|
|
||||||
if len(set(metric_values)) != 1:
|
|
||||||
raise ImportFailure(
|
|
||||||
f"{label}: mobile export v1 exige des séries uniformes"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
tracking_mode == "reps"
|
|
||||||
and metric_values[0] < 1
|
|
||||||
):
|
|
||||||
raise ImportFailure(
|
|
||||||
f"{label}: mobile export v1 ne peut pas dériver une cible depuis 0 reps"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_sessions(
|
def validate_sessions(
|
||||||
payload,
|
payload,
|
||||||
|
|
@ -659,14 +642,14 @@ def validate_payload(payload):
|
||||||
validate_body(payload)
|
validate_body(payload)
|
||||||
|
|
||||||
|
|
||||||
def require_schema_v4(connection):
|
def require_schema_v5(connection):
|
||||||
version = connection.execute(
|
version = connection.execute(
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
if version != 4:
|
if version != 5:
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v4 attendue, version trouvée: {version}"
|
f"base desktop schema v5 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -856,26 +839,6 @@ def import_set_session_exercise(
|
||||||
):
|
):
|
||||||
sets = item["sets"]
|
sets = item["sets"]
|
||||||
tracking = item["tracking_mode"]
|
tracking = item["tracking_mode"]
|
||||||
metric_values = []
|
|
||||||
|
|
||||||
for set_item in sets:
|
|
||||||
if tracking == "reps":
|
|
||||||
metric_values.append(
|
|
||||||
set_item["reps"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
metric_values.append(
|
|
||||||
set_item["duration_seconds"]
|
|
||||||
)
|
|
||||||
|
|
||||||
target_metric = metric_values[0]
|
|
||||||
|
|
||||||
if tracking == "reps":
|
|
||||||
target_reps = target_metric
|
|
||||||
target_duration = None
|
|
||||||
else:
|
|
||||||
target_reps = None
|
|
||||||
target_duration = target_metric
|
|
||||||
|
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -894,7 +857,7 @@ def import_set_session_exercise(
|
||||||
notes
|
notes
|
||||||
) VALUES(
|
) VALUES(
|
||||||
?, ?, 'sets', ?, ?, 'none', 0,
|
?, ?, 'sets', ?, ?, 'none', 0,
|
||||||
?, ?, ?, NULL, NULL
|
NULL, NULL, NULL, NULL, NULL
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -902,9 +865,6 @@ def import_set_session_exercise(
|
||||||
exercise_row,
|
exercise_row,
|
||||||
item["data_fields"],
|
item["data_fields"],
|
||||||
position,
|
position,
|
||||||
len(sets),
|
|
||||||
target_reps,
|
|
||||||
target_duration,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -940,7 +900,6 @@ def import_set_session_exercise(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def import_continuous_session_exercise(
|
def import_continuous_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
|
|
@ -1205,7 +1164,7 @@ def run_import(
|
||||||
"PRAGMA foreign_keys = ON;"
|
"PRAGMA foreign_keys = ON;"
|
||||||
)
|
)
|
||||||
|
|
||||||
require_schema_v4(
|
require_schema_v5(
|
||||||
connection
|
connection
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 4
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 5
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
26
tui/include/trainlog/reps.h
Normal file
26
tui/include/trainlog/reps.h
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
#ifndef TRAINLOG_REPS_H
|
||||||
|
#define TRAINLOG_REPS_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse one bounded repetition sequence.
|
||||||
|
*
|
||||||
|
* Accepted forms:
|
||||||
|
*
|
||||||
|
* - 5x10 -> 10,10,10,10,10
|
||||||
|
* - 4,5,6,7 -> explicit ordered sets
|
||||||
|
* - 4..10..4 -> ascending then descending pyramid
|
||||||
|
*
|
||||||
|
* Repetitions are bounded to 0..10000 and output is never truncated.
|
||||||
|
*/
|
||||||
|
TrainlogStatus trainlog_reps_parse_sequence(
|
||||||
|
const char *text,
|
||||||
|
int *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -35,6 +35,7 @@ trainlog_core_sources = files(
|
||||||
'src/timeutil.c',
|
'src/timeutil.c',
|
||||||
'src/usb.c',
|
'src/usb.c',
|
||||||
'src/mtp.c',
|
'src/mtp.c',
|
||||||
|
'src/reps.c',
|
||||||
)
|
)
|
||||||
|
|
||||||
trainlog_core = static_library(
|
trainlog_core = static_library(
|
||||||
|
|
@ -287,3 +288,48 @@ trainlog_mtp_mobile_export_probe = executable(
|
||||||
c_args: strict_c_args,
|
c_args: strict_c_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_reps = executable(
|
||||||
|
'test_reps',
|
||||||
|
'tests/test_reps.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'reps',
|
||||||
|
test_reps,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_variable_sets = executable(
|
||||||
|
'test_variable_sets',
|
||||||
|
'tests/test_variable_sets.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'variable_sets',
|
||||||
|
test_variable_sets,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_schema_v5_migration = executable(
|
||||||
|
'test_schema_v5_migration',
|
||||||
|
'tests/test_schema_v5_migration.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test(
|
||||||
|
'schema_v5_migration',
|
||||||
|
test_schema_v5_migration,
|
||||||
|
)
|
||||||
|
|
||||||
|
python3_trainlog_tests = find_program('python3')
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mobile_import_variable_sets',
|
||||||
|
python3_trainlog_tests,
|
||||||
|
args: [
|
||||||
|
meson.project_source_root() / 'tests/test_mobile_import_variable_sets.py',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ struct TrainlogDatabase {
|
||||||
sqlite3 *connection;
|
sqlite3 *connection;
|
||||||
};
|
};
|
||||||
|
|
||||||
static const char *const SCHEMA_V4_SQL_A =
|
static const char *const SCHEMA_V5_SQL_A =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS exercises ("
|
"CREATE TABLE IF NOT EXISTS exercises ("
|
||||||
|
|
@ -69,11 +69,16 @@ static const char *const SCHEMA_V4_SQL_A =
|
||||||
" UNIQUE (session_row_id, exercise_row_id),"
|
" UNIQUE (session_row_id, exercise_row_id),"
|
||||||
" CHECK ("
|
" CHECK ("
|
||||||
" (recording_mode = 'sets' AND"
|
" (recording_mode = 'sets' AND"
|
||||||
" target_sets IS NOT NULL AND"
|
" ("
|
||||||
|
" (target_sets IS NULL AND"
|
||||||
|
" target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NULL) OR"
|
||||||
|
" (target_sets IS NOT NULL AND"
|
||||||
" ((target_reps IS NOT NULL AND"
|
" ((target_reps IS NOT NULL AND"
|
||||||
" target_duration_seconds IS NULL) OR"
|
" target_duration_seconds IS NULL) OR"
|
||||||
" (target_reps IS NULL AND"
|
" (target_reps IS NULL AND"
|
||||||
" target_duration_seconds IS NOT NULL))) OR"
|
" target_duration_seconds IS NOT NULL)))"
|
||||||
|
" )) OR"
|
||||||
" (recording_mode = 'continuous' AND"
|
" (recording_mode = 'continuous' AND"
|
||||||
" target_sets IS NULL AND"
|
" target_sets IS NULL AND"
|
||||||
" target_reps IS NULL AND"
|
" target_reps IS NULL AND"
|
||||||
|
|
@ -104,7 +109,7 @@ static const char *const SCHEMA_V4_SQL_A =
|
||||||
" )"
|
" )"
|
||||||
");";
|
");";
|
||||||
|
|
||||||
static const char *const SCHEMA_V4_SQL_B =
|
static const char *const SCHEMA_V5_SQL_B =
|
||||||
"CREATE TABLE IF NOT EXISTS continuous_activity ("
|
"CREATE TABLE IF NOT EXISTS continuous_activity ("
|
||||||
" id INTEGER PRIMARY KEY,"
|
" id INTEGER PRIMARY KEY,"
|
||||||
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
||||||
|
|
@ -147,7 +152,7 @@ static const char *const SCHEMA_V4_SQL_B =
|
||||||
" )"
|
" )"
|
||||||
");"
|
");"
|
||||||
|
|
||||||
"PRAGMA user_version = 4;"
|
"PRAGMA user_version = 5;"
|
||||||
"COMMIT;";
|
"COMMIT;";
|
||||||
|
|
||||||
static const char *const MIGRATE_V1_TO_V3_SQL =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
|
|
@ -315,6 +320,123 @@ static const char *const MIGRATE_V3_TO_V4_SQL_B =
|
||||||
"PRAGMA user_version = 4;"
|
"PRAGMA user_version = 4;"
|
||||||
"COMMIT;";
|
"COMMIT;";
|
||||||
|
|
||||||
|
static const char *const MIGRATE_V4_TO_V5_SQL_A =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
|
||||||
|
"CREATE TABLE session_exercises_v5 ("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" session_row_id INTEGER NOT NULL"
|
||||||
|
" REFERENCES sessions(id) ON DELETE CASCADE,"
|
||||||
|
" exercise_row_id INTEGER NOT NULL"
|
||||||
|
" REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||||
|
" recording_mode TEXT NOT NULL DEFAULT 'sets'"
|
||||||
|
" CHECK (recording_mode IN ('sets', 'continuous')),"
|
||||||
|
" data_fields INTEGER NOT NULL DEFAULT 0"
|
||||||
|
" CHECK (data_fields >= 0 AND (data_fields & ~3) = 0),"
|
||||||
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
||||||
|
" load_mode TEXT NOT NULL"
|
||||||
|
" CHECK (load_mode IN ('none', 'external', 'assistance')),"
|
||||||
|
" rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0),"
|
||||||
|
" target_sets INTEGER CHECK (target_sets > 0),"
|
||||||
|
" target_reps INTEGER CHECK (target_reps >= 1),"
|
||||||
|
" target_duration_seconds INTEGER"
|
||||||
|
" CHECK (target_duration_seconds > 0),"
|
||||||
|
" target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
|
||||||
|
" notes TEXT,"
|
||||||
|
" UNIQUE (session_row_id, position),"
|
||||||
|
" UNIQUE (session_row_id, exercise_row_id),"
|
||||||
|
" CHECK ("
|
||||||
|
" (recording_mode = 'sets' AND"
|
||||||
|
" ("
|
||||||
|
" (target_sets IS NULL AND"
|
||||||
|
" target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NULL) OR"
|
||||||
|
" (target_sets IS NOT NULL AND"
|
||||||
|
" ((target_reps IS NOT NULL AND"
|
||||||
|
" target_duration_seconds IS NULL) OR"
|
||||||
|
" (target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NOT NULL)))"
|
||||||
|
" )) OR"
|
||||||
|
" (recording_mode = 'continuous' AND"
|
||||||
|
" target_sets IS NULL AND"
|
||||||
|
" target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NULL AND"
|
||||||
|
" load_mode = 'none' AND"
|
||||||
|
" rest_seconds = 0 AND"
|
||||||
|
" target_weight_kg IS NULL)"
|
||||||
|
" ),"
|
||||||
|
" CHECK ("
|
||||||
|
" (load_mode = 'none' AND target_weight_kg IS NULL) OR"
|
||||||
|
" (load_mode IN ('external', 'assistance') AND"
|
||||||
|
" target_weight_kg IS NOT NULL)"
|
||||||
|
" )"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO session_exercises_v5("
|
||||||
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
||||||
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
||||||
|
"target_duration_seconds, target_weight_kg, notes"
|
||||||
|
") SELECT "
|
||||||
|
"id, session_row_id, exercise_row_id, recording_mode, data_fields,"
|
||||||
|
"position, load_mode, rest_seconds, target_sets, target_reps,"
|
||||||
|
"target_duration_seconds, target_weight_kg, notes "
|
||||||
|
"FROM session_exercises;"
|
||||||
|
|
||||||
|
"CREATE TABLE performed_sets_v5 ("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" session_exercise_row_id INTEGER NOT NULL"
|
||||||
|
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
|
||||||
|
" position INTEGER NOT NULL CHECK (position >= 0),"
|
||||||
|
" reps INTEGER CHECK (reps >= 0),"
|
||||||
|
" duration_seconds INTEGER CHECK (duration_seconds > 0),"
|
||||||
|
" weight_kg REAL CHECK (weight_kg > 0.0),"
|
||||||
|
" UNIQUE (session_exercise_row_id, position),"
|
||||||
|
" CHECK ("
|
||||||
|
" (reps IS NOT NULL AND duration_seconds IS NULL) OR"
|
||||||
|
" (reps IS NULL AND duration_seconds IS NOT NULL)"
|
||||||
|
" )"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO performed_sets_v5("
|
||||||
|
"id, session_exercise_row_id, position, reps,"
|
||||||
|
"duration_seconds, weight_kg"
|
||||||
|
") SELECT "
|
||||||
|
"id, session_exercise_row_id, position, reps,"
|
||||||
|
"duration_seconds, weight_kg "
|
||||||
|
"FROM performed_sets;";
|
||||||
|
|
||||||
|
static const char *const MIGRATE_V4_TO_V5_SQL_B =
|
||||||
|
"CREATE TABLE continuous_activity_v5 ("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
||||||
|
" REFERENCES session_exercises_v5(id) ON DELETE CASCADE,"
|
||||||
|
" duration_seconds INTEGER NOT NULL CHECK (duration_seconds > 0),"
|
||||||
|
" speed_kmh REAL CHECK (speed_kmh > 0.0),"
|
||||||
|
" distance_km REAL CHECK (distance_km > 0.0)"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO continuous_activity_v5("
|
||||||
|
"id, session_exercise_row_id, duration_seconds,"
|
||||||
|
"speed_kmh, distance_km"
|
||||||
|
") SELECT "
|
||||||
|
"id, session_exercise_row_id, duration_seconds,"
|
||||||
|
"speed_kmh, distance_km "
|
||||||
|
"FROM continuous_activity;"
|
||||||
|
|
||||||
|
"DROP TABLE continuous_activity;"
|
||||||
|
"DROP TABLE performed_sets;"
|
||||||
|
"DROP TABLE session_exercises;"
|
||||||
|
|
||||||
|
"ALTER TABLE session_exercises_v5"
|
||||||
|
" RENAME TO session_exercises;"
|
||||||
|
"ALTER TABLE performed_sets_v5"
|
||||||
|
" RENAME TO performed_sets;"
|
||||||
|
"ALTER TABLE continuous_activity_v5"
|
||||||
|
" RENAME TO continuous_activity;"
|
||||||
|
|
||||||
|
"PRAGMA user_version = 5;"
|
||||||
|
"COMMIT;";
|
||||||
|
|
||||||
static TrainlogStatus execute_sql(
|
static TrainlogStatus execute_sql(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const char *sql
|
const char *sql
|
||||||
|
|
@ -373,90 +495,160 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
int version = 0;
|
int version = 0;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
status = trainlog_database_schema_version(
|
status =
|
||||||
|
trainlog_database_schema_version(
|
||||||
database,
|
database,
|
||||||
&version
|
&version
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (
|
||||||
|
status !=
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version > TRAINLOG_DATABASE_SCHEMA_VERSION) {
|
if (
|
||||||
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
version >
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version == TRAINLOG_DATABASE_SCHEMA_VERSION) {
|
if (
|
||||||
|
version ==
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
) {
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version == 0) {
|
if (version == 0) {
|
||||||
status = execute_sql(
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
SCHEMA_V4_SQL_A
|
SCHEMA_V5_SQL_A
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
SCHEMA_V4_SQL_B
|
SCHEMA_V5_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (version == 1) {
|
} else {
|
||||||
status = execute_sql(
|
if (version == 1) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V1_TO_V3_SQL
|
MIGRATE_V1_TO_V3_SQL
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_A
|
MIGRATE_V3_TO_V4_SQL_A
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_B
|
MIGRATE_V3_TO_V4_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (version == 2) {
|
} else if (version == 2) {
|
||||||
status = execute_sql(
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V2_TO_V3_SQL
|
MIGRATE_V2_TO_V3_SQL
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_A
|
MIGRATE_V3_TO_V4_SQL_A
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_B
|
MIGRATE_V3_TO_V4_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (version == 3) {
|
} else if (version == 3) {
|
||||||
status = execute_sql(
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_A
|
MIGRATE_V3_TO_V4_SQL_A
|
||||||
);
|
);
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (
|
||||||
status = execute_sql(
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
database,
|
database,
|
||||||
MIGRATE_V3_TO_V4_SQL_B
|
MIGRATE_V3_TO_V4_SQL_B
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (version == 4) {
|
||||||
|
status =
|
||||||
|
TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
return
|
||||||
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (
|
||||||
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
|
database,
|
||||||
|
MIGRATE_V4_TO_V5_SQL_A
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
status ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
status =
|
||||||
|
execute_sql(
|
||||||
|
database,
|
||||||
|
MIGRATE_V4_TO_V5_SQL_B
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
status !=
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
(void)sqlite3_exec(
|
(void)sqlite3_exec(
|
||||||
database->connection,
|
database->connection,
|
||||||
"ROLLBACK;",
|
"ROLLBACK;",
|
||||||
|
|
@ -1278,10 +1470,39 @@ static TrainlogStatus insert_session_exercise(
|
||||||
input->recording_mode ==
|
input->recording_mode ==
|
||||||
TRAINLOG_RECORDING_SETS
|
TRAINLOG_RECORDING_SETS
|
||||||
) {
|
) {
|
||||||
if (input->target_sets <= 0 ||
|
bool has_target_sets =
|
||||||
|
input->target_sets > 0;
|
||||||
|
|
||||||
|
bool has_target_reps =
|
||||||
|
input->target_reps > 0;
|
||||||
|
|
||||||
|
bool has_target_duration =
|
||||||
|
input->target_duration_seconds > 0;
|
||||||
|
|
||||||
|
bool any_target =
|
||||||
|
has_target_sets ||
|
||||||
|
has_target_reps ||
|
||||||
|
has_target_duration;
|
||||||
|
|
||||||
|
bool target_valid =
|
||||||
|
!any_target ||
|
||||||
|
(
|
||||||
|
has_target_sets &&
|
||||||
|
(
|
||||||
|
has_target_reps !=
|
||||||
|
has_target_duration
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
input->target_sets < 0 ||
|
||||||
|
input->target_reps < 0 ||
|
||||||
|
input->target_duration_seconds < 0 ||
|
||||||
|
!target_valid ||
|
||||||
input->continuous_duration_seconds != 0 ||
|
input->continuous_duration_seconds != 0 ||
|
||||||
input->continuous_has_speed ||
|
input->continuous_has_speed ||
|
||||||
input->continuous_has_distance) {
|
input->continuous_has_distance
|
||||||
|
) {
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
429
tui/src/reps.c
Normal file
429
tui/src/reps.c
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
/**
|
||||||
|
* @file reps.c
|
||||||
|
* @brief Bounded parser for Trainlog repetition-set shorthand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "trainlog/reps.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define TRAINLOG_REPS_TEXT_MAX 511U
|
||||||
|
#define TRAINLOG_REPS_VALUE_MAX 10000
|
||||||
|
|
||||||
|
static bool parse_token(
|
||||||
|
const char *start,
|
||||||
|
size_t length,
|
||||||
|
int *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char buffer[32];
|
||||||
|
char *end = NULL;
|
||||||
|
long value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
start == NULL ||
|
||||||
|
output == NULL ||
|
||||||
|
length == 0U ||
|
||||||
|
length >= sizeof(buffer)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)memcpy(
|
||||||
|
buffer,
|
||||||
|
start,
|
||||||
|
length
|
||||||
|
);
|
||||||
|
|
||||||
|
buffer[length] = '\0';
|
||||||
|
|
||||||
|
value =
|
||||||
|
strtol(
|
||||||
|
buffer,
|
||||||
|
&end,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
end == buffer ||
|
||||||
|
*end != '\0' ||
|
||||||
|
value < 0L ||
|
||||||
|
value > TRAINLOG_REPS_VALUE_MAX
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output = (int)value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool compact_text(
|
||||||
|
const char *text,
|
||||||
|
char output[TRAINLOG_REPS_TEXT_MAX + 1U]
|
||||||
|
)
|
||||||
|
{
|
||||||
|
size_t read_index;
|
||||||
|
size_t write_index = 0U;
|
||||||
|
|
||||||
|
if (
|
||||||
|
text == NULL ||
|
||||||
|
output == NULL
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
read_index = 0U;
|
||||||
|
text[read_index] != '\0';
|
||||||
|
++read_index
|
||||||
|
) {
|
||||||
|
unsigned char value =
|
||||||
|
(unsigned char)text[read_index];
|
||||||
|
|
||||||
|
if (isspace(value) != 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
write_index >=
|
||||||
|
TRAINLOG_REPS_TEXT_MAX
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[write_index] =
|
||||||
|
(char)value;
|
||||||
|
|
||||||
|
++write_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[write_index] = '\0';
|
||||||
|
|
||||||
|
return write_index > 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus parse_repeat(
|
||||||
|
char *text,
|
||||||
|
int *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char *separator =
|
||||||
|
strchr(
|
||||||
|
text,
|
||||||
|
'x'
|
||||||
|
);
|
||||||
|
|
||||||
|
int count;
|
||||||
|
int reps;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (separator == NULL) {
|
||||||
|
separator =
|
||||||
|
strchr(
|
||||||
|
text,
|
||||||
|
'X'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (separator == NULL) {
|
||||||
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
strchr(
|
||||||
|
separator + 1,
|
||||||
|
'x'
|
||||||
|
) != NULL ||
|
||||||
|
strchr(
|
||||||
|
separator + 1,
|
||||||
|
'X'
|
||||||
|
) != NULL
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*separator = '\0';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!parse_token(
|
||||||
|
text,
|
||||||
|
strlen(text),
|
||||||
|
&count
|
||||||
|
) ||
|
||||||
|
!parse_token(
|
||||||
|
separator + 1,
|
||||||
|
strlen(separator + 1),
|
||||||
|
&reps
|
||||||
|
) ||
|
||||||
|
count < 1 ||
|
||||||
|
(size_t)count > capacity
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
index = 0U;
|
||||||
|
index < (size_t)count;
|
||||||
|
++index
|
||||||
|
) {
|
||||||
|
output[index] = reps;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_count = (size_t)count;
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus parse_pyramid(
|
||||||
|
char *text,
|
||||||
|
int *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char *first =
|
||||||
|
strstr(
|
||||||
|
text,
|
||||||
|
".."
|
||||||
|
);
|
||||||
|
|
||||||
|
char *second;
|
||||||
|
int start;
|
||||||
|
int peak;
|
||||||
|
int end;
|
||||||
|
size_t count = 0U;
|
||||||
|
int value;
|
||||||
|
|
||||||
|
if (first == NULL) {
|
||||||
|
return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
second =
|
||||||
|
strstr(
|
||||||
|
first + 2,
|
||||||
|
".."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
second == NULL ||
|
||||||
|
strstr(
|
||||||
|
second + 2,
|
||||||
|
".."
|
||||||
|
) != NULL
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*first = '\0';
|
||||||
|
*second = '\0';
|
||||||
|
|
||||||
|
if (
|
||||||
|
!parse_token(
|
||||||
|
text,
|
||||||
|
strlen(text),
|
||||||
|
&start
|
||||||
|
) ||
|
||||||
|
!parse_token(
|
||||||
|
first + 2,
|
||||||
|
strlen(first + 2),
|
||||||
|
&peak
|
||||||
|
) ||
|
||||||
|
!parse_token(
|
||||||
|
second + 2,
|
||||||
|
strlen(second + 2),
|
||||||
|
&end
|
||||||
|
) ||
|
||||||
|
start > peak ||
|
||||||
|
end > peak
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
value = start;
|
||||||
|
value <= peak;
|
||||||
|
++value
|
||||||
|
) {
|
||||||
|
if (count >= capacity) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[count] = value;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peak > end) {
|
||||||
|
for (
|
||||||
|
value = peak - 1;
|
||||||
|
value >= end;
|
||||||
|
--value
|
||||||
|
) {
|
||||||
|
if (count >= capacity) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[count] = value;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0U) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_count = count;
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus parse_explicit(
|
||||||
|
const char *text,
|
||||||
|
int *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const char *cursor = text;
|
||||||
|
const char *token_start = text;
|
||||||
|
size_t count = 0U;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (
|
||||||
|
*cursor == ',' ||
|
||||||
|
*cursor == ';' ||
|
||||||
|
*cursor == '\0'
|
||||||
|
) {
|
||||||
|
int reps;
|
||||||
|
|
||||||
|
if (
|
||||||
|
count >= capacity ||
|
||||||
|
!parse_token(
|
||||||
|
token_start,
|
||||||
|
(size_t)(
|
||||||
|
cursor -
|
||||||
|
token_start
|
||||||
|
),
|
||||||
|
&reps
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
output[count] = reps;
|
||||||
|
++count;
|
||||||
|
|
||||||
|
if (*cursor == '\0') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
token_start =
|
||||||
|
cursor + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
++cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0U) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_count = count;
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_reps_parse_sequence(
|
||||||
|
const char *text,
|
||||||
|
int *output,
|
||||||
|
size_t capacity,
|
||||||
|
size_t *output_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char compact[
|
||||||
|
TRAINLOG_REPS_TEXT_MAX + 1U
|
||||||
|
];
|
||||||
|
|
||||||
|
char working[
|
||||||
|
TRAINLOG_REPS_TEXT_MAX + 1U
|
||||||
|
];
|
||||||
|
|
||||||
|
TrainlogStatus status;
|
||||||
|
|
||||||
|
if (
|
||||||
|
text == NULL ||
|
||||||
|
output_count == NULL ||
|
||||||
|
capacity == 0U ||
|
||||||
|
output == NULL
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output_count = 0U;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!compact_text(
|
||||||
|
text,
|
||||||
|
compact
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
working,
|
||||||
|
sizeof(working),
|
||||||
|
"%s",
|
||||||
|
compact
|
||||||
|
);
|
||||||
|
|
||||||
|
status =
|
||||||
|
parse_repeat(
|
||||||
|
working,
|
||||||
|
output,
|
||||||
|
capacity,
|
||||||
|
output_count
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
status !=
|
||||||
|
TRAINLOG_STATUS_NOT_FOUND
|
||||||
|
) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
working,
|
||||||
|
sizeof(working),
|
||||||
|
"%s",
|
||||||
|
compact
|
||||||
|
);
|
||||||
|
|
||||||
|
status =
|
||||||
|
parse_pyramid(
|
||||||
|
working,
|
||||||
|
output,
|
||||||
|
capacity,
|
||||||
|
output_count
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
status !=
|
||||||
|
TRAINLOG_STATUS_NOT_FOUND
|
||||||
|
) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
parse_explicit(
|
||||||
|
compact,
|
||||||
|
output,
|
||||||
|
capacity,
|
||||||
|
output_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
#include "trainlog/duration.h"
|
#include "trainlog/duration.h"
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
#include "trainlog/mtp.h"
|
#include "trainlog/mtp.h"
|
||||||
|
#include "trainlog/reps.h"
|
||||||
#include "trainlog/theme.h"
|
#include "trainlog/theme.h"
|
||||||
#include "trainlog/timeutil.h"
|
#include "trainlog/timeutil.h"
|
||||||
#include "trainlog/usb.h"
|
#include "trainlog/usb.h"
|
||||||
|
|
@ -40,6 +41,7 @@
|
||||||
|
|
||||||
/* TRAINLOG_TUI_V02_POLISH */
|
/* TRAINLOG_TUI_V02_POLISH */
|
||||||
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
||||||
|
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
|
||||||
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */
|
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */
|
||||||
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
|
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
|
||||||
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
|
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
|
||||||
|
|
@ -4493,7 +4495,10 @@ static bool build_session_exercise(
|
||||||
int target_sets = 3;
|
int target_sets = 3;
|
||||||
int target_metric;
|
int target_metric;
|
||||||
int rest_seconds = 60;
|
int rest_seconds = 60;
|
||||||
int actual_sets;
|
int actual_sets = 0;
|
||||||
|
int rep_values[MAX_SETS_PER_EXERCISE];
|
||||||
|
size_t rep_count = 0U;
|
||||||
|
char rep_sequence[512];
|
||||||
bool target_has_weight = false;
|
bool target_has_weight = false;
|
||||||
double target_weight = 0.0;
|
double target_weight = 0.0;
|
||||||
size_t set_index;
|
size_t set_index;
|
||||||
|
|
@ -4689,6 +4694,41 @@ static bool build_session_exercise(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
exercise.tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_REPS
|
||||||
|
) {
|
||||||
|
for (;;) {
|
||||||
|
if (!prompt_text(
|
||||||
|
9,
|
||||||
|
"Séries réalisées (5x10 | 4,5,6,... | 4..10..4) : ",
|
||||||
|
rep_sequence,
|
||||||
|
sizeof(rep_sequence),
|
||||||
|
false
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
rep_sequence,
|
||||||
|
rep_values,
|
||||||
|
set_capacity,
|
||||||
|
&rep_count
|
||||||
|
) ==
|
||||||
|
TRAINLOG_STATUS_OK
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line(
|
||||||
|
"Séries invalides. Exemples : 5x10 · 4,5,6,7 · 4..10..4",
|
||||||
|
TRAINLOG_COLOR_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (!prompt_int_value(
|
if (!prompt_int_value(
|
||||||
9,
|
9,
|
||||||
"Séries réellement faites",
|
"Séries réellement faites",
|
||||||
|
|
@ -4699,6 +4739,7 @@ static bool build_session_exercise(
|
||||||
)) {
|
)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
output->load_mode =
|
output->load_mode =
|
||||||
load_mode == 1
|
load_mode == 1
|
||||||
|
|
@ -4732,7 +4773,10 @@ static bool build_session_exercise(
|
||||||
|
|
||||||
output->sets = set_storage;
|
output->sets = set_storage;
|
||||||
output->set_count =
|
output->set_count =
|
||||||
(size_t)actual_sets;
|
exercise.tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_REPS
|
||||||
|
? rep_count
|
||||||
|
: (size_t)actual_sets;
|
||||||
|
|
||||||
for (set_index = 0U;
|
for (set_index = 0U;
|
||||||
set_index < output->set_count;
|
set_index < output->set_count;
|
||||||
|
|
@ -4746,6 +4790,11 @@ static bool build_session_exercise(
|
||||||
sizeof(set_storage[set_index])
|
sizeof(set_storage[set_index])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
exercise.tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_DURATION ||
|
||||||
|
target_has_weight
|
||||||
|
) {
|
||||||
draw_shell(
|
draw_shell(
|
||||||
exercise.name,
|
exercise.name,
|
||||||
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
|
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
|
||||||
|
|
@ -4758,20 +4807,15 @@ static bool build_session_exercise(
|
||||||
set_index + 1U,
|
set_index + 1U,
|
||||||
output->set_count
|
output->set_count
|
||||||
);
|
);
|
||||||
|
|
||||||
if (exercise.tracking_mode ==
|
|
||||||
TRAINLOG_TRACKING_REPS) {
|
|
||||||
if (!prompt_int_value(
|
|
||||||
5,
|
|
||||||
"Répétitions réalisées",
|
|
||||||
0,
|
|
||||||
10000,
|
|
||||||
target_metric,
|
|
||||||
&actual_metric
|
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
exercise.tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_REPS
|
||||||
|
) {
|
||||||
|
actual_metric =
|
||||||
|
rep_values[set_index];
|
||||||
|
|
||||||
set_storage[set_index].reps =
|
set_storage[set_index].reps =
|
||||||
actual_metric;
|
actual_metric;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -6612,8 +6656,18 @@ static void screen_session_detail(
|
||||||
rest_text
|
rest_text
|
||||||
);
|
);
|
||||||
|
|
||||||
if (exercise->tracking_mode ==
|
if (
|
||||||
TRAINLOG_TRACKING_REPS) {
|
exercise->target_sets <= 0
|
||||||
|
) {
|
||||||
|
mvprintw(
|
||||||
|
target_row,
|
||||||
|
decorated ? 5 : 4,
|
||||||
|
"Cible : non renseignée"
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
exercise->tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_REPS
|
||||||
|
) {
|
||||||
mvprintw(
|
mvprintw(
|
||||||
target_row,
|
target_row,
|
||||||
decorated ? 5 : 4,
|
decorated ? 5 : 4,
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ static bool test_profile_roundtrip(void)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool test_v2_to_v3_migration(void)
|
static bool test_v2_to_current_migration(void)
|
||||||
{
|
{
|
||||||
char path[] =
|
char path[] =
|
||||||
"/tmp/trainlog-schema-v2-profile-XXXXXX";
|
"/tmp/trainlog-schema-v2-profile-XXXXXX";
|
||||||
|
|
@ -206,7 +206,10 @@ static bool test_v2_to_v3_migration(void)
|
||||||
) == TRAINLOG_STATUS_OK
|
) == TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(version == 3);
|
CHECK(
|
||||||
|
version ==
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
database = NULL;
|
database = NULL;
|
||||||
|
|
@ -281,7 +284,7 @@ static bool test_v2_to_v3_migration(void)
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
CHECK(test_profile_roundtrip());
|
CHECK(test_profile_roundtrip());
|
||||||
CHECK(test_v2_to_v3_migration());
|
CHECK(test_v2_to_current_migration());
|
||||||
|
|
||||||
(void)printf("PASS exercise_profile_schema\n");
|
(void)printf("PASS exercise_profile_schema\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
178
tui/tests/test_reps.c
Normal file
178
tui/tests/test_reps.c
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "trainlog/reps.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf( \
|
||||||
|
stderr, \
|
||||||
|
"CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, \
|
||||||
|
__LINE__, \
|
||||||
|
#condition \
|
||||||
|
); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool test_repeat(void)
|
||||||
|
{
|
||||||
|
int values[16];
|
||||||
|
size_t count = 0U;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"5x10",
|
||||||
|
values,
|
||||||
|
16U,
|
||||||
|
&count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(count == 5U);
|
||||||
|
|
||||||
|
for (
|
||||||
|
index = 0U;
|
||||||
|
index < count;
|
||||||
|
++index
|
||||||
|
) {
|
||||||
|
CHECK(values[index] == 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_explicit(void)
|
||||||
|
{
|
||||||
|
static const int expected[] = {
|
||||||
|
4, 5, 6, 7, 8, 9, 10,
|
||||||
|
9, 8, 7, 6, 5, 4
|
||||||
|
};
|
||||||
|
|
||||||
|
int values[16];
|
||||||
|
size_t count = 0U;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"4,5,6,7,8,9,10,9,8,7,6,5,4",
|
||||||
|
values,
|
||||||
|
16U,
|
||||||
|
&count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
count ==
|
||||||
|
sizeof(expected) /
|
||||||
|
sizeof(expected[0])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
index = 0U;
|
||||||
|
index < count;
|
||||||
|
++index
|
||||||
|
) {
|
||||||
|
CHECK(
|
||||||
|
values[index] ==
|
||||||
|
expected[index]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_pyramid(void)
|
||||||
|
{
|
||||||
|
static const int expected[] = {
|
||||||
|
4, 5, 6, 7, 8, 9, 10,
|
||||||
|
9, 8, 7, 6, 5, 4
|
||||||
|
};
|
||||||
|
|
||||||
|
int values[16];
|
||||||
|
size_t count = 0U;
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"4..10..4",
|
||||||
|
values,
|
||||||
|
16U,
|
||||||
|
&count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
count ==
|
||||||
|
sizeof(expected) /
|
||||||
|
sizeof(expected[0])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
index = 0U;
|
||||||
|
index < count;
|
||||||
|
++index
|
||||||
|
) {
|
||||||
|
CHECK(
|
||||||
|
values[index] ==
|
||||||
|
expected[index]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_invalid(void)
|
||||||
|
{
|
||||||
|
int values[4];
|
||||||
|
size_t count = 0U;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"5x10",
|
||||||
|
values,
|
||||||
|
4U,
|
||||||
|
&count
|
||||||
|
) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"10..4..10",
|
||||||
|
values,
|
||||||
|
4U,
|
||||||
|
&count
|
||||||
|
) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_reps_parse_sequence(
|
||||||
|
"4,,5",
|
||||||
|
values,
|
||||||
|
4U,
|
||||||
|
&count
|
||||||
|
) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(test_repeat());
|
||||||
|
CHECK(test_explicit());
|
||||||
|
CHECK(test_pyramid());
|
||||||
|
CHECK(test_invalid());
|
||||||
|
|
||||||
|
(void)printf(
|
||||||
|
"PASS reps_sequence\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
239
tui/tests/test_schema_v5_migration.c
Normal file
239
tui/tests/test_schema_v5_migration.c
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
/**
|
||||||
|
* @file test_schema_v5_migration.c
|
||||||
|
* @brief Direct v4 -> v5 migration regression test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf( \
|
||||||
|
stderr, \
|
||||||
|
"CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, \
|
||||||
|
__LINE__, \
|
||||||
|
#condition \
|
||||||
|
); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool test_v4_to_v5_preserves_session(void)
|
||||||
|
{
|
||||||
|
char path[] =
|
||||||
|
"/tmp/trainlog-schema-v4-v5-XXXXXX";
|
||||||
|
|
||||||
|
static const char *const V4_SQL =
|
||||||
|
"CREATE TABLE exercises ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"exercise_id TEXT NOT NULL UNIQUE,"
|
||||||
|
"name TEXT NOT NULL,"
|
||||||
|
"normalized_name TEXT NOT NULL UNIQUE,"
|
||||||
|
"tracking_mode TEXT NOT NULL,"
|
||||||
|
"recording_mode TEXT NOT NULL DEFAULT 'sets',"
|
||||||
|
"data_fields INTEGER NOT NULL DEFAULT 0"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"CREATE TABLE sessions ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"session_id TEXT NOT NULL UNIQUE,"
|
||||||
|
"started_at TEXT NOT NULL,"
|
||||||
|
"ended_at TEXT,"
|
||||||
|
"session_type TEXT NOT NULL DEFAULT 'training',"
|
||||||
|
"notes TEXT"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"CREATE TABLE session_exercises ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"session_row_id INTEGER NOT NULL REFERENCES sessions(id),"
|
||||||
|
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),"
|
||||||
|
"recording_mode TEXT NOT NULL DEFAULT 'sets',"
|
||||||
|
"data_fields INTEGER NOT NULL DEFAULT 0,"
|
||||||
|
"position INTEGER NOT NULL,"
|
||||||
|
"load_mode TEXT NOT NULL,"
|
||||||
|
"rest_seconds INTEGER NOT NULL,"
|
||||||
|
"target_sets INTEGER CHECK(target_sets > 0),"
|
||||||
|
"target_reps INTEGER CHECK(target_reps >= 1),"
|
||||||
|
"target_duration_seconds INTEGER CHECK(target_duration_seconds > 0),"
|
||||||
|
"target_weight_kg REAL,"
|
||||||
|
"notes TEXT,"
|
||||||
|
"CHECK("
|
||||||
|
" (recording_mode='sets' AND"
|
||||||
|
" target_sets IS NOT NULL AND"
|
||||||
|
" ((target_reps IS NOT NULL AND"
|
||||||
|
" target_duration_seconds IS NULL) OR"
|
||||||
|
" (target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NOT NULL))) OR"
|
||||||
|
" (recording_mode='continuous' AND"
|
||||||
|
" target_sets IS NULL AND"
|
||||||
|
" target_reps IS NULL AND"
|
||||||
|
" target_duration_seconds IS NULL)"
|
||||||
|
")"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"CREATE TABLE performed_sets ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"session_exercise_row_id INTEGER NOT NULL"
|
||||||
|
" REFERENCES session_exercises(id),"
|
||||||
|
"position INTEGER NOT NULL,"
|
||||||
|
"reps INTEGER,"
|
||||||
|
"duration_seconds INTEGER,"
|
||||||
|
"weight_kg REAL"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"CREATE TABLE continuous_activity ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"session_exercise_row_id INTEGER NOT NULL UNIQUE"
|
||||||
|
" REFERENCES session_exercises(id),"
|
||||||
|
"duration_seconds INTEGER NOT NULL,"
|
||||||
|
"speed_kmh REAL,"
|
||||||
|
"distance_km REAL"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO exercises("
|
||||||
|
"exercise_id,name,normalized_name,tracking_mode,"
|
||||||
|
"recording_mode,data_fields"
|
||||||
|
") VALUES("
|
||||||
|
"'ex_v4','Pompes','pompes','reps','sets',0"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO sessions("
|
||||||
|
"session_id,started_at,ended_at,session_type,notes"
|
||||||
|
") VALUES("
|
||||||
|
"'se_v4',"
|
||||||
|
"'2026-09-06T10:00:00+02:00',"
|
||||||
|
"'2026-09-06T10:30:00+02:00',"
|
||||||
|
"'training',NULL"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO session_exercises("
|
||||||
|
"session_row_id,exercise_row_id,recording_mode,data_fields,"
|
||||||
|
"position,load_mode,rest_seconds,target_sets,target_reps,"
|
||||||
|
"target_duration_seconds,target_weight_kg,notes"
|
||||||
|
") VALUES("
|
||||||
|
"1,1,'sets',0,0,'none',60,3,10,NULL,NULL,NULL"
|
||||||
|
");"
|
||||||
|
|
||||||
|
"INSERT INTO performed_sets("
|
||||||
|
"session_exercise_row_id,position,reps,duration_seconds,weight_kg"
|
||||||
|
") VALUES"
|
||||||
|
"(1,0,10,NULL,NULL),"
|
||||||
|
"(1,1,9,NULL,NULL),"
|
||||||
|
"(1,2,8,NULL,NULL);"
|
||||||
|
|
||||||
|
"PRAGMA user_version=4;";
|
||||||
|
|
||||||
|
sqlite3 *raw = NULL;
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogSessionSummary summary;
|
||||||
|
TrainlogPersistedExerciseDetail details[2];
|
||||||
|
size_t detail_count = 0U;
|
||||||
|
int version = 0;
|
||||||
|
int fd;
|
||||||
|
|
||||||
|
fd = mkstemp(path);
|
||||||
|
CHECK(fd >= 0);
|
||||||
|
CHECK(close(fd) == 0);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
sqlite3_open(
|
||||||
|
path,
|
||||||
|
&raw
|
||||||
|
) == SQLITE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
sqlite3_exec(
|
||||||
|
raw,
|
||||||
|
V4_SQL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
) == SQLITE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
sqlite3_close(raw) ==
|
||||||
|
SQLITE_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
raw = NULL;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_open(
|
||||||
|
path,
|
||||||
|
&database
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_schema_version(
|
||||||
|
database,
|
||||||
|
&version
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
version ==
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
version == 5
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_get_session_details(
|
||||||
|
database,
|
||||||
|
"se_v4",
|
||||||
|
&summary,
|
||||||
|
details,
|
||||||
|
2U,
|
||||||
|
&detail_count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(detail_count == 1U);
|
||||||
|
CHECK(details[0].target_sets == 3);
|
||||||
|
CHECK(details[0].target_reps == 10);
|
||||||
|
CHECK(details[0].actual_set_count == 3U);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
strcmp(
|
||||||
|
details[0].actual_summary,
|
||||||
|
"10 / 9 / 8"
|
||||||
|
) == 0
|
||||||
|
);
|
||||||
|
|
||||||
|
trainlog_database_close(
|
||||||
|
database
|
||||||
|
);
|
||||||
|
|
||||||
|
database = NULL;
|
||||||
|
|
||||||
|
CHECK(unlink(path) == 0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(
|
||||||
|
test_v4_to_v5_preserves_session()
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)printf(
|
||||||
|
"PASS schema_v5_migration\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
@ -352,9 +352,156 @@ static bool test_load_replace_and_rollback(void)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool test_remove_exercise_from_session(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogSessionExerciseInput exercises[2];
|
||||||
|
TrainlogSessionExerciseInput replacement;
|
||||||
|
TrainlogSetInput press_sets[2];
|
||||||
|
TrainlogSetInput plank_sets[1];
|
||||||
|
TrainlogSessionInput session;
|
||||||
|
TrainlogSessionSummary loaded_session;
|
||||||
|
TrainlogEditableExerciseRecord loaded_exercises[4];
|
||||||
|
TrainlogSetInput loaded_sets[16];
|
||||||
|
size_t exercise_count = 0U;
|
||||||
|
size_t set_count = 0U;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_open(
|
||||||
|
":memory:",
|
||||||
|
&database
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(add_exercises(database));
|
||||||
|
|
||||||
|
bind_reps_exercise(
|
||||||
|
&exercises[0],
|
||||||
|
press_sets,
|
||||||
|
2U,
|
||||||
|
80.0
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&exercises[1],
|
||||||
|
0,
|
||||||
|
sizeof(exercises[1])
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
plank_sets,
|
||||||
|
0,
|
||||||
|
sizeof(plank_sets)
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
exercises[1].exercise_id,
|
||||||
|
sizeof(exercises[1].exercise_id),
|
||||||
|
"%s",
|
||||||
|
"ex_plank"
|
||||||
|
);
|
||||||
|
|
||||||
|
exercises[1].recording_mode =
|
||||||
|
TRAINLOG_RECORDING_SETS;
|
||||||
|
|
||||||
|
exercises[1].load_mode =
|
||||||
|
TRAINLOG_LOAD_NONE;
|
||||||
|
|
||||||
|
exercises[1].rest_seconds = 30;
|
||||||
|
exercises[1].target_sets = 1;
|
||||||
|
exercises[1].target_duration_seconds = 60;
|
||||||
|
exercises[1].sets = plank_sets;
|
||||||
|
exercises[1].set_count = 1U;
|
||||||
|
|
||||||
|
plank_sets[0].duration_seconds = 55;
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&session,
|
||||||
|
0,
|
||||||
|
sizeof(session)
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.session_id,
|
||||||
|
sizeof(session.session_id),
|
||||||
|
"%s",
|
||||||
|
"se_remove_exercise"
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.started_at,
|
||||||
|
sizeof(session.started_at),
|
||||||
|
"%s",
|
||||||
|
"2026-09-06T14:00:00+02:00"
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.ended_at,
|
||||||
|
sizeof(session.ended_at),
|
||||||
|
"%s",
|
||||||
|
"2026-09-06T14:30:00+02:00"
|
||||||
|
);
|
||||||
|
|
||||||
|
session.session_type =
|
||||||
|
TRAINLOG_SESSION_TRAINING;
|
||||||
|
|
||||||
|
session.exercises = exercises;
|
||||||
|
session.exercise_count = 2U;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_insert_session(
|
||||||
|
database,
|
||||||
|
&session
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
replacement = exercises[0];
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_replace_session_exercises(
|
||||||
|
database,
|
||||||
|
"se_remove_exercise",
|
||||||
|
&replacement,
|
||||||
|
1U
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_load_session_editable(
|
||||||
|
database,
|
||||||
|
"se_remove_exercise",
|
||||||
|
&loaded_session,
|
||||||
|
loaded_exercises,
|
||||||
|
4U,
|
||||||
|
&exercise_count,
|
||||||
|
loaded_sets,
|
||||||
|
16U,
|
||||||
|
&set_count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(exercise_count == 1U);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
strcmp(
|
||||||
|
loaded_exercises[0].exercise_id,
|
||||||
|
"ex_press"
|
||||||
|
) == 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(set_count == 2U);
|
||||||
|
|
||||||
|
trainlog_database_close(
|
||||||
|
database
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
CHECK(test_load_replace_and_rollback());
|
CHECK(test_load_replace_and_rollback());
|
||||||
|
CHECK(test_remove_exercise_from_session());
|
||||||
|
|
||||||
(void)printf(
|
(void)printf(
|
||||||
"PASS session_edit\n"
|
"PASS session_edit\n"
|
||||||
|
|
|
||||||
209
tui/tests/test_variable_sets.c
Normal file
209
tui/tests/test_variable_sets.c
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "trainlog/database.h"
|
||||||
|
|
||||||
|
#define CHECK(condition) \
|
||||||
|
do { \
|
||||||
|
if (!(condition)) { \
|
||||||
|
(void)fprintf( \
|
||||||
|
stderr, \
|
||||||
|
"CHECK failed at %s:%d: %s\n", \
|
||||||
|
__FILE__, \
|
||||||
|
__LINE__, \
|
||||||
|
#condition \
|
||||||
|
); \
|
||||||
|
return false; \
|
||||||
|
} \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
static bool test_targetless_variable_sets(void)
|
||||||
|
{
|
||||||
|
static const int reps[] = {
|
||||||
|
4, 5, 6, 7, 8, 9, 10,
|
||||||
|
9, 8, 7, 6, 5, 4
|
||||||
|
};
|
||||||
|
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogSetInput sets[
|
||||||
|
sizeof(reps) /
|
||||||
|
sizeof(reps[0])
|
||||||
|
];
|
||||||
|
|
||||||
|
TrainlogSessionExerciseInput exercise;
|
||||||
|
TrainlogSessionInput session;
|
||||||
|
TrainlogSessionSummary summary;
|
||||||
|
TrainlogPersistedExerciseDetail detail[2];
|
||||||
|
size_t detail_count = 0U;
|
||||||
|
size_t index;
|
||||||
|
int schema_version = 0;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_open(
|
||||||
|
":memory:",
|
||||||
|
&database
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_schema_version(
|
||||||
|
database,
|
||||||
|
&schema_version
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
schema_version ==
|
||||||
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_insert_exercise(
|
||||||
|
database,
|
||||||
|
"ex_variable_reps",
|
||||||
|
"Pompes pyramide",
|
||||||
|
"pompes pyramide",
|
||||||
|
TRAINLOG_TRACKING_REPS
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
sets,
|
||||||
|
0,
|
||||||
|
sizeof(sets)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
index = 0U;
|
||||||
|
index <
|
||||||
|
sizeof(reps) /
|
||||||
|
sizeof(reps[0]);
|
||||||
|
++index
|
||||||
|
) {
|
||||||
|
sets[index].reps =
|
||||||
|
reps[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&exercise,
|
||||||
|
0,
|
||||||
|
sizeof(exercise)
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
exercise.exercise_id,
|
||||||
|
sizeof(exercise.exercise_id),
|
||||||
|
"%s",
|
||||||
|
"ex_variable_reps"
|
||||||
|
);
|
||||||
|
|
||||||
|
exercise.recording_mode =
|
||||||
|
TRAINLOG_RECORDING_SETS;
|
||||||
|
|
||||||
|
exercise.load_mode =
|
||||||
|
TRAINLOG_LOAD_NONE;
|
||||||
|
|
||||||
|
exercise.rest_seconds = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* No target is invented. These are actual observations imported from
|
||||||
|
* a mobile session.
|
||||||
|
*/
|
||||||
|
exercise.target_sets = 0;
|
||||||
|
exercise.target_reps = 0;
|
||||||
|
exercise.target_duration_seconds = 0;
|
||||||
|
|
||||||
|
exercise.sets = sets;
|
||||||
|
|
||||||
|
exercise.set_count =
|
||||||
|
sizeof(reps) /
|
||||||
|
sizeof(reps[0]);
|
||||||
|
|
||||||
|
(void)memset(
|
||||||
|
&session,
|
||||||
|
0,
|
||||||
|
sizeof(session)
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.session_id,
|
||||||
|
sizeof(session.session_id),
|
||||||
|
"%s",
|
||||||
|
"se_variable_reps"
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.started_at,
|
||||||
|
sizeof(session.started_at),
|
||||||
|
"%s",
|
||||||
|
"2026-09-06T16:30:00+02:00"
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)snprintf(
|
||||||
|
session.ended_at,
|
||||||
|
sizeof(session.ended_at),
|
||||||
|
"%s",
|
||||||
|
"2026-09-06T16:45:00+02:00"
|
||||||
|
);
|
||||||
|
|
||||||
|
session.exercises =
|
||||||
|
&exercise;
|
||||||
|
|
||||||
|
session.exercise_count = 1U;
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_insert_session(
|
||||||
|
database,
|
||||||
|
&session
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
trainlog_database_get_session_details(
|
||||||
|
database,
|
||||||
|
"se_variable_reps",
|
||||||
|
&summary,
|
||||||
|
detail,
|
||||||
|
2U,
|
||||||
|
&detail_count
|
||||||
|
) == TRAINLOG_STATUS_OK
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(detail_count == 1U);
|
||||||
|
CHECK(summary.exercise_count == 1U);
|
||||||
|
CHECK(detail[0].target_sets == 0);
|
||||||
|
CHECK(detail[0].target_reps == 0);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
detail[0].actual_set_count ==
|
||||||
|
sizeof(reps) /
|
||||||
|
sizeof(reps[0])
|
||||||
|
);
|
||||||
|
|
||||||
|
CHECK(
|
||||||
|
strcmp(
|
||||||
|
detail[0].actual_summary,
|
||||||
|
"4 / 5 / 6 / 7 / 8 / 9 / 10 / 9 / 8 / 7 / 6 / 5 / 4"
|
||||||
|
) == 0
|
||||||
|
);
|
||||||
|
|
||||||
|
trainlog_database_close(
|
||||||
|
database
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
CHECK(
|
||||||
|
test_targetless_variable_sets()
|
||||||
|
);
|
||||||
|
|
||||||
|
(void)printf(
|
||||||
|
"PASS variable_sets\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue