feat(sets): add per-set reps and weight editing
This commit is contained in:
parent
f4271c9f3e
commit
1b08db009e
46 changed files with 2449 additions and 480 deletions
16
CHANGELOG.md
16
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
|
||||
|
|
|
|||
22
README.md
22
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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
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, it,
|
||||
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<RawSetRow>.replaceAt(index: Int, value: RawSetRow): List<RawSetRow> =
|
||||
mapIndexed { rowIndex, existing -> if (rowIndex == index) value else existing }
|
||||
|
||||
internal fun encodeRawReps(rows: List<RawSetRow>): String =
|
||||
rows.joinToString(";") { it.repsText }
|
||||
|
||||
internal fun encodeRawWeights(rows: List<RawSetRow>): String =
|
||||
rows.joinToString(";") { it.weightText }
|
||||
|
||||
/**
|
||||
* WHY: schema v9 already has durable raw form columns. Parallel token strings
|
||||
* preserve row order and interior blanks without inventing a schema migration.
|
||||
* Legacy compact rep expressions are expanded once when the row editor opens.
|
||||
*/
|
||||
internal fun rawSetRowsFromForm(form: SessionDraftForm): List<RawSetRow> {
|
||||
val repTokens =
|
||||
if (';' in form.repsText) {
|
||||
form.repsText.split(';')
|
||||
} else if (',' in form.repsText) {
|
||||
/* Legacy compact lists may end in an unfinished token. Keep that
|
||||
* blank row instead of normalizing it away during first reopen. */
|
||||
form.repsText.split(',')
|
||||
} else {
|
||||
val compact = parseRepSequence(form.repsText)
|
||||
if (compact != null) {
|
||||
compact.map(Int::toString)
|
||||
} else {
|
||||
listOf(form.repsText)
|
||||
}
|
||||
}
|
||||
val weightTokens =
|
||||
if (';' in form.weightText) {
|
||||
form.weightText.split(';')
|
||||
} else {
|
||||
listOf(form.weightText)
|
||||
}
|
||||
|
||||
if (repTokens.isEmpty()) return listOf(RawSetRow())
|
||||
return repTokens.mapIndexed { index, reps ->
|
||||
RawSetRow(
|
||||
repsText = reps,
|
||||
/* Compatibility only: a legacy single compact load was broadcast
|
||||
* by the old editor. New edits always persist one token per row. */
|
||||
weightText =
|
||||
if (weightTokens.size == 1) weightTokens.single()
|
||||
else weightTokens.getOrElse(index) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseRawSetRows(rows: List<RawSetRow>): List<SessionSetDraft>? {
|
||||
if (rows.isEmpty() || rows.size > MAX_SESSION_SETS) return null
|
||||
return rows.map { row ->
|
||||
val reps = row.repsText.trim().toIntOrNull()
|
||||
if (reps == null || reps !in 0..MAX_REPS_PER_SET) return null
|
||||
|
||||
val rawWeight = row.weightText.trim()
|
||||
val weight =
|
||||
if (rawWeight.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
rawWeight.replace(',', '.').toDoubleOrNull()
|
||||
?.takeIf { it.isFinite() && it >= 0.0 }
|
||||
?: return null
|
||||
}
|
||||
SessionSetDraft(reps = reps, weightKg = weight)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setRowValidationError(rows: List<RawSetRow>): String {
|
||||
if (rows.isEmpty()) return "Ajoutez au moins une série."
|
||||
rows.forEachIndexed { index, row ->
|
||||
val reps = row.repsText.trim().toIntOrNull()
|
||||
if (reps == null || reps !in 0..MAX_REPS_PER_SET) {
|
||||
return "Série ${index + 1} : saisissez des répétitions entre 0 et $MAX_REPS_PER_SET."
|
||||
}
|
||||
if (row.weightText.isNotBlank()) {
|
||||
val weight = row.weightText.trim().replace(',', '.').toDoubleOrNull()
|
||||
if (weight == null || !weight.isFinite() || weight < 0.0) {
|
||||
return "Série ${index + 1} : saisissez une charge non négative ou laissez la case vide."
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Valeurs de séries invalides."
|
||||
}
|
||||
|
||||
private fun parseRepSequence(
|
||||
text: String,
|
||||
): List<Int>? {
|
||||
|
|
@ -1304,6 +1461,7 @@ private fun buildSessionExerciseDraft(
|
|||
weightText: String = "",
|
||||
maxWeightText: String = "",
|
||||
entryId: String? = null,
|
||||
rawSetRows: List<RawSetRow>? = 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<Double?>? {
|
||||
if (text.trim().isEmpty()) return List(count) { null }
|
||||
val values = text.split(';').map { token ->
|
||||
token.trim().replace(',', '.').toDoubleOrNull()
|
||||
}
|
||||
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val parsed = values as List<Double>
|
||||
return when {
|
||||
parsed.size == 1 -> List(count) { parsed.single() }
|
||||
parsed.size == count -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun draftSummary(
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
docs/tui.md
40
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
sync_compose_diagnostic(
|
||||
output->error,
|
||||
sizeof(output->error),
|
||||
"Android→PC : import équipement : ",
|
||||
useful[0] != '\0'
|
||||
? useful
|
||||
: "échec sans diagnostic du script");
|
||||
: "é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é"
|
||||
|
|
|
|||
737
tui/src/tui.c
737
tui/src/tui.c
|
|
@ -9,6 +9,7 @@
|
|||
#include <fcntl.h>
|
||||
#include <locale.h>
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
|
@ -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."
|
||||
);
|
||||
|
||||
trainlog_terminal_style_off(tui_terminal,
|
||||
trainlog_theme_style(
|
||||
TRAINLOG_COLOR_WARNING
|
||||
)
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,11 @@ int main(void)
|
|||
) == 0
|
||||
);
|
||||
|
||||
trainlog_database_free_session_details(
|
||||
details,
|
||||
count
|
||||
);
|
||||
|
||||
trainlog_database_close(
|
||||
database
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
183
tui/tests/test_schema_v9_migration.c
Normal file
183
tui/tests/test_schema_v9_migration.c
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* @file test_schema_v9_migration.c
|
||||
* @brief Lossless performed-set weight migration regression.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
|
||||
#define CHECK(condition) do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
|
||||
__FILE__, __LINE__, #condition); \
|
||||
return false; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static const char *const V9_FIXTURE_SQL =
|
||||
"PRAGMA foreign_keys=ON;"
|
||||
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,"
|
||||
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
|
||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,"
|
||||
"started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
||||
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,position INTEGER NOT NULL,"
|
||||
"load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,"
|
||||
"target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
|
||||
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL "
|
||||
"REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL CHECK(position>=0),"
|
||||
"reps INTEGER CHECK(reps>=0),duration_seconds INTEGER CHECK(duration_seconds>0),"
|
||||
"weight_kg REAL CHECK(weight_kg>0.0),UNIQUE(session_exercise_row_id,position),"
|
||||
"CHECK((reps IS NOT NULL AND duration_seconds IS NULL) OR "
|
||||
"(reps IS NULL AND duration_seconds IS NOT NULL)));"
|
||||
"CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY "
|
||||
"REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL NOT NULL "
|
||||
"CHECK(max_weight_kg>0.0));"
|
||||
"INSERT INTO exercises VALUES(7,'ex_fixture','Fixture','fixture','reps','sets',0);"
|
||||
"INSERT INTO sessions VALUES(11,'se_fixture','2026-09-09T10:00:00+02:00',NULL,'training',NULL);"
|
||||
"INSERT INTO session_exercises VALUES(13,'sxe_fixture',11,7,'sets',0,4,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
|
||||
"INSERT INTO performed_sets VALUES(17,13,2,8,NULL,32.5);"
|
||||
"INSERT INTO performed_sets VALUES(19,13,5,7,NULL,NULL);"
|
||||
"PRAGMA user_version=9;";
|
||||
|
||||
static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
bool matches = false;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
const unsigned char *value = sqlite3_column_text(statement, 0);
|
||||
matches = value != NULL && strcmp((const char *)value, expected) == 0;
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return matches;
|
||||
}
|
||||
|
||||
static bool test_v9_to_v10_is_lossless(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-schema-v9-v10-XXXXXX";
|
||||
sqlite3 *raw = NULL;
|
||||
sqlite3_stmt *rows = NULL;
|
||||
TrainlogDatabase *database = NULL;
|
||||
int version = 0;
|
||||
int foreign_keys = 0;
|
||||
int fd = mkstemp(path);
|
||||
|
||||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, V9_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 10);
|
||||
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
|
||||
CHECK(foreign_keys == 1);
|
||||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
|
||||
CHECK(sqlite3_prepare_v2(raw, "PRAGMA foreign_key_check;", -1, &rows, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(rows) == SQLITE_DONE);
|
||||
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
|
||||
rows = NULL;
|
||||
CHECK(sqlite3_prepare_v2(raw,
|
||||
"SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
|
||||
"FROM performed_sets ORDER BY position;", -1, &rows, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(rows) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int64(rows, 0) == 17);
|
||||
CHECK(sqlite3_column_int64(rows, 1) == 13);
|
||||
CHECK(sqlite3_column_int(rows, 2) == 2);
|
||||
CHECK(sqlite3_column_int(rows, 3) == 8);
|
||||
CHECK(sqlite3_column_type(rows, 4) == SQLITE_NULL);
|
||||
CHECK(sqlite3_column_double(rows, 5) == 32.5);
|
||||
CHECK(sqlite3_step(rows) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int64(rows, 0) == 19);
|
||||
CHECK(sqlite3_column_type(rows, 5) == SQLITE_NULL);
|
||||
CHECK(sqlite3_step(rows) == SQLITE_DONE);
|
||||
CHECK(sqlite3_finalize(rows) == SQLITE_OK);
|
||||
rows = NULL;
|
||||
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"INSERT INTO performed_sets VALUES(23,13,6,6,NULL,0.0);",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"INSERT INTO performed_sets VALUES(29,13,7,5,NULL,-0.5);",
|
||||
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"INSERT INTO performed_sets VALUES(31,13,8,4,3,NULL);",
|
||||
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"INSERT INTO performed_sets VALUES(37,999,9,4,NULL,NULL);",
|
||||
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_v9_to_v10_failure_rolls_back(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-schema-v9-v10-failure-XXXXXX";
|
||||
char diagnostic[256];
|
||||
sqlite3 *raw = NULL;
|
||||
TrainlogDatabase *database = NULL;
|
||||
int fd = mkstemp(path);
|
||||
|
||||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, V9_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"CREATE TABLE performed_sets_v9(collision INTEGER);",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v10") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar_text_is(raw, "PRAGMA integrity_check;", "ok"));
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
CHECK(sqlite3_prepare_v2(raw, "PRAGMA user_version;", -1,
|
||||
&statement, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int(statement, 0) == 9);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM performed_sets;",
|
||||
-1, &statement, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int(statement, 0) == 2);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
}
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
CHECK(test_v9_to_v10_is_lossless());
|
||||
CHECK(test_v9_to_v10_failure_rolls_back());
|
||||
(void)printf("PASS schema_v9_migration\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
248
tui/tests/test_tui_workflows.c
Normal file
248
tui/tests/test_tui_workflows.c
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* @file test_tui_workflows.c
|
||||
* @brief Scripted regressions through the production TUI workflow functions.
|
||||
*/
|
||||
|
||||
#include <stdarg.h>
|
||||
|
||||
/* The workflows remain file-private in production. Including their translation
|
||||
* unit lets this test drive the exact code paths with the terminal port mocked,
|
||||
* without adding a public test API to Trainlog. */
|
||||
#include "../src/tui.c"
|
||||
|
||||
struct TrainlogTerminal {
|
||||
int events[128];
|
||||
size_t event_count;
|
||||
size_t event_index;
|
||||
char output[32768];
|
||||
size_t output_used;
|
||||
};
|
||||
|
||||
struct TrainlogPanel { int unused; };
|
||||
|
||||
#define CHECK(condition) do { if (!(condition)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
|
||||
__FILE__, __LINE__, #condition); return false; } } while (0)
|
||||
|
||||
static void script(TrainlogTerminal *terminal, const int *events, size_t count)
|
||||
{
|
||||
(void)memset(terminal, 0, sizeof(*terminal));
|
||||
(void)memcpy(terminal->events, events, count * sizeof(events[0]));
|
||||
terminal->event_count = count;
|
||||
}
|
||||
|
||||
TrainlogTerminal *trainlog_terminal_create(void) { return NULL; }
|
||||
void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { (void)terminal; return 24; }
|
||||
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { (void)terminal; return 80; }
|
||||
void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
void trainlog_terminal_render(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
|
||||
void trainlog_terminal_style_off(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
|
||||
void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column,
|
||||
const char *format, ...)
|
||||
{
|
||||
va_list arguments;
|
||||
int written;
|
||||
(void)row;
|
||||
(void)column;
|
||||
if (terminal->output_used >= sizeof(terminal->output)) return;
|
||||
va_start(arguments, format);
|
||||
written = vsnprintf(terminal->output + terminal->output_used,
|
||||
sizeof(terminal->output) - terminal->output_used, format, arguments);
|
||||
va_end(arguments);
|
||||
if (written > 0 && (size_t)written < sizeof(terminal->output) - terminal->output_used) {
|
||||
terminal->output_used += (size_t)written;
|
||||
terminal->output[terminal->output_used++] = '\n';
|
||||
terminal->output[terminal->output_used] = '\0';
|
||||
}
|
||||
}
|
||||
void trainlog_terminal_putn(TrainlogTerminal *terminal, const char *text, size_t length) { (void)terminal; (void)text; (void)length; }
|
||||
void trainlog_terminal_move(TrainlogTerminal *terminal, int row, int column) { (void)terminal; (void)row; (void)column; }
|
||||
void trainlog_terminal_cursor_yx(const TrainlogTerminal *terminal, int *row, int *column) { (void)terminal; if (row) *row = 0; if (column) *column = 40; }
|
||||
void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible) { (void)terminal; (void)visible; }
|
||||
void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint) { (void)terminal; (void)row; (void)column; (void)codepoint; }
|
||||
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right) { (void)terminal; (void)top; (void)left; (void)bottom; (void)right; }
|
||||
int trainlog_terminal_get_key(TrainlogTerminal *terminal) { return terminal->event_index < terminal->event_count ? terminal->events[terminal->event_index++] : TRAINLOG_KEY_NONE; }
|
||||
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5])
|
||||
{
|
||||
int value = trainlog_terminal_get_key(terminal);
|
||||
if (value == TRAINLOG_KEY_NONE) return false;
|
||||
*codepoint = value;
|
||||
utf8[0] = value >= 0 && value < 128 ? (char)value : '\0';
|
||||
utf8[1] = '\0';
|
||||
return true;
|
||||
}
|
||||
bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key) { (void)terminal; (void)key; return false; }
|
||||
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, int top, int left) { (void)terminal; (void)height; (void)width; (void)top; (void)left; return NULL; }
|
||||
void tui_panel_destroy(TrainlogPanel *panel) { (void)panel; }
|
||||
void tui_panel_box(TrainlogPanel *panel) { (void)panel; }
|
||||
void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
|
||||
void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
|
||||
void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) { (void)panel; (void)row; (void)column; (void)format; }
|
||||
void tui_panel_commit(TrainlogPanel *panel) { (void)panel; }
|
||||
|
||||
static bool test_assistance_creation_labels(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExercise created;
|
||||
TrainlogSessionExerciseInput input;
|
||||
TrainlogSessionDraftExercise draft;
|
||||
TrainlogSetInput sets[4];
|
||||
TrainlogTerminal terminal;
|
||||
const int planning_events[] = {
|
||||
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
|
||||
'3', '\n', '2', '0', '\n', '\n', '\n', '\n'
|
||||
};
|
||||
const int table_events[] = {
|
||||
'a', '5', '\n', TRAINLOG_KEY_RIGHT, TRAINLOG_KEY_ENTER,
|
||||
'1', '5', '\n', 'f'
|
||||
};
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_catalog_create_exercise_profiled(database, "Tractions assistées",
|
||||
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, &created) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
script(&terminal, planning_events,
|
||||
sizeof(planning_events) / sizeof(planning_events[0]));
|
||||
tui_terminal = &terminal;
|
||||
CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input,
|
||||
sets, sizeof(sets) / sizeof(sets[0])));
|
||||
CHECK(input.load_mode == TRAINLOG_LOAD_ASSISTANCE);
|
||||
CHECK(input.target_weight_kg == 20.0 && input.set_count == 0U);
|
||||
CHECK(strstr(terminal.output, "Assistance cible kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "Séries réalisées") == NULL);
|
||||
CHECK(strstr(terminal.output, "Séries réellement faites") == NULL);
|
||||
CHECK(strstr(terminal.output, "Charge cible kg") == NULL);
|
||||
|
||||
(void)memset(&draft, 0, sizeof(draft));
|
||||
draft.input = input;
|
||||
draft.input.sets = draft.sets;
|
||||
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||
(void)snprintf(draft.name, sizeof(draft.name), "%s", created.name);
|
||||
script(&terminal, table_events,
|
||||
sizeof(table_events) / sizeof(table_events[0]));
|
||||
draft_edit_sets(&draft);
|
||||
CHECK(draft.input.set_count == 1U);
|
||||
CHECK(draft.sets[0].reps == 5);
|
||||
CHECK(draft.sets[0].reps != draft.input.target_reps);
|
||||
CHECK(draft.sets[0].has_weight && draft.sets[0].weight_kg == 15.0);
|
||||
CHECK(strstr(terminal.output, "Assistance") != NULL);
|
||||
CHECK(strstr(terminal.output, "Charge kg (vide") == NULL);
|
||||
tui_terminal = NULL;
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_duration_creation_starts_empty(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExercise created;
|
||||
TrainlogSessionExerciseInput input;
|
||||
TrainlogSetInput sets[4];
|
||||
TrainlogTerminal terminal;
|
||||
const int planning_events[] = {
|
||||
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
|
||||
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
|
||||
TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER
|
||||
};
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_catalog_create_exercise_profiled(database, "Gainage",
|
||||
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, &created) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
script(&terminal, planning_events,
|
||||
sizeof(planning_events) / sizeof(planning_events[0]));
|
||||
tui_terminal = &terminal;
|
||||
CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input,
|
||||
sets, sizeof(sets) / sizeof(sets[0])));
|
||||
CHECK(input.target_duration_seconds == 45);
|
||||
CHECK(input.set_count == 0U);
|
||||
CHECK(strstr(terminal.output, "Séries réellement faites") == NULL);
|
||||
CHECK(strstr(terminal.output, "Durée réalisée") == NULL);
|
||||
tui_terminal = NULL;
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_append_requires_actual_and_rolls_back(void)
|
||||
{
|
||||
TrainlogSessionDraftExercise draft;
|
||||
TrainlogSessionDraftExercise duration_draft;
|
||||
TrainlogTerminal terminal;
|
||||
const int accepted[] = {'7', '\n'};
|
||||
const int duration_accepted[] = {'1', ':', '3', '0', '\n'};
|
||||
const int cancelled[] = {27};
|
||||
|
||||
(void)memset(&draft, 0, sizeof(draft));
|
||||
draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||
draft.input.target_reps = 12;
|
||||
draft.input.set_count = 1U;
|
||||
draft.sets[0].reps = 5;
|
||||
|
||||
script(&terminal, accepted, sizeof(accepted) / sizeof(accepted[0]));
|
||||
tui_terminal = &terminal;
|
||||
CHECK(draft_append_incomplete_set(&draft));
|
||||
CHECK(draft.input.set_count == 2U);
|
||||
CHECK(draft.sets[0].reps == 5);
|
||||
CHECK(draft.sets[1].reps == 7);
|
||||
CHECK(draft.sets[1].reps != draft.input.target_reps);
|
||||
|
||||
script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0]));
|
||||
CHECK(!draft_append_incomplete_set(&draft));
|
||||
CHECK(draft.input.set_count == 2U);
|
||||
CHECK(draft.sets[0].reps == 5 && draft.sets[1].reps == 7);
|
||||
|
||||
(void)memset(&duration_draft, 0, sizeof(duration_draft));
|
||||
duration_draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
duration_draft.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||||
duration_draft.input.target_duration_seconds = 45;
|
||||
script(&terminal, duration_accepted,
|
||||
sizeof(duration_accepted) / sizeof(duration_accepted[0]));
|
||||
CHECK(draft_append_incomplete_set(&duration_draft));
|
||||
CHECK(duration_draft.input.set_count == 1U);
|
||||
CHECK(duration_draft.sets[0].duration_seconds == 90);
|
||||
CHECK(duration_draft.sets[0].duration_seconds !=
|
||||
duration_draft.input.target_duration_seconds);
|
||||
|
||||
script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0]));
|
||||
CHECK(!draft_append_incomplete_set(&duration_draft));
|
||||
CHECK(duration_draft.input.set_count == 1U);
|
||||
CHECK(duration_draft.sets[0].duration_seconds == 90);
|
||||
tui_terminal = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_empty_sets_cannot_finish(void)
|
||||
{
|
||||
TrainlogSessionDraftExercise draft;
|
||||
TrainlogTerminal terminal;
|
||||
size_t count = 1U;
|
||||
const int events[] = {'f', 'x', 'q'};
|
||||
|
||||
(void)memset(&draft, 0, sizeof(draft));
|
||||
draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
draft.tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||
(void)snprintf(draft.name, sizeof(draft.name), "Squat");
|
||||
script(&terminal, events, sizeof(events) / sizeof(events[0]));
|
||||
tui_terminal = &terminal;
|
||||
CHECK(!edit_session_draft(NULL, &draft, &count,
|
||||
TRAINLOG_SESSION_TRAINING));
|
||||
CHECK(count == 1U && draft.input.set_count == 0U);
|
||||
CHECK(strstr(terminal.output,
|
||||
"Ajoutez au moins une série réalisée pour Squat.") != NULL);
|
||||
tui_terminal = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
CHECK(test_assistance_creation_labels());
|
||||
CHECK(test_duration_creation_starts_empty());
|
||||
CHECK(test_append_requires_actual_and_rolls_back());
|
||||
CHECK(test_empty_sets_cannot_finish());
|
||||
(void)printf("PASS tui_workflows\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -188,6 +188,7 @@ static bool test_targetless_variable_sets(void)
|
|||
) == 0
|
||||
);
|
||||
|
||||
trainlog_database_free_session_details(detail, detail_count);
|
||||
trainlog_database_close(
|
||||
database
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue