feat(sets): add per-set reps and weight editing

This commit is contained in:
fy59 2026-09-09 10:34:25 +02:00
parent f4271c9f3e
commit 1b08db009e
46 changed files with 2449 additions and 480 deletions

View file

@ -30,6 +30,10 @@ Detailed implementation chronology remains available in Git history and
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated - multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
catalogue exercises in one session, per-set actual weights, and occurrence catalogue exercises in one session, per-set actual weights, and occurrence
equipment associations across Android, desktop, import and export; equipment associations across Android, desktop, import and export;
- structured Android and Notcurses per-set row editing: independent actual
repetitions and nullable Charge/Assistance values, ordered add/delete/edit
operations, and ordered history/detail presentation without target-value
substitution;
- shared versioned equipment catalogue, Android machine selection/search, - shared versioned equipment catalogue, Android machine selection/search,
Android-local custom equipment creation, and explicit rejection of unknown Android-local custom equipment creation, and explicit rejection of unknown
equipment identities rather than silent association loss; equipment identities rather than silent association loss;
@ -66,8 +70,8 @@ Detailed implementation chronology remains available in Git history and
- profile-aware exercise model using recording mode, tracking mode, and - profile-aware exercise model using recording mode, tracking mode, and
supplemental fields; supplemental fields;
- continuous activity persistence without synthetic sets; - continuous activity persistence without synthetic sets;
- variable repetition-set input including `5x10`, explicit lists, and pyramid - explicit table-based desktop actual-set entry, with independently added rows
shorthand such as `4..10..4`; and no compact performed-repetition input;
- persisted desktop session editing and exercise removal; - persisted desktop session editing and exercise removal;
- Android current-session draft exercise removal; - Android current-session draft exercise removal;
- body-observation history, editing, graphs, and normalized overlays; - body-observation history, editing, graphs, and normalized overlays;
@ -87,6 +91,10 @@ Detailed implementation chronology remains available in Git history and
### Changed ### Changed
- desktop schema v10 losslessly rebuilds only `performed_sets` to accept an
explicit zero actual `weight_kg`; historic NULL and positive actual loads
remain unchanged, while planned targets and explicit MAX results stay
strictly positive;
- desktop and Android schema v9 add one-to-one completed/draft max-result rows; - desktop and Android schema v9 add one-to-one completed/draft max-result rows;
`TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data `TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data
rather than losing or fabricating it; rather than losing or fabricating it;
@ -166,8 +174,8 @@ Current validated baseline:
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V9=PASS DESKTOP_SCHEMA_V10=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS

View file

@ -16,7 +16,7 @@ desktop.
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V9=PASS DESKTOP_SCHEMA_V10=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V9=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
@ -38,7 +38,7 @@ EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
``` ```
@ -97,20 +97,20 @@ SETS + DURATION
CONTINUOUS + DURATION CONTINUOUS + DURATION
``` ```
Actual repetition sets are stored independently. Compact input supports: Actual repetition sets are stored independently. The Notcurses desktop flow
collects planning only, then creates actual work in an ordered table: the user
```text explicitly adds every row and enters its actual repetitions (or duration) and
5x10 optional load. It does not accept compact performed-repetition input; normal
4,5,6,7,8,9,10,9,8,7,6,5,4 set sessions cannot finish with zero actual rows.
4..10..4
```
A session may contain several ordered occurrences of the same catalogue A session may contain several ordered occurrences of the same catalogue
exercise. Each occurrence has a stable `entry_id`, distinct from the stable exercise. Each occurrence has a stable `entry_id`, distinct from the stable
`exercise_id` of the catalogue item. Equipment selection belongs to that `exercise_id` of the catalogue item. Equipment selection belongs to that
occurrence, as do its actual per-set loads. `external` records an applied or occurrence, as do its actual per-set loads. `external` records an applied or
machine-displayed load; `assistance` records assistance and is not interpreted machine-displayed load; `assistance` records assistance and is not interpreted
as increasing strength. as increasing strength. An actual load is either absent or finite and
non-negative, so an explicit zero remains distinct from no recorded load;
planned targets remain strictly positive and are never substituted for actuals.
In a `max_test` session, an occurrence may instead own one explicit positive In a `max_test` session, an occurrence may instead own one explicit positive
`max_weight_kg`. This result has no performed set, repetitions, or target-set `max_weight_kg`. This result has no performed set, repetitions, or target-set
@ -296,5 +296,5 @@ BODY_ANALYTICS_V1=PASS
BODY_COMPOSITION_ESTIMATE=PASS BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
``` ```

View file

@ -2,6 +2,7 @@ package com.labfytools.trainlog.ui
import android.content.Context import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -76,15 +77,13 @@ class SessionDraftUiInstrumentedTest {
} }
@Test @Test
fun resumeRestoresRawFormAfterActivityRecreation() { fun resumeRestoresSetRowsAfterActivityRecreation() {
compose.onNodeWithText( compose.onNodeWithText(
"Reprendre la séance en cours" "Reprendre la séance en cours"
).assertIsDisplayed() ).assertIsDisplayed()
.performClick() .performClick()
compose.onNodeWithText( compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
"4,5,6," compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
).performScrollTo()
.assertIsDisplayed()
compose.activityRule.scenario.recreate() compose.activityRule.scenario.recreate()
@ -92,10 +91,8 @@ class SessionDraftUiInstrumentedTest {
"Reprendre la séance en cours" "Reprendre la séance en cours"
).assertIsDisplayed() ).assertIsDisplayed()
.performClick() .performClick()
compose.onNodeWithText( compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
"4,5,6," compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
).performScrollTo()
.assertIsDisplayed()
compose.onNodeWithText( compose.onNodeWithText(
"Retirer Test UI" "Retirer Test UI"
).performScrollTo() ).performScrollTo()
@ -220,9 +217,30 @@ class SessionDraftUiInstrumentedTest {
compose.onNodeWithText("Annuler la recherche").performClick() compose.onNodeWithText("Annuler la recherche").performClick()
compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed() compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed()
compose.onNodeWithText("4,5,6,") compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
.performScrollTo() compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
.assertIsDisplayed() }
@Test
fun rowEditFrenchWeightDeleteAndAddPersistAcrossRecreation() {
compose.onNodeWithText("Reprendre la séance en cours").performClick()
compose.onNodeWithTag("session-set-1-reps")
.performScrollTo().performTextClearance()
compose.onNodeWithTag("session-set-1-reps").performTextInput("9")
compose.onNodeWithTag("session-set-1-weight")
.performScrollTo().performTextInput("32,5")
compose.onNodeWithText("Supprimer la série 1").performScrollTo().performClick()
compose.onNodeWithText("Ajouter une série").performScrollTo().performClick()
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")
compose.onNodeWithTag("session-set-0-weight").performScrollTo().assertTextEquals("32,5")
compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("")
compose.activityRule.scenario.recreate()
compose.onNodeWithText("Reprendre la séance en cours").performClick()
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")
compose.onNodeWithTag("session-set-0-weight").performScrollTo().assertTextEquals("32,5")
compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("")
} }
@Test @Test

View file

@ -2061,7 +2061,7 @@ class TrainlogRepository(
val maximum = if (tracking == "reps") 10000 else 86400 val maximum = if (tracking == "reps") 10000 else 86400
if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) || if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) ||
!set.value(valueKey).isJsonInt(minimum, maximum) || !set.value(valueKey).isJsonInt(minimum, maximum) ||
(set.has("weight_kg") && !set.value("weight_kg").isPositiveJsonNumber())) { (set.has("weight_kg") && !set.value("weight_kg").isNonnegativeJsonNumber())) {
return "Série V2 invalide." return "Série V2 invalide."
} }
} }
@ -2114,6 +2114,8 @@ class TrainlogRepository(
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
} }
private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0 private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
private fun Any?.isNonnegativeJsonNumber(): Boolean =
this is Number && toDouble().isFinite() && toDouble() >= 0.0
private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean { private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean {
val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use { val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use {
@ -3667,13 +3669,17 @@ class TrainlogRepository(
TrackingMode.REPS -> TrackingMode.REPS ->
draft.sets.all { draft.sets.all {
it.reps >= 0 && it.reps >= 0 &&
it.durationSeconds == 0 it.durationSeconds == 0 &&
(it.weightKg == null ||
(it.weightKg.isFinite() && it.weightKg >= 0.0))
} }
TrackingMode.DURATION -> TrackingMode.DURATION ->
draft.sets.all { draft.sets.all {
it.durationSeconds > 0 && it.durationSeconds > 0 &&
it.reps == 0 it.reps == 0 &&
(it.weightKg == null ||
(it.weightKg.isFinite() && it.weightKg >= 0.0))
} }
} }
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDetail import com.labfytools.trainlog.model.SessionExerciseDetail
@ -37,6 +38,10 @@ fun SessionDetailScreen(
) )
} }
} }
val equipmentEntries =
remember(sessionId, revision) {
repository.listEquipment()
}
TrainlogScreen( TrainlogScreen(
subtitle = "D E T A I L S E A N C E" subtitle = "D E T A I L S E A N C E"
@ -177,7 +182,10 @@ fun SessionDetailScreen(
) )
} else { } else {
SetsDetail( SetsDetail(
exercise exercise = exercise,
loadSemantics = equipmentEntries
.firstOrNull { it.equipmentId == exercise.equipmentId }
?.loadSemantics,
) )
} }
} }
@ -188,6 +196,7 @@ fun SessionDetailScreen(
@Composable @Composable
private fun SetsDetail( private fun SetsDetail(
exercise: SessionExerciseDetail, exercise: SessionExerciseDetail,
loadSemantics: EquipmentLoadSemantics?,
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -205,6 +214,18 @@ private fun SetsDetail(
color = colors.accent, color = colors.accent,
) )
if (exercise.trackingMode == TrackingMode.REPS) {
val loadHeading =
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
"Assistance (kg)"
} else {
"Charge (kg)"
}
/* Readable row table: history must expose every persisted value and
* distinguish an absent load from an explicit zero. */
TrainlogInfo("Série | Répétitions | $loadHeading", color = colors.muted)
}
exercise.sets exercise.sets
.forEachIndexed { .forEachIndexed {
index, index,
@ -215,13 +236,10 @@ private fun SetsDetail(
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
buildString { val renderedWeight = set.weightKg?.let {
append("Série ${index + 1} : ${set.reps} reps") "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
set.weightKg?.let { } ?: ""
val rendered = "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',') "${index + 1} | ${set.reps} | $renderedWeight"
append(" · $rendered kg")
}
}
} else { } else {
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}" "Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
} }

View file

@ -710,17 +710,33 @@ private fun SessionExerciseForm(
) )
} }
val initialSetRows =
remember(key) {
rawSetRowsFromForm(initialForm)
}
var repsText by var repsText by
remember(key) { remember(key) {
mutableStateOf( mutableStateOf(
initialForm.repsText encodeRawReps(initialSetRows)
) )
} }
var weightText by var weightText by
remember(key) { remember(key) {
mutableStateOf( mutableStateOf(
initialForm.weightText encodeRawWeights(initialSetRows)
)
}
/* CONTRACT: SETS + REPS is edited as occurrence-owned rows. The raw
* strings (including blanks and invalid fragments) are mirrored into the
* durable form after every mutation, while the saved occurrence is only
* replaced when the user confirms with "Ajouter à la séance". */
var setRows by
remember(key) {
mutableStateOf(
initialSetRows
) )
} }
@ -854,56 +870,98 @@ private fun SessionExerciseForm(
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
SessionNumberField(
label =
"Séries / répétitions",
value =
repsText,
onValueChange = {
repsText = it
error = null
onFormChanged(
currentForm(
exercise,
setCountText,
it,
durationText,
speedText,
distanceText,
selectedEquipmentId,
weightText,
)
)
},
)
TrainlogInfo( TrainlogInfo(
text = text = "Chaque série conserve ses propres répétitions et sa propre charge.",
"Formats : 5x10 · 4,5,6,7 · 4..10..4", color = colors.muted,
color =
colors.muted,
) )
val loadLabel =
SessionNumberField( if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
label = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
"Assistance (kg)" "Assistance (kg)"
} else { } else {
"Charge (kg)" "Charge (kg)"
}, }
value = weightText, setRows.forEachIndexed { index, row ->
onValueChange = { TrainlogInfo(
weightText = it text = "Série ${index + 1}",
error = null color = colors.accent,
onFormChanged( )
currentForm( SessionNumberField(
exercise, setCountText, repsText, durationText, label = "Série ${index + 1} — Répétitions",
speedText, distanceText, selectedEquipmentId, it, value = row.repsText,
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
) )
) },
)
SessionNumberField(
label = "Série ${index + 1}$loadLabel",
value = row.weightText,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
TrainlogAction(
label = "Supprimer la série ${index + 1}",
description = "Retirer uniquement cette série.",
accent = colors.error,
onClick = {
val updated = setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
}
TrainlogAction(
label = "Ajouter une série",
description = "Ajouter une ligne vide sans modifier les autres séries.",
accent = colors.success,
onClick = {
if (setRows.size < MAX_SESSION_SETS) {
val updated = setRows + RawSetRow()
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
}
}, },
) )
TrainlogInfo( TrainlogInfo(
text = "Une valeur par série séparée par ; (ex. 12,5;15). Une seule valeur s'applique à toutes les séries.", text = "Charge facultative ; virgule française acceptée. Une case vide n'est pas zéro.",
color = colors.muted, color = colors.muted,
) )
} else { } else {
@ -1063,12 +1121,18 @@ private fun SessionExerciseForm(
weightText = weightText, weightText = weightText,
maxWeightText = maxWeightText, maxWeightText = maxWeightText,
entryId = initialForm.editingEntryId, entryId = initialForm.editingEntryId,
rawSetRows = setRows,
) )
if (draft == null) { if (draft == null) {
error = error =
if (sessionType == SessionType.MAX_TEST) { if (sessionType == SessionType.MAX_TEST) {
"Saisissez un poids max strictement positif (ex. 100 ou 86,5)." "Saisissez un poids max strictement positif (ex. 100 ou 86,5)."
} else if (
exercise.recordingMode == RecordingMode.SETS &&
exercise.trackingMode == TrackingMode.REPS
) {
setRowValidationError(setRows)
} else { } else {
"Valeurs invalides." "Valeurs invalides."
} }
@ -1129,18 +1193,111 @@ private fun SessionNumberField(
label: String, label: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
testTag: String? = null,
) { ) {
TrainlogInputField( TrainlogInputField(
label = label, label = label,
value = value, value = value,
onValueChange = onValueChange =
onValueChange, onValueChange,
testTag = testTag,
) )
} }
private const val MAX_SESSION_SETS = 64 private const val MAX_SESSION_SETS = 64
private const val MAX_REPS_PER_SET = 10000 private const val MAX_REPS_PER_SET = 10000
internal data class RawSetRow(
val repsText: String = "",
val weightText: String = "",
)
internal fun List<RawSetRow>.replaceAt(index: Int, value: RawSetRow): List<RawSetRow> =
mapIndexed { rowIndex, existing -> if (rowIndex == index) value else existing }
internal fun encodeRawReps(rows: List<RawSetRow>): String =
rows.joinToString(";") { it.repsText }
internal fun encodeRawWeights(rows: List<RawSetRow>): String =
rows.joinToString(";") { it.weightText }
/**
* WHY: schema v9 already has durable raw form columns. Parallel token strings
* preserve row order and interior blanks without inventing a schema migration.
* Legacy compact rep expressions are expanded once when the row editor opens.
*/
internal fun rawSetRowsFromForm(form: SessionDraftForm): List<RawSetRow> {
val repTokens =
if (';' in form.repsText) {
form.repsText.split(';')
} else if (',' in form.repsText) {
/* Legacy compact lists may end in an unfinished token. Keep that
* blank row instead of normalizing it away during first reopen. */
form.repsText.split(',')
} else {
val compact = parseRepSequence(form.repsText)
if (compact != null) {
compact.map(Int::toString)
} else {
listOf(form.repsText)
}
}
val weightTokens =
if (';' in form.weightText) {
form.weightText.split(';')
} else {
listOf(form.weightText)
}
if (repTokens.isEmpty()) return listOf(RawSetRow())
return repTokens.mapIndexed { index, reps ->
RawSetRow(
repsText = reps,
/* Compatibility only: a legacy single compact load was broadcast
* by the old editor. New edits always persist one token per row. */
weightText =
if (weightTokens.size == 1) weightTokens.single()
else weightTokens.getOrElse(index) { "" },
)
}
}
internal fun parseRawSetRows(rows: List<RawSetRow>): List<SessionSetDraft>? {
if (rows.isEmpty() || rows.size > MAX_SESSION_SETS) return null
return rows.map { row ->
val reps = row.repsText.trim().toIntOrNull()
if (reps == null || reps !in 0..MAX_REPS_PER_SET) return null
val rawWeight = row.weightText.trim()
val weight =
if (rawWeight.isEmpty()) {
null
} else {
rawWeight.replace(',', '.').toDoubleOrNull()
?.takeIf { it.isFinite() && it >= 0.0 }
?: return null
}
SessionSetDraft(reps = reps, weightKg = weight)
}
}
internal fun setRowValidationError(rows: List<RawSetRow>): String {
if (rows.isEmpty()) return "Ajoutez au moins une série."
rows.forEachIndexed { index, row ->
val reps = row.repsText.trim().toIntOrNull()
if (reps == null || reps !in 0..MAX_REPS_PER_SET) {
return "Série ${index + 1} : saisissez des répétitions entre 0 et $MAX_REPS_PER_SET."
}
if (row.weightText.isNotBlank()) {
val weight = row.weightText.trim().replace(',', '.').toDoubleOrNull()
if (weight == null || !weight.isFinite() || weight < 0.0) {
return "Série ${index + 1} : saisissez une charge non négative ou laissez la case vide."
}
}
}
return "Valeurs de séries invalides."
}
private fun parseRepSequence( private fun parseRepSequence(
text: String, text: String,
): List<Int>? { ): List<Int>? {
@ -1304,6 +1461,7 @@ private fun buildSessionExerciseDraft(
weightText: String = "", weightText: String = "",
maxWeightText: String = "", maxWeightText: String = "",
entryId: String? = null, entryId: String? = null,
rawSetRows: List<RawSetRow>? = null,
): SessionExerciseDraft? { ): SessionExerciseDraft? {
if (sessionType == SessionType.MAX_TEST) { if (sessionType == SessionType.MAX_TEST) {
val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull() val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull()
@ -1391,24 +1549,18 @@ private fun buildSessionExerciseDraft(
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
val reps = val parsedSets =
parseRepSequence( parseRawSetRows(
repsText rawSetRows ?: rawSetRowsFromForm(
SessionDraftForm(repsText = repsText, weightText = weightText)
)
) ?: return null ) ?: return null
val weights = parseWeightSequence(weightText, reps.size) ?: return null
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(), entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
exercise = exercise, exercise = exercise,
equipmentId = equipmentId, equipmentId = equipmentId,
sets = sets = parsedSets,
reps.mapIndexed { index, rep ->
SessionSetDraft(
reps = rep,
weightKg = weights[index],
)
},
) )
} else { } else {
val count = val count =
@ -1445,7 +1597,7 @@ private fun buildSessionExerciseDraft(
/** Reconstruct editable text from the entry itself; editing never mutates a /** Reconstruct editable text from the entry itself; editing never mutates a
* different entry or the global exercise definition. */ * different entry or the global exercise definition. */
private fun formForExistingExercise( internal fun formForExistingExercise(
draft: SessionExerciseDraft, draft: SessionExerciseDraft,
index: Int, index: Int,
): SessionDraftForm = ): SessionDraftForm =
@ -1461,7 +1613,11 @@ private fun formForExistingExercise(
} else { } else {
"3x10" "3x10"
}, },
weightText = draft.sets.mapNotNull { it.weightKg }.joinToString(";") { "%g".format(java.util.Locale.FRANCE, it) }, /* INVARIANT: keep one load token per performed-set row. mapNotNull
* would shift later weights left when an earlier row is blank. */
weightText = draft.sets.joinToString(";") {
it.weightKg?.let(::formatMaxWeight).orEmpty()
},
durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) { durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
(draft.continuousDurationSeconds / 60).toString() (draft.continuousDurationSeconds / 60).toString()
} else { } else {
@ -1471,23 +1627,6 @@ private fun formForExistingExercise(
distanceText = draft.distanceKm?.toString().orEmpty(), distanceText = draft.distanceKm?.toString().orEmpty(),
) )
/** Accept French decimal commas without confusing them with the set separator.
* CONTRACT: blank means no load recorded; zero is a real explicit value. */
private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
if (text.trim().isEmpty()) return List(count) { null }
val values = text.split(';').map { token ->
token.trim().replace(',', '.').toDoubleOrNull()
}
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
@Suppress("UNCHECKED_CAST")
val parsed = values as List<Double>
return when {
parsed.size == 1 -> List(count) { parsed.single() }
parsed.size == count -> parsed
else -> null
}
}
private fun draftSummary( private fun draftSummary(
draft: SessionExerciseDraft, draft: SessionExerciseDraft,
): String { ): String {
@ -1531,6 +1670,7 @@ private fun draftSummary(
if ( if (
reps.isNotEmpty() && reps.isNotEmpty() &&
draft.sets.all { it.weightKg == null } &&
reps.all { reps.all {
it == reps.first() it == reps.first()
} }
@ -1544,10 +1684,12 @@ private fun draftSummary(
( (
"${draft.exercise.name} · " + "${draft.exercise.name} · " +
"${reps.size} séries · " + "${reps.size} séries · " +
reps.joinToString( draft.sets.joinToString(separator = " ; ") { set ->
separator = "," buildString {
) + append("${set.reps} reps")
" reps" set.weightKg?.let { append(" @ ${formatMaxWeight(it)} kg") }
}
}
) )
} }
} else { } else {

View file

@ -104,6 +104,115 @@ class TrainlogRepositoryDraftTest {
assertTrue(restored.updatedAt.isNotBlank()) assertTrue(restored.updatedAt.isNotBlank())
} }
@Test
fun actualSetWeightsPreservePositiveZeroAndAbsentAcrossDraftFinalizeAndExport() {
val first = openRepository()
val exercise = createExercise(first, "Charges exactes", RecordingMode.SETS, TrackingMode.REPS)
val expectedSets = listOf(
SessionSetDraft(reps = 8, weightKg = 32.5),
SessionSetDraft(reps = 7, weightKg = 0.0),
SessionSetDraft(reps = 6, weightKg = null),
)
assertEquals(
ActiveDraftMutationResult.Saved,
first.saveActiveSessionDraft(
ActiveSessionDraft(
exercises = listOf(SessionExerciseDraft(exercise = exercise, sets = expectedSets)),
),
),
)
first.close()
repository = null
val reopened = openRepository()
assertEquals(expectedSets, loadDraft(reopened).exercises.single().sets)
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val exportedSets = JSONObject(reopened.buildMobileExportV2Json())
.getJSONArray("sessions").getJSONObject(0)
.getJSONArray("exercises").getJSONObject(0).getJSONArray("sets")
assertEquals(32.5, exportedSets.getJSONObject(0).getDouble("weight_kg"), 0.0)
assertTrue(exportedSets.getJSONObject(1).has("weight_kg"))
assertEquals(0.0, exportedSets.getJSONObject(1).getDouble("weight_kg"), 0.0)
assertFalse(exportedSets.getJSONObject(2).has("weight_kg"))
val invalidDraft = SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5, weightKg = Double.NaN)),
)
assertTrue(reopened.saveSession(SessionDraft(listOf(invalidDraft))) is SaveSessionResult.Invalid)
assertTrue(
reopened.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(invalidDraft)))
is ActiveDraftMutationResult.Error,
)
}
@Test
fun pcMobileV2AcceptsZeroWeightAndRejectsInvalidWeightsAtomically() {
val repo = openRepository()
val localExercise = createExercise(repo, "Charge V2", RecordingMode.SETS, TrackingMode.REPS)
fun artifact(weight: Any, sessionId: String, max: Boolean = false): JSONObject {
val exercise = JSONObject()
.put("exercise_id", localExercise.exerciseId)
.put("name", "Charge V2")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
val entry = JSONObject()
.put("entry_id", "sxe_$sessionId")
.put("position", 0)
.put("exercise_id", localExercise.exerciseId)
.put("name", "Charge V2")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("equipment_id", JSONObject.NULL)
if (max) {
entry.put("max_weight_kg", weight)
} else {
entry.put("sets", org.json.JSONArray().put(JSONObject().put("reps", 5).put("weight_kg", weight)))
}
return JSONObject()
.put("format", "trainlog-mobile-export")
.put("version", 2)
.put("generated_at", "2026-09-09T10:00:00+02:00")
.put("exercises", org.json.JSONArray().put(exercise))
.put(
"sessions",
org.json.JSONArray().put(
JSONObject()
.put("session_id", sessionId)
.put("started_at", "2026-09-09T10:00:00+02:00")
.put("session_type", if (max) "max_test" else "training")
.put("exercises", org.json.JSONArray().put(entry)),
),
)
.put("body_observations", org.json.JSONArray())
}
val accepted = artifact(0.0, "se_zero_v2")
assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), repo.applyPcMobileExportV2Json(accepted.toString()))
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), repo.applyPcMobileExportV2Json(accepted.toString()))
val before = JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString()
listOf(-1.0, true, "0").forEachIndexed { index, value ->
assertTrue(
repo.applyPcMobileExportV2Json(artifact(value, "se_invalid_$index").toString())
is MobileSessionImportResult.Invalid,
)
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
}
listOf("NaN", "Infinity", "-Infinity").forEachIndexed { index, token ->
val invalidJson = artifact(1.234567, "se_nonfinite_$index").toString()
.replace("1.234567", token)
assertTrue(repo.applyPcMobileExportV2Json(invalidJson) is MobileSessionImportResult.Invalid)
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
}
assertTrue(repo.applyPcMobileExportV2Json(artifact(0.0, "se_zero_max", max = true).toString()) is MobileSessionImportResult.Invalid)
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
}
@Test @Test
fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() { fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() {
val repo = openRepository() val repo = openRepository()
@ -215,9 +324,18 @@ class TrainlogRepositoryDraftTest {
val draft = ActiveSessionDraft( val draft = ActiveSessionDraft(
exercises = listOf(SessionExerciseDraft( exercises = listOf(SessionExerciseDraft(
exercise = exercise, equipmentId = equipmentId, exercise = exercise, equipmentId = equipmentId,
sets = listOf(SessionSetDraft(10, weightKg = 12.5), SessionSetDraft(8, weightKg = 15.0)), sets = listOf(
SessionSetDraft(10, weightKg = 12.5),
SessionSetDraft(8, weightKg = null),
SessionSetDraft(6, weightKg = 15.0),
),
)), )),
form = SessionDraftForm(selectedExercise = exercise, selectedEquipmentId = equipmentId, weightText = "12,5;15"), form = SessionDraftForm(
selectedExercise = exercise,
selectedEquipmentId = equipmentId,
repsText = "10;8;6",
weightText = "12,5;;15",
),
) )
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft)) assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
repo.close(); repository = null repo.close(); repository = null
@ -226,7 +344,8 @@ class TrainlogRepositoryDraftTest {
assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId }) assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId })
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!! val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!!
assertEquals(listOf(12.5, 15.0), detail.exercises.single().sets.map { it.weightKg }) assertEquals(listOf(10, 8, 6), detail.exercises.single().sets.map { it.reps })
assertEquals(listOf(12.5, null, 15.0), detail.exercises.single().sets.map { it.weightKg })
} }
@Test @Test

View file

@ -0,0 +1,137 @@
package com.labfytools.trainlog.ui
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionSetRowEditorTest {
@Test
fun heterogeneousRowsAcceptFrenchCommaBlankAndExplicitZero() {
val parsed = parseRawSetRows(
listOf(
RawSetRow("12", "30"),
RawSetRow("8", "32,5"),
RawSetRow("6", ""),
RawSetRow("5", "0"),
)
)
assertEquals(
listOf(
SessionSetDraft(reps = 12, weightKg = 30.0),
SessionSetDraft(reps = 8, weightKg = 32.5),
SessionSetDraft(reps = 6, weightKg = null),
SessionSetDraft(reps = 5, weightKg = 0.0),
),
parsed,
)
}
@Test
fun invalidFieldDoesNotDestroyRawRowState() {
val rows = listOf(
RawSetRow("10", "20"),
RawSetRow("8x", "32,"),
RawSetRow("6", ""),
)
assertNull(parseRawSetRows(rows))
assertEquals(
"Série 2 : saisissez des répétitions entre 0 et 10000.",
setRowValidationError(rows),
)
assertEquals("10;8x;6", encodeRawReps(rows))
assertEquals("20;32,;", encodeRawWeights(rows))
assertEquals(
rows,
rawSetRowsFromForm(
SessionDraftForm(
repsText = encodeRawReps(rows),
weightText = encodeRawWeights(rows),
)
),
)
}
@Test
fun legacyPartialCompactListKeepsItsTrailingRawRow() {
assertEquals(
listOf(
RawSetRow("4", ""),
RawSetRow("5", ""),
RawSetRow("6", ""),
RawSetRow("", ""),
),
rawSetRowsFromForm(SessionDraftForm(repsText = "4,5,6,")),
)
}
@Test
fun editDeleteAndAddAreScopedToTheirRows() {
var rows = listOf(
RawSetRow("12", "30"),
RawSetRow("10", "31"),
RawSetRow("8", "32"),
)
rows = rows.replaceAt(1, rows[1].copy(weightText = "32,5"))
assertEquals(RawSetRow("12", "30"), rows[0])
assertEquals(RawSetRow("8", "32"), rows[2])
rows = rows.filterIndexed { index, _ -> index != 0 }
rows = rows + RawSetRow("6", "")
assertEquals(
listOf(
RawSetRow("10", "32,5"),
RawSetRow("8", "32"),
RawSetRow("6", ""),
),
rows,
)
}
@Test
fun reopeningOccurrenceKeepsMixedNullWeightsAligned() {
val form = formForExistingExercise(
SessionExerciseDraft(
entryId = "sxe_test",
exercise = repsExercise,
sets = listOf(
SessionSetDraft(reps = 12, weightKg = 30.0),
SessionSetDraft(reps = 10, weightKg = null),
SessionSetDraft(reps = 8, weightKg = 32.5),
),
),
index = 2,
)
assertEquals("12,10,8", form.repsText)
assertEquals("30;;32,5", form.weightText)
assertEquals(
listOf(
RawSetRow("12", "30"),
RawSetRow("10", ""),
RawSetRow("8", "32,5"),
),
rawSetRowsFromForm(form),
)
assertEquals(2, form.editingExerciseIndex)
assertEquals("sxe_test", form.editingEntryId)
}
private val repsExercise =
ExerciseProfile(
exerciseId = "ex_00000000-0000-4000-8000-000000000001",
name = "Développé",
normalizedName = "développé",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
)
}

View file

@ -109,9 +109,21 @@ Session entry is profile-aware.
### Sets + repetitions ### Sets + repetitions
Actual set values may be heterogeneous. Actual set values may be heterogeneous. The `SETS + REPS` editor presents
ordered rows, each with its own repetitions and optional load. **Ajouter une
série** appends one blank row and **Supprimer la série** removes only the chosen
row; editing or removing a row does not alter the remaining row values.
Compact entry supports: The load heading is **Charge (kg)** for external resistance and
**Assistance (kg)** for assistance equipment. A blank load is no recorded load,
not `0`; an entered load is finite and non-negative, and French decimal commas
are accepted. The durable raw form preserves
partial row input (including a blank row or a fragment such as `32,`) across
draft save and restore, and a failed validation or draft write presents a
specific error without claiming the row was saved.
When an older compact raw draft is reopened, its repetition text can be
expanded into the row editor from:
```text ```text
5x10 5x10
@ -140,17 +152,20 @@ it does not merge or alter another passage of the same exercise.
`Machine / équipement (optionnel)` searches the shared manifest by display `Machine / équipement (optionnel)` searches the shared manifest by display
name, physical-machine label and aliases. A selected equipment identity is name, physical-machine label and aliases. A selected equipment identity is
stored on that occurrence in both the active draft and completed session. stored on that occurrence in both the active draft and completed session.
For `SETS + REPS`, `Charge (kg)` accepts one value for all sets or `;`-separated For `SETS + REPS`, the form is a row editor: every set owns an independently
per-set values; French decimal commas are accepted. `Assistance (kg)` is an editable repetitions field and optional load field, and rows can be added or
explicit alternative load semantic, not an external charge. Empty load and an deleted without changing their neighbours. French decimal commas are accepted.
entered zero remain distinct. `Assistance (kg)` is an explicit alternative load semantic, not an external
charge. Empty load and an entered zero remain distinct. The completed-session
detail renders the persisted rows in order with the matching Charge or
Assistance heading, including an explicit empty-load marker.
## 6. Session draft editing ## 6. Session draft editing
The repository durably saves every meaningful mutation, including session type, The repository durably saves every meaningful mutation, including session type,
exercise selection/addition/removal, actual values and raw form edits. Partial exercise selection/addition/removal, actual values and raw per-set form edits.
text such as `4,5,6,` is retained without normalization. A failed write displays Partial row text such as `32,` is retained without normalization. A failed write
a specific error and does not claim the latest change was saved. displays a specific error and does not claim the latest change was saved.
Home shows **Reprendre la séance en cours** and an exercise-count/type summary. Home shows **Reprendre la séance en cours** and an exercise-count/type summary.
The ordinary new-session action opens an existing draft without overwriting it. The ordinary new-session action opens an existing draft without overwriting it.

View file

@ -120,7 +120,10 @@ Continuous work is persisted separately from performed sets.
### Desktop ### Desktop
Desktop SQLite schema v9 is canonical long-term history. `session_exercises` Desktop SQLite schema v10 is canonical long-term history. Its v9 -> v10
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
be finite `>= 0`; the column already existed and targets/max results retain
their strictly-positive contracts. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion. occur more than once in one session without identity fusion.

View file

@ -1,6 +1,6 @@
# Current implementation state # Current implementation state
Canonical snapshot: 2026-09-08. Canonical snapshot: 2026-09-09.
This document is the compact source of truth for the implemented Trainlog This document is the compact source of truth for the implemented Trainlog
baseline. Detailed behavior belongs in the topic-specific documents. baseline. Detailed behavior belongs in the topic-specific documents.
@ -14,7 +14,7 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V9=PASS DESKTOP_SCHEMA_V10=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V9=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_DURABLE=PASS
@ -57,7 +57,7 @@ EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS HARDWARE_SYNC_VALIDATION=PASS
``` ```
@ -67,10 +67,15 @@ HARDWARE_SYNC_VALIDATION=PASS
Implemented: Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- SQLite schema v9, with stable ordered `session_exercises.entry_id`, - SQLite schema v10, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment occurrence-level equipment identity, and desktop-local custom-equipment
definitions, plus occurrence-owned `max_results`; definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
rebuilds only `performed_sets` to permit explicit zero actual loads while
preserving historic NULL and positive rows;
- direct session entry; - direct session entry;
- normal desktop SETS planning followed by the table-only explicit actual-row
editor; zero-row completion is rejected while MAX and continuous entries keep
their separate no-set contracts;
- persisted session detail and editing; - persisted session detail and editing;
- exercise removal from a session through transactional child replacement; - exercise removal from a session through transactional child replacement;
- exercise catalog; - exercise catalog;
@ -104,7 +109,7 @@ Primary navigation:
Implemented: Implemented:
- native Kotlin/Compose application; - native Kotlin/Compose application;
- local SQLite database v8, with non-destructive v3 -> v8 migration; - local SQLite database v9, with non-destructive v3 -> v9 migration;
- one durable active-session draft, Home resume and raw-form restoration; - one durable active-session draft, Home resume and raw-form restoration;
- explicit confirmed discard and atomic completed-save/draft-clear; - explicit confirmed discard and atomic completed-save/draft-clear;
- exercise creation; - exercise creation;
@ -221,17 +226,17 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
34/34 Meson tests PASS for the current desktop schema v9 baseline 36/36 Meson tests PASS for the current desktop schema v10 baseline
frozen JSON validator PASS JSON valid/invalid checks PASS
import-contract validator PASS import-contract validator 6/6 PASS
ASan/UBSan 34/34 Meson tests PASS ASan/UBSan 14/14 Meson tests PASS postrepair
git diff --check PASS git diff --check PASS
``` ```
Android: Android:
```text ```text
testDebugUnitTest PASS 37 Android unit tests PASS
assembleDebug PASS assembleDebug PASS
``` ```
@ -282,11 +287,11 @@ ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
``` ```
A measured maximum belongs to an exercise occurrence in an explicit `max_test` A measured maximum belongs to an exercise occurrence in an explicit `max_test`
session. Schema v9 persists a positive `max_weight_kg` separately from sets; session. Schema v9 introduced the positive `max_weight_kg` separately from sets;
equipment is optional context, so one physical machine may carry independent equipment is optional context, so one physical machine may carry independent
Pec Fly and Rear Delt Fly results. Ordinary training is never promoted Pec Fly and Rear Delt Fly results. Ordinary training is never promoted
implicitly. implicitly.
@ -312,7 +317,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
``` ```
Android remains capture-only for this feature. Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=9 TRAINLOG_DATABASE_SCHEMA_VERSION=10
DATABASE_SCHEMA_V9=PASS DATABASE_SCHEMA_V10=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -23,9 +23,15 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
9 10
``` ```
The independent actual-set loads documented in the current desktop, Android
and V2 flows use the existing ordered `performed_sets.weight_kg` field and the
corresponding durable draft-set field. The per-set column itself pre-existed,
but desktop v10 is required: v9 -> v10 rebuilds `performed_sets` solely to
widen actual `weight_kg` from finite `> 0` to finite `>= 0`.
Supported historical databases are migrated explicitly through the implemented Supported historical databases are migrated explicitly through the implemented
migration chain. A database newer than the running binary understands is migration chain. A database newer than the running binary understands is
rejected. rejected.
@ -47,6 +53,14 @@ performed set with `reps = 1`, no duration, and a positive weight. The stable
session, occurrence, exercise, position, and equipment identities are retained. session, occurrence, exercise, position, and equipment identities are retained.
Multiple attempts and every other ambiguous shape remain as historical sets. Multiple attempts and every other ambiguous shape remain as historical sets.
Version 10 rebuilds only `performed_sets`. Its explicit projection preserves
every row ID, owning occurrence, position, repetitions-or-duration, and
existing `NULL` or positive `weight_kg` value unchanged; it permits a new
explicit zero actual load. The migration is transactional. Regression coverage
checks lossless migration, rollback after an injected rebuild-name collision,
`PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key
enforcement, and rejection of negative loads or invalid metric shapes.
A schema fixture must represent the real historical structure. Rewriting only A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test. `user_version` is not an acceptable migration test.
@ -179,7 +193,12 @@ duration_seconds
Actual repetitions may be zero. Actual repetitions may be zero.
Each row is independent; heterogeneous repetition sequences are first-class Each row is independent; heterogeneous repetition sequences are first-class
data. data. Its nullable `weight_kg` is likewise occurrence-set data: blank is
distinct from an explicit zero and from a planned target weight. When present,
an actual weight is finite and `>= 0`.
Planned `target_weight_kg` remains distinct planning metadata and, when
present, is finite and `> 0`; it is never copied into an actual set.
### `continuous_activity` ### `continuous_activity`

View file

@ -92,30 +92,26 @@ equivalence across different machines.
`SETS + REPS` stores one performed-set row per actual set. `SETS + REPS` stores one performed-set row per actual set.
Actual repetitions can differ across sets. Each ordered performed set independently owns its repetitions and an optional
`weight_kg`. Actual repetitions and actual loads can therefore differ from one
Accepted compact repetition input includes: set to the next. A missing load is not a zero load and is not filled from a
planned target. When supplied, an actual load is finite and `>= 0`; an explicit
```text zero is preserved as an observed value.
5x10
4,5,6,7,8,9,10,9,8,7,6,5,4
4..10..4
```
The pyramid shorthand:
```text
4..10..4
```
expands to:
```text
4,5,6,7,8,9,10,9,8,7,6,5,4
```
Each performed row is the source of truth for actual work. Each performed row is the source of truth for actual work.
New normal desktop set work is created only as explicit actual rows. Compact
performed-repetition expressions are not an active desktop entry form; Android
legacy-draft decoding is a separate compatibility behavior documented in
`docs/android.md`.
Existing historical rows retain their stored repetitions, optional loads and
order unchanged. Editing or exchanging a session never normalizes heterogeneous
actual values into a uniform prescription.
This per-set capture contract does not introduce volume/tonnage, estimated 1RM,
or progression calculations.
`SETS + DURATION` likewise stores one actual duration per performed set. `SETS + DURATION` likewise stores one actual duration per performed set.
## 5. Planned versus actual ## 5. Planned versus actual

View file

@ -14,7 +14,7 @@ GATE_1=PASS
GATE_2=PASS GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V9=PASS DESKTOP_SCHEMA_V10=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V9=PASS
DIRECT_MTP_TRANSPORT=PASS DIRECT_MTP_TRANSPORT=PASS
@ -28,7 +28,7 @@ BODY_ANALYTICS_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
DESKTOP_TESTS=34/34 PASS DESKTOP_TESTS=36/36 PASS
TUI_NOTCURSES_V1=PASS TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=PASS NOTCURSES_TRUECOLOR_THEME=PASS

View file

@ -142,6 +142,17 @@ The companion corroborates explicit `set`/`cleared` state; it does not overwrite
a divergent occurrence. Reimporting either artifact reconciles stable a divergent occurrence. Reimporting either artifact reconciles stable
identities; it neither duplicates sessions nor regenerates occurrence IDs. identities; it neither duplicates sessions nor regenerates occurrence IDs.
Within an ordered `sets[]` array, `weight_kg` belongs to that individual set,
not to the occurrence or its planned target. Each set therefore replays its own
repetitions-or-duration and nullable load in its original order. A replay is
idempotent: it preserves historic heterogeneous values and does not replace
blank loads with zero, a target value, or another set's load.
An omitted `weight_kg` remains a null/absent actual load. When present, V2
requires a finite value `>= 0`, including explicit zero. This does not alter
the separate strictly-positive `max_weight_kg`/`max_results` contract, V2's
version number, or frozen `TRAINLOG_FORMAT_V1`.
Exercise profile fields: Exercise profile fields:
```text ```text

View file

@ -86,6 +86,7 @@ continuous_detail
reps reps
variable_sets variable_sets
schema_v5_migration schema_v5_migration
schema_v9_migration
schema_v7_migration schema_v7_migration
mobile_import_variable_sets mobile_import_variable_sets
mobile_import_multi_occurrence mobile_import_multi_occurrence
@ -100,12 +101,13 @@ max_results
max_sync max_sync
body_analytics body_analytics
terminal_input_event_type_policy terminal_input_event_type_policy
tui_workflows
``` ```
Validated current suite: Validated current suite:
```text ```text
34/34 Meson tests PASS 36/36 Meson tests PASS
``` ```
The desktop executable is additionally smoke-checked in isolated tmux PTYs at The desktop executable is additionally smoke-checked in isolated tmux PTYs at
@ -120,10 +122,21 @@ Notable regression coverage:
- body-observation stable-identity editing; - body-observation stable-identity editing;
- profile-aware exercise constraints; - profile-aware exercise constraints;
- continuous activity without fake sets; - continuous activity without fake sets;
- repetition shorthand/list/pyramid parsing; - table-only desktop SETS workflow: planning does not create actual rows,
explicit add requires an actual metric, and normal zero-row completion is
rejected; Android legacy compact-draft decoding remains separately covered;
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration; - direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration;
- bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation; - bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation;
- lossless v9 -> v10 `performed_sets` rebuild: historic NULL and positive
weights/IDs/owners/positions/metrics survive, explicit zero is accepted, and
injected failure rolls back with integrity, foreign-key and enforcement
checks;
- heterogeneous mobile-set import; - heterogeneous mobile-set import;
- per-set load persistence and correction: ordered rows retain mixed actual
repetitions, nullable loads, positions and assistance semantics through
desktop replacement and detail retrieval;
- V2 mobile round-trip and idempotent replay preserve each ordered set's own
nullable `weight_kg`, without collapsing it to an occurrence target;
- V2 explicit-max Android -> desktop -> Android replay and resumed same-session update; - V2 explicit-max Android -> desktop -> Android replay and resumed same-session update;
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
RELEASE event is consumed without creating a second navigation action. RELEASE event is consumed without creating a second navigation action.
@ -319,7 +332,7 @@ Coverage proves:
Validated current normal suite: Validated current normal suite:
```text ```text
34/34 Meson tests PASS 36/36 Meson tests PASS
``` ```
## 12. Body analytics regression ## 12. Body analytics regression
@ -344,7 +357,7 @@ Coverage includes:
Validated current normal suite: Validated current normal suite:
```text ```text
34/34 Meson tests PASS 36/36 Meson tests PASS
``` ```
## 13. Android session draft v1 ## 13. Android session draft v1
@ -363,6 +376,35 @@ explicit max creation/edit/finalization without sets, distinct movement values
on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume
and bounded conversion that leaves multiple legacy attempts untouched. and bounded conversion that leaves multiple legacy attempts untouched.
The current set-row-editor coverage additionally proves that row edits,
deletion and append preserve neighbouring rows; French-comma loads, blank
loads and explicit zero loads remain distinct; reopening preserves aligned raw
row fields; and completed history keeps the ordered per-set values. The
instrumentation source exercises row edit, deletion, append and Activity
recreation, but this document does not claim that instrumentation was executed
for the current documentation checkpoint.
For the settled implementation, the validation inventory is:
```text
Android JVM: ./gradlew testDebugUnitTest
Android compilation: ./gradlew assembleDebug
Android instrumentation: adb shell am instrument ... (execution is explicit;
no device execution is asserted here)
Desktop: meson compile -C build
meson test -C build --print-errorlogs
Frozen JSON/import: python tools/validate_json.py
python tools/validate_import_contract.py
V2 regression: mobile_import_variable_sets and the desktop/Android
V2 round-trip/idempotent-replay coverage
Sanitizers: clang ASan/UBSan Meson build and test invocation
```
Executed postrepair evidence is: 37 Android unit tests, `assembleDebug`, 36/36
Meson tests, valid and invalid JSON checks, import-contract 6/6, and 14/14
ASan/UBSan Meson tests. No device, installation, real-store migration, or
instrumentation execution is asserted by this checkpoint.
```bash ```bash
cd android cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test

View file

@ -99,16 +99,39 @@ Unicode-aware normalized-name uniqueness prevents duplicate logical names.
The TUI can record sessions directly. The TUI can record sessions directly.
Set-based entry supports planned targets and actual work. Set-based entry supports planned targets and actual work. Planned target values
remain planning metadata: they are never copied into an actual performed set.
For repetition work, compact actual-set input supports: For a normal `SETS` occurrence, creation collects planning metadata only: it
does not accept a compact performed-repetitions expression, a performed set
count, sequential performed durations, or a pre-table performed load. It starts
with zero actual rows, then opens the ordered, keyboard-first actual-set table
as the sole path for creating actual work. Each row contains its actual
repetitions (or duration) and, when the occurrence has a load mode, its
independently optional Charge or Assistance value. The table uses:
```text ```text
5x10 Up/Down select a row
4,5,6,7,8,9,10,9,8,7,6,5,4 Left/Right/Tab select metric or load cell
4..10..4 Enter edit the selected cell
a append a row and enter its required actual metric
d/Delete delete the selected set
Escape cancel the active cell, or leave the table
f/b finish the table
``` ```
Adding a row requires an explicit actual repetitions/duration value and creates
no actual load; an empty load cell remains absent rather than inheriting the
target. An entered actual load is finite and non-negative;
the planned target remains a separate, strictly positive planning value. The
current-session summary is assembled from each
actual row, so mixed repetitions, optional loads and assistance values are not
collapsed into one target value.
Finishing a normal `SETS` draft with zero actual rows is blocked with an
explicit diagnostic. This guard does not apply to `MAX` or continuous entries,
whose distinct persistence contracts contain no performed-set rows.
For timed work, the shared duration parser accepts forms such as: For timed work, the shared duration parser accepts forms such as:
```text ```text
@ -131,6 +154,11 @@ History is keyboard navigable.
`Enter` opens full session detail. `Enter` opens full session detail.
For set-based occurrences, detail displays an ordered, scrollable table of the
persisted rows with series number, repetitions or duration, and Charge or
Assistance. It exposes every stored per-set value, including absent loads,
rather than only a compact aggregate.
Persisted session editing preserves the parent session identity and timestamps Persisted session editing preserves the parent session identity and timestamps
while replacing child exercise/set data transactionally. while replacing child exercise/set data transactionally.
@ -327,7 +355,7 @@ meson test -C build --print-errorlogs
Validated current normal suite: Validated current normal suite:
```text ```text
34/34 Meson tests PASS 36/36 Meson tests PASS
``` ```
## 16. Measured max view ## 16. Measured max view

View file

@ -67,7 +67,7 @@ CREATE TABLE custom_equipment(
equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL, equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL,
equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL
); );
PRAGMA user_version=9; PRAGMA user_version=10;
""" """

View file

@ -22,7 +22,7 @@ CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0)); CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0));
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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); CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL); CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
PRAGMA user_version=9; PRAGMA user_version=10;
""" """

View file

@ -24,7 +24,7 @@ CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL); CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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); CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,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);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL); CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
PRAGMA user_version=9; PRAGMA user_version=10;
""" """
def payload(): def payload():

View file

@ -13,6 +13,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools" / "import_mobile_export.py" IMPORTER = ROOT / "tools" / "import_mobile_export.py"
EXPORTER = ROOT / "tools" / "export_pc_mobile.py"
EXPECTED_REPS = [ EXPECTED_REPS = [
4, 4,
@ -55,6 +56,7 @@ CREATE TABLE sessions (
CREATE TABLE session_exercises ( CREATE TABLE session_exercises (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
entry_id TEXT NOT NULL UNIQUE,
session_row_id INTEGER NOT NULL session_row_id INTEGER NOT NULL
REFERENCES sessions(id) ON DELETE CASCADE, REFERENCES sessions(id) ON DELETE CASCADE,
exercise_row_id INTEGER NOT NULL exercise_row_id INTEGER NOT NULL
@ -68,7 +70,8 @@ CREATE TABLE session_exercises (
target_reps INTEGER, target_reps INTEGER,
target_duration_seconds INTEGER, target_duration_seconds INTEGER,
target_weight_kg REAL, target_weight_kg REAL,
notes TEXT notes TEXT,
equipment_id TEXT
); );
CREATE TABLE performed_sets ( CREATE TABLE performed_sets (
@ -78,7 +81,7 @@ CREATE TABLE performed_sets (
position INTEGER NOT NULL, position INTEGER NOT NULL,
reps INTEGER, reps INTEGER,
duration_seconds INTEGER, duration_seconds INTEGER,
weight_kg REAL weight_kg REAL CHECK(weight_kg >= 0.0)
); );
CREATE TABLE continuous_activity ( CREATE TABLE continuous_activity (
@ -112,16 +115,40 @@ CREATE TABLE body_observations (
notes TEXT notes TEXT
); );
PRAGMA user_version=5; CREATE TABLE max_results (
session_exercise_row_id INTEGER PRIMARY KEY,
max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0)
);
CREATE TABLE custom_equipment (
equipment_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
label_name TEXT NOT NULL,
equipment_type TEXT NOT NULL,
load_semantics TEXT NOT NULL
);
PRAGMA user_version=10;
""" """
EXPECTED_SETS = [
(
reps,
32.5 if index == 0 else
0.0 if index == 1 else
None if index in (2, 6) else
40.25 + index * 1.5,
)
for index, reps in enumerate(EXPECTED_REPS)
]
def payload() -> dict: def payload() -> dict:
return { return {
"format": "format":
"trainlog-mobile-export", "trainlog-mobile-export",
"version": "version":
1, 2,
"generated_at": "generated_at":
"2026-09-06T16:00:00+02:00", "2026-09-06T16:00:00+02:00",
"exercises": [ "exercises": [
@ -148,6 +175,10 @@ def payload() -> dict:
"training", "training",
"exercises": [ "exercises": [
{ {
"entry_id":
"sxe_mobile_pyramid",
"position":
0,
"exercise_id": "exercise_id":
"ex_mobile_pyramid", "ex_mobile_pyramid",
"name": "name":
@ -162,12 +193,12 @@ def payload() -> dict:
"none", "none",
"rest_seconds": "rest_seconds":
0, 0,
"equipment_id":
None,
"sets": [ "sets": [
{ ({"reps": reps} if weight is None else
"reps": reps {"reps": reps, "weight_kg": weight})
} for reps, weight in EXPECTED_SETS
for reps
in EXPECTED_REPS
], ],
} }
], ],
@ -204,6 +235,64 @@ def run_import(
return result.stdout return result.stdout
def run_export(output_path: Path, database_path: Path) -> None:
result = subprocess.run(
[sys.executable, str(EXPORTER), str(output_path),
"--database", str(database_path)],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise AssertionError("export failed:\n" + result.stdout + result.stderr)
def database_contents(database_path: Path) -> tuple:
"""Capture durable rows so rejected payloads prove transaction atomicity."""
with sqlite3.connect(database_path) as connection:
return tuple(
tuple(connection.execute(
f"SELECT * FROM {table} ORDER BY rowid"
).fetchall())
for table in (
"exercises", "sessions", "session_exercises",
"performed_sets", "continuous_activity",
"body_observations", "max_results",
)
)
def require_weight_rejection(
base: Path,
database_path: Path,
value,
suffix: str,
) -> None:
rejected = payload()
rejected["sessions"][0]["session_id"] = f"se_rejected_{suffix}"
rejected["sessions"][0]["exercises"][0]["sets"][0]["weight_kg"] = value
rejected_path = base / f"invalid-weight-{suffix}.json"
rejected_path.write_text(
json.dumps(rejected, ensure_ascii=False, allow_nan=True),
encoding="utf-8",
)
before = database_contents(database_path)
result = subprocess.run(
[sys.executable, str(IMPORTER), str(rejected_path),
"--database", str(database_path)],
check=False,
capture_output=True,
text=True,
)
if result.returncode == 0 or "weight_kg" not in result.stderr:
raise AssertionError(
f"invalid set weight accepted ({suffix}):\n"
+ result.stdout + result.stderr
)
if database_contents(database_path) != before:
raise AssertionError(f"invalid set weight mutated database ({suffix})")
def main() -> int: def main() -> int:
with tempfile.TemporaryDirectory( with tempfile.TemporaryDirectory(
prefix="trainlog-mobile-variable-" prefix="trainlog-mobile-variable-"
@ -285,7 +374,7 @@ def main() -> int:
try: try:
rows = connection.execute( rows = connection.execute(
""" """
SELECT ps.reps SELECT ps.reps, ps.weight_kg
FROM performed_sets ps FROM performed_sets ps
JOIN session_exercises se JOIN session_exercises se
ON se.id = ON se.id =
@ -298,14 +387,9 @@ def main() -> int:
""" """
).fetchall() ).fetchall()
reps = [ if rows != EXPECTED_SETS:
row[0]
for row in rows
]
if reps != EXPECTED_REPS:
raise AssertionError( raise AssertionError(
f"reps mismatch: {reps!r}" f"ordered sets mismatch: {rows!r}"
) )
target = connection.execute( target = connection.execute(
@ -350,6 +434,74 @@ def main() -> int:
finally: finally:
connection.close() connection.close()
exported_path = base / "desktop-v2.json"
roundtrip_db = base / "roundtrip.db"
run_export(exported_path, database_path)
exported = json.loads(exported_path.read_text(encoding="utf-8"))
exported_sets = exported["sessions"][0]["exercises"][0]["sets"]
expected_json_sets = [
({"reps": reps} if weight is None else
{"reps": reps, "weight_kg": weight})
for reps, weight in EXPECTED_SETS
]
if exported_sets != expected_json_sets:
raise AssertionError(f"V2 export changed sets: {exported_sets!r}")
with sqlite3.connect(roundtrip_db) as connection:
connection.executescript(SCHEMA)
imported = run_import(exported_path, roundtrip_db)
if "sessions_imported=1" not in imported:
raise AssertionError("V2 reimport failed:\n" + imported)
replay = run_import(exported_path, roundtrip_db)
if "sessions_skipped=1" not in replay:
raise AssertionError("V2 replay not idempotent:\n" + replay)
with sqlite3.connect(roundtrip_db) as connection:
roundtrip_sets = connection.execute(
"SELECT reps,weight_kg FROM performed_sets ORDER BY position"
).fetchall()
if roundtrip_sets != EXPECTED_SETS:
raise AssertionError(f"V2 reimport changed sets: {roundtrip_sets!r}")
# INVARIANT: all validation precedes mutation; every invalid optional
# set weight therefore rejects the complete artifact atomically.
for invalid_weight, suffix in (
(-1.5, "negative"),
(True, "bool"),
("20", "string"),
(float("nan"), "nan"),
(float("inf"), "infinity"),
):
require_weight_rejection(
base, database_path, invalid_weight, suffix
)
# A migration-era v9 database cannot represent explicit zero. Reject
# before beginning the import instead of collapsing zero into NULL or
# leaving a partially inserted graph.
v9_database = base / "unmigrated-v9.db"
with sqlite3.connect(v9_database) as connection:
connection.executescript(
SCHEMA.replace(
"weight_kg REAL CHECK(weight_kg >= 0.0)",
"weight_kg REAL CHECK(weight_kg > 0.0)",
).replace("PRAGMA user_version=10", "PRAGMA user_version=9")
)
before_v9 = database_contents(v9_database)
rejected_v9 = subprocess.run(
[sys.executable, str(IMPORTER), str(exported_path),
"--database", str(v9_database)],
check=False,
capture_output=True,
text=True,
)
if rejected_v9.returncode == 0 or "schéma desktop v10" not in rejected_v9.stderr:
raise AssertionError(
"zero-bearing V2 artifact did not fail explicitly on v9:\n" +
rejected_v9.stdout + rejected_v9.stderr
)
if database_contents(v9_database) != before_v9:
raise AssertionError("zero-bearing V2 artifact mutated v9 database")
print( print(
"PASS mobile_import_variable_sets" "PASS mobile_import_variable_sets"
) )

View file

@ -27,8 +27,8 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10):
raise ValueError("schema desktop v8 ou v9 requis") raise ValueError("schema desktop v8, v9 ou v10 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute( known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -23,8 +23,8 @@ def main():
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10):
raise ValueError("schema desktop v8 ou v9 requis") raise ValueError("schema desktop v8, v9 ou v10 requis")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")] "FROM custom_equipment ORDER BY equipment_id")]

View file

@ -64,7 +64,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9): if version not in (8, 9, 10):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -30,8 +30,8 @@ def main():
con = sqlite3.connect(args.database) con = sqlite3.connect(args.database)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] != 9: if con.execute("PRAGMA user_version").fetchone()[0] != 10:
raise ValueError("schema desktop v9 requis") raise ValueError("schema desktop v10 requis")
known_equipment = supplied_equipment_ids() known_equipment = supplied_equipment_ids()
known_equipment.update(row[0] for row in con.execute( known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -113,8 +113,8 @@ def main():
fail("clés extension équipement invalides") fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10):
fail("schema desktop v8 ou v9 requis") fail("schema desktop v8, v9 ou v10 requis")
known = load_catalog(args.catalog) known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute( known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -60,8 +60,8 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = sqlite3.connect(args.database) connection = sqlite3.connect(args.database)
try: try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10):
fail("schema desktop v8 ou v9 requis") fail("schema desktop v8, v9 ou v10 requis")
imported = skipped = 0 imported = skipped = 0
# Validate every same-ID row before inserting any definition. # Validate every same-ID row before inserting any definition.
for definition in definitions: for definition in definitions:

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import json import json
import math
import os import os
import sqlite3 import sqlite3
import sys import sys
@ -153,7 +154,7 @@ def require_positive_number(value, label):
parsed = float(value) parsed = float(value)
if parsed <= 0.0: if not math.isfinite(parsed) or parsed <= 0.0:
raise ImportFailure( raise ImportFailure(
f"{label}: nombre positif attendu" f"{label}: nombre positif attendu"
) )
@ -161,6 +162,17 @@ def require_positive_number(value, label):
return parsed return parsed
def require_nonnegative_number(value, label):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ImportFailure(f"{label}: nombre attendu")
parsed = float(value)
if not math.isfinite(parsed) or parsed < 0.0:
raise ImportFailure(f"{label}: nombre non négatif attendu")
return parsed
def normalize_name(value): def normalize_name(value):
folded = unicodedata.normalize( folded = unicodedata.normalize(
"NFC", "NFC",
@ -367,6 +379,13 @@ def validate_set_item(
label, label,
): ):
allowed_weight = {"weight_kg"} allowed_weight = {"weight_kg"}
# CONTRACT: absent actual load is omitted/SQL NULL; when present it is a
# finite nonnegative observation, including an explicit zero.
if "weight_kg" in value:
require_nonnegative_number(
value["weight_kg"],
f"{label}.weight_kg",
)
if tracking_mode == "reps": if tracking_mode == "reps":
require_exact_keys( require_exact_keys(
value, value,
@ -757,9 +776,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9): if version not in (5, 6, 7, 8, 9, 10):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v9 attendue, version trouvée: {version}" f"base desktop schema v5 à v10 attendue, version trouvée: {version}"
) )
@ -1646,6 +1665,17 @@ def run_import(
) )
if has_explicit_max and schema_version < 9: if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9") raise ImportFailure("max_weight_kg exige le schéma desktop v9")
has_explicit_zero_set_weight = any(
set_item.get("weight_kg") == 0
for session in payload["sessions"]
for entry in session["exercises"]
for set_item in entry.get("sets", [])
if "weight_kg" in set_item
)
if has_explicit_zero_set_weight and schema_version < 10:
raise ImportFailure(
"weight_kg=0 exige le schéma desktop v10; import annulé"
)
connection.execute( connection.execute(
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"

View file

@ -263,6 +263,21 @@ def validate_mobile_export_v2(document: Any) -> None:
value = entry["max_weight_kg"] value = entry["max_weight_kg"]
if session.get("session_type") != "max_test" or isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value <= 0: if session.get("session_type") != "max_test" or isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value <= 0:
raise TrainlogSemanticError("mobile export V2: invalid explicit max") raise TrainlogSemanticError("mobile export V2: invalid explicit max")
if has_sets:
sets = entry["sets"]
if not isinstance(sets, list):
raise TrainlogSemanticError("mobile export V2: invalid sets")
for actual_set in sets:
if not isinstance(actual_set, dict):
raise TrainlogSemanticError("mobile export V2: invalid set")
if "weight_kg" in actual_set:
value = actual_set["weight_kg"]
if (isinstance(value, bool) or
not isinstance(value, (int, float)) or
not math.isfinite(float(value)) or value < 0):
raise TrainlogSemanticError(
"mobile export V2: invalid actual-set weight"
)
entry_ids.add(entry["entry_id"]); positions.add(entry["position"]) entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
def structural_errors( def structural_errors(
validator: jsonschema.Draft202012Validator, validator: jsonschema.Draft202012Validator,

View file

@ -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 9 #define TRAINLOG_DATABASE_SCHEMA_VERSION 10
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;
@ -198,13 +198,20 @@ typedef struct TrainlogPersistedExerciseDetail {
double continuous_distance_km; double continuous_distance_km;
size_t actual_set_count; size_t actual_set_count;
/* CONTRACT: history consumes this occurrence-owned ordered snapshot;
* rendering must not reach into SQLite or impose a global dataset cap. */
TrainlogSetInput *actual_sets;
char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U]; char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U];
} TrainlogPersistedExerciseDetail; } TrainlogPersistedExerciseDetail;
/** /**
* @brief Load one session header plus ordered exercise details. * @brief Load one session header plus ordered exercise details.
* *
* The function is read-only and allocates nothing. * Callers must release a prior successful result before reusing its array.
* On success, each copied detail owns actual_sets until released with
* trainlog_database_free_session_details(). On failure, the function releases
* every partial allocation and, when output_exercise_count is non-NULL, sets
* it to zero. A nonzero capacity requires a non-NULL output_exercises array.
*/ */
TrainlogStatus trainlog_database_get_session_details( TrainlogStatus trainlog_database_get_session_details(
TrainlogDatabase *database, TrainlogDatabase *database,
@ -215,6 +222,12 @@ TrainlogStatus trainlog_database_get_session_details(
size_t *output_exercise_count size_t *output_exercise_count
); );
/* Safe for zero-initialized details and details returned by the loader. */
void trainlog_database_free_session_details(
TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count
);
/* TRAINLOG_BODY_METRIC_HISTORY_API */ /* TRAINLOG_BODY_METRIC_HISTORY_API */

View file

@ -360,6 +360,15 @@ test(
test_schema_v5_migration, test_schema_v5_migration,
) )
test_schema_v9_migration = executable(
'test_schema_v9_migration',
'tests/test_schema_v9_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v9_migration', test_schema_v9_migration)
test_schema_v7_migration = executable( test_schema_v7_migration = executable(
'test_schema_v7_migration', 'test_schema_v7_migration',
'tests/test_schema_v7_migration.c', 'tests/test_schema_v7_migration.c',
@ -484,3 +493,14 @@ test(
'terminal_input_event_type_policy', 'terminal_input_event_type_policy',
test_terminal_input, test_terminal_input,
) )
test_tui_workflows = executable(
'test_tui_workflows',
'tests/test_tui_workflows.c',
'src/theme.c',
'src/sync_screen_action.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('tui_workflows', test_tui_workflows)

View file

@ -290,6 +290,37 @@ static const char *const MIGRATE_V8_TO_V9_SQL =
"(SELECT session_exercise_row_id FROM max_results);" "(SELECT session_exercise_row_id FROM max_results);"
"PRAGMA user_version = 9;COMMIT;"; "PRAGMA user_version = 9;COMMIT;";
/*
* WHY: actual load absence and an explicit zero are distinct observations.
* CONTRACT: v10 changes only performed_sets.weight_kg from strictly positive
* to nonnegative. The explicit projection preserves every row ID, owning
* occurrence, position, metric value and NULL/positive load byte-for-byte.
*/
static const char *const MIGRATE_V9_TO_V10_SQL =
"PRAGMA foreign_keys = OFF;"
"BEGIN IMMEDIATE;"
"ALTER TABLE performed_sets RENAME TO performed_sets_v9;"
"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 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("
"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_v9;"
"DROP TABLE performed_sets_v9;"
"PRAGMA user_version = 10;"
"COMMIT;"
"PRAGMA foreign_keys = ON;";
static const char *const MIGRATE_V1_TO_V3_SQL = static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"ALTER TABLE sessions " "ALTER TABLE sessions "
@ -697,6 +728,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL); status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
} }
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
}
} else if (version == 7) { } else if (version == 7) {
/* CONTRACT: v7 is the immediate historic schema and must open through /* CONTRACT: v7 is the immediate historic schema and must open through
* its lossless custom-equipment-table migration. */ * its lossless custom-equipment-table migration. */
@ -704,8 +738,16 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL); status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
} }
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
}
} else if (version == 8) { } else if (version == 8) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL); status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
}
} else if (version == 9) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
} else { } else {
if (version == 1) { if (version == 1) {
status = status =
@ -833,6 +875,9 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL); status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
} }
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
}
} }
if ( if (
@ -842,7 +887,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic( set_open_diagnostic(
output_diagnostic, output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v9" : "migrate database to schema v9", version == 0 ? "create schema v10" : "migrate database to schema v10",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );
@ -853,6 +898,16 @@ static TrainlogStatus initialize_or_validate_schema(
NULL, NULL,
NULL NULL
); );
/* MIGRATE_V9_TO_V10_SQL disables foreign keys outside its transaction;
* a failed statement must not leave the live handle with enforcement
* disabled after the rollback. */
(void)sqlite3_exec(
database->connection,
"PRAGMA foreign_keys = ON;",
NULL,
NULL,
NULL
);
} }
return status; return status;
@ -2239,6 +2294,15 @@ static TrainlogStatus insert_performed_set(
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
int rc; int rc;
if (database == NULL || database->connection == NULL || input == NULL ||
position > (size_t)INT64_MAX ||
(input->has_weight &&
(!isfinite(input->weight_kg) || input->weight_kg < 0.0))) {
/* CONTRACT: blank is represented by has_weight=false/SQL NULL;
* explicit zero is a finite, observed load and must survive exactly. */
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
rc = sqlite3_prepare_v2( rc = sqlite3_prepare_v2(
database->connection, database->connection,
"INSERT INTO performed_sets(" "INSERT INTO performed_sets("
@ -2426,6 +2490,40 @@ static TrainlogStatus insert_session_children(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
static TrainlogStatus validate_actual_set_weights(
const TrainlogSessionExerciseInput *exercises,
size_t exercise_count
)
{
size_t exercise_index;
if ((exercise_count > 0U && exercises == NULL) ||
exercise_count > (size_t)INT64_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (exercise_index = 0U; exercise_index < exercise_count;
++exercise_index) {
const TrainlogSessionExerciseInput *exercise =
&exercises[exercise_index];
size_t set_index;
if (exercise->set_count > (size_t)INT64_MAX ||
(exercise->set_count > 0U && exercise->sets == NULL)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (set_index = 0U; set_index < exercise->set_count; ++set_index) {
const TrainlogSetInput *set = &exercise->sets[set_index];
if (set->has_weight &&
(!isfinite(set->weight_kg) || set->weight_kg < 0.0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
}
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_database_insert_session( TrainlogStatus trainlog_database_insert_session(
TrainlogDatabase *database, TrainlogDatabase *database,
const TrainlogSessionInput *session const TrainlogSessionInput *session
@ -2444,6 +2542,14 @@ TrainlogStatus trainlog_database_insert_session(
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
status = validate_actual_set_weights(
session->exercises,
session->exercise_count
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_database_begin(database); status = trainlog_database_begin(database);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
@ -2500,6 +2606,11 @@ TrainlogStatus trainlog_database_replace_session_exercises(
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
status = validate_actual_set_weights(exercises, exercise_count);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_database_begin(database); status = trainlog_database_begin(database);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
return status; return status;
@ -3022,10 +3133,47 @@ static TrainlogStatus detail_fill_sets(
"ORDER BY position ASC;"; "ORDER BY position ASC;";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
sqlite3_stmt *count_statement = NULL;
size_t used = 0U; size_t used = 0U;
size_t count = 0U; size_t count = 0U;
int rc; int rc;
rc = sqlite3_prepare_v2(database->connection,
"SELECT COUNT(*) FROM performed_sets "
"WHERE session_exercise_row_id = ?1;", -1, &count_statement, NULL);
if (rc != SQLITE_OK ||
sqlite3_bind_int64(count_statement, 1, session_exercise_row_id) !=
SQLITE_OK ||
sqlite3_step(count_statement) != SQLITE_ROW) {
(void)sqlite3_finalize(count_statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
{
sqlite3_int64 persisted_count = sqlite3_column_int64(count_statement, 0);
if (persisted_count < 0 ||
(uint64_t)persisted_count >
(uint64_t)(SIZE_MAX / sizeof(*detail->actual_sets))) {
(void)sqlite3_finalize(count_statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
count = (size_t)persisted_count;
}
if (sqlite3_finalize(count_statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
count_statement = NULL;
/* WHY: performed_sets has no schema/domain maximum. Exact occurrence-owned
* storage bounds memory to the selected session without limiting history. */
if (count > 0U) {
detail->actual_sets = calloc(count, sizeof(*detail->actual_sets));
if (detail->actual_sets == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
detail->actual_set_count = count;
count = 0U;
rc = sqlite3_prepare_v2( rc = sqlite3_prepare_v2(
database->connection, database->connection,
SQL, SQL,
@ -3034,6 +3182,9 @@ static TrainlogStatus detail_fill_sets(
NULL NULL
); );
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3044,6 +3195,9 @@ static TrainlogStatus detail_fill_sets(
); );
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3059,6 +3213,33 @@ static TrainlogStatus detail_fill_sets(
int has_weight = int has_weight =
sqlite3_column_type(statement, 2) != SQLITE_NULL; sqlite3_column_type(statement, 2) != SQLITE_NULL;
/* INVARIANT: COUNT and SELECT observe the same connection operation;
* any mismatch is corruption/concurrent mutation, never truncation. */
if (count >= detail->actual_set_count) {
(void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR;
}
/* INVARIANT: array index is persisted position order. Nullable load
* stays explicit instead of inheriting occurrence target metadata. */
detail->actual_sets[count].reps =
has_reps != 0
? sqlite3_column_int(statement, 0)
: 0;
detail->actual_sets[count].duration_seconds =
has_duration != 0
? sqlite3_column_int(statement, 1)
: 0;
detail->actual_sets[count].has_weight =
has_weight != 0;
detail->actual_sets[count].weight_kg =
has_weight != 0
? sqlite3_column_double(statement, 2)
: 0.0;
if (count > 0U) { if (count > 0U) {
TrainlogStatus status = detail_append_text( TrainlogStatus status = detail_append_text(
detail->actual_summary, detail->actual_summary,
@ -3069,6 +3250,9 @@ static TrainlogStatus detail_fill_sets(
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return status; return status;
} }
} }
@ -3103,6 +3287,9 @@ static TrainlogStatus detail_fill_sets(
sizeof(duration_text) sizeof(duration_text)
) != TRAINLOG_STATUS_OK) { ) != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3124,12 +3311,18 @@ static TrainlogStatus detail_fill_sets(
} }
} else { } else {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (written < 0 || if (written < 0 ||
(size_t)written >= sizeof(fragment)) { (size_t)written >= sizeof(fragment)) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3143,6 +3336,9 @@ static TrainlogStatus detail_fill_sets(
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return status; return status;
} }
} }
@ -3152,14 +3348,25 @@ static TrainlogStatus detail_fill_sets(
if (rc != SQLITE_DONE) { if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (sqlite3_finalize(statement) != SQLITE_OK) { if (sqlite3_finalize(statement) != SQLITE_OK) {
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
detail->actual_set_count = count; if (count != detail->actual_set_count) {
free(detail->actual_sets);
detail->actual_sets = NULL;
detail->actual_set_count = 0U;
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (count == 0U) { if (count == 0U) {
(void)snprintf( (void)snprintf(
@ -3173,6 +3380,23 @@ static TrainlogStatus detail_fill_sets(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
void trainlog_database_free_session_details(
TrainlogPersistedExerciseDetail *exercises,
size_t exercise_count
)
{
size_t index;
if (exercises == NULL) {
return;
}
for (index = 0U; index < exercise_count; ++index) {
free(exercises[index].actual_sets);
exercises[index].actual_sets = NULL;
exercises[index].actual_set_count = 0U;
}
}
TrainlogStatus trainlog_database_get_session_details( TrainlogStatus trainlog_database_get_session_details(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *session_id, const char *session_id,
@ -3219,6 +3443,10 @@ TrainlogStatus trainlog_database_get_session_details(
size_t total = 0U; size_t total = 0U;
int rc; int rc;
if (output_exercise_count != NULL) {
*output_exercise_count = 0U;
}
if (database == NULL || if (database == NULL ||
database->connection == NULL || database->connection == NULL ||
session_id == NULL || session_id == NULL ||
@ -3236,8 +3464,6 @@ TrainlogStatus trainlog_database_get_session_details(
sizeof(*output_session) sizeof(*output_session)
); );
*output_exercise_count = 0U;
rc = sqlite3_prepare_v2( rc = sqlite3_prepare_v2(
database->connection, database->connection,
HEADER_SQL, HEADER_SQL,
@ -3389,6 +3615,7 @@ TrainlogStatus trainlog_database_get_session_details(
(((TrainlogExerciseDataFields)data_fields) & (((TrainlogExerciseDataFields)data_fields) &
~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
(void)sqlite3_finalize(exercises); (void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3404,6 +3631,8 @@ TrainlogStatus trainlog_database_get_session_details(
(const char *)equipment_id); (const char *)equipment_id);
} }
if (entry_id == NULL) { if (entry_id == NULL) {
(void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
(void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s", (void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s",
@ -3506,6 +3735,7 @@ TrainlogStatus trainlog_database_get_session_details(
10 10
) == SQLITE_NULL) { ) == SQLITE_NULL) {
(void)sqlite3_finalize(exercises); (void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3548,6 +3778,7 @@ TrainlogStatus trainlog_database_get_session_details(
(detail->has_continuous_distance != 0) != (detail->has_continuous_distance != 0) !=
distance_required) { distance_required) {
(void)sqlite3_finalize(exercises); (void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
@ -3568,6 +3799,7 @@ TrainlogStatus trainlog_database_get_session_details(
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(exercises); (void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return status; return status;
} }
} }
@ -3580,10 +3812,12 @@ TrainlogStatus trainlog_database_get_session_details(
if (rc != SQLITE_DONE) { if (rc != SQLITE_DONE) {
(void)sqlite3_finalize(exercises); (void)sqlite3_finalize(exercises);
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (sqlite3_finalize(exercises) != SQLITE_OK) { if (sqlite3_finalize(exercises) != SQLITE_OK) {
trainlog_database_free_session_details(output_exercises, copied);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }

View file

@ -97,6 +97,39 @@ typedef struct SyncSilence {
int null_fd; int null_fd;
} SyncSilence; } SyncSilence;
static void sync_compose_diagnostic(
char *output,
size_t output_size,
const char *prefix,
const char *detail
)
{
size_t written = 0U;
if (output == NULL || output_size == 0U) {
return;
}
/* CONTRACT: diagnostics retain their specific context and as much of the
* underlying failure as fits. INVARIANT: output is always NUL-terminated,
* even when an external tool supplies the maximum-length error. */
while (prefix != NULL && prefix[written] != '\0' &&
written + 1U < output_size) {
output[written] = prefix[written];
written += 1U;
}
if (detail != NULL) {
size_t detail_index = 0U;
while (detail[detail_index] != '\0' &&
written + 1U < output_size) {
output[written] = detail[detail_index];
written += 1U;
detail_index += 1U;
}
}
output[written] = '\0';
}
TrainlogSyncDirectionPlan trainlog_sync_direction_plan( TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
TrainlogSyncDirection direction TrainlogSyncDirection direction
) )
@ -2451,10 +2484,10 @@ void trainlog_sync_build_summary(
report->catalog_published); report->catalog_published);
} }
} else { } else {
(void)snprintf( sync_compose_diagnostic(
report->summary, report->summary,
sizeof(report->summary), sizeof(report->summary),
"%s", "",
report->error[0] != '\0' report->error[0] != '\0'
? report->error ? report->error
: "Synchronisation échouée." : "Synchronisation échouée."
@ -2811,9 +2844,12 @@ TrainlogStatus trainlog_sync_run(
strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) { strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_last_nonempty_line(tool_output, useful, sizeof(useful));
(void)snprintf(output->error, sizeof(output->error), sync_compose_diagnostic(
"Android→PC : définitions équipement : %s", output->error,
useful[0] != '\0' ? useful : "import échoué"); sizeof(output->error),
"Android→PC : définitions équipement : ",
useful[0] != '\0' ? useful : "import échoué"
);
final_status = TRAINLOG_STATUS_DATABASE_ERROR; final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize; goto finalize;
} }
@ -2886,10 +2922,10 @@ TrainlogStatus trainlog_sync_run(
sizeof(useful) sizeof(useful)
); );
(void)snprintf( sync_compose_diagnostic(
output->error, output->error,
sizeof(output->error), sizeof(output->error),
"Android→PC : %s", "Android→PC : ",
useful[0] != '\0' useful[0] != '\0'
? useful ? useful
: "import mobile échoué" : "import mobile échoué"
@ -2932,11 +2968,14 @@ TrainlogStatus trainlog_sync_run(
if (status != TRAINLOG_STATUS_OK || if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) { strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
sync_last_nonempty_line(tool_output, useful, sizeof(useful)); sync_last_nonempty_line(tool_output, useful, sizeof(useful));
(void)snprintf(output->error, sizeof(output->error), sync_compose_diagnostic(
"Android→PC : import équipement : %s", output->error,
useful[0] != '\0' sizeof(output->error),
? useful "Android→PC : import équipement : ",
: "échec sans diagnostic du script"); useful[0] != '\0'
? useful
: "échec sans diagnostic du script"
);
final_status = TRAINLOG_STATUS_DATABASE_ERROR; final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize; goto finalize;
} }
@ -3004,10 +3043,10 @@ outbound:
sizeof(useful) sizeof(useful)
); );
(void)snprintf( sync_compose_diagnostic(
output->error, output->error,
sizeof(output->error), sizeof(output->error),
"PC→Android : %s", "PC→Android : ",
useful[0] != '\0' useful[0] != '\0'
? useful ? useful
: "export catalogue échoué" : "export catalogue échoué"

View file

@ -9,6 +9,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <locale.h> #include <locale.h>
#include <limits.h> #include <limits.h>
#include <math.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@ -540,7 +541,25 @@ static bool parse_double_positive(const char *text, double *output)
} }
value = strtod(text, &end); value = strtod(text, &end);
if (end == text || *end != '\0' || value <= 0.0) { if (end == text || *end != '\0' || !isfinite(value) || value <= 0.0) {
return false;
}
*output = value;
return true;
}
static bool parse_double_nonnegative(const char *text, double *output)
{
char *end = NULL;
double value;
if (text == NULL || output == NULL || text[0] == '\0') {
return false;
}
value = strtod(text, &end);
if (end == text || *end != '\0' || !isfinite(value) || value < 0.0) {
return false; return false;
} }
@ -618,6 +637,36 @@ static bool prompt_optional_double(
} }
} }
static bool prompt_optional_nonnegative_double(
int row,
const char *label,
bool *present,
double *output
)
{
char buffer[64];
for (;;) {
if (!prompt_text(row, label, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
*present = false;
*output = 0.0;
return true;
}
if (parse_double_nonnegative(buffer, output)) {
*present = true;
return true;
}
status_line("Nombre non négatif invalide.", TRAINLOG_COLOR_ERROR);
trainlog_terminal_render(tui_terminal);
}
}
/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */ /* TRAINLOG_EXERCISE_PERFORMANCE_TUI */
#define EXERCISE_GRAPH_POINTS 12U #define EXERCISE_GRAPH_POINTS 12U
@ -5181,13 +5230,8 @@ 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 = 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;
if (!choose_exercise( if (!choose_exercise(
database, database,
@ -5357,9 +5401,14 @@ static bool build_session_exercise(
} }
if (load_mode != 1) { if (load_mode != 1) {
const char *target_load_prompt =
load_mode == 3
? "Assistance cible kg : "
: "Charge cible kg : ";
if (!prompt_optional_double( if (!prompt_optional_double(
5, 5,
"Charge cible kg : ", target_load_prompt,
&target_has_weight, &target_has_weight,
&target_weight &target_weight
) || ) ||
@ -5415,53 +5464,6 @@ 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
);
trainlog_terminal_render(tui_terminal);
}
} else {
if (!prompt_int_value(
9,
"Séries réellement faites",
0,
(int)set_capacity,
target_sets,
&actual_sets
)) {
return false;
}
}
output->load_mode = output->load_mode =
load_mode == 1 load_mode == 1
? TRAINLOG_LOAD_NONE ? TRAINLOG_LOAD_NONE
@ -5493,118 +5495,7 @@ static bool build_session_exercise(
target_weight; target_weight;
output->sets = set_storage; output->sets = set_storage;
output->set_count = output->set_count = 0U;
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
? rep_count
: (size_t)actual_sets;
for (set_index = 0U;
set_index < output->set_count;
++set_index) {
int actual_metric =
target_metric;
(void)memset(
&set_storage[set_index],
0,
sizeof(set_storage[set_index])
);
if (
exercise.tracking_mode ==
TRAINLOG_TRACKING_DURATION ||
target_has_weight
) {
draw_shell(
exercise.name,
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
);
trainlog_terminal_printf(tui_terminal,
3,
4,
"Série %zu / %zu",
set_index + 1U,
output->set_count
);
}
if (
exercise.tracking_mode ==
TRAINLOG_TRACKING_REPS
) {
actual_metric =
rep_values[set_index];
set_storage[set_index].reps =
actual_metric;
} else {
if (!prompt_duration_value(
5,
"Durée réalisée",
1,
86400,
target_metric,
&actual_metric
)) {
return false;
}
set_storage[
set_index
].duration_seconds =
actual_metric;
}
if (target_has_weight) {
char buffer[64];
char prompt[128];
double actual_weight =
target_weight;
(void)snprintf(
prompt,
sizeof(prompt),
"Charge kg [%.1f] : ",
target_weight
);
if (!prompt_text(
6,
prompt,
buffer,
sizeof(buffer),
true
)) {
return false;
}
if (buffer[0] != '\0' &&
!parse_double_positive(
buffer,
&actual_weight
)) {
status_line(
"Charge invalide.",
TRAINLOG_COLOR_ERROR
);
wait_key();
return false;
}
set_storage[
set_index
].has_weight =
true;
set_storage[
set_index
].weight_kg =
actual_weight;
}
}
return true; return true;
} }
@ -6165,6 +6056,292 @@ static void draft_bind_input(
: NULL; : NULL;
} }
static void draft_format_set_metric(
const TrainlogSessionDraftExercise *draft,
const TrainlogSetInput *set,
char *output,
size_t output_size
)
{
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
if (trainlog_duration_format(
set->duration_seconds,
output,
output_size
) != TRAINLOG_STATUS_OK) {
(void)snprintf(output, output_size, "%d s",
set->duration_seconds);
}
} else {
(void)snprintf(output, output_size, "%d", set->reps);
}
}
static bool draft_edit_set_cell(
TrainlogSessionDraftExercise *draft,
size_t row,
size_t field,
bool require_metric_input
)
{
TrainlogSetInput candidate;
if (draft == NULL || row >= draft->input.set_count) {
return false;
}
candidate = draft->sets[row];
if (field == 0U) {
int value = draft->tracking_mode == TRAINLOG_TRACKING_DURATION
? candidate.duration_seconds
: candidate.reps;
bool accepted;
if (require_metric_input) {
char buffer[64];
const char *label =
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
? "Durée réalisée (requise) : "
: "Répétitions réalisées (requises) : ";
for (;;) {
if (!prompt_text(6, label, buffer, sizeof(buffer), false)) {
accepted = false;
break;
}
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
accepted =
trainlog_duration_parse(buffer, &value) ==
TRAINLOG_STATUS_OK &&
value >= 1 && value <= 86400;
} else {
accepted = parse_int(buffer, 0, 10000, &value);
}
if (accepted) {
break;
}
status_line("Valeur réalisée invalide.",
TRAINLOG_COLOR_ERROR);
trainlog_terminal_render(tui_terminal);
}
} else {
accepted = draft->tracking_mode == TRAINLOG_TRACKING_DURATION
? prompt_duration_value(6, "Durée réalisée", 1, 86400,
value > 0 ? value : 1, &value)
: prompt_int_value(6, "Répétitions réalisées", 0, 10000,
value >= 0 ? value : 0, &value);
}
if (!accepted) {
/* CONTRACT: Escape cancels the active cell; the candidate was
* edited off to the side, so the draft remains byte-for-byte. */
return false;
}
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
candidate.duration_seconds = value;
} else {
candidate.reps = value;
}
} else {
bool has_weight = false;
double weight = 0.0;
if (!prompt_optional_nonnegative_double(
6,
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
? "Assistance kg (vide = aucune) : "
: "Charge kg (vide = aucune) : ",
&has_weight,
&weight
)) {
return false;
}
candidate.has_weight = has_weight;
candidate.weight_kg = has_weight ? weight : 0.0;
}
draft->sets[row] = candidate;
return true;
}
static bool draft_append_incomplete_set(
TrainlogSessionDraftExercise *draft
)
{
TrainlogSetInput *added;
size_t appended_index;
if (draft == NULL ||
draft->input.set_count >= MAX_SETS_PER_EXERCISE) {
return false;
}
appended_index = draft->input.set_count;
added = &draft->sets[appended_index];
(void)memset(added, 0, sizeof(*added));
++draft->input.set_count;
/* INVARIANT: targets are planning metadata. A new actual row exists only
* if its metric prompt is accepted; cancellation rolls the append back. */
if (!draft_edit_set_cell(draft, appended_index, 0U, true)) {
--draft->input.set_count;
(void)memset(added, 0, sizeof(*added));
return false;
}
return true;
}
static void draft_edit_sets(
TrainlogSessionDraftExercise *draft
)
{
size_t selected = 0U;
size_t field = 0U;
if (draft == NULL ||
draft->input.recording_mode != TRAINLOG_RECORDING_SETS ||
draft->input.has_max_weight) {
return;
}
for (;;) {
size_t index;
size_t top = 0U;
size_t visible;
int first_row = 7;
int rows = trainlog_terminal_rows(tui_terminal);
int key;
bool has_load = draft->input.load_mode != TRAINLOG_LOAD_NONE;
const char *metric_label =
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
? "Durée"
: "Reps";
const char *load_label =
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
? "Assistance"
: "Charge";
visible = rows > first_row + 3
? (size_t)(rows - first_row - 3)
: 1U;
if (draft->input.set_count > 0U &&
selected >= draft->input.set_count) {
selected = draft->input.set_count - 1U;
}
if (selected >= visible) {
top = selected - visible + 1U;
}
draw_shell(
draft->name,
"↑↓ série ←→/Tab champ Entrée modifier a ajouter d supprimer f/b terminer"
);
trainlog_terminal_printf(tui_terminal, 3, 4,
"Valeurs réellement effectuées — cible indicative, jamais recopiée");
trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD);
trainlog_terminal_printf(tui_terminal, 5, 4,
has_load ? "%-8s %-20s %-20s" : "%-8s %-20s",
"Série", metric_label, has_load ? load_label : "");
trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD);
if (draft->input.set_count == 0U) {
trainlog_terminal_printf(tui_terminal, first_row, 4,
"Aucune série. a = ajouter une série.");
}
for (index = top;
index < draft->input.set_count && index - top < visible;
++index) {
char metric[64];
char weight[64];
int row = first_row + (int)(index - top);
draft_format_set_metric(draft, &draft->sets[index], metric,
sizeof(metric));
if (draft->sets[index].has_weight) {
(void)snprintf(weight, sizeof(weight), "%.2f kg",
draft->sets[index].weight_kg);
} else {
(void)snprintf(weight, sizeof(weight), "");
}
if (index == selected) {
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_REVERSE |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
}
trainlog_terminal_printf(tui_terminal, row, 4,
has_load ? "%c %-6zu %-20.20s %-20.20s"
: "%c %-6zu %-20.20s",
index == selected ? (field == 0U ? '>' : '*') : ' ',
index + 1U, metric, has_load ? weight : "");
if (index == selected) {
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_REVERSE |
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
}
}
trainlog_terminal_render(tui_terminal);
key = trainlog_terminal_get_key(tui_terminal);
if (key == 'f' || key == 'F' || key == 'b' || key == 'B' ||
key == 27 || key == TRAINLOG_KEY_ESCAPE) {
return;
}
if (key == TRAINLOG_KEY_UP && draft->input.set_count > 0U) {
selected = selected > 0U ? selected - 1U
: draft->input.set_count - 1U;
continue;
}
if (key == TRAINLOG_KEY_DOWN && draft->input.set_count > 0U) {
selected = selected + 1U < draft->input.set_count
? selected + 1U : 0U;
continue;
}
if (has_load && (key == TRAINLOG_KEY_LEFT ||
key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB ||
key == TRAINLOG_KEY_SHIFT_TAB)) {
field = field == 0U ? 1U : 0U;
continue;
}
if ((key == '\n' || key == TRAINLOG_KEY_ENTER) &&
draft->input.set_count > 0U) {
(void)draft_edit_set_cell(draft, selected, field, false);
continue;
}
if ((key == 'a' || key == 'A') &&
draft->input.set_count < MAX_SETS_PER_EXERCISE) {
selected = draft->input.set_count;
field = 0U;
(void)draft_append_incomplete_set(draft);
if (draft->input.set_count == 0U ||
selected >= draft->input.set_count) {
selected = draft->input.set_count > 0U
? draft->input.set_count - 1U : 0U;
}
continue;
}
if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
draft->input.set_count > 0U) {
for (index = selected; index + 1U < draft->input.set_count;
++index) {
draft->sets[index] = draft->sets[index + 1U];
}
--draft->input.set_count;
if (draft->input.set_count > 0U &&
selected >= draft->input.set_count) {
selected = draft->input.set_count - 1U;
}
}
}
}
static void draft_delete_exercise( static void draft_delete_exercise(
TrainlogSessionDraftExercise *drafts, TrainlogSessionDraftExercise *drafts,
size_t *count, size_t *count,
@ -6207,6 +6384,9 @@ static void draft_set_summary(
size_t output_size size_t output_size
) )
{ {
size_t index;
size_t used = 0U;
if (draft == NULL || if (draft == NULL ||
output == NULL || output == NULL ||
output_size == 0U) { output_size == 0U) {
@ -6227,33 +6407,56 @@ static void draft_set_summary(
"Max %s kg", "Max %s kg",
weight weight
); );
} else if (draft->input.load_mode == } else if (draft->input.recording_mode ==
TRAINLOG_LOAD_EXTERNAL) { TRAINLOG_RECORDING_CONTINUOUS) {
(void)snprintf( char duration[64];
output, (void)trainlog_duration_format(
output_size, draft->input.continuous_duration_seconds,
"%zu séries · %.1f kg", duration,
draft->input.set_count, sizeof(duration)
draft->input.target_weight_kg
);
} else if (
draft->input.load_mode ==
TRAINLOG_LOAD_ASSISTANCE
) {
(void)snprintf(
output,
output_size,
"%zu séries · %.1f kg aide",
draft->input.set_count,
draft->input.target_weight_kg
); );
(void)snprintf(output, output_size, "Continu · %s", duration);
} else { } else {
(void)snprintf( int written = snprintf(output, output_size, "%zu séries · ",
output, draft->input.set_count);
output_size, if (written < 0) {
"%zu séries", output[0] = '\0';
draft->input.set_count return;
); }
used = (size_t)written < output_size
? (size_t)written : output_size - 1U;
/* CONTRACT: the in-progress summary reflects each actual row. It
* must not collapse heterogeneous loads back to the target weight. */
for (index = 0U; index < draft->input.set_count && used + 1U < output_size;
++index) {
char metric[64];
char fragment[96];
draft_format_set_metric(draft, &draft->sets[index], metric,
sizeof(metric));
if (draft->input.load_mode == TRAINLOG_LOAD_NONE) {
written = snprintf(fragment, sizeof(fragment), "%s%s",
index > 0U ? " / " : "", metric);
} else {
written = snprintf(fragment, sizeof(fragment), "%s%s×%s%s",
index > 0U ? " / " : "", metric,
draft->sets[index].has_weight ? "" : "",
draft->sets[index].has_weight ? "kg" : "");
if (draft->sets[index].has_weight) {
written = snprintf(fragment, sizeof(fragment),
"%s%s×%.2fkg%s",
index > 0U ? " / " : "", metric,
draft->sets[index].weight_kg,
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
? " aide" : "");
}
}
if (written < 0) {
break;
}
(void)snprintf(output + used, output_size - used, "%s", fragment);
used = strlen(output);
}
} }
} }
@ -6376,7 +6579,7 @@ static bool edit_session_draft(
2, 2,
"%.*s", "%.*s",
trainlog_terminal_columns(tui_terminal) - 4, trainlog_terminal_columns(tui_terminal) - 4,
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner" "↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner"
); );
trainlog_terminal_style_off(tui_terminal, trainlog_terminal_style_off(tui_terminal,
@ -6387,7 +6590,7 @@ static bool edit_session_draft(
} else { } else {
draw_shell( draw_shell(
"TRAINLOG — Séance en cours", "TRAINLOG — Séance en cours",
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner" "↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner"
); );
} }
@ -6480,6 +6683,8 @@ static bool edit_session_draft(
if (key == 'f' || if (key == 'f' ||
key == 'F') { key == 'F') {
size_t draft_index;
if (*count == 0U) { if (*count == 0U) {
status_line( status_line(
"Ajoutez au moins un exercice avant d'enregistrer.", "Ajoutez au moins un exercice avant d'enregistrer.",
@ -6491,6 +6696,28 @@ static bool edit_session_draft(
continue; continue;
} }
for (draft_index = 0U; draft_index < *count; ++draft_index) {
if (drafts[draft_index].input.recording_mode ==
TRAINLOG_RECORDING_SETS &&
!drafts[draft_index].input.has_max_weight &&
drafts[draft_index].input.set_count == 0U) {
char message[192];
selected = draft_index;
(void)snprintf(message, sizeof(message),
"Ajoutez au moins une série réalisée pour %s.",
drafts[draft_index].name);
status_line(message, TRAINLOG_COLOR_WARNING);
trainlog_terminal_render(tui_terminal);
(void)trainlog_terminal_get_key(tui_terminal);
break;
}
}
if (draft_index < *count) {
continue;
}
return true; return true;
} }
@ -6534,6 +6761,7 @@ static bool edit_session_draft(
)) { )) {
selected = *count; selected = *count;
++(*count); ++(*count);
draft_edit_sets(&drafts[selected]);
} }
continue; continue;
@ -6544,6 +6772,11 @@ static bool edit_session_draft(
key == '\n' || key == '\n' ||
key == TRAINLOG_KEY_ENTER) && key == TRAINLOG_KEY_ENTER) &&
*count > 0U) { *count > 0U) {
draft_edit_sets(&drafts[selected]);
continue;
}
if ((key == 'r' || key == 'R') && *count > 0U) {
TrainlogSessionDraftExercise replacement; TrainlogSessionDraftExercise replacement;
if (draft_build_exercise( if (draft_build_exercise(
@ -6932,6 +7165,7 @@ static void screen_new_session(
NULL NULL
)) { )) {
exercise_count = 1U; exercise_count = 1U;
draft_edit_sets(&drafts[0]);
} }
if (!edit_session_draft( if (!edit_session_draft(
@ -7207,6 +7441,7 @@ static void screen_session_detail(
size_t count = 0U; size_t count = 0U;
size_t selected = 0U; size_t selected = 0U;
size_t set_top = 0U;
TrainlogStatus status; TrainlogStatus status;
status = status =
@ -7277,7 +7512,7 @@ static void screen_session_detail(
2, 2,
"%.*s", "%.*s",
trainlog_terminal_columns(tui_terminal) - 4, trainlog_terminal_columns(tui_terminal) - 4,
"←→/↑↓ exercice précédent/suivant i fiche équipement e modifier b/Échap retour" "←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour"
); );
trainlog_terminal_style_off(tui_terminal, trainlog_terminal_style_off(tui_terminal,
@ -7317,7 +7552,7 @@ static void screen_session_detail(
} else { } else {
draw_shell( draw_shell(
"TRAINLOG — Détail séance", "TRAINLOG — Détail séance",
"←→/↑↓ naviguer i fiche équipement e modifier b/Échap retour" "←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour"
); );
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
@ -7531,11 +7766,10 @@ static void screen_session_detail(
int actual_row = int actual_row =
decorated ? 23 : 13; decorated ? 23 : 13;
int summary_row = int table_header_row =
decorated ? 25 : 15; decorated ? 25 : 15;
int table_last_row =
int warning_row = trainlog_terminal_rows(tui_terminal) - 4;
decorated ? 27 : 17;
if (trainlog_duration_format( if (trainlog_duration_format(
exercise->rest_seconds, exercise->rest_seconds,
@ -7631,14 +7865,18 @@ static void screen_session_detail(
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
weight_row, weight_row,
decorated ? 5 : 4, decorated ? 5 : 4,
"Charge cible : %.1f kg", "%s cible : %.1f kg",
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
? "Assistance" : "Charge",
exercise->target_weight_kg exercise->target_weight_kg
); );
} else { } else {
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
weight_row, weight_row,
decorated ? 5 : 4, decorated ? 5 : 4,
"Charge cible : —" "%s cible : —",
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
? "Assistance" : "Charge"
); );
} }
@ -7663,34 +7901,72 @@ static void screen_session_detail(
) )
); );
trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD);
trainlog_terminal_printf(tui_terminal, trainlog_terminal_printf(tui_terminal,
summary_row, table_header_row,
decorated ? 5 : 4, decorated ? 5 : 4,
"%.*s", "%-8s %-18s %-18s",
trainlog_terminal_columns(tui_terminal) - 10, "Série",
exercise->actual_summary exercise->tracking_mode == TRAINLOG_TRACKING_REPS
? "Reps" : "Durée",
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
? "Assistance" : "Charge"
); );
trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD);
if (exercise->load_mode == {
TRAINLOG_LOAD_ASSISTANCE) { size_t set_index;
trainlog_terminal_style_on(tui_terminal, size_t visible_sets = table_last_row > table_header_row
trainlog_theme_style( ? (size_t)(table_last_row - table_header_row)
TRAINLOG_COLOR_WARNING : 0U;
)
);
trainlog_terminal_printf(tui_terminal, if (exercise->actual_set_count == 0U) {
warning_row, set_top = 0U;
decorated ? 5 : 4, } else if (set_top >= exercise->actual_set_count) {
"Assistance : plus de kg = davantage d'aide." set_top = exercise->actual_set_count - 1U;
); }
for (set_index = set_top;
set_index < exercise->actual_set_count &&
set_index - set_top < visible_sets;
++set_index) {
char metric[64];
char weight[64];
const TrainlogSetInput *set =
&exercise->actual_sets[set_index];
if (exercise->tracking_mode ==
TRAINLOG_TRACKING_DURATION) {
if (trainlog_duration_format(
set->duration_seconds,
metric,
sizeof(metric)
) != TRAINLOG_STATUS_OK) {
(void)snprintf(metric, sizeof(metric), "%d s",
set->duration_seconds);
}
} else {
(void)snprintf(metric, sizeof(metric), "%d",
set->reps);
}
if (set->has_weight) {
(void)snprintf(weight, sizeof(weight), "%.2f kg",
set->weight_kg);
} else {
(void)snprintf(weight, sizeof(weight), "");
}
trainlog_terminal_printf(tui_terminal,
table_header_row + 1 +
(int)(set_index - set_top),
decorated ? 5 : 4,
"%-8zu %-18.18s %-18.18s",
set_index + 1U, metric, weight);
}
trainlog_terminal_style_off(tui_terminal,
trainlog_theme_style(
TRAINLOG_COLOR_WARNING
)
);
} }
} }
} }
@ -7700,6 +7976,7 @@ static void screen_session_detail(
if (key == 'b' || if (key == 'b' ||
key == 'B' || key == 'B' ||
key == 27) { key == 27) {
trainlog_database_free_session_details(exercises, count);
return; return;
} }
@ -7710,6 +7987,7 @@ static void screen_session_detail(
session_id session_id
); );
trainlog_database_free_session_details(exercises, count);
return; return;
} }
@ -7723,6 +8001,17 @@ static void screen_session_detail(
continue; continue;
} }
if (count > 0U && key == TRAINLOG_KEY_PAGE_UP) {
set_top = set_top > 0U ? set_top - 1U : 0U;
continue;
}
if (count > 0U && key == TRAINLOG_KEY_PAGE_DOWN &&
set_top + 1U < exercises[selected].actual_set_count) {
++set_top;
continue;
}
if (count > 0U && if (count > 0U &&
(key == TRAINLOG_KEY_RIGHT || (key == TRAINLOG_KEY_RIGHT ||
key == TRAINLOG_KEY_DOWN)) { key == TRAINLOG_KEY_DOWN)) {
@ -7730,6 +8019,7 @@ static void screen_session_detail(
selected + 1U < count selected + 1U < count
? selected + 1U ? selected + 1U
: 0U; : 0U;
set_top = 0U;
} else if ( } else if (
count > 0U && count > 0U &&
(key == TRAINLOG_KEY_LEFT || (key == TRAINLOG_KEY_LEFT ||
@ -7739,6 +8029,7 @@ static void screen_session_detail(
selected > 0U selected > 0U
? selected - 1U ? selected - 1U
: count - 1U; : count - 1U;
set_top = 0U;
} }
} }
} }

View file

@ -156,6 +156,11 @@ int main(void)
) == 0 ) == 0
); );
trainlog_database_free_session_details(
details,
count
);
trainlog_database_close( trainlog_database_close(
database database
); );

View file

@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void)
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 9); CHECK(version == 10);
(void)memset(&custom, 0, sizeof(custom)); (void)memset(&custom, 0, sizeof(custom));
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
"%s", "eq_123e4567-e89b-42d3-a456-426614174000"); "%s", "eq_123e4567-e89b-42d3-a456-426614174000");
@ -100,6 +100,7 @@ static bool test_custom_equipment_round_trip(void)
CHECK(count == 1U); CHECK(count == 1U);
CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0); CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0);
CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0); CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0);
trainlog_database_free_session_details(detail, count);
CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id, CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id,
used, 2U, &count) == TRAINLOG_STATUS_OK); used, 2U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U); CHECK(count == 1U);

View file

@ -4,6 +4,7 @@
*/ */
#include <stdbool.h> #include <stdbool.h>
#include <math.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
@ -150,7 +151,7 @@ static bool seed_exercise(TrainlogDatabase *database)
static bool test_session_insert(void) static bool test_session_insert(void)
{ {
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
TrainlogSetInput sets[2]; TrainlogSetInput sets[3];
TrainlogSessionExerciseInput exercise; TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session; TrainlogSessionInput session;
TrainlogSessionSummary persisted_session; TrainlogSessionSummary persisted_session;
@ -166,8 +167,12 @@ static bool test_session_insert(void)
(void)memset(sets, 0, sizeof(sets)); (void)memset(sets, 0, sizeof(sets));
sets[0].reps = 5; sets[0].reps = 5;
sets[0].has_weight = true; sets[0].has_weight = true;
sets[0].weight_kg = 80.0; sets[0].weight_kg = 32.5;
sets[1] = sets[0]; sets[1] = sets[0];
sets[1].weight_kg = 0.0;
sets[2] = sets[0];
sets[2].has_weight = false;
sets[2].weight_kg = 0.0;
(void)memset(&exercise, 0, sizeof(exercise)); (void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf( (void)snprintf(
@ -178,12 +183,12 @@ static bool test_session_insert(void)
); );
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
exercise.rest_seconds = 60; exercise.rest_seconds = 60;
exercise.target_sets = 2; exercise.target_sets = 3;
exercise.target_reps = 5; exercise.target_reps = 5;
exercise.target_has_weight = true; exercise.target_has_weight = true;
exercise.target_weight_kg = 80.0; exercise.target_weight_kg = 80.0;
exercise.sets = sets; exercise.sets = sets;
exercise.set_count = 2U; exercise.set_count = 3U;
(void)memset(&session, 0, sizeof(session)); (void)memset(&session, 0, sizeof(session));
(void)snprintf( (void)snprintf(
@ -233,6 +238,28 @@ static bool test_session_insert(void)
CHECK(count == 1U); CHECK(count == 1U);
/* Regression: local occurrences are `sxe`, never synchronization runs. */ /* Regression: local occurrences are `sxe`, never synchronization runs. */
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0); CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
CHECK(persisted_exercises[0].actual_set_count == 3U);
CHECK(persisted_exercises[0].actual_sets[0].has_weight);
CHECK(persisted_exercises[0].actual_sets[0].weight_kg == 32.5);
CHECK(persisted_exercises[0].actual_sets[1].has_weight);
CHECK(persisted_exercises[0].actual_sets[1].weight_kg == 0.0);
CHECK(!persisted_exercises[0].actual_sets[2].has_weight);
trainlog_database_free_session_details(persisted_exercises, count);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_negative_weight");
sets[0].weight_kg = -0.5;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_nonfinite_weight");
sets[0].weight_kg = INFINITY;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK);
CHECK(count == 1U);
trainlog_database_close(database); trainlog_database_close(database);
return true; return true;

View file

@ -101,6 +101,8 @@ static bool test_explicit_max_round_trip_and_identity(void)
CHECK(details[2].has_continuous_speed != 0 && CHECK(details[2].has_continuous_speed != 0 &&
details[2].continuous_speed_kmh == 5.5); details[2].continuous_speed_kmh == 5.5);
trainlog_database_free_session_details(details, count);
CHECK(trainlog_database_load_session_editable(database, "se_explicit_max", CHECK(trainlog_database_load_session_editable(database, "se_explicit_max",
&summary, editable, 3U, &count, NULL, 0U, &set_count) == &summary, editable, 3U, &count, NULL, 0U, &set_count) ==
TRAINLOG_STATUS_OK); TRAINLOG_STATUS_OK);
@ -124,6 +126,8 @@ static bool test_explicit_max_round_trip_and_identity(void)
CHECK(details[0].max_weight_kg == 101.5); CHECK(details[0].max_weight_kg == 101.5);
CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0); CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0);
trainlog_database_free_session_details(details, count);
session.session_type = TRAINLOG_SESSION_TRAINING; session.session_type = TRAINLOG_SESSION_TRAINING;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", (void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_invalid_training_max"); "se_invalid_training_max");
@ -146,9 +150,11 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
"INSERT INTO sessions VALUES(1,'max_test');" "INSERT INTO sessions VALUES(1,'max_test');"
"INSERT INTO session_exercises VALUES(10,1,'sets');" "INSERT INTO session_exercises VALUES(10,1,'sets');"
"INSERT INTO session_exercises VALUES(11,1,'sets');" "INSERT INTO session_exercises VALUES(11,1,'sets');"
"INSERT INTO session_exercises VALUES(12,1,'sets');"
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);" "INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);" "INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);" "INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
"INSERT INTO performed_sets VALUES(23,12,0,1,NULL,0.0);"
"PRAGMA user_version=8;"; "PRAGMA user_version=8;";
char path[] = "/tmp/trainlog-max-v8-XXXXXX"; char path[] = "/tmp/trainlog-max-v8-XXXXXX";
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
@ -172,6 +178,15 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
CHECK(sqlite3_column_double(statement, 1) == 100.0); CHECK(sqlite3_column_double(statement, 1) == 100.0);
CHECK(sqlite3_step(statement) == SQLITE_DONE); CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK); CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw,
"SELECT reps,weight_kg FROM performed_sets "
"WHERE session_exercise_row_id=12;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_column_double(statement, 1) == 0.0);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, CHECK(sqlite3_prepare_v2(raw,
"SELECT position,reps,weight_kg FROM performed_sets " "SELECT position,reps,weight_kg FROM performed_sets "
"WHERE session_exercise_row_id=11 ORDER BY position;", "WHERE session_exercise_row_id=11 ORDER BY position;",

View file

@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void)
TRAINLOG_DATABASE_SCHEMA_VERSION TRAINLOG_DATABASE_SCHEMA_VERSION
); );
CHECK(version == 9); CHECK(version == 10);
CHECK( CHECK(
trainlog_database_get_session_details( trainlog_database_get_session_details(
@ -212,6 +212,7 @@ static bool test_v4_to_current_preserves_session(void)
) == 0 ) == 0
); );
trainlog_database_free_session_details(details, detail_count);
trainlog_database_close( trainlog_database_close(
database database
); );

View file

@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void)
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 9); CHECK(version == 10);
trainlog_database_close(database); trainlog_database_close(database);
CHECK(verify_preserved_values(path)); CHECK(verify_preserved_values(path));
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v8: SQLite rc=", CHECK(strncmp(diagnostic, "migrate database to schema v10: SQLite rc=",
strlen("migrate database to schema v8: SQLite rc=")) == 0); strlen("migrate database to schema v10: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
static bool test_newer_schema_has_application_diagnostic(void) static bool test_newer_schema_has_application_diagnostic(void)
{ {
char path[] = "/tmp/trainlog-schema-v10-XXXXXX"; char path[] = "/tmp/trainlog-schema-v11-XXXXXX";
char diagnostic[256]; char diagnostic[256];
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=10;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=11;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 10 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 11 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

@ -0,0 +1,183 @@
/**
* @file test_schema_v9_migration.c
* @brief Lossless performed-set weight migration regression.
*/
#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 const char *const V9_FIXTURE_SQL =
"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,entry_id TEXT NOT NULL UNIQUE,"
"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,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,"
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
"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 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)));"
"CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY "
"REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL NOT NULL "
"CHECK(max_weight_kg>0.0));"
"INSERT INTO exercises VALUES(7,'ex_fixture','Fixture','fixture','reps','sets',0);"
"INSERT INTO sessions VALUES(11,'se_fixture','2026-09-09T10:00:00+02:00',NULL,'training',NULL);"
"INSERT INTO session_exercises VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
"INSERT INTO performed_sets VALUES(17,13,2,8,NULL,32.5);"
"INSERT INTO performed_sets VALUES(19,13,5,7,NULL,NULL);"
"PRAGMA user_version=9;";
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
{
sqlite3_stmt *statement = NULL;
bool matches = false;
if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
const unsigned char *value = sqlite3_column_text(statement, 0);
matches = value != NULL && strcmp((const char *)value, expected) == 0;
}
(void)sqlite3_finalize(statement);
return matches;
}
static bool test_v9_to_v10_is_lossless(void)
{
char path[] = "/tmp/trainlog-schema-v9-v10-XXXXXX";
sqlite3 *raw = NULL;
sqlite3_stmt *rows = NULL;
TrainlogDatabase *database = NULL;
int version = 0;
int foreign_keys = 0;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V9_FIXTURE_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 == 10);
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
CHECK(foreign_keys == 1);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
CHECK(sqlite3_prepare_v2(raw, "PRAGMA foreign_key_check;", -1, &rows, NULL) == SQLITE_OK);
CHECK(sqlite3_step(rows) == SQLITE_DONE);
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_prepare_v2(raw,
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
"FROM performed_sets ORDER BY position;", -1, &rows, NULL) == SQLITE_OK);
CHECK(sqlite3_step(rows) == SQLITE_ROW);
CHECK(sqlite3_column_int64(rows, 0) == 17);
CHECK(sqlite3_column_int64(rows, 1) == 13);
CHECK(sqlite3_column_int(rows, 2) == 2);
CHECK(sqlite3_column_int(rows, 3) == 8);
CHECK(sqlite3_column_type(rows, 4) == SQLITE_NULL);
CHECK(sqlite3_column_double(rows, 5) == 32.5);
CHECK(sqlite3_step(rows) == SQLITE_ROW);
CHECK(sqlite3_column_int64(rows, 0) == 19);
CHECK(sqlite3_column_type(rows, 5) == SQLITE_NULL);
CHECK(sqlite3_step(rows) == SQLITE_DONE);
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
rows = NULL;
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_exec(raw,
"INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return true;
}
static bool test_v9_to_v10_failure_rolls_back(void)
{
char path[] = "/tmp/trainlog-schema-v9-v10-failure-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
int fd = mkstemp(path);
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, V9_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"CREATE TABLE performed_sets_v9(collision INTEGER);",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v10") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
{
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 9);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM performed_sets;",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 2);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
}
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
CHECK(test_v9_to_v10_is_lossless());
CHECK(test_v9_to_v10_failure_rolls_back());
(void)printf("PASS schema_v9_migration\n");
return 0;
}

View file

@ -26,7 +26,7 @@
static bool test_session_details(void) static bool test_session_details(void)
{ {
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
TrainlogSetInput sets[3]; TrainlogSetInput sets[80];
TrainlogSessionExerciseInput exercise; TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session; TrainlogSessionInput session;
TrainlogSessionSummary summary; TrainlogSessionSummary summary;
@ -52,15 +52,22 @@ static bool test_session_details(void)
(void)memset(sets, 0, sizeof(sets)); (void)memset(sets, 0, sizeof(sets));
for (size_t index = 0U; index < 80U; ++index) {
sets[index].reps = (int)index + 1;
}
sets[0].reps = 5; sets[0].reps = 5;
sets[0].has_weight = true; sets[0].has_weight = true;
sets[0].weight_kg = 80.0; sets[0].weight_kg = 78.25;
sets[1] = sets[0]; sets[1] = sets[0];
sets[1].reps = 4;
sets[1].has_weight = false;
sets[1].weight_kg = 0.0;
sets[2].reps = 3; sets[2].reps = 3;
sets[2].has_weight = true; sets[2].has_weight = true;
sets[2].weight_kg = 80.0; sets[2].weight_kg = 81.75;
(void)memset( (void)memset(
&exercise, &exercise,
@ -78,14 +85,14 @@ static bool test_session_details(void)
"%s", "leg_press"); "%s", "leg_press");
exercise.load_mode = exercise.load_mode =
TRAINLOG_LOAD_EXTERNAL; TRAINLOG_LOAD_ASSISTANCE;
exercise.rest_seconds = 60; exercise.rest_seconds = 60;
exercise.target_sets = 3; exercise.target_sets = 3;
exercise.target_reps = 5; exercise.target_reps = 5;
exercise.target_has_weight = true; exercise.target_has_weight = true;
exercise.target_weight_kg = 80.0; exercise.target_weight_kg = 80.0;
exercise.sets = sets; exercise.sets = sets;
exercise.set_count = 3U; exercise.set_count = 80U;
(void)memset( (void)memset(
&session, &session,
@ -147,14 +154,21 @@ static bool test_session_details(void)
CHECK(details[0].target_reps == 5); CHECK(details[0].target_reps == 5);
CHECK(details[0].rest_seconds == 60); CHECK(details[0].rest_seconds == 60);
CHECK(strcmp(details[0].equipment_id, "leg_press") == 0); CHECK(strcmp(details[0].equipment_id, "leg_press") == 0);
CHECK(details[0].actual_set_count == 3U); CHECK(details[0].actual_set_count == 80U);
CHECK( CHECK(details[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
strcmp( CHECK(details[0].actual_sets[0].reps == 5);
details[0].actual_summary, CHECK(details[0].actual_sets[0].has_weight);
"5@80.0 / 5@80.0 / 3@80.0" CHECK(details[0].actual_sets[0].weight_kg > 78.24);
) == 0 CHECK(details[0].actual_sets[0].weight_kg < 78.26);
); CHECK(details[0].actual_sets[1].reps == 4);
CHECK(!details[0].actual_sets[1].has_weight);
CHECK(details[0].actual_sets[2].reps == 3);
CHECK(details[0].actual_sets[2].weight_kg > 81.74);
CHECK(details[0].actual_sets[2].weight_kg < 81.76);
CHECK(details[0].actual_sets[64].reps == 65);
CHECK(details[0].actual_sets[79].reps == 80);
trainlog_database_free_session_details(details, count);
trainlog_database_close(database); trainlog_database_close(database);
return true; return true;
} }

View file

@ -498,10 +498,115 @@ static bool test_remove_exercise_from_session(void)
return true; return true;
} }
static bool test_per_set_correction_sequence(void)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput created[3];
TrainlogSetInput corrected[3];
TrainlogSetInput loaded_sets[8];
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
TrainlogSessionSummary loaded_session;
TrainlogEditableExerciseRecord records[2];
TrainlogPersistedExerciseDetail details[2];
size_t exercise_count = 0U;
size_t set_count = 0U;
size_t detail_count = 0U;
CHECK(trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK);
CHECK(add_exercises(database));
(void)memset(created, 0, sizeof(created));
created[0].reps = 10;
created[0].has_weight = true;
created[0].weight_kg = 52.25;
created[1].reps = 9;
created[1].has_weight = true;
created[1].weight_kg = 57.5;
created[2].reps = 8;
created[2].has_weight = false;
bind_reps_exercise(&exercise, created, 3U, 55.0);
exercise.load_mode = TRAINLOG_LOAD_ASSISTANCE;
/* bind_reps_exercise initializes uniform values; restore the deliberately
* heterogeneous actual rows after occurrence metadata is initialized. */
created[0].reps = 10;
created[0].weight_kg = 52.25;
created[1].reps = 9;
created[1].weight_kg = 57.5;
created[2].reps = 8;
created[2].has_weight = false;
created[2].weight_kg = 0.0;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_per_set_edit");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-09T08:00:00+02:00");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_load_session_editable(database,
"se_per_set_edit", &loaded_session, records, 2U, &exercise_count,
loaded_sets, 8U, &set_count) == TRAINLOG_STATUS_OK);
CHECK(set_count == 3U);
CHECK(loaded_sets[1].weight_kg > 57.49);
CHECK(!loaded_sets[2].has_weight);
/* CONTRACT: model the row editor sequence: edit row 2, delete row 1,
* preserve the untouched row 3, then append a new (originally row 4)
* blank-load set. Replacement persists positions 0..N-1 in this order. */
corrected[0] = loaded_sets[1];
corrected[0].reps = 11;
corrected[0].weight_kg = 58.75;
corrected[1] = loaded_sets[2];
(void)memset(&corrected[2], 0, sizeof(corrected[2]));
corrected[2].reps = 7;
corrected[2].has_weight = false;
exercise.sets = corrected;
exercise.set_count = 3U;
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
records[0].entry_id);
CHECK(trainlog_database_replace_session_exercises(database,
"se_per_set_edit", &exercise, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_load_session_editable(database,
"se_per_set_edit", &loaded_session, records, 2U, &exercise_count,
loaded_sets, 8U, &set_count) == TRAINLOG_STATUS_OK);
CHECK(set_count == 3U);
CHECK(loaded_sets[0].reps == 11);
CHECK(loaded_sets[0].weight_kg > 58.74);
CHECK(loaded_sets[0].weight_kg < 58.76);
CHECK(loaded_sets[1].reps == 8);
CHECK(!loaded_sets[1].has_weight);
CHECK(loaded_sets[2].reps == 7);
CHECK(!loaded_sets[2].has_weight);
CHECK(records[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
CHECK(trainlog_database_get_session_details(database, "se_per_set_edit",
&loaded_session, details, 2U, &detail_count) == TRAINLOG_STATUS_OK);
CHECK(detail_count == 1U);
CHECK(details[0].actual_set_count == 3U);
CHECK(details[0].actual_sets[0].reps == 11);
CHECK(details[0].actual_sets[1].reps == 8);
CHECK(details[0].actual_sets[2].reps == 7);
CHECK(details[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
trainlog_database_free_session_details(details, detail_count);
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()); CHECK(test_remove_exercise_from_session());
CHECK(test_per_set_correction_sequence());
(void)printf( (void)printf(
"PASS session_edit\n" "PASS session_edit\n"

View file

@ -92,6 +92,14 @@ int main(void)
report.exercises_imported = 1U; report.exercises_imported = 1U;
trainlog_sync_build_summary(&report); trainlog_sync_build_summary(&report);
CHECK(strstr(report.summary, "+1 exercice(s)") != NULL); CHECK(strstr(report.summary, "+1 exercice(s)") != NULL);
report.success = false;
memset(report.error, 'x', TRAINLOG_SYNC_ERROR_MAX);
report.error[TRAINLOG_SYNC_ERROR_MAX] = '\0';
trainlog_sync_build_summary(&report);
CHECK(strlen(report.summary) == TRAINLOG_SYNC_SUMMARY_MAX);
CHECK(report.summary[TRAINLOG_SYNC_SUMMARY_MAX] == '\0');
CHECK(report.summary[0] == 'x');
puts("PASS sync direction plan"); puts("PASS sync direction plan");
return 0; return 0;
} }

View file

@ -0,0 +1,248 @@
/**
* @file test_tui_workflows.c
* @brief Scripted regressions through the production TUI workflow functions.
*/
#include <stdarg.h>
/* The workflows remain file-private in production. Including their translation
* unit lets this test drive the exact code paths with the terminal port mocked,
* without adding a public test API to Trainlog. */
#include "../src/tui.c"
struct TrainlogTerminal {
int events[128];
size_t event_count;
size_t event_index;
char output[32768];
size_t output_used;
};
struct TrainlogPanel { int unused; };
#define CHECK(condition) do { if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); return false; } } while (0)
static void script(TrainlogTerminal *terminal, const int *events, size_t count)
{
(void)memset(terminal, 0, sizeof(*terminal));
(void)memcpy(terminal->events, events, count * sizeof(events[0]));
terminal->event_count = count;
}
TrainlogTerminal *trainlog_terminal_create(void) { return NULL; }
void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; }
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { (void)terminal; return 24; }
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { (void)terminal; return 80; }
void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; }
void trainlog_terminal_render(TrainlogTerminal *terminal) { (void)terminal; }
void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
void trainlog_terminal_style_off(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column,
const char *format, ...)
{
va_list arguments;
int written;
(void)row;
(void)column;
if (terminal->output_used >= sizeof(terminal->output)) return;
va_start(arguments, format);
written = vsnprintf(terminal->output + terminal->output_used,
sizeof(terminal->output) - terminal->output_used, format, arguments);
va_end(arguments);
if (written > 0 && (size_t)written < sizeof(terminal->output) - terminal->output_used) {
terminal->output_used += (size_t)written;
terminal->output[terminal->output_used++] = '\n';
terminal->output[terminal->output_used] = '\0';
}
}
void trainlog_terminal_putn(TrainlogTerminal *terminal, const char *text, size_t length) { (void)terminal; (void)text; (void)length; }
void trainlog_terminal_move(TrainlogTerminal *terminal, int row, int column) { (void)terminal; (void)row; (void)column; }
void trainlog_terminal_cursor_yx(const TrainlogTerminal *terminal, int *row, int *column) { (void)terminal; if (row) *row = 0; if (column) *column = 40; }
void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal) { (void)terminal; }
void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible) { (void)terminal; (void)visible; }
void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint) { (void)terminal; (void)row; (void)column; (void)codepoint; }
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right) { (void)terminal; (void)top; (void)left; (void)bottom; (void)right; }
int trainlog_terminal_get_key(TrainlogTerminal *terminal) { return terminal->event_index < terminal->event_count ? terminal->events[terminal->event_index++] : TRAINLOG_KEY_NONE; }
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5])
{
int value = trainlog_terminal_get_key(terminal);
if (value == TRAINLOG_KEY_NONE) return false;
*codepoint = value;
utf8[0] = value >= 0 && value < 128 ? (char)value : '\0';
utf8[1] = '\0';
return true;
}
bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key) { (void)terminal; (void)key; return false; }
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, int top, int left) { (void)terminal; (void)height; (void)width; (void)top; (void)left; return NULL; }
void tui_panel_destroy(TrainlogPanel *panel) { (void)panel; }
void tui_panel_box(TrainlogPanel *panel) { (void)panel; }
void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) { (void)panel; (void)row; (void)column; (void)format; }
void tui_panel_commit(TrainlogPanel *panel) { (void)panel; }
static bool test_assistance_creation_labels(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise created;
TrainlogSessionExerciseInput input;
TrainlogSessionDraftExercise draft;
TrainlogSetInput sets[4];
TrainlogTerminal terminal;
const int planning_events[] = {
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
'3', '\n', '2', '0', '\n', '\n', '\n', '\n'
};
const int table_events[] = {
'a', '5', '\n', TRAINLOG_KEY_RIGHT, TRAINLOG_KEY_ENTER,
'1', '5', '\n', 'f'
};
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_catalog_create_exercise_profiled(database, "Tractions assistées",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, &created) ==
TRAINLOG_STATUS_OK);
script(&terminal, planning_events,
sizeof(planning_events) / sizeof(planning_events[0]));
tui_terminal = &terminal;
CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input,
sets, sizeof(sets) / sizeof(sets[0])));
CHECK(input.load_mode == TRAINLOG_LOAD_ASSISTANCE);
CHECK(input.target_weight_kg == 20.0 && input.set_count == 0U);
CHECK(strstr(terminal.output, "Assistance cible kg") != NULL);
CHECK(strstr(terminal.output, "Séries réalisées") == NULL);
CHECK(strstr(terminal.output, "Séries réellement faites") == NULL);
CHECK(strstr(terminal.output, "Charge cible kg") == NULL);
(void)memset(&draft, 0, sizeof(draft));
draft.input = input;
draft.input.sets = draft.sets;
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
(void)snprintf(draft.name, sizeof(draft.name), "%s", created.name);
script(&terminal, table_events,
sizeof(table_events) / sizeof(table_events[0]));
draft_edit_sets(&draft);
CHECK(draft.input.set_count == 1U);
CHECK(draft.sets[0].reps == 5);
CHECK(draft.sets[0].reps != draft.input.target_reps);
CHECK(draft.sets[0].has_weight && draft.sets[0].weight_kg == 15.0);
CHECK(strstr(terminal.output, "Assistance") != NULL);
CHECK(strstr(terminal.output, "Charge kg (vide") == NULL);
tui_terminal = NULL;
trainlog_database_close(database);
return true;
}
static bool test_duration_creation_starts_empty(void)
{
TrainlogDatabase *database = NULL;
TrainlogExercise created;
TrainlogSessionExerciseInput input;
TrainlogSetInput sets[4];
TrainlogTerminal terminal;
const int planning_events[] = {
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER
};
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_catalog_create_exercise_profiled(database, "Gainage",
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, &created) ==
TRAINLOG_STATUS_OK);
script(&terminal, planning_events,
sizeof(planning_events) / sizeof(planning_events[0]));
tui_terminal = &terminal;
CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input,
sets, sizeof(sets) / sizeof(sets[0])));
CHECK(input.target_duration_seconds == 45);
CHECK(input.set_count == 0U);
CHECK(strstr(terminal.output, "Séries réellement faites") == NULL);
CHECK(strstr(terminal.output, "Durée réalisée") == NULL);
tui_terminal = NULL;
trainlog_database_close(database);
return true;
}
static bool test_append_requires_actual_and_rolls_back(void)
{
TrainlogSessionDraftExercise draft;
TrainlogSessionDraftExercise duration_draft;
TrainlogTerminal terminal;
const int accepted[] = {'7', '\n'};
const int duration_accepted[] = {'1', ':', '3', '0', '\n'};
const int cancelled[] = {27};
(void)memset(&draft, 0, sizeof(draft));
draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
draft.input.target_reps = 12;
draft.input.set_count = 1U;
draft.sets[0].reps = 5;
script(&terminal, accepted, sizeof(accepted) / sizeof(accepted[0]));
tui_terminal = &terminal;
CHECK(draft_append_incomplete_set(&draft));
CHECK(draft.input.set_count == 2U);
CHECK(draft.sets[0].reps == 5);
CHECK(draft.sets[1].reps == 7);
CHECK(draft.sets[1].reps != draft.input.target_reps);
script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0]));
CHECK(!draft_append_incomplete_set(&draft));
CHECK(draft.input.set_count == 2U);
CHECK(draft.sets[0].reps == 5 && draft.sets[1].reps == 7);
(void)memset(&duration_draft, 0, sizeof(duration_draft));
duration_draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
duration_draft.tracking_mode = TRAINLOG_TRACKING_DURATION;
duration_draft.input.target_duration_seconds = 45;
script(&terminal, duration_accepted,
sizeof(duration_accepted) / sizeof(duration_accepted[0]));
CHECK(draft_append_incomplete_set(&duration_draft));
CHECK(duration_draft.input.set_count == 1U);
CHECK(duration_draft.sets[0].duration_seconds == 90);
CHECK(duration_draft.sets[0].duration_seconds !=
duration_draft.input.target_duration_seconds);
script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0]));
CHECK(!draft_append_incomplete_set(&duration_draft));
CHECK(duration_draft.input.set_count == 1U);
CHECK(duration_draft.sets[0].duration_seconds == 90);
tui_terminal = NULL;
return true;
}
static bool test_empty_sets_cannot_finish(void)
{
TrainlogSessionDraftExercise draft;
TrainlogTerminal terminal;
size_t count = 1U;
const int events[] = {'f', 'x', 'q'};
(void)memset(&draft, 0, sizeof(draft));
draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
(void)snprintf(draft.name, sizeof(draft.name), "Squat");
script(&terminal, events, sizeof(events) / sizeof(events[0]));
tui_terminal = &terminal;
CHECK(!edit_session_draft(NULL, &draft, &count,
TRAINLOG_SESSION_TRAINING));
CHECK(count == 1U && draft.input.set_count == 0U);
CHECK(strstr(terminal.output,
"Ajoutez au moins une série réalisée pour Squat.") != NULL);
tui_terminal = NULL;
return true;
}
int main(void)
{
CHECK(test_assistance_creation_labels());
CHECK(test_duration_creation_starts_empty());
CHECK(test_append_requires_actual_and_rolls_back());
CHECK(test_empty_sets_cannot_finish());
(void)printf("PASS tui_workflows\n");
return 0;
}

View file

@ -188,6 +188,7 @@ static bool test_targetless_variable_sets(void)
) == 0 ) == 0
); );
trainlog_database_free_session_details(detail, detail_count);
trainlog_database_close( trainlog_database_close(
database database
); );