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,
|
||||
detailed sync inspection, common sync engine and PC-side `trainlog-syncd`.
|
||||
<!-- 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
|
||||
|
||||
/* TRAINLOG_ANDROID_SESSION_REMOVE */
|
||||
|
||||
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -113,6 +117,30 @@ fun SessionScreen(
|
|||
color =
|
||||
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 ==
|
||||
"Séance enregistrée." ||
|
||||
message ==
|
||||
"Exercice ajouté à la séance."
|
||||
"Exercice ajouté à la séance." ||
|
||||
message ==
|
||||
"Exercice retiré de la séance."
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
|
|
@ -382,7 +412,7 @@ private fun SessionExerciseForm(
|
|||
|
||||
var repsText by
|
||||
remember(key) {
|
||||
mutableStateOf("10")
|
||||
mutableStateOf("3x10")
|
||||
}
|
||||
|
||||
var durationText by
|
||||
|
|
@ -423,24 +453,13 @@ private fun SessionExerciseForm(
|
|||
exercise.recordingMode ==
|
||||
RecordingMode.SETS
|
||||
) {
|
||||
SessionNumberField(
|
||||
label =
|
||||
"Nombre de séries",
|
||||
value =
|
||||
setCountText,
|
||||
onValueChange = {
|
||||
setCountText = it
|
||||
error = null
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
SessionNumberField(
|
||||
label =
|
||||
"Répétitions par série",
|
||||
"Séries / répétitions",
|
||||
value =
|
||||
repsText,
|
||||
onValueChange = {
|
||||
|
|
@ -448,7 +467,25 @@ private fun SessionExerciseForm(
|
|||
error = null
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Formats : 5x10 · 4,5,6,7 · 4..10..4",
|
||||
color =
|
||||
colors.muted,
|
||||
)
|
||||
} else {
|
||||
SessionNumberField(
|
||||
label =
|
||||
"Nombre de séries",
|
||||
value =
|
||||
setCountText,
|
||||
onValueChange = {
|
||||
setCountText = it
|
||||
error = null
|
||||
},
|
||||
)
|
||||
|
||||
SessionNumberField(
|
||||
label =
|
||||
"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(
|
||||
exercise: ExerciseProfile,
|
||||
setCountText: String,
|
||||
|
|
@ -637,8 +828,7 @@ private fun buildSessionExerciseDraft(
|
|||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
val minutes =
|
||||
durationText
|
||||
.toIntOrNull()
|
||||
durationText.toIntOrNull()
|
||||
|
||||
if (
|
||||
minutes == null ||
|
||||
|
|
@ -649,35 +839,25 @@ private fun buildSessionExerciseDraft(
|
|||
} else {
|
||||
val wantsSpeed =
|
||||
exercise.dataFields and
|
||||
ExerciseDataFields
|
||||
.SPEED_KMH != 0
|
||||
ExerciseDataFields.SPEED_KMH != 0
|
||||
|
||||
val wantsDistance =
|
||||
exercise.dataFields and
|
||||
ExerciseDataFields
|
||||
.DISTANCE_KM != 0
|
||||
ExerciseDataFields.DISTANCE_KM != 0
|
||||
|
||||
val speed =
|
||||
if (wantsSpeed) {
|
||||
speedText
|
||||
.replace(
|
||||
',',
|
||||
'.'
|
||||
)
|
||||
.replace(',', '.')
|
||||
.toDoubleOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val distance =
|
||||
if (
|
||||
wantsDistance
|
||||
) {
|
||||
if (wantsDistance) {
|
||||
distanceText
|
||||
.replace(
|
||||
',',
|
||||
'.'
|
||||
)
|
||||
.replace(',', '.')
|
||||
.toDoubleOrNull()
|
||||
} else {
|
||||
null
|
||||
|
|
@ -686,152 +866,148 @@ private fun buildSessionExerciseDraft(
|
|||
if (
|
||||
(
|
||||
wantsSpeed &&
|
||||
(
|
||||
speed == null ||
|
||||
speed <= 0.0
|
||||
)
|
||||
(
|
||||
speed == null ||
|
||||
speed <= 0.0
|
||||
)
|
||||
) ||
|
||||
(
|
||||
wantsDistance &&
|
||||
(
|
||||
distance == null ||
|
||||
distance <= 0.0
|
||||
)
|
||||
(
|
||||
distance == null ||
|
||||
distance <= 0.0
|
||||
)
|
||||
)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
SessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
exercise = exercise,
|
||||
continuousDurationSeconds =
|
||||
minutes * 60,
|
||||
speedKmh =
|
||||
speed,
|
||||
distanceKm =
|
||||
distance,
|
||||
speedKmh = speed,
|
||||
distanceKm = distance,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
val reps =
|
||||
parseRepSequence(
|
||||
repsText
|
||||
) ?: return null
|
||||
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets =
|
||||
reps.map {
|
||||
SessionSetDraft(
|
||||
reps = it
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
val count =
|
||||
setCountText
|
||||
.toIntOrNull()
|
||||
setCountText.toIntOrNull()
|
||||
|
||||
val seconds =
|
||||
durationText.toIntOrNull()
|
||||
|
||||
if (
|
||||
count == null ||
|
||||
count <= 0 ||
|
||||
count > 64
|
||||
count > MAX_SESSION_SETS ||
|
||||
seconds == null ||
|
||||
seconds <= 0 ||
|
||||
seconds > 86400
|
||||
) {
|
||||
null
|
||||
} else if (
|
||||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
val reps =
|
||||
repsText
|
||||
.toIntOrNull()
|
||||
|
||||
if (
|
||||
reps == null ||
|
||||
reps < 0 ||
|
||||
reps > 10000
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
SessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
sets =
|
||||
List(count) {
|
||||
SessionSetDraft(
|
||||
reps = reps
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val seconds =
|
||||
durationText
|
||||
.toIntOrNull()
|
||||
|
||||
if (
|
||||
seconds == null ||
|
||||
seconds <= 0 ||
|
||||
seconds > 86400
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
SessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
sets =
|
||||
List(count) {
|
||||
SessionSetDraft(
|
||||
durationSeconds =
|
||||
seconds
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets =
|
||||
List(count) {
|
||||
SessionSetDraft(
|
||||
durationSeconds =
|
||||
seconds
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun draftSummary(
|
||||
draft:
|
||||
SessionExerciseDraft,
|
||||
draft: SessionExerciseDraft,
|
||||
): String {
|
||||
val exercise =
|
||||
draft.exercise
|
||||
|
||||
return if (
|
||||
exercise.recordingMode ==
|
||||
draft.exercise.recordingMode ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
buildString {
|
||||
append(
|
||||
exercise.name
|
||||
draft.exercise.name
|
||||
)
|
||||
|
||||
append(" · ")
|
||||
|
||||
append(
|
||||
draft
|
||||
.continuousDurationSeconds /
|
||||
60
|
||||
" · ${draft.continuousDurationSeconds / 60} min"
|
||||
)
|
||||
|
||||
append(" min")
|
||||
|
||||
draft.speedKmh
|
||||
?.let {
|
||||
append(
|
||||
" · %.1f km/h"
|
||||
.format(it)
|
||||
)
|
||||
}
|
||||
|
||||
draft.distanceKm
|
||||
?.let {
|
||||
append(
|
||||
" · %.2f km"
|
||||
.format(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val metric =
|
||||
if (
|
||||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
"${draft.sets.firstOrNull()?.reps ?: 0} reps"
|
||||
} else {
|
||||
"${draft.sets.firstOrNull()?.durationSeconds ?: 0} s"
|
||||
draft.speedKmh?.let {
|
||||
append(
|
||||
" · %.1f km/h"
|
||||
.format(it)
|
||||
)
|
||||
}
|
||||
|
||||
"${exercise.name} · ${draft.sets.size} × $metric"
|
||||
draft.distanceKm?.let {
|
||||
append(
|
||||
" · %.2f km"
|
||||
.format(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
draft.exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
val reps =
|
||||
draft.sets.map {
|
||||
it.reps
|
||||
}
|
||||
|
||||
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,
|
||||
rather than manual export/import steps.
|
||||
<!-- 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
|
||||
```
|
||||
<!-- 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
|
||||
```
|
||||
<!-- 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.
|
||||
<!-- 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;"
|
||||
).fetchone()[0]
|
||||
|
||||
if version != 4:
|
||||
if version != 5:
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -491,30 +491,13 @@ def validate_session_exercise(
|
|||
f"{label}.sets: tableau non vide attendu"
|
||||
)
|
||||
|
||||
metric_values = []
|
||||
|
||||
for set_index, set_item in enumerate(sets):
|
||||
_, metric = validate_set_item(
|
||||
validate_set_item(
|
||||
set_item,
|
||||
tracking_mode,
|
||||
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(
|
||||
payload,
|
||||
|
|
@ -659,14 +642,14 @@ def validate_payload(payload):
|
|||
validate_body(payload)
|
||||
|
||||
|
||||
def require_schema_v4(connection):
|
||||
def require_schema_v5(connection):
|
||||
version = connection.execute(
|
||||
"PRAGMA user_version;"
|
||||
).fetchone()[0]
|
||||
|
||||
if version != 4:
|
||||
if version != 5:
|
||||
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"]
|
||||
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(
|
||||
"""
|
||||
|
|
@ -894,7 +857,7 @@ def import_set_session_exercise(
|
|||
notes
|
||||
) VALUES(
|
||||
?, ?, 'sets', ?, ?, 'none', 0,
|
||||
?, ?, ?, NULL, NULL
|
||||
NULL, NULL, NULL, NULL, NULL
|
||||
);
|
||||
""",
|
||||
(
|
||||
|
|
@ -902,9 +865,6 @@ def import_set_session_exercise(
|
|||
exercise_row,
|
||||
item["data_fields"],
|
||||
position,
|
||||
len(sets),
|
||||
target_reps,
|
||||
target_duration,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -940,7 +900,6 @@ def import_set_session_exercise(
|
|||
),
|
||||
)
|
||||
|
||||
|
||||
def import_continuous_session_exercise(
|
||||
connection,
|
||||
session_row_id,
|
||||
|
|
@ -1205,7 +1164,7 @@ def run_import(
|
|||
"PRAGMA foreign_keys = ON;"
|
||||
)
|
||||
|
||||
require_schema_v4(
|
||||
require_schema_v5(
|
||||
connection
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 4
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 5
|
||||
|
||||
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/usb.c',
|
||||
'src/mtp.c',
|
||||
'src/reps.c',
|
||||
)
|
||||
|
||||
trainlog_core = static_library(
|
||||
|
|
@ -287,3 +288,48 @@ trainlog_mtp_mobile_export_probe = executable(
|
|||
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;
|
||||
};
|
||||
|
||||
static const char *const SCHEMA_V4_SQL_A =
|
||||
static const char *const SCHEMA_V5_SQL_A =
|
||||
"BEGIN IMMEDIATE;"
|
||||
|
||||
"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),"
|
||||
" 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"
|
||||
" ("
|
||||
" (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"
|
||||
|
|
@ -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 ("
|
||||
" id INTEGER PRIMARY KEY,"
|
||||
" 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;";
|
||||
|
||||
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;"
|
||||
"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(
|
||||
TrainlogDatabase *database,
|
||||
const char *sql
|
||||
|
|
@ -373,90 +495,160 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
int version = 0;
|
||||
TrainlogStatus status;
|
||||
|
||||
status = trainlog_database_schema_version(
|
||||
database,
|
||||
&version
|
||||
);
|
||||
status =
|
||||
trainlog_database_schema_version(
|
||||
database,
|
||||
&version
|
||||
);
|
||||
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
if (
|
||||
status !=
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (version > TRAINLOG_DATABASE_SCHEMA_VERSION) {
|
||||
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||
if (
|
||||
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;
|
||||
}
|
||||
|
||||
if (version == 0) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
SCHEMA_V4_SQL_A
|
||||
);
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
SCHEMA_V4_SQL_B
|
||||
SCHEMA_V5_SQL_A
|
||||
);
|
||||
}
|
||||
} else if (version == 1) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V1_TO_V3_SQL
|
||||
);
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
}
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
}
|
||||
} else if (version == 2) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V2_TO_V3_SQL
|
||||
);
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
}
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
}
|
||||
} else if (version == 3) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
SCHEMA_V5_SQL_B
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||
if (version == 1) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V1_TO_V3_SQL
|
||||
);
|
||||
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
}
|
||||
} else if (version == 2) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V2_TO_V3_SQL
|
||||
);
|
||||
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
}
|
||||
} else if (version == 3) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_A
|
||||
);
|
||||
|
||||
if (
|
||||
status ==
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
status =
|
||||
execute_sql(
|
||||
database,
|
||||
MIGRATE_V3_TO_V4_SQL_B
|
||||
);
|
||||
}
|
||||
} else if (version == 4) {
|
||||
status =
|
||||
TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
return
|
||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (
|
||||
status !=
|
||||
TRAINLOG_STATUS_OK
|
||||
) {
|
||||
(void)sqlite3_exec(
|
||||
database->connection,
|
||||
"ROLLBACK;",
|
||||
|
|
@ -1278,10 +1470,39 @@ static TrainlogStatus insert_session_exercise(
|
|||
input->recording_mode ==
|
||||
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_has_speed ||
|
||||
input->continuous_has_distance) {
|
||||
input->continuous_has_distance
|
||||
) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
} 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
|
||||
);
|
||||
}
|
||||
126
tui/src/tui.c
126
tui/src/tui.c
|
|
@ -25,6 +25,7 @@
|
|||
#include "trainlog/duration.h"
|
||||
#include "trainlog/id.h"
|
||||
#include "trainlog/mtp.h"
|
||||
#include "trainlog/reps.h"
|
||||
#include "trainlog/theme.h"
|
||||
#include "trainlog/timeutil.h"
|
||||
#include "trainlog/usb.h"
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
|
||||
/* TRAINLOG_TUI_V02_POLISH */
|
||||
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
||||
/* TRAINLOG_VARIABLE_SET_REPS_V1 */
|
||||
/* TRAINLOG_SYNC_RESPONSIVE_CACHE */
|
||||
/* TRAINLOG_SYNC_LARGE_LAYOUT_S_FIX */
|
||||
/* TRAINLOG_SYNC_HISTORY_BIDIRECTIONAL_V1 */
|
||||
|
|
@ -4493,7 +4495,10 @@ static bool build_session_exercise(
|
|||
int target_sets = 3;
|
||||
int target_metric;
|
||||
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;
|
||||
double target_weight = 0.0;
|
||||
size_t set_index;
|
||||
|
|
@ -4689,15 +4694,51 @@ static bool build_session_exercise(
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!prompt_int_value(
|
||||
9,
|
||||
"Séries réellement faites",
|
||||
0,
|
||||
(int)set_capacity,
|
||||
target_sets,
|
||||
&actual_sets
|
||||
)) {
|
||||
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(
|
||||
9,
|
||||
"Séries réellement faites",
|
||||
0,
|
||||
(int)set_capacity,
|
||||
target_sets,
|
||||
&actual_sets
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
output->load_mode =
|
||||
|
|
@ -4732,7 +4773,10 @@ static bool build_session_exercise(
|
|||
|
||||
output->sets = set_storage;
|
||||
output->set_count =
|
||||
(size_t)actual_sets;
|
||||
exercise.tracking_mode ==
|
||||
TRAINLOG_TRACKING_REPS
|
||||
? rep_count
|
||||
: (size_t)actual_sets;
|
||||
|
||||
for (set_index = 0U;
|
||||
set_index < output->set_count;
|
||||
|
|
@ -4746,31 +4790,31 @@ static bool build_session_exercise(
|
|||
sizeof(set_storage[set_index])
|
||||
);
|
||||
|
||||
draw_shell(
|
||||
exercise.name,
|
||||
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
|
||||
);
|
||||
if (
|
||||
exercise.tracking_mode ==
|
||||
TRAINLOG_TRACKING_DURATION ||
|
||||
target_has_weight
|
||||
) {
|
||||
draw_shell(
|
||||
exercise.name,
|
||||
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
|
||||
);
|
||||
|
||||
mvprintw(
|
||||
3,
|
||||
4,
|
||||
"Série %zu / %zu",
|
||||
set_index + 1U,
|
||||
output->set_count
|
||||
);
|
||||
mvprintw(
|
||||
3,
|
||||
4,
|
||||
"Série %zu / %zu",
|
||||
set_index + 1U,
|
||||
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 =
|
||||
actual_metric;
|
||||
|
|
@ -6612,8 +6656,18 @@ static void screen_session_detail(
|
|||
rest_text
|
||||
);
|
||||
|
||||
if (exercise->tracking_mode ==
|
||||
TRAINLOG_TRACKING_REPS) {
|
||||
if (
|
||||
exercise->target_sets <= 0
|
||||
) {
|
||||
mvprintw(
|
||||
target_row,
|
||||
decorated ? 5 : 4,
|
||||
"Cible : non renseignée"
|
||||
);
|
||||
} else if (
|
||||
exercise->tracking_mode ==
|
||||
TRAINLOG_TRACKING_REPS
|
||||
) {
|
||||
mvprintw(
|
||||
target_row,
|
||||
decorated ? 5 : 4,
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ static bool test_profile_roundtrip(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool test_v2_to_v3_migration(void)
|
||||
static bool test_v2_to_current_migration(void)
|
||||
{
|
||||
char path[] =
|
||||
"/tmp/trainlog-schema-v2-profile-XXXXXX";
|
||||
|
|
@ -206,7 +206,10 @@ static bool test_v2_to_v3_migration(void)
|
|||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(version == 3);
|
||||
CHECK(
|
||||
version ==
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||
);
|
||||
|
||||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
|
@ -281,7 +284,7 @@ static bool test_v2_to_v3_migration(void)
|
|||
int main(void)
|
||||
{
|
||||
CHECK(test_profile_roundtrip());
|
||||
CHECK(test_v2_to_v3_migration());
|
||||
CHECK(test_v2_to_current_migration());
|
||||
|
||||
(void)printf("PASS exercise_profile_schema\n");
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
CHECK(test_load_replace_and_rollback());
|
||||
CHECK(test_remove_exercise_from_session());
|
||||
|
||||
(void)printf(
|
||||
"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