From 1b08db009e9c3659ee978125d19444df9cb16600 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 9 Sep 2026 10:34:25 +0200 Subject: [PATCH] feat(sets): add per-set reps and weight editing --- CHANGELOG.md | 16 +- README.md | 22 +- .../ui/SessionDraftUiInstrumentedTest.kt | 42 +- .../trainlog/data/TrainlogRepository.kt | 12 +- .../trainlog/ui/SessionDetailScreen.kt | 34 +- .../labfytools/trainlog/ui/SessionScreen.kt | 298 +++++-- .../data/TrainlogRepositoryDraftTest.kt | 125 ++- .../trainlog/ui/SessionSetRowEditorTest.kt | 137 ++++ docs/android.md | 33 +- docs/architecture.md | 5 +- docs/current_state.md | 33 +- docs/database.md | 27 +- docs/exercise_data_model.md | 38 +- docs/roadmap.md | 4 +- docs/sync_exchange.md | 11 + docs/tests.md | 50 +- docs/tui.md | 40 +- tests/test_exercise_reconciliation.py | 2 +- tests/test_max_sync.py | 2 +- tests/test_mobile_import_multi_occurrence.py | 2 +- tests/test_mobile_import_variable_sets.py | 186 ++++- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 4 +- tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_mobile_export.py | 36 +- tools/validate_json.py | 15 + tui/include/trainlog/database.h | 17 +- tui/meson.build | 20 + tui/src/database.c | 242 +++++- tui/src/sync.c | 67 +- tui/src/tui.c | 735 ++++++++++++------ tui/tests/test_continuous_detail.c | 5 + tui/tests/test_custom_equipment.c | 3 +- tui/tests/test_database.c | 35 +- tui/tests/test_max_results.c | 15 + tui/tests/test_schema_v5_migration.c | 3 +- tui/tests/test_schema_v7_migration.c | 12 +- tui/tests/test_schema_v9_migration.c | 183 +++++ tui/tests/test_session_detail.c | 38 +- tui/tests/test_session_edit.c | 105 +++ tui/tests/test_sync_direction.c | 8 + tui/tests/test_tui_workflows.c | 248 ++++++ tui/tests/test_variable_sets.c | 1 + 46 files changed, 2449 insertions(+), 480 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt create mode 100644 tui/tests/test_schema_v9_migration.c create mode 100644 tui/tests/test_tui_workflows.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a769fe..15d9f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ Detailed implementation chronology remains available in Git history and - multi-occurrence session V2: stable per-occurrence `entry_id`, repeated catalogue exercises in one session, per-set actual weights, and occurrence 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, Android-local custom equipment creation, and explicit rejection of unknown 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 supplemental fields; - continuous activity persistence without synthetic sets; -- variable repetition-set input including `5x10`, explicit lists, and pyramid - shorthand such as `4..10..4`; +- explicit table-based desktop actual-set entry, with independently added rows + and no compact performed-repetition input; - persisted desktop session editing and exercise removal; - Android current-session draft exercise removal; - body-observation history, editing, graphs, and normalized overlays; @@ -87,6 +91,10 @@ Detailed implementation chronology remains available in Git history and ### 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; `TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data rather than losing or fabricating it; @@ -166,8 +174,8 @@ Current validated baseline: ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V9=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_SCHEMA_V10=PASS +DESKTOP_TESTS=36/36 PASS ANDROID_BUILD=PASS ANDROID_LOCAL_WORKFLOWS=PASS diff --git a/README.md b/README.md index e7bb124..ccf0b11 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ desktop. ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V9=PASS +DESKTOP_SCHEMA_V10=PASS ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_SESSION_DRAFT_V1=PASS @@ -38,7 +38,7 @@ EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V1=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_TESTS=36/36 PASS ANDROID_BUILD=PASS ``` @@ -97,20 +97,20 @@ SETS + DURATION CONTINUOUS + DURATION ``` -Actual repetition sets are stored independently. Compact input supports: - -```text -5x10 -4,5,6,7,8,9,10,9,8,7,6,5,4 -4..10..4 -``` +Actual repetition sets are stored independently. The Notcurses desktop flow +collects planning only, then creates actual work in an ordered table: the user +explicitly adds every row and enters its actual repetitions (or duration) and +optional load. It does not accept compact performed-repetition input; normal +set sessions cannot finish with zero actual rows. A session may contain several ordered occurrences of the same catalogue exercise. Each occurrence has a stable `entry_id`, distinct from the stable `exercise_id` of the catalogue item. Equipment selection belongs to that occurrence, as do its actual per-set loads. `external` records an applied or 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 `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_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_TESTS=36/36 PASS ``` diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt index 7c2aca7..e87a4ba 100644 --- a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt @@ -2,6 +2,7 @@ package com.labfytools.trainlog.ui import android.content.Context 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.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -76,15 +77,13 @@ class SessionDraftUiInstrumentedTest { } @Test - fun resumeRestoresRawFormAfterActivityRecreation() { + fun resumeRestoresSetRowsAfterActivityRecreation() { compose.onNodeWithText( "Reprendre la séance en cours" ).assertIsDisplayed() .performClick() - compose.onNodeWithText( - "4,5,6," - ).performScrollTo() - .assertIsDisplayed() + compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4") + compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6") compose.activityRule.scenario.recreate() @@ -92,10 +91,8 @@ class SessionDraftUiInstrumentedTest { "Reprendre la séance en cours" ).assertIsDisplayed() .performClick() - compose.onNodeWithText( - "4,5,6," - ).performScrollTo() - .assertIsDisplayed() + compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4") + compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6") compose.onNodeWithText( "Retirer Test UI" ).performScrollTo() @@ -220,9 +217,30 @@ class SessionDraftUiInstrumentedTest { compose.onNodeWithText("Annuler la recherche").performClick() compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed() - compose.onNodeWithText("4,5,6,") - .performScrollTo() - .assertIsDisplayed() + compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4") + compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6") + } + + @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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 279eb29..dd87784 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -2061,7 +2061,7 @@ class TrainlogRepository( val maximum = if (tracking == "reps") 10000 else 86400 if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) || !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." } } @@ -2114,6 +2114,8 @@ class TrainlogRepository( 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?.isNonnegativeJsonNumber(): Boolean = + this is Number && toDouble().isFinite() && toDouble() >= 0.0 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 { @@ -3667,13 +3669,17 @@ class TrainlogRepository( TrackingMode.REPS -> draft.sets.all { it.reps >= 0 && - it.durationSeconds == 0 + it.durationSeconds == 0 && + (it.weightKg == null || + (it.weightKg.isFinite() && it.weightKg >= 0.0)) } TrackingMode.DURATION -> draft.sets.all { it.durationSeconds > 0 && - it.reps == 0 + it.reps == 0 && + (it.weightKg == null || + (it.weightKg.isFinite() && it.weightKg >= 0.0)) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index d4dc2fa..cf23541 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.ActiveDraftMutationResult +import com.labfytools.trainlog.data.EquipmentLoadSemantics import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionExerciseDetail @@ -37,6 +38,10 @@ fun SessionDetailScreen( ) } } + val equipmentEntries = + remember(sessionId, revision) { + repository.listEquipment() + } TrainlogScreen( subtitle = "D E T A I L S E A N C E" @@ -177,7 +182,10 @@ fun SessionDetailScreen( ) } else { SetsDetail( - exercise + exercise = exercise, + loadSemantics = equipmentEntries + .firstOrNull { it.equipmentId == exercise.equipmentId } + ?.loadSemantics, ) } } @@ -188,6 +196,7 @@ fun SessionDetailScreen( @Composable private fun SetsDetail( exercise: SessionExerciseDetail, + loadSemantics: EquipmentLoadSemantics?, ) { val colors = LocalTrainlogColors.current @@ -205,6 +214,18 @@ private fun SetsDetail( 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 .forEachIndexed { index, @@ -215,13 +236,10 @@ private fun SetsDetail( exercise.trackingMode == TrackingMode.REPS ) { - buildString { - append("Série ${index + 1} : ${set.reps} reps") - set.weightKg?.let { - val rendered = "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',') - append(" · $rendered kg") - } - } + val renderedWeight = set.weightKg?.let { + "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',') + } ?: "—" + "${index + 1} | ${set.reps} | $renderedWeight" } else { "Série ${index + 1} : ${formatDuration(set.durationSeconds)}" } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 837894b..f01ca4f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -710,17 +710,33 @@ private fun SessionExerciseForm( ) } + val initialSetRows = + remember(key) { + rawSetRowsFromForm(initialForm) + } + var repsText by remember(key) { mutableStateOf( - initialForm.repsText + encodeRawReps(initialSetRows) ) } var weightText by remember(key) { 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 == 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( - text = - "Formats : 5x10 · 4,5,6,7 · 4..10..4", - color = - colors.muted, + text = "Chaque série conserve ses propres répétitions et sa propre charge.", + color = colors.muted, ) - - SessionNumberField( - label = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) { + val loadLabel = + if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) { "Assistance (kg)" } else { "Charge (kg)" - }, - value = weightText, - onValueChange = { - weightText = it - error = null - onFormChanged( - currentForm( - exercise, setCountText, repsText, durationText, - speedText, distanceText, selectedEquipmentId, it, + } + setRows.forEachIndexed { index, row -> + TrainlogInfo( + text = "Série ${index + 1}", + color = colors.accent, + ) + SessionNumberField( + label = "Série ${index + 1} — Répétitions", + 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( - 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, ) } else { @@ -1063,12 +1121,18 @@ private fun SessionExerciseForm( weightText = weightText, maxWeightText = maxWeightText, entryId = initialForm.editingEntryId, + rawSetRows = setRows, ) if (draft == null) { error = if (sessionType == SessionType.MAX_TEST) { "Saisissez un poids max strictement positif (ex. 100 ou 86,5)." + } else if ( + exercise.recordingMode == RecordingMode.SETS && + exercise.trackingMode == TrackingMode.REPS + ) { + setRowValidationError(setRows) } else { "Valeurs invalides." } @@ -1129,18 +1193,111 @@ private fun SessionNumberField( label: String, value: String, onValueChange: (String) -> Unit, + testTag: String? = null, ) { TrainlogInputField( label = label, value = value, onValueChange = onValueChange, + testTag = testTag, ) } private const val MAX_SESSION_SETS = 64 private const val MAX_REPS_PER_SET = 10000 +internal data class RawSetRow( + val repsText: String = "", + val weightText: String = "", +) + +internal fun List.replaceAt(index: Int, value: RawSetRow): List = + mapIndexed { rowIndex, existing -> if (rowIndex == index) value else existing } + +internal fun encodeRawReps(rows: List): String = + rows.joinToString(";") { it.repsText } + +internal fun encodeRawWeights(rows: List): 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 { + 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): List? { + 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): 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( text: String, ): List? { @@ -1304,6 +1461,7 @@ private fun buildSessionExerciseDraft( weightText: String = "", maxWeightText: String = "", entryId: String? = null, + rawSetRows: List? = null, ): SessionExerciseDraft? { if (sessionType == SessionType.MAX_TEST) { val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull() @@ -1391,24 +1549,18 @@ private fun buildSessionExerciseDraft( exercise.trackingMode == TrackingMode.REPS ) { - val reps = - parseRepSequence( - repsText + val parsedSets = + parseRawSetRows( + rawSetRows ?: rawSetRowsFromForm( + SessionDraftForm(repsText = repsText, weightText = weightText) + ) ) ?: return null - val weights = parseWeightSequence(weightText, reps.size) ?: return null - SessionExerciseDraft( entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(), exercise = exercise, equipmentId = equipmentId, - sets = - reps.mapIndexed { index, rep -> - SessionSetDraft( - reps = rep, - weightKg = weights[index], - ) - }, + sets = parsedSets, ) } else { val count = @@ -1445,7 +1597,7 @@ private fun buildSessionExerciseDraft( /** Reconstruct editable text from the entry itself; editing never mutates a * different entry or the global exercise definition. */ -private fun formForExistingExercise( +internal fun formForExistingExercise( draft: SessionExerciseDraft, index: Int, ): SessionDraftForm = @@ -1461,7 +1613,11 @@ private fun formForExistingExercise( } else { "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) { (draft.continuousDurationSeconds / 60).toString() } else { @@ -1471,23 +1627,6 @@ private fun formForExistingExercise( 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? { - 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 - return when { - parsed.size == 1 -> List(count) { parsed.single() } - parsed.size == count -> parsed - else -> null - } -} - private fun draftSummary( draft: SessionExerciseDraft, ): String { @@ -1531,6 +1670,7 @@ private fun draftSummary( if ( reps.isNotEmpty() && + draft.sets.all { it.weightKg == null } && reps.all { it == reps.first() } @@ -1544,10 +1684,12 @@ private fun draftSummary( ( "${draft.exercise.name} · " + "${reps.size} séries · " + - reps.joinToString( - separator = "," - ) + - " reps" + draft.sets.joinToString(separator = " ; ") { set -> + buildString { + append("${set.reps} reps") + set.weightKg?.let { append(" @ ${formatMaxWeight(it)} kg") } + } + } ) } } else { diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index ab28174..ac920c8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -104,6 +104,115 @@ class TrainlogRepositoryDraftTest { 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 fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() { val repo = openRepository() @@ -215,9 +324,18 @@ class TrainlogRepositoryDraftTest { val draft = ActiveSessionDraft( exercises = listOf(SessionExerciseDraft( 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)) repo.close(); repository = null @@ -226,7 +344,8 @@ class TrainlogRepositoryDraftTest { assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId }) assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved) 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 diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt new file mode 100644 index 0000000..86cfffd --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt @@ -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, + ) +} diff --git a/docs/android.md b/docs/android.md index f548bf6..44bb771 100644 --- a/docs/android.md +++ b/docs/android.md @@ -109,9 +109,21 @@ Session entry is profile-aware. ### 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 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 name, physical-machine label and aliases. A selected equipment identity is 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 -per-set values; French decimal commas are accepted. `Assistance (kg)` is an -explicit alternative load semantic, not an external charge. Empty load and an -entered zero remain distinct. +For `SETS + REPS`, the form is a row editor: every set owns an independently +editable repetitions field and optional load field, and rows can be added or +deleted without changing their neighbours. French decimal commas are accepted. +`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 The repository durably saves every meaningful mutation, including session type, -exercise selection/addition/removal, actual values and raw form edits. Partial -text such as `4,5,6,` is retained without normalization. A failed write displays -a specific error and does not claim the latest change was saved. +exercise selection/addition/removal, actual values and raw per-set form edits. +Partial row text such as `32,` is retained without normalization. A failed write +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. The ordinary new-session action opens an existing draft without overwriting it. diff --git a/docs/architecture.md b/docs/architecture.md index b18b9a8..35b5aae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,7 +120,10 @@ Continuous work is persisted separately from performed sets. ### 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 occur more than once in one session without identity fusion. diff --git a/docs/current_state.md b/docs/current_state.md index 4de87fb..5279925 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,6 +1,6 @@ # 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 baseline. Detailed behavior belongs in the topic-specific documents. @@ -14,7 +14,7 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V9=PASS +DESKTOP_SCHEMA_V10=PASS ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_SESSION_DRAFT_V1=PASS ANDROID_DRAFT_DURABLE=PASS @@ -57,7 +57,7 @@ EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V1=PASS MAX_TEST_RESUME_STABLE_ID=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_TESTS=36/36 PASS ANDROID_BUILD=PASS HARDWARE_SYNC_VALIDATION=PASS ``` @@ -67,10 +67,15 @@ HARDWARE_SYNC_VALIDATION=PASS Implemented: - 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 - 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; +- 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; - exercise removal from a session through transactional child replacement; - exercise catalog; @@ -104,7 +109,7 @@ Primary navigation: Implemented: - 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; - explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; @@ -221,17 +226,17 @@ No mounted Android filesystem is required. Desktop: ```text -34/34 Meson tests PASS for the current desktop schema v9 baseline -frozen JSON validator PASS -import-contract validator PASS -ASan/UBSan 34/34 Meson tests PASS +36/36 Meson tests PASS for the current desktop schema v10 baseline +JSON valid/invalid checks PASS +import-contract validator 6/6 PASS +ASan/UBSan 14/14 Meson tests PASS postrepair git diff --check PASS ``` Android: ```text -testDebugUnitTest PASS +37 Android unit tests PASS assembleDebug PASS ``` @@ -282,11 +287,11 @@ ASSISTANCE_DIRECTION_AWARE=PASS ANDROID_MAX_TEST_SESSION=PASS EXPLICIT_MAX_RESULTS_V1=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` -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 Pec Fly and Rear Delt Fly results. Ordinary training is never promoted implicitly. @@ -312,7 +317,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_TESTS=36/36 PASS ``` Android remains capture-only for this feature. diff --git a/docs/database.md b/docs/database.md index 039d2f8..03248e1 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,8 +3,8 @@ ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=9 -DATABASE_SCHEMA_V9=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=10 +DATABASE_SCHEMA_V10=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -23,9 +23,15 @@ PRAGMA user_version; Current value: ```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 migration chain. A database newer than the running binary understands is 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. 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 `user_version` is not an acceptable migration test. @@ -179,7 +193,12 @@ duration_seconds Actual repetitions may be zero. 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` diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index e3245a4..3f64c03 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -92,30 +92,26 @@ equivalence across different machines. `SETS + REPS` stores one performed-set row per actual set. -Actual repetitions can differ across sets. - -Accepted compact repetition input includes: - -```text -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 ordered performed set independently owns its repetitions and an optional +`weight_kg`. Actual repetitions and actual loads can therefore differ from one +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 +zero is preserved as an observed value. 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. ## 5. Planned versus actual diff --git a/docs/roadmap.md b/docs/roadmap.md index b2b3ca3..53ab33f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ GATE_1=PASS GATE_2=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V9=PASS +DESKTOP_SCHEMA_V10=PASS ANDROID_LOCAL_DATABASE_V9=PASS DIRECT_MTP_TRANSPORT=PASS @@ -28,7 +28,7 @@ BODY_ANALYTICS_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS -DESKTOP_TESTS=34/34 PASS +DESKTOP_TESTS=36/36 PASS TUI_NOTCURSES_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index d9403b0..1a8cd8d 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -142,6 +142,17 @@ The companion corroborates explicit `set`/`cleared` state; it does not overwrite a divergent occurrence. Reimporting either artifact reconciles stable 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: ```text diff --git a/docs/tests.md b/docs/tests.md index 47a8548..b398d25 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -86,6 +86,7 @@ continuous_detail reps variable_sets schema_v5_migration +schema_v9_migration schema_v7_migration mobile_import_variable_sets mobile_import_multi_occurrence @@ -100,12 +101,13 @@ max_results max_sync body_analytics terminal_input_event_type_policy +tui_workflows ``` Validated current suite: ```text -34/34 Meson tests PASS +36/36 Meson tests PASS ``` The desktop executable is additionally smoke-checked in isolated tmux PTYs at @@ -120,10 +122,21 @@ Notable regression coverage: - body-observation stable-identity editing; - profile-aware exercise constraints; - 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; - 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; +- 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; - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a RELEASE event is consumed without creating a second navigation action. @@ -319,7 +332,7 @@ Coverage proves: Validated current normal suite: ```text -34/34 Meson tests PASS +36/36 Meson tests PASS ``` ## 12. Body analytics regression @@ -344,7 +357,7 @@ Coverage includes: Validated current normal suite: ```text -34/34 Meson tests PASS +36/36 Meson tests PASS ``` ## 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 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 cd android JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test diff --git a/docs/tui.md b/docs/tui.md index 07aa1c1..f9bc13c 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -99,16 +99,39 @@ Unicode-aware normalized-name uniqueness prevents duplicate logical names. 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 -5x10 -4,5,6,7,8,9,10,9,8,7,6,5,4 -4..10..4 +Up/Down select a row +Left/Right/Tab select metric or load cell +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: ```text @@ -131,6 +154,11 @@ History is keyboard navigable. `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 while replacing child exercise/set data transactionally. @@ -327,7 +355,7 @@ meson test -C build --print-errorlogs Validated current normal suite: ```text -34/34 Meson tests PASS +36/36 Meson tests PASS ``` ## 16. Measured max view diff --git a/tests/test_exercise_reconciliation.py b/tests/test_exercise_reconciliation.py index 56c6c08..a2bf6ba 100644 --- a/tests/test_exercise_reconciliation.py +++ b/tests/test_exercise_reconciliation.py @@ -67,7 +67,7 @@ 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; """ diff --git a/tests/test_max_sync.py b/tests/test_max_sync.py index 430e86e..ecb62c7 100644 --- a/tests/test_max_sync.py +++ b/tests/test_max_sync.py @@ -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 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); -PRAGMA user_version=9; +PRAGMA user_version=10; """ diff --git a/tests/test_mobile_import_multi_occurrence.py b/tests/test_mobile_import_multi_occurrence.py index a9276bf..171150e 100644 --- a/tests/test_mobile_import_multi_occurrence.py +++ b/tests/test_mobile_import_multi_occurrence.py @@ -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 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); -PRAGMA user_version=9; +PRAGMA user_version=10; """ def payload(): diff --git a/tests/test_mobile_import_variable_sets.py b/tests/test_mobile_import_variable_sets.py index bf75054..8e8125e 100755 --- a/tests/test_mobile_import_variable_sets.py +++ b/tests/test_mobile_import_variable_sets.py @@ -13,6 +13,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] IMPORTER = ROOT / "tools" / "import_mobile_export.py" +EXPORTER = ROOT / "tools" / "export_pc_mobile.py" EXPECTED_REPS = [ 4, @@ -55,6 +56,7 @@ CREATE TABLE sessions ( 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 @@ -68,7 +70,8 @@ CREATE TABLE session_exercises ( target_reps INTEGER, target_duration_seconds INTEGER, target_weight_kg REAL, - notes TEXT + notes TEXT, + equipment_id TEXT ); CREATE TABLE performed_sets ( @@ -78,7 +81,7 @@ CREATE TABLE performed_sets ( position INTEGER NOT NULL, reps INTEGER, duration_seconds INTEGER, - weight_kg REAL + weight_kg REAL CHECK(weight_kg >= 0.0) ); CREATE TABLE continuous_activity ( @@ -112,16 +115,40 @@ CREATE TABLE body_observations ( 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: return { "format": "trainlog-mobile-export", "version": - 1, + 2, "generated_at": "2026-09-06T16:00:00+02:00", "exercises": [ @@ -148,6 +175,10 @@ def payload() -> dict: "training", "exercises": [ { + "entry_id": + "sxe_mobile_pyramid", + "position": + 0, "exercise_id": "ex_mobile_pyramid", "name": @@ -162,12 +193,12 @@ def payload() -> dict: "none", "rest_seconds": 0, + "equipment_id": + None, "sets": [ - { - "reps": reps - } - for reps - in EXPECTED_REPS + ({"reps": reps} if weight is None else + {"reps": reps, "weight_kg": weight}) + for reps, weight in EXPECTED_SETS ], } ], @@ -204,6 +235,64 @@ def run_import( 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: with tempfile.TemporaryDirectory( prefix="trainlog-mobile-variable-" @@ -285,7 +374,7 @@ def main() -> int: try: rows = connection.execute( """ - SELECT ps.reps + SELECT ps.reps, ps.weight_kg FROM performed_sets ps JOIN session_exercises se ON se.id = @@ -298,14 +387,9 @@ def main() -> int: """ ).fetchall() - reps = [ - row[0] - for row in rows - ] - - if reps != EXPECTED_REPS: + if rows != EXPECTED_SETS: raise AssertionError( - f"reps mismatch: {reps!r}" + f"ordered sets mismatch: {rows!r}" ) target = connection.execute( @@ -350,6 +434,74 @@ def main() -> int: finally: 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( "PASS mobile_import_variable_sets" ) diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 35b8cc2..ed209cb 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -27,8 +27,8 @@ def main(): args = parser.parse_args() connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9): - raise ValueError("schema desktop v8 ou v9 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10): + raise ValueError("schema desktop v8, v9 ou v10 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index a552c05..747c293 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -23,8 +23,8 @@ def main(): connection = sqlite3.connect(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9): - raise ValueError("schema desktop v8 ou v9 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10): + raise ValueError("schema desktop v8, v9 ou v10 requis") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "FROM custom_equipment ORDER BY equipment_id")] diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 3f3903c..06c3f81 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -64,7 +64,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # 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( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 334c7d3..8031083 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -30,8 +30,8 @@ def main(): con = sqlite3.connect(args.database) con.row_factory = sqlite3.Row try: - if con.execute("PRAGMA user_version").fetchone()[0] != 9: - raise ValueError("schema desktop v9 requis") + if con.execute("PRAGMA user_version").fetchone()[0] != 10: + raise ValueError("schema desktop v10 requis") known_equipment = supplied_equipment_ids() known_equipment.update(row[0] for row in con.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 3fbd9e0..5bf680a 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -113,8 +113,8 @@ def main(): fail("clés extension équipement invalides") connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9): - fail("schema desktop v8 ou v9 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10): + fail("schema desktop v8, v9 ou v10 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 32b2c3d..713fea5 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -60,8 +60,8 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9): - fail("schema desktop v8 ou v9 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10): + fail("schema desktop v8, v9 ou v10 requis") imported = skipped = 0 # Validate every same-ID row before inserting any definition. for definition in definitions: diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 5548fe5..d676c66 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import json +import math import os import sqlite3 import sys @@ -153,7 +154,7 @@ def require_positive_number(value, label): parsed = float(value) - if parsed <= 0.0: + if not math.isfinite(parsed) or parsed <= 0.0: raise ImportFailure( f"{label}: nombre positif attendu" ) @@ -161,6 +162,17 @@ def require_positive_number(value, label): 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): folded = unicodedata.normalize( "NFC", @@ -367,6 +379,13 @@ def validate_set_item( label, ): 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": require_exact_keys( value, @@ -757,9 +776,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # 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( - 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: 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( "BEGIN IMMEDIATE;" diff --git a/tools/validate_json.py b/tools/validate_json.py index 7db0560..2f79c75 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -263,6 +263,21 @@ def validate_mobile_export_v2(document: Any) -> None: 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: 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"]) def structural_errors( validator: jsonschema.Draft202012Validator, diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 37a20ee..7329190 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 9 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 10 typedef struct TrainlogDatabase TrainlogDatabase; @@ -198,13 +198,20 @@ typedef struct TrainlogPersistedExerciseDetail { double continuous_distance_km; 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]; } TrainlogPersistedExerciseDetail; /** * @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( TrainlogDatabase *database, @@ -215,6 +222,12 @@ TrainlogStatus trainlog_database_get_session_details( 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 */ diff --git a/tui/meson.build b/tui/meson.build index 71ca8cb..3dc5e93 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -360,6 +360,15 @@ test( 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', 'tests/test_schema_v7_migration.c', @@ -484,3 +493,14 @@ test( 'terminal_input_event_type_policy', 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) diff --git a/tui/src/database.c b/tui/src/database.c index 7e31da2..de8b6a6 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -290,6 +290,37 @@ static const char *const MIGRATE_V8_TO_V9_SQL = "(SELECT session_exercise_row_id FROM max_results);" "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 = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -697,6 +728,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK) { 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) { /* CONTRACT: v7 is the immediate historic schema and must open through * its lossless custom-equipment-table migration. */ @@ -704,8 +738,16 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK) { 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) { 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 { if (version == 1) { status = @@ -833,6 +875,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK) { status = execute_sql(database, MIGRATE_V8_TO_V9_SQL); } + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql(database, MIGRATE_V9_TO_V10_SQL); + } } if ( @@ -842,7 +887,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, 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, SQLITE_ERROR ); @@ -853,6 +898,16 @@ static TrainlogStatus initialize_or_validate_schema( 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; @@ -2239,6 +2294,15 @@ static TrainlogStatus insert_performed_set( sqlite3_stmt *statement = NULL; 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( database->connection, "INSERT INTO performed_sets(" @@ -2426,6 +2490,40 @@ static TrainlogStatus insert_session_children( 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( TrainlogDatabase *database, const TrainlogSessionInput *session @@ -2444,6 +2542,14 @@ TrainlogStatus trainlog_database_insert_session( 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); if (status != TRAINLOG_STATUS_OK) { return status; @@ -2500,6 +2606,11 @@ TrainlogStatus trainlog_database_replace_session_exercises( 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); if (status != TRAINLOG_STATUS_OK) { return status; @@ -3022,10 +3133,47 @@ static TrainlogStatus detail_fill_sets( "ORDER BY position ASC;"; sqlite3_stmt *statement = NULL; + sqlite3_stmt *count_statement = NULL; size_t used = 0U; size_t count = 0U; 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( database->connection, SQL, @@ -3034,6 +3182,9 @@ static TrainlogStatus detail_fill_sets( NULL ); if (rc != SQLITE_OK) { + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3044,6 +3195,9 @@ static TrainlogStatus detail_fill_sets( ); if (rc != SQLITE_OK) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3059,6 +3213,33 @@ static TrainlogStatus detail_fill_sets( int has_weight = 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) { TrainlogStatus status = detail_append_text( detail->actual_summary, @@ -3069,6 +3250,9 @@ static TrainlogStatus detail_fill_sets( if (status != TRAINLOG_STATUS_OK) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return status; } } @@ -3103,6 +3287,9 @@ static TrainlogStatus detail_fill_sets( sizeof(duration_text) ) != TRAINLOG_STATUS_OK) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3124,12 +3311,18 @@ static TrainlogStatus detail_fill_sets( } } else { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } if (written < 0 || (size_t)written >= sizeof(fragment)) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3143,6 +3336,9 @@ static TrainlogStatus detail_fill_sets( if (status != TRAINLOG_STATUS_OK) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return status; } } @@ -3152,14 +3348,25 @@ static TrainlogStatus detail_fill_sets( if (rc != SQLITE_DONE) { (void)sqlite3_finalize(statement); + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; return TRAINLOG_STATUS_DATABASE_ERROR; } if (sqlite3_finalize(statement) != SQLITE_OK) { + free(detail->actual_sets); + detail->actual_sets = NULL; + detail->actual_set_count = 0U; 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) { (void)snprintf( @@ -3173,6 +3380,23 @@ static TrainlogStatus detail_fill_sets( 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( TrainlogDatabase *database, const char *session_id, @@ -3219,6 +3443,10 @@ TrainlogStatus trainlog_database_get_session_details( size_t total = 0U; int rc; + if (output_exercise_count != NULL) { + *output_exercise_count = 0U; + } + if (database == NULL || database->connection == NULL || session_id == NULL || @@ -3236,8 +3464,6 @@ TrainlogStatus trainlog_database_get_session_details( sizeof(*output_session) ); - *output_exercise_count = 0U; - rc = sqlite3_prepare_v2( database->connection, HEADER_SQL, @@ -3389,6 +3615,7 @@ TrainlogStatus trainlog_database_get_session_details( (((TrainlogExerciseDataFields)data_fields) & ~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) { (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3404,6 +3631,8 @@ TrainlogStatus trainlog_database_get_session_details( (const char *)equipment_id); } if (entry_id == NULL) { + (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } (void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s", @@ -3506,6 +3735,7 @@ TrainlogStatus trainlog_database_get_session_details( 10 ) == SQLITE_NULL) { (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3548,6 +3778,7 @@ TrainlogStatus trainlog_database_get_session_details( (detail->has_continuous_distance != 0) != distance_required) { (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } @@ -3568,6 +3799,7 @@ TrainlogStatus trainlog_database_get_session_details( if (status != TRAINLOG_STATUS_OK) { (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return status; } } @@ -3580,10 +3812,12 @@ TrainlogStatus trainlog_database_get_session_details( if (rc != SQLITE_DONE) { (void)sqlite3_finalize(exercises); + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } if (sqlite3_finalize(exercises) != SQLITE_OK) { + trainlog_database_free_session_details(output_exercises, copied); return TRAINLOG_STATUS_DATABASE_ERROR; } diff --git a/tui/src/sync.c b/tui/src/sync.c index 7159c1c..cdf23bb 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -97,6 +97,39 @@ typedef struct SyncSilence { int null_fd; } 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( TrainlogSyncDirection direction ) @@ -2451,10 +2484,10 @@ void trainlog_sync_build_summary( report->catalog_published); } } else { - (void)snprintf( + sync_compose_diagnostic( report->summary, sizeof(report->summary), - "%s", + "", report->error[0] != '\0' ? report->error : "Synchronisation échouée." @@ -2811,9 +2844,12 @@ TrainlogStatus trainlog_sync_run( strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) { char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; sync_last_nonempty_line(tool_output, useful, sizeof(useful)); - (void)snprintf(output->error, sizeof(output->error), - "Android→PC : définitions équipement : %s", - useful[0] != '\0' ? useful : "import échoué"); + sync_compose_diagnostic( + output->error, + sizeof(output->error), + "Android→PC : définitions équipement : ", + useful[0] != '\0' ? useful : "import échoué" + ); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } @@ -2886,10 +2922,10 @@ TrainlogStatus trainlog_sync_run( sizeof(useful) ); - (void)snprintf( + sync_compose_diagnostic( output->error, sizeof(output->error), - "Android→PC : %s", + "Android→PC : ", useful[0] != '\0' ? useful : "import mobile échoué" @@ -2932,11 +2968,14 @@ TrainlogStatus trainlog_sync_run( if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) { sync_last_nonempty_line(tool_output, useful, sizeof(useful)); - (void)snprintf(output->error, sizeof(output->error), - "Android→PC : import équipement : %s", - useful[0] != '\0' - ? useful - : "échec sans diagnostic du script"); + sync_compose_diagnostic( + output->error, + sizeof(output->error), + "Android→PC : import équipement : ", + useful[0] != '\0' + ? useful + : "échec sans diagnostic du script" + ); final_status = TRAINLOG_STATUS_DATABASE_ERROR; goto finalize; } @@ -3004,10 +3043,10 @@ outbound: sizeof(useful) ); - (void)snprintf( + sync_compose_diagnostic( output->error, sizeof(output->error), - "PC→Android : %s", + "PC→Android : ", useful[0] != '\0' ? useful : "export catalogue échoué" diff --git a/tui/src/tui.c b/tui/src/tui.c index 5feaa91..a51a680 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -540,7 +541,25 @@ static bool parse_double_positive(const char *text, double *output) } 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; } @@ -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 */ #define EXERCISE_GRAPH_POINTS 12U @@ -5181,13 +5230,8 @@ static bool build_session_exercise( int target_sets = 3; int target_metric; 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; double target_weight = 0.0; - size_t set_index; if (!choose_exercise( database, @@ -5357,9 +5401,14 @@ static bool build_session_exercise( } if (load_mode != 1) { + const char *target_load_prompt = + load_mode == 3 + ? "Assistance cible kg : " + : "Charge cible kg : "; + if (!prompt_optional_double( 5, - "Charge cible kg : ", + target_load_prompt, &target_has_weight, &target_weight ) || @@ -5415,53 +5464,6 @@ static bool build_session_exercise( 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 = load_mode == 1 ? TRAINLOG_LOAD_NONE @@ -5493,118 +5495,7 @@ static bool build_session_exercise( target_weight; output->sets = set_storage; - output->set_count = - 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; - } - } + output->set_count = 0U; return true; } @@ -6165,6 +6056,292 @@ static void draft_bind_input( : 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( TrainlogSessionDraftExercise *drafts, size_t *count, @@ -6207,6 +6384,9 @@ static void draft_set_summary( size_t output_size ) { + size_t index; + size_t used = 0U; + if (draft == NULL || output == NULL || output_size == 0U) { @@ -6227,33 +6407,56 @@ static void draft_set_summary( "Max %s kg", weight ); - } else if (draft->input.load_mode == - TRAINLOG_LOAD_EXTERNAL) { - (void)snprintf( - output, - output_size, - "%zu séries · %.1f kg", - draft->input.set_count, - 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 + } else if (draft->input.recording_mode == + TRAINLOG_RECORDING_CONTINUOUS) { + char duration[64]; + (void)trainlog_duration_format( + draft->input.continuous_duration_seconds, + duration, + sizeof(duration) ); + (void)snprintf(output, output_size, "Continu · %s", duration); } else { - (void)snprintf( - output, - output_size, - "%zu séries", - draft->input.set_count - ); + int written = snprintf(output, output_size, "%zu séries · ", + draft->input.set_count); + if (written < 0) { + output[0] = '\0'; + 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, "%.*s", 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, @@ -6387,7 +6590,7 @@ static bool edit_session_draft( } else { draw_shell( "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' || key == 'F') { + size_t draft_index; + if (*count == 0U) { status_line( "Ajoutez au moins un exercice avant d'enregistrer.", @@ -6491,6 +6696,28 @@ static bool edit_session_draft( 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; } @@ -6534,6 +6761,7 @@ static bool edit_session_draft( )) { selected = *count; ++(*count); + draft_edit_sets(&drafts[selected]); } continue; @@ -6544,6 +6772,11 @@ static bool edit_session_draft( key == '\n' || key == TRAINLOG_KEY_ENTER) && *count > 0U) { + draft_edit_sets(&drafts[selected]); + continue; + } + + if ((key == 'r' || key == 'R') && *count > 0U) { TrainlogSessionDraftExercise replacement; if (draft_build_exercise( @@ -6932,6 +7165,7 @@ static void screen_new_session( NULL )) { exercise_count = 1U; + draft_edit_sets(&drafts[0]); } if (!edit_session_draft( @@ -7207,6 +7441,7 @@ static void screen_session_detail( size_t count = 0U; size_t selected = 0U; + size_t set_top = 0U; TrainlogStatus status; status = @@ -7277,7 +7512,7 @@ static void screen_session_detail( 2, "%.*s", 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, @@ -7317,7 +7552,7 @@ static void screen_session_detail( } else { draw_shell( "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, @@ -7531,11 +7766,10 @@ static void screen_session_detail( int actual_row = decorated ? 23 : 13; - int summary_row = + int table_header_row = decorated ? 25 : 15; - - int warning_row = - decorated ? 27 : 17; + int table_last_row = + trainlog_terminal_rows(tui_terminal) - 4; if (trainlog_duration_format( exercise->rest_seconds, @@ -7631,14 +7865,18 @@ static void screen_session_detail( trainlog_terminal_printf(tui_terminal, weight_row, decorated ? 5 : 4, - "Charge cible : %.1f kg", + "%s cible : %.1f kg", + exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE + ? "Assistance" : "Charge", exercise->target_weight_kg ); } else { trainlog_terminal_printf(tui_terminal, weight_row, 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, - summary_row, + table_header_row, decorated ? 5 : 4, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 10, - exercise->actual_summary + "%-8s %-18s %-18s", + "Série", + 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) { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); + { + size_t set_index; + size_t visible_sets = table_last_row > table_header_row + ? (size_t)(table_last_row - table_header_row) + : 0U; - trainlog_terminal_printf(tui_terminal, - warning_row, - decorated ? 5 : 4, - "Assistance : plus de kg = davantage d'aide." - ); + if (exercise->actual_set_count == 0U) { + set_top = 0U; + } else if (set_top >= exercise->actual_set_count) { + 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' || key == 'B' || key == 27) { + trainlog_database_free_session_details(exercises, count); return; } @@ -7710,6 +7987,7 @@ static void screen_session_detail( session_id ); + trainlog_database_free_session_details(exercises, count); return; } @@ -7723,6 +8001,17 @@ static void screen_session_detail( 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 && (key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_DOWN)) { @@ -7730,6 +8019,7 @@ static void screen_session_detail( selected + 1U < count ? selected + 1U : 0U; + set_top = 0U; } else if ( count > 0U && (key == TRAINLOG_KEY_LEFT || @@ -7739,6 +8029,7 @@ static void screen_session_detail( selected > 0U ? selected - 1U : count - 1U; + set_top = 0U; } } } diff --git a/tui/tests/test_continuous_detail.c b/tui/tests/test_continuous_detail.c index e9dd366..c202a62 100644 --- a/tui/tests/test_continuous_detail.c +++ b/tui/tests/test_continuous_detail.c @@ -156,6 +156,11 @@ int main(void) ) == 0 ); + trainlog_database_free_session_details( + details, + count + ); + trainlog_database_close( database ); diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c index b9bbf58..045b683 100644 --- a/tui/tests/test_custom_equipment.c +++ b/tui/tests/test_custom_equipment.c @@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void) CHECK(trainlog_database_open(path, &database) == 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)snprintf(custom.equipment_id, sizeof(custom.equipment_id), "%s", "eq_123e4567-e89b-42d3-a456-426614174000"); @@ -100,6 +100,7 @@ static bool test_custom_equipment_round_trip(void) CHECK(count == 1U); CHECK(strcmp(detail[0].entry_id, exercise.entry_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, used, 2U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index cc83688..17af653 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -4,6 +4,7 @@ */ #include +#include #include #include @@ -150,7 +151,7 @@ static bool seed_exercise(TrainlogDatabase *database) static bool test_session_insert(void) { TrainlogDatabase *database = NULL; - TrainlogSetInput sets[2]; + TrainlogSetInput sets[3]; TrainlogSessionExerciseInput exercise; TrainlogSessionInput session; TrainlogSessionSummary persisted_session; @@ -166,8 +167,12 @@ static bool test_session_insert(void) (void)memset(sets, 0, sizeof(sets)); sets[0].reps = 5; sets[0].has_weight = true; - sets[0].weight_kg = 80.0; + sets[0].weight_kg = 32.5; 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)snprintf( @@ -178,12 +183,12 @@ static bool test_session_insert(void) ); exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; exercise.rest_seconds = 60; - exercise.target_sets = 2; + exercise.target_sets = 3; exercise.target_reps = 5; exercise.target_has_weight = true; exercise.target_weight_kg = 80.0; exercise.sets = sets; - exercise.set_count = 2U; + exercise.set_count = 3U; (void)memset(&session, 0, sizeof(session)); (void)snprintf( @@ -233,6 +238,28 @@ static bool test_session_insert(void) CHECK(count == 1U); /* Regression: local occurrences are `sxe`, never synchronization runs. */ 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); return true; diff --git a/tui/tests/test_max_results.c b/tui/tests/test_max_results.c index cd73671..042a95b 100644 --- a/tui/tests/test_max_results.c +++ b/tui/tests/test_max_results.c @@ -101,6 +101,8 @@ static bool test_explicit_max_round_trip_and_identity(void) CHECK(details[2].has_continuous_speed != 0 && details[2].continuous_speed_kmh == 5.5); + trainlog_database_free_session_details(details, count); + CHECK(trainlog_database_load_session_editable(database, "se_explicit_max", &summary, editable, 3U, &count, NULL, 0U, &set_count) == 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(strcmp(details[0].entry_id, "sxe_pec") == 0); + trainlog_database_free_session_details(details, count); + session.session_type = TRAINLOG_SESSION_TRAINING; (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "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 session_exercises VALUES(10,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(21,11,0,1,NULL,80.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;"; char path[] = "/tmp/trainlog-max-v8-XXXXXX"; 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_step(statement) == SQLITE_DONE); 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, "SELECT position,reps,weight_kg FROM performed_sets " "WHERE session_exercise_row_id=11 ORDER BY position;", diff --git a/tui/tests/test_schema_v5_migration.c b/tui/tests/test_schema_v5_migration.c index 78b289d..a2ba7c1 100644 --- a/tui/tests/test_schema_v5_migration.c +++ b/tui/tests/test_schema_v5_migration.c @@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void) TRAINLOG_DATABASE_SCHEMA_VERSION ); - CHECK(version == 9); + CHECK(version == 10); CHECK( trainlog_database_get_session_details( @@ -212,6 +212,7 @@ static bool test_v4_to_current_preserves_session(void) ) == 0 ); + trainlog_database_free_session_details(details, detail_count); trainlog_database_close( database ); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 57b8c2f..b064ec5 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void) CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 9); + CHECK(version == 10); trainlog_database_close(database); 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, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v8: SQLite rc=", - strlen("migrate database to schema v8: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v10: SQLite rc=", + strlen("migrate database to schema v10: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != 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) { - char path[] = "/tmp/trainlog-schema-v10-XXXXXX"; + char path[] = "/tmp/trainlog-schema-v11-XXXXXX"; char diagnostic[256]; sqlite3 *raw = NULL; TrainlogDatabase *database = NULL; @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); 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(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); 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(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c new file mode 100644 index 0000000..491d1ec --- /dev/null +++ b/tui/tests/test_schema_v9_migration.c @@ -0,0 +1,183 @@ +/** + * @file test_schema_v9_migration.c + * @brief Lossless performed-set weight migration regression. + */ + +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_session_detail.c b/tui/tests/test_session_detail.c index da35e48..bce36cc 100644 --- a/tui/tests/test_session_detail.c +++ b/tui/tests/test_session_detail.c @@ -26,7 +26,7 @@ static bool test_session_details(void) { TrainlogDatabase *database = NULL; - TrainlogSetInput sets[3]; + TrainlogSetInput sets[80]; TrainlogSessionExerciseInput exercise; TrainlogSessionInput session; TrainlogSessionSummary summary; @@ -52,15 +52,22 @@ static bool test_session_details(void) (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].has_weight = true; - sets[0].weight_kg = 80.0; + sets[0].weight_kg = 78.25; 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].has_weight = true; - sets[2].weight_kg = 80.0; + sets[2].weight_kg = 81.75; (void)memset( &exercise, @@ -78,14 +85,14 @@ static bool test_session_details(void) "%s", "leg_press"); exercise.load_mode = - TRAINLOG_LOAD_EXTERNAL; + TRAINLOG_LOAD_ASSISTANCE; exercise.rest_seconds = 60; exercise.target_sets = 3; exercise.target_reps = 5; exercise.target_has_weight = true; exercise.target_weight_kg = 80.0; exercise.sets = sets; - exercise.set_count = 3U; + exercise.set_count = 80U; (void)memset( &session, @@ -147,14 +154,21 @@ static bool test_session_details(void) CHECK(details[0].target_reps == 5); CHECK(details[0].rest_seconds == 60); CHECK(strcmp(details[0].equipment_id, "leg_press") == 0); - CHECK(details[0].actual_set_count == 3U); - CHECK( - strcmp( - details[0].actual_summary, - "5@80.0 / 5@80.0 / 3@80.0" - ) == 0 - ); + CHECK(details[0].actual_set_count == 80U); + CHECK(details[0].load_mode == TRAINLOG_LOAD_ASSISTANCE); + CHECK(details[0].actual_sets[0].reps == 5); + CHECK(details[0].actual_sets[0].has_weight); + CHECK(details[0].actual_sets[0].weight_kg > 78.24); + 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); return true; } diff --git a/tui/tests/test_session_edit.c b/tui/tests/test_session_edit.c index 1c91345..c8571cf 100644 --- a/tui/tests/test_session_edit.c +++ b/tui/tests/test_session_edit.c @@ -498,10 +498,115 @@ static bool test_remove_exercise_from_session(void) 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) { CHECK(test_load_replace_and_rollback()); CHECK(test_remove_exercise_from_session()); + CHECK(test_per_set_correction_sequence()); (void)printf( "PASS session_edit\n" diff --git a/tui/tests/test_sync_direction.c b/tui/tests/test_sync_direction.c index 1ca685f..8ec28b1 100644 --- a/tui/tests/test_sync_direction.c +++ b/tui/tests/test_sync_direction.c @@ -92,6 +92,14 @@ int main(void) report.exercises_imported = 1U; trainlog_sync_build_summary(&report); 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"); return 0; } diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c new file mode 100644 index 0000000..1b0e66c --- /dev/null +++ b/tui/tests/test_tui_workflows.c @@ -0,0 +1,248 @@ +/** + * @file test_tui_workflows.c + * @brief Scripted regressions through the production TUI workflow functions. + */ + +#include + +/* 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; +} diff --git a/tui/tests/test_variable_sets.c b/tui/tests/test_variable_sets.c index b788b4d..c3caeb7 100644 --- a/tui/tests/test_variable_sets.c +++ b/tui/tests/test_variable_sets.c @@ -188,6 +188,7 @@ static bool test_targetless_variable_sets(void) ) == 0 ); + trainlog_database_free_session_details(detail, detail_count); trainlog_database_close( database );