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