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
|
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
|
||||||
catalogue exercises in one session, per-set actual weights, and occurrence
|
catalogue exercises in one session, per-set actual weights, and occurrence
|
||||||
equipment associations across Android, desktop, import and export;
|
equipment associations across Android, desktop, import and export;
|
||||||
|
- structured Android and Notcurses per-set row editing: independent actual
|
||||||
|
repetitions and nullable Charge/Assistance values, ordered add/delete/edit
|
||||||
|
operations, and ordered history/detail presentation without target-value
|
||||||
|
substitution;
|
||||||
- shared versioned equipment catalogue, Android machine selection/search,
|
- shared versioned equipment catalogue, Android machine selection/search,
|
||||||
Android-local custom equipment creation, and explicit rejection of unknown
|
Android-local custom equipment creation, and explicit rejection of unknown
|
||||||
equipment identities rather than silent association loss;
|
equipment identities rather than silent association loss;
|
||||||
|
|
@ -66,8 +70,8 @@ Detailed implementation chronology remains available in Git history and
|
||||||
- profile-aware exercise model using recording mode, tracking mode, and
|
- profile-aware exercise model using recording mode, tracking mode, and
|
||||||
supplemental fields;
|
supplemental fields;
|
||||||
- continuous activity persistence without synthetic sets;
|
- continuous activity persistence without synthetic sets;
|
||||||
- variable repetition-set input including `5x10`, explicit lists, and pyramid
|
- explicit table-based desktop actual-set entry, with independently added rows
|
||||||
shorthand such as `4..10..4`;
|
and no compact performed-repetition input;
|
||||||
- persisted desktop session editing and exercise removal;
|
- persisted desktop session editing and exercise removal;
|
||||||
- Android current-session draft exercise removal;
|
- Android current-session draft exercise removal;
|
||||||
- body-observation history, editing, graphs, and normalized overlays;
|
- body-observation history, editing, graphs, and normalized overlays;
|
||||||
|
|
@ -87,6 +91,10 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- desktop schema v10 losslessly rebuilds only `performed_sets` to accept an
|
||||||
|
explicit zero actual `weight_kg`; historic NULL and positive actual loads
|
||||||
|
remain unchanged, while planned targets and explicit MAX results stay
|
||||||
|
strictly positive;
|
||||||
- desktop and Android schema v9 add one-to-one completed/draft max-result rows;
|
- desktop and Android schema v9 add one-to-one completed/draft max-result rows;
|
||||||
`TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data
|
`TRAINLOG_FORMAT_V1` remains frozen and V1 export refuses explicit MAX data
|
||||||
rather than losing or fabricating it;
|
rather than losing or fabricating it;
|
||||||
|
|
@ -166,8 +174,8 @@ Current validated baseline:
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V9=PASS
|
DESKTOP_SCHEMA_V10=PASS
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
|
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -16,7 +16,7 @@ desktop.
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V9=PASS
|
DESKTOP_SCHEMA_V10=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
|
|
@ -38,7 +38,7 @@ EQUIPMENT_DEFINITIONS_V1=PASS
|
||||||
EXERCISE_RECONCILIATION_V2=PASS
|
EXERCISE_RECONCILIATION_V2=PASS
|
||||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -97,20 +97,20 @@ SETS + DURATION
|
||||||
CONTINUOUS + DURATION
|
CONTINUOUS + DURATION
|
||||||
```
|
```
|
||||||
|
|
||||||
Actual repetition sets are stored independently. Compact input supports:
|
Actual repetition sets are stored independently. The Notcurses desktop flow
|
||||||
|
collects planning only, then creates actual work in an ordered table: the user
|
||||||
```text
|
explicitly adds every row and enters its actual repetitions (or duration) and
|
||||||
5x10
|
optional load. It does not accept compact performed-repetition input; normal
|
||||||
4,5,6,7,8,9,10,9,8,7,6,5,4
|
set sessions cannot finish with zero actual rows.
|
||||||
4..10..4
|
|
||||||
```
|
|
||||||
|
|
||||||
A session may contain several ordered occurrences of the same catalogue
|
A session may contain several ordered occurrences of the same catalogue
|
||||||
exercise. Each occurrence has a stable `entry_id`, distinct from the stable
|
exercise. Each occurrence has a stable `entry_id`, distinct from the stable
|
||||||
`exercise_id` of the catalogue item. Equipment selection belongs to that
|
`exercise_id` of the catalogue item. Equipment selection belongs to that
|
||||||
occurrence, as do its actual per-set loads. `external` records an applied or
|
occurrence, as do its actual per-set loads. `external` records an applied or
|
||||||
machine-displayed load; `assistance` records assistance and is not interpreted
|
machine-displayed load; `assistance` records assistance and is not interpreted
|
||||||
as increasing strength.
|
as increasing strength. An actual load is either absent or finite and
|
||||||
|
non-negative, so an explicit zero remains distinct from no recorded load;
|
||||||
|
planned targets remain strictly positive and are never substituted for actuals.
|
||||||
|
|
||||||
In a `max_test` session, an occurrence may instead own one explicit positive
|
In a `max_test` session, an occurrence may instead own one explicit positive
|
||||||
`max_weight_kg`. This result has no performed set, repetitions, or target-set
|
`max_weight_kg`. This result has no performed set, repetitions, or target-set
|
||||||
|
|
@ -296,5 +296,5 @@ BODY_ANALYTICS_V1=PASS
|
||||||
BODY_COMPOSITION_ESTIMATE=PASS
|
BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
|
@ -76,15 +77,13 @@ class SessionDraftUiInstrumentedTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun resumeRestoresRawFormAfterActivityRecreation() {
|
fun resumeRestoresSetRowsAfterActivityRecreation() {
|
||||||
compose.onNodeWithText(
|
compose.onNodeWithText(
|
||||||
"Reprendre la séance en cours"
|
"Reprendre la séance en cours"
|
||||||
).assertIsDisplayed()
|
).assertIsDisplayed()
|
||||||
.performClick()
|
.performClick()
|
||||||
compose.onNodeWithText(
|
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
|
||||||
"4,5,6,"
|
compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
|
||||||
).performScrollTo()
|
|
||||||
.assertIsDisplayed()
|
|
||||||
|
|
||||||
compose.activityRule.scenario.recreate()
|
compose.activityRule.scenario.recreate()
|
||||||
|
|
||||||
|
|
@ -92,10 +91,8 @@ class SessionDraftUiInstrumentedTest {
|
||||||
"Reprendre la séance en cours"
|
"Reprendre la séance en cours"
|
||||||
).assertIsDisplayed()
|
).assertIsDisplayed()
|
||||||
.performClick()
|
.performClick()
|
||||||
compose.onNodeWithText(
|
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
|
||||||
"4,5,6,"
|
compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
|
||||||
).performScrollTo()
|
|
||||||
.assertIsDisplayed()
|
|
||||||
compose.onNodeWithText(
|
compose.onNodeWithText(
|
||||||
"Retirer Test UI"
|
"Retirer Test UI"
|
||||||
).performScrollTo()
|
).performScrollTo()
|
||||||
|
|
@ -220,9 +217,30 @@ class SessionDraftUiInstrumentedTest {
|
||||||
compose.onNodeWithText("Annuler la recherche").performClick()
|
compose.onNodeWithText("Annuler la recherche").performClick()
|
||||||
|
|
||||||
compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed()
|
compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed()
|
||||||
compose.onNodeWithText("4,5,6,")
|
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
|
||||||
.performScrollTo()
|
compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
|
||||||
.assertIsDisplayed()
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rowEditFrenchWeightDeleteAndAddPersistAcrossRecreation() {
|
||||||
|
compose.onNodeWithText("Reprendre la séance en cours").performClick()
|
||||||
|
compose.onNodeWithTag("session-set-1-reps")
|
||||||
|
.performScrollTo().performTextClearance()
|
||||||
|
compose.onNodeWithTag("session-set-1-reps").performTextInput("9")
|
||||||
|
compose.onNodeWithTag("session-set-1-weight")
|
||||||
|
.performScrollTo().performTextInput("32,5")
|
||||||
|
compose.onNodeWithText("Supprimer la série 1").performScrollTo().performClick()
|
||||||
|
compose.onNodeWithText("Ajouter une série").performScrollTo().performClick()
|
||||||
|
|
||||||
|
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")
|
||||||
|
compose.onNodeWithTag("session-set-0-weight").performScrollTo().assertTextEquals("32,5")
|
||||||
|
compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("")
|
||||||
|
|
||||||
|
compose.activityRule.scenario.recreate()
|
||||||
|
compose.onNodeWithText("Reprendre la séance en cours").performClick()
|
||||||
|
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")
|
||||||
|
compose.onNodeWithTag("session-set-0-weight").performScrollTo().assertTextEquals("32,5")
|
||||||
|
compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -2061,7 +2061,7 @@ class TrainlogRepository(
|
||||||
val maximum = if (tracking == "reps") 10000 else 86400
|
val maximum = if (tracking == "reps") 10000 else 86400
|
||||||
if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) ||
|
if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) ||
|
||||||
!set.value(valueKey).isJsonInt(minimum, maximum) ||
|
!set.value(valueKey).isJsonInt(minimum, maximum) ||
|
||||||
(set.has("weight_kg") && !set.value("weight_kg").isPositiveJsonNumber())) {
|
(set.has("weight_kg") && !set.value("weight_kg").isNonnegativeJsonNumber())) {
|
||||||
return "Série V2 invalide."
|
return "Série V2 invalide."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2114,6 +2114,8 @@ class TrainlogRepository(
|
||||||
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
|
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
|
||||||
}
|
}
|
||||||
private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
|
private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
|
||||||
|
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
||||||
|
this is Number && toDouble().isFinite() && toDouble() >= 0.0
|
||||||
|
|
||||||
private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean {
|
private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean {
|
||||||
val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use {
|
val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use {
|
||||||
|
|
@ -3667,13 +3669,17 @@ class TrainlogRepository(
|
||||||
TrackingMode.REPS ->
|
TrackingMode.REPS ->
|
||||||
draft.sets.all {
|
draft.sets.all {
|
||||||
it.reps >= 0 &&
|
it.reps >= 0 &&
|
||||||
it.durationSeconds == 0
|
it.durationSeconds == 0 &&
|
||||||
|
(it.weightKg == null ||
|
||||||
|
(it.weightKg.isFinite() && it.weightKg >= 0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackingMode.DURATION ->
|
TrackingMode.DURATION ->
|
||||||
draft.sets.all {
|
draft.sets.all {
|
||||||
it.durationSeconds > 0 &&
|
it.durationSeconds > 0 &&
|
||||||
it.reps == 0
|
it.reps == 0 &&
|
||||||
|
(it.weightKg == null ||
|
||||||
|
(it.weightKg.isFinite() && it.weightKg >= 0.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
import com.labfytools.trainlog.model.SessionExerciseDetail
|
||||||
|
|
@ -37,6 +38,10 @@ fun SessionDetailScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val equipmentEntries =
|
||||||
|
remember(sessionId, revision) {
|
||||||
|
repository.listEquipment()
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
TrainlogScreen(
|
||||||
subtitle = "D E T A I L S E A N C E"
|
subtitle = "D E T A I L S E A N C E"
|
||||||
|
|
@ -177,7 +182,10 @@ fun SessionDetailScreen(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
SetsDetail(
|
SetsDetail(
|
||||||
exercise
|
exercise = exercise,
|
||||||
|
loadSemantics = equipmentEntries
|
||||||
|
.firstOrNull { it.equipmentId == exercise.equipmentId }
|
||||||
|
?.loadSemantics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +196,7 @@ fun SessionDetailScreen(
|
||||||
@Composable
|
@Composable
|
||||||
private fun SetsDetail(
|
private fun SetsDetail(
|
||||||
exercise: SessionExerciseDetail,
|
exercise: SessionExerciseDetail,
|
||||||
|
loadSemantics: EquipmentLoadSemantics?,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -205,6 +214,18 @@ private fun SetsDetail(
|
||||||
color = colors.accent,
|
color = colors.accent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (exercise.trackingMode == TrackingMode.REPS) {
|
||||||
|
val loadHeading =
|
||||||
|
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
||||||
|
"Assistance (kg)"
|
||||||
|
} else {
|
||||||
|
"Charge (kg)"
|
||||||
|
}
|
||||||
|
/* Readable row table: history must expose every persisted value and
|
||||||
|
* distinguish an absent load from an explicit zero. */
|
||||||
|
TrainlogInfo("Série | Répétitions | $loadHeading", color = colors.muted)
|
||||||
|
}
|
||||||
|
|
||||||
exercise.sets
|
exercise.sets
|
||||||
.forEachIndexed {
|
.forEachIndexed {
|
||||||
index,
|
index,
|
||||||
|
|
@ -215,13 +236,10 @@ private fun SetsDetail(
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
buildString {
|
val renderedWeight = set.weightKg?.let {
|
||||||
append("Série ${index + 1} : ${set.reps} reps")
|
"%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
|
||||||
set.weightKg?.let {
|
} ?: "—"
|
||||||
val rendered = "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
|
"${index + 1} | ${set.reps} | $renderedWeight"
|
||||||
append(" · $rendered kg")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
|
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -710,17 +710,33 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val initialSetRows =
|
||||||
|
remember(key) {
|
||||||
|
rawSetRowsFromForm(initialForm)
|
||||||
|
}
|
||||||
|
|
||||||
var repsText by
|
var repsText by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
initialForm.repsText
|
encodeRawReps(initialSetRows)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var weightText by
|
var weightText by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
initialForm.weightText
|
encodeRawWeights(initialSetRows)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTRACT: SETS + REPS is edited as occurrence-owned rows. The raw
|
||||||
|
* strings (including blanks and invalid fragments) are mirrored into the
|
||||||
|
* durable form after every mutation, while the saved occurrence is only
|
||||||
|
* replaced when the user confirms with "Ajouter à la séance". */
|
||||||
|
var setRows by
|
||||||
|
remember(key) {
|
||||||
|
mutableStateOf(
|
||||||
|
initialSetRows
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -854,56 +870,98 @@ private fun SessionExerciseForm(
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
SessionNumberField(
|
|
||||||
label =
|
|
||||||
"Séries / répétitions",
|
|
||||||
value =
|
|
||||||
repsText,
|
|
||||||
onValueChange = {
|
|
||||||
repsText = it
|
|
||||||
error = null
|
|
||||||
onFormChanged(
|
|
||||||
currentForm(
|
|
||||||
exercise,
|
|
||||||
setCountText,
|
|
||||||
it,
|
|
||||||
durationText,
|
|
||||||
speedText,
|
|
||||||
distanceText,
|
|
||||||
selectedEquipmentId,
|
|
||||||
weightText,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text = "Chaque série conserve ses propres répétitions et sa propre charge.",
|
||||||
"Formats : 5x10 · 4,5,6,7 · 4..10..4",
|
color = colors.muted,
|
||||||
color =
|
|
||||||
colors.muted,
|
|
||||||
)
|
)
|
||||||
|
val loadLabel =
|
||||||
SessionNumberField(
|
if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
||||||
label = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
|
||||||
"Assistance (kg)"
|
"Assistance (kg)"
|
||||||
} else {
|
} else {
|
||||||
"Charge (kg)"
|
"Charge (kg)"
|
||||||
},
|
}
|
||||||
value = weightText,
|
setRows.forEachIndexed { index, row ->
|
||||||
onValueChange = {
|
TrainlogInfo(
|
||||||
weightText = it
|
text = "Série ${index + 1}",
|
||||||
error = null
|
color = colors.accent,
|
||||||
onFormChanged(
|
)
|
||||||
currentForm(
|
SessionNumberField(
|
||||||
exercise, setCountText, repsText, durationText,
|
label = "Série ${index + 1} — Répétitions",
|
||||||
speedText, distanceText, selectedEquipmentId, it,
|
value = row.repsText,
|
||||||
|
testTag = "session-set-$index-reps",
|
||||||
|
onValueChange = { value ->
|
||||||
|
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
SessionNumberField(
|
||||||
|
label = "Série ${index + 1} — $loadLabel",
|
||||||
|
value = row.weightText,
|
||||||
|
testTag = "session-set-$index-weight",
|
||||||
|
onValueChange = { value ->
|
||||||
|
val updated = setRows.replaceAt(index, row.copy(weightText = value))
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Supprimer la série ${index + 1}",
|
||||||
|
description = "Retirer uniquement cette série.",
|
||||||
|
accent = colors.error,
|
||||||
|
onClick = {
|
||||||
|
val updated = setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogAction(
|
||||||
|
label = "Ajouter une série",
|
||||||
|
description = "Ajouter une ligne vide sans modifier les autres séries.",
|
||||||
|
accent = colors.success,
|
||||||
|
onClick = {
|
||||||
|
if (setRows.size < MAX_SESSION_SETS) {
|
||||||
|
val updated = setRows + RawSetRow()
|
||||||
|
setRows = updated
|
||||||
|
repsText = encodeRawReps(updated)
|
||||||
|
weightText = encodeRawWeights(updated)
|
||||||
|
error = null
|
||||||
|
onFormChanged(
|
||||||
|
currentForm(
|
||||||
|
exercise, setCountText, repsText, durationText,
|
||||||
|
speedText, distanceText, selectedEquipmentId, weightText,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text = "Une valeur par série séparée par ; (ex. 12,5;15). Une seule valeur s'applique à toutes les séries.",
|
text = "Charge facultative ; virgule française acceptée. Une case vide n'est pas zéro.",
|
||||||
color = colors.muted,
|
color = colors.muted,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1063,12 +1121,18 @@ private fun SessionExerciseForm(
|
||||||
weightText = weightText,
|
weightText = weightText,
|
||||||
maxWeightText = maxWeightText,
|
maxWeightText = maxWeightText,
|
||||||
entryId = initialForm.editingEntryId,
|
entryId = initialForm.editingEntryId,
|
||||||
|
rawSetRows = setRows,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (draft == null) {
|
if (draft == null) {
|
||||||
error =
|
error =
|
||||||
if (sessionType == SessionType.MAX_TEST) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
"Saisissez un poids max strictement positif (ex. 100 ou 86,5)."
|
"Saisissez un poids max strictement positif (ex. 100 ou 86,5)."
|
||||||
|
} else if (
|
||||||
|
exercise.recordingMode == RecordingMode.SETS &&
|
||||||
|
exercise.trackingMode == TrackingMode.REPS
|
||||||
|
) {
|
||||||
|
setRowValidationError(setRows)
|
||||||
} else {
|
} else {
|
||||||
"Valeurs invalides."
|
"Valeurs invalides."
|
||||||
}
|
}
|
||||||
|
|
@ -1129,18 +1193,111 @@ private fun SessionNumberField(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
|
testTag: String? = null,
|
||||||
) {
|
) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(
|
||||||
label = label,
|
label = label,
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange =
|
onValueChange =
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
testTag = testTag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val MAX_SESSION_SETS = 64
|
private const val MAX_SESSION_SETS = 64
|
||||||
private const val MAX_REPS_PER_SET = 10000
|
private const val MAX_REPS_PER_SET = 10000
|
||||||
|
|
||||||
|
internal data class RawSetRow(
|
||||||
|
val repsText: String = "",
|
||||||
|
val weightText: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<RawSetRow>.replaceAt(index: Int, value: RawSetRow): List<RawSetRow> =
|
||||||
|
mapIndexed { rowIndex, existing -> if (rowIndex == index) value else existing }
|
||||||
|
|
||||||
|
internal fun encodeRawReps(rows: List<RawSetRow>): String =
|
||||||
|
rows.joinToString(";") { it.repsText }
|
||||||
|
|
||||||
|
internal fun encodeRawWeights(rows: List<RawSetRow>): String =
|
||||||
|
rows.joinToString(";") { it.weightText }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY: schema v9 already has durable raw form columns. Parallel token strings
|
||||||
|
* preserve row order and interior blanks without inventing a schema migration.
|
||||||
|
* Legacy compact rep expressions are expanded once when the row editor opens.
|
||||||
|
*/
|
||||||
|
internal fun rawSetRowsFromForm(form: SessionDraftForm): List<RawSetRow> {
|
||||||
|
val repTokens =
|
||||||
|
if (';' in form.repsText) {
|
||||||
|
form.repsText.split(';')
|
||||||
|
} else if (',' in form.repsText) {
|
||||||
|
/* Legacy compact lists may end in an unfinished token. Keep that
|
||||||
|
* blank row instead of normalizing it away during first reopen. */
|
||||||
|
form.repsText.split(',')
|
||||||
|
} else {
|
||||||
|
val compact = parseRepSequence(form.repsText)
|
||||||
|
if (compact != null) {
|
||||||
|
compact.map(Int::toString)
|
||||||
|
} else {
|
||||||
|
listOf(form.repsText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val weightTokens =
|
||||||
|
if (';' in form.weightText) {
|
||||||
|
form.weightText.split(';')
|
||||||
|
} else {
|
||||||
|
listOf(form.weightText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repTokens.isEmpty()) return listOf(RawSetRow())
|
||||||
|
return repTokens.mapIndexed { index, reps ->
|
||||||
|
RawSetRow(
|
||||||
|
repsText = reps,
|
||||||
|
/* Compatibility only: a legacy single compact load was broadcast
|
||||||
|
* by the old editor. New edits always persist one token per row. */
|
||||||
|
weightText =
|
||||||
|
if (weightTokens.size == 1) weightTokens.single()
|
||||||
|
else weightTokens.getOrElse(index) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseRawSetRows(rows: List<RawSetRow>): List<SessionSetDraft>? {
|
||||||
|
if (rows.isEmpty() || rows.size > MAX_SESSION_SETS) return null
|
||||||
|
return rows.map { row ->
|
||||||
|
val reps = row.repsText.trim().toIntOrNull()
|
||||||
|
if (reps == null || reps !in 0..MAX_REPS_PER_SET) return null
|
||||||
|
|
||||||
|
val rawWeight = row.weightText.trim()
|
||||||
|
val weight =
|
||||||
|
if (rawWeight.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
rawWeight.replace(',', '.').toDoubleOrNull()
|
||||||
|
?.takeIf { it.isFinite() && it >= 0.0 }
|
||||||
|
?: return null
|
||||||
|
}
|
||||||
|
SessionSetDraft(reps = reps, weightKg = weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun setRowValidationError(rows: List<RawSetRow>): String {
|
||||||
|
if (rows.isEmpty()) return "Ajoutez au moins une série."
|
||||||
|
rows.forEachIndexed { index, row ->
|
||||||
|
val reps = row.repsText.trim().toIntOrNull()
|
||||||
|
if (reps == null || reps !in 0..MAX_REPS_PER_SET) {
|
||||||
|
return "Série ${index + 1} : saisissez des répétitions entre 0 et $MAX_REPS_PER_SET."
|
||||||
|
}
|
||||||
|
if (row.weightText.isNotBlank()) {
|
||||||
|
val weight = row.weightText.trim().replace(',', '.').toDoubleOrNull()
|
||||||
|
if (weight == null || !weight.isFinite() || weight < 0.0) {
|
||||||
|
return "Série ${index + 1} : saisissez une charge non négative ou laissez la case vide."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Valeurs de séries invalides."
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseRepSequence(
|
private fun parseRepSequence(
|
||||||
text: String,
|
text: String,
|
||||||
): List<Int>? {
|
): List<Int>? {
|
||||||
|
|
@ -1304,6 +1461,7 @@ private fun buildSessionExerciseDraft(
|
||||||
weightText: String = "",
|
weightText: String = "",
|
||||||
maxWeightText: String = "",
|
maxWeightText: String = "",
|
||||||
entryId: String? = null,
|
entryId: String? = null,
|
||||||
|
rawSetRows: List<RawSetRow>? = null,
|
||||||
): SessionExerciseDraft? {
|
): SessionExerciseDraft? {
|
||||||
if (sessionType == SessionType.MAX_TEST) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull()
|
val maxWeight = maxWeightText.trim().replace(',', '.').toDoubleOrNull()
|
||||||
|
|
@ -1391,24 +1549,18 @@ private fun buildSessionExerciseDraft(
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
) {
|
) {
|
||||||
val reps =
|
val parsedSets =
|
||||||
parseRepSequence(
|
parseRawSetRows(
|
||||||
repsText
|
rawSetRows ?: rawSetRowsFromForm(
|
||||||
|
SessionDraftForm(repsText = repsText, weightText = weightText)
|
||||||
|
)
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
||||||
val weights = parseWeightSequence(weightText, reps.size) ?: return null
|
|
||||||
|
|
||||||
SessionExerciseDraft(
|
SessionExerciseDraft(
|
||||||
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
|
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
equipmentId = equipmentId,
|
equipmentId = equipmentId,
|
||||||
sets =
|
sets = parsedSets,
|
||||||
reps.mapIndexed { index, rep ->
|
|
||||||
SessionSetDraft(
|
|
||||||
reps = rep,
|
|
||||||
weightKg = weights[index],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val count =
|
val count =
|
||||||
|
|
@ -1445,7 +1597,7 @@ private fun buildSessionExerciseDraft(
|
||||||
|
|
||||||
/** Reconstruct editable text from the entry itself; editing never mutates a
|
/** Reconstruct editable text from the entry itself; editing never mutates a
|
||||||
* different entry or the global exercise definition. */
|
* different entry or the global exercise definition. */
|
||||||
private fun formForExistingExercise(
|
internal fun formForExistingExercise(
|
||||||
draft: SessionExerciseDraft,
|
draft: SessionExerciseDraft,
|
||||||
index: Int,
|
index: Int,
|
||||||
): SessionDraftForm =
|
): SessionDraftForm =
|
||||||
|
|
@ -1461,7 +1613,11 @@ private fun formForExistingExercise(
|
||||||
} else {
|
} else {
|
||||||
"3x10"
|
"3x10"
|
||||||
},
|
},
|
||||||
weightText = draft.sets.mapNotNull { it.weightKg }.joinToString(";") { "%g".format(java.util.Locale.FRANCE, it) },
|
/* INVARIANT: keep one load token per performed-set row. mapNotNull
|
||||||
|
* would shift later weights left when an earlier row is blank. */
|
||||||
|
weightText = draft.sets.joinToString(";") {
|
||||||
|
it.weightKg?.let(::formatMaxWeight).orEmpty()
|
||||||
|
},
|
||||||
durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
|
durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
|
||||||
(draft.continuousDurationSeconds / 60).toString()
|
(draft.continuousDurationSeconds / 60).toString()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1471,23 +1627,6 @@ private fun formForExistingExercise(
|
||||||
distanceText = draft.distanceKm?.toString().orEmpty(),
|
distanceText = draft.distanceKm?.toString().orEmpty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Accept French decimal commas without confusing them with the set separator.
|
|
||||||
* CONTRACT: blank means no load recorded; zero is a real explicit value. */
|
|
||||||
private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
|
|
||||||
if (text.trim().isEmpty()) return List(count) { null }
|
|
||||||
val values = text.split(';').map { token ->
|
|
||||||
token.trim().replace(',', '.').toDoubleOrNull()
|
|
||||||
}
|
|
||||||
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val parsed = values as List<Double>
|
|
||||||
return when {
|
|
||||||
parsed.size == 1 -> List(count) { parsed.single() }
|
|
||||||
parsed.size == count -> parsed
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun draftSummary(
|
private fun draftSummary(
|
||||||
draft: SessionExerciseDraft,
|
draft: SessionExerciseDraft,
|
||||||
): String {
|
): String {
|
||||||
|
|
@ -1531,6 +1670,7 @@ private fun draftSummary(
|
||||||
|
|
||||||
if (
|
if (
|
||||||
reps.isNotEmpty() &&
|
reps.isNotEmpty() &&
|
||||||
|
draft.sets.all { it.weightKg == null } &&
|
||||||
reps.all {
|
reps.all {
|
||||||
it == reps.first()
|
it == reps.first()
|
||||||
}
|
}
|
||||||
|
|
@ -1544,10 +1684,12 @@ private fun draftSummary(
|
||||||
(
|
(
|
||||||
"${draft.exercise.name} · " +
|
"${draft.exercise.name} · " +
|
||||||
"${reps.size} séries · " +
|
"${reps.size} séries · " +
|
||||||
reps.joinToString(
|
draft.sets.joinToString(separator = " ; ") { set ->
|
||||||
separator = ","
|
buildString {
|
||||||
) +
|
append("${set.reps} reps")
|
||||||
" reps"
|
set.weightKg?.let { append(" @ ${formatMaxWeight(it)} kg") }
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,115 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertTrue(restored.updatedAt.isNotBlank())
|
assertTrue(restored.updatedAt.isNotBlank())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun actualSetWeightsPreservePositiveZeroAndAbsentAcrossDraftFinalizeAndExport() {
|
||||||
|
val first = openRepository()
|
||||||
|
val exercise = createExercise(first, "Charges exactes", RecordingMode.SETS, TrackingMode.REPS)
|
||||||
|
val expectedSets = listOf(
|
||||||
|
SessionSetDraft(reps = 8, weightKg = 32.5),
|
||||||
|
SessionSetDraft(reps = 7, weightKg = 0.0),
|
||||||
|
SessionSetDraft(reps = 6, weightKg = null),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
first.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
exercises = listOf(SessionExerciseDraft(exercise = exercise, sets = expectedSets)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
first.close()
|
||||||
|
repository = null
|
||||||
|
|
||||||
|
val reopened = openRepository()
|
||||||
|
assertEquals(expectedSets, loadDraft(reopened).exercises.single().sets)
|
||||||
|
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
|
val exportedSets = JSONObject(reopened.buildMobileExportV2Json())
|
||||||
|
.getJSONArray("sessions").getJSONObject(0)
|
||||||
|
.getJSONArray("exercises").getJSONObject(0).getJSONArray("sets")
|
||||||
|
assertEquals(32.5, exportedSets.getJSONObject(0).getDouble("weight_kg"), 0.0)
|
||||||
|
assertTrue(exportedSets.getJSONObject(1).has("weight_kg"))
|
||||||
|
assertEquals(0.0, exportedSets.getJSONObject(1).getDouble("weight_kg"), 0.0)
|
||||||
|
assertFalse(exportedSets.getJSONObject(2).has("weight_kg"))
|
||||||
|
|
||||||
|
val invalidDraft = SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 5, weightKg = Double.NaN)),
|
||||||
|
)
|
||||||
|
assertTrue(reopened.saveSession(SessionDraft(listOf(invalidDraft))) is SaveSessionResult.Invalid)
|
||||||
|
assertTrue(
|
||||||
|
reopened.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(invalidDraft)))
|
||||||
|
is ActiveDraftMutationResult.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pcMobileV2AcceptsZeroWeightAndRejectsInvalidWeightsAtomically() {
|
||||||
|
val repo = openRepository()
|
||||||
|
val localExercise = createExercise(repo, "Charge V2", RecordingMode.SETS, TrackingMode.REPS)
|
||||||
|
|
||||||
|
fun artifact(weight: Any, sessionId: String, max: Boolean = false): JSONObject {
|
||||||
|
val exercise = JSONObject()
|
||||||
|
.put("exercise_id", localExercise.exerciseId)
|
||||||
|
.put("name", "Charge V2")
|
||||||
|
.put("recording_mode", "sets")
|
||||||
|
.put("tracking_mode", "reps")
|
||||||
|
.put("data_fields", 0)
|
||||||
|
val entry = JSONObject()
|
||||||
|
.put("entry_id", "sxe_$sessionId")
|
||||||
|
.put("position", 0)
|
||||||
|
.put("exercise_id", localExercise.exerciseId)
|
||||||
|
.put("name", "Charge V2")
|
||||||
|
.put("recording_mode", "sets")
|
||||||
|
.put("tracking_mode", "reps")
|
||||||
|
.put("data_fields", 0)
|
||||||
|
.put("load_mode", "none")
|
||||||
|
.put("rest_seconds", 0)
|
||||||
|
.put("equipment_id", JSONObject.NULL)
|
||||||
|
if (max) {
|
||||||
|
entry.put("max_weight_kg", weight)
|
||||||
|
} else {
|
||||||
|
entry.put("sets", org.json.JSONArray().put(JSONObject().put("reps", 5).put("weight_kg", weight)))
|
||||||
|
}
|
||||||
|
return JSONObject()
|
||||||
|
.put("format", "trainlog-mobile-export")
|
||||||
|
.put("version", 2)
|
||||||
|
.put("generated_at", "2026-09-09T10:00:00+02:00")
|
||||||
|
.put("exercises", org.json.JSONArray().put(exercise))
|
||||||
|
.put(
|
||||||
|
"sessions",
|
||||||
|
org.json.JSONArray().put(
|
||||||
|
JSONObject()
|
||||||
|
.put("session_id", sessionId)
|
||||||
|
.put("started_at", "2026-09-09T10:00:00+02:00")
|
||||||
|
.put("session_type", if (max) "max_test" else "training")
|
||||||
|
.put("exercises", org.json.JSONArray().put(entry)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.put("body_observations", org.json.JSONArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
val accepted = artifact(0.0, "se_zero_v2")
|
||||||
|
assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), repo.applyPcMobileExportV2Json(accepted.toString()))
|
||||||
|
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), repo.applyPcMobileExportV2Json(accepted.toString()))
|
||||||
|
val before = JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString()
|
||||||
|
listOf(-1.0, true, "0").forEachIndexed { index, value ->
|
||||||
|
assertTrue(
|
||||||
|
repo.applyPcMobileExportV2Json(artifact(value, "se_invalid_$index").toString())
|
||||||
|
is MobileSessionImportResult.Invalid,
|
||||||
|
)
|
||||||
|
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
|
||||||
|
}
|
||||||
|
listOf("NaN", "Infinity", "-Infinity").forEachIndexed { index, token ->
|
||||||
|
val invalidJson = artifact(1.234567, "se_nonfinite_$index").toString()
|
||||||
|
.replace("1.234567", token)
|
||||||
|
assertTrue(repo.applyPcMobileExportV2Json(invalidJson) is MobileSessionImportResult.Invalid)
|
||||||
|
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
|
||||||
|
}
|
||||||
|
assertTrue(repo.applyPcMobileExportV2Json(artifact(0.0, "se_zero_max", max = true).toString()) is MobileSessionImportResult.Invalid)
|
||||||
|
assertEquals(before, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").toString())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() {
|
fun removingExerciseAndDiscardingDraftDoNotDeleteCatalog() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
@ -215,9 +324,18 @@ class TrainlogRepositoryDraftTest {
|
||||||
val draft = ActiveSessionDraft(
|
val draft = ActiveSessionDraft(
|
||||||
exercises = listOf(SessionExerciseDraft(
|
exercises = listOf(SessionExerciseDraft(
|
||||||
exercise = exercise, equipmentId = equipmentId,
|
exercise = exercise, equipmentId = equipmentId,
|
||||||
sets = listOf(SessionSetDraft(10, weightKg = 12.5), SessionSetDraft(8, weightKg = 15.0)),
|
sets = listOf(
|
||||||
|
SessionSetDraft(10, weightKg = 12.5),
|
||||||
|
SessionSetDraft(8, weightKg = null),
|
||||||
|
SessionSetDraft(6, weightKg = 15.0),
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
form = SessionDraftForm(selectedExercise = exercise, selectedEquipmentId = equipmentId, weightText = "12,5;15"),
|
form = SessionDraftForm(
|
||||||
|
selectedExercise = exercise,
|
||||||
|
selectedEquipmentId = equipmentId,
|
||||||
|
repsText = "10;8;6",
|
||||||
|
weightText = "12,5;;15",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
|
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
|
||||||
repo.close(); repository = null
|
repo.close(); repository = null
|
||||||
|
|
@ -226,7 +344,8 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId })
|
assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId })
|
||||||
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!!
|
val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!!
|
||||||
assertEquals(listOf(12.5, 15.0), detail.exercises.single().sets.map { it.weightKg })
|
assertEquals(listOf(10, 8, 6), detail.exercises.single().sets.map { it.reps })
|
||||||
|
assertEquals(listOf(12.5, null, 15.0), detail.exercises.single().sets.map { it.weightKg })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -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
|
### Sets + repetitions
|
||||||
|
|
||||||
Actual set values may be heterogeneous.
|
Actual set values may be heterogeneous. The `SETS + REPS` editor presents
|
||||||
|
ordered rows, each with its own repetitions and optional load. **Ajouter une
|
||||||
|
série** appends one blank row and **Supprimer la série** removes only the chosen
|
||||||
|
row; editing or removing a row does not alter the remaining row values.
|
||||||
|
|
||||||
Compact entry supports:
|
The load heading is **Charge (kg)** for external resistance and
|
||||||
|
**Assistance (kg)** for assistance equipment. A blank load is no recorded load,
|
||||||
|
not `0`; an entered load is finite and non-negative, and French decimal commas
|
||||||
|
are accepted. The durable raw form preserves
|
||||||
|
partial row input (including a blank row or a fragment such as `32,`) across
|
||||||
|
draft save and restore, and a failed validation or draft write presents a
|
||||||
|
specific error without claiming the row was saved.
|
||||||
|
|
||||||
|
When an older compact raw draft is reopened, its repetition text can be
|
||||||
|
expanded into the row editor from:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
5x10
|
5x10
|
||||||
|
|
@ -140,17 +152,20 @@ it does not merge or alter another passage of the same exercise.
|
||||||
`Machine / équipement (optionnel)` searches the shared manifest by display
|
`Machine / équipement (optionnel)` searches the shared manifest by display
|
||||||
name, physical-machine label and aliases. A selected equipment identity is
|
name, physical-machine label and aliases. A selected equipment identity is
|
||||||
stored on that occurrence in both the active draft and completed session.
|
stored on that occurrence in both the active draft and completed session.
|
||||||
For `SETS + REPS`, `Charge (kg)` accepts one value for all sets or `;`-separated
|
For `SETS + REPS`, the form is a row editor: every set owns an independently
|
||||||
per-set values; French decimal commas are accepted. `Assistance (kg)` is an
|
editable repetitions field and optional load field, and rows can be added or
|
||||||
explicit alternative load semantic, not an external charge. Empty load and an
|
deleted without changing their neighbours. French decimal commas are accepted.
|
||||||
entered zero remain distinct.
|
`Assistance (kg)` is an explicit alternative load semantic, not an external
|
||||||
|
charge. Empty load and an entered zero remain distinct. The completed-session
|
||||||
|
detail renders the persisted rows in order with the matching Charge or
|
||||||
|
Assistance heading, including an explicit empty-load marker.
|
||||||
|
|
||||||
## 6. Session draft editing
|
## 6. Session draft editing
|
||||||
|
|
||||||
The repository durably saves every meaningful mutation, including session type,
|
The repository durably saves every meaningful mutation, including session type,
|
||||||
exercise selection/addition/removal, actual values and raw form edits. Partial
|
exercise selection/addition/removal, actual values and raw per-set form edits.
|
||||||
text such as `4,5,6,` is retained without normalization. A failed write displays
|
Partial row text such as `32,` is retained without normalization. A failed write
|
||||||
a specific error and does not claim the latest change was saved.
|
displays a specific error and does not claim the latest change was saved.
|
||||||
|
|
||||||
Home shows **Reprendre la séance en cours** and an exercise-count/type summary.
|
Home shows **Reprendre la séance en cours** and an exercise-count/type summary.
|
||||||
The ordinary new-session action opens an existing draft without overwriting it.
|
The ordinary new-session action opens an existing draft without overwriting it.
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,10 @@ Continuous work is persisted separately from performed sets.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v9 is canonical long-term history. `session_exercises`
|
Desktop SQLite schema v10 is canonical long-term history. Its v9 -> v10
|
||||||
|
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
||||||
|
be finite `>= 0`; the column already existed and targets/max results retain
|
||||||
|
their strictly-positive contracts. `session_exercises`
|
||||||
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
||||||
occur more than once in one session without identity fusion.
|
occur more than once in one session without identity fusion.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Current implementation state
|
# Current implementation state
|
||||||
|
|
||||||
Canonical snapshot: 2026-09-08.
|
Canonical snapshot: 2026-09-09.
|
||||||
|
|
||||||
This document is the compact source of truth for the implemented Trainlog
|
This document is the compact source of truth for the implemented Trainlog
|
||||||
baseline. Detailed behavior belongs in the topic-specific documents.
|
baseline. Detailed behavior belongs in the topic-specific documents.
|
||||||
|
|
@ -14,7 +14,7 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V9=PASS
|
DESKTOP_SCHEMA_V10=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
ANDROID_DRAFT_DURABLE=PASS
|
ANDROID_DRAFT_DURABLE=PASS
|
||||||
|
|
@ -57,7 +57,7 @@ EXERCISE_RECONCILIATION_V2=PASS
|
||||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||||
MAX_TEST_RESUME_STABLE_ID=PASS
|
MAX_TEST_RESUME_STABLE_ID=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=PASS
|
HARDWARE_SYNC_VALIDATION=PASS
|
||||||
```
|
```
|
||||||
|
|
@ -67,10 +67,15 @@ HARDWARE_SYNC_VALIDATION=PASS
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
||||||
- SQLite schema v9, with stable ordered `session_exercises.entry_id`,
|
- SQLite schema v10, with stable ordered `session_exercises.entry_id`,
|
||||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
definitions, plus occurrence-owned `max_results`;
|
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||||
|
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
||||||
|
preserving historic NULL and positive rows;
|
||||||
- direct session entry;
|
- direct session entry;
|
||||||
|
- normal desktop SETS planning followed by the table-only explicit actual-row
|
||||||
|
editor; zero-row completion is rejected while MAX and continuous entries keep
|
||||||
|
their separate no-set contracts;
|
||||||
- persisted session detail and editing;
|
- persisted session detail and editing;
|
||||||
- exercise removal from a session through transactional child replacement;
|
- exercise removal from a session through transactional child replacement;
|
||||||
- exercise catalog;
|
- exercise catalog;
|
||||||
|
|
@ -104,7 +109,7 @@ Primary navigation:
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- native Kotlin/Compose application;
|
- native Kotlin/Compose application;
|
||||||
- local SQLite database v8, with non-destructive v3 -> v8 migration;
|
- local SQLite database v9, with non-destructive v3 -> v9 migration;
|
||||||
- one durable active-session draft, Home resume and raw-form restoration;
|
- one durable active-session draft, Home resume and raw-form restoration;
|
||||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||||
- exercise creation;
|
- exercise creation;
|
||||||
|
|
@ -221,17 +226,17 @@ No mounted Android filesystem is required.
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
34/34 Meson tests PASS for the current desktop schema v9 baseline
|
36/36 Meson tests PASS for the current desktop schema v10 baseline
|
||||||
frozen JSON validator PASS
|
JSON valid/invalid checks PASS
|
||||||
import-contract validator PASS
|
import-contract validator 6/6 PASS
|
||||||
ASan/UBSan 34/34 Meson tests PASS
|
ASan/UBSan 14/14 Meson tests PASS postrepair
|
||||||
git diff --check PASS
|
git diff --check PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android:
|
Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
testDebugUnitTest PASS
|
37 Android unit tests PASS
|
||||||
assembleDebug PASS
|
assembleDebug PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -282,11 +287,11 @@ ASSISTANCE_DIRECTION_AWARE=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=PASS
|
ANDROID_MAX_TEST_SESSION=PASS
|
||||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||||
MAX_TEST_RESUME_STABLE_ID=PASS
|
MAX_TEST_RESUME_STABLE_ID=PASS
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
A measured maximum belongs to an exercise occurrence in an explicit `max_test`
|
A measured maximum belongs to an exercise occurrence in an explicit `max_test`
|
||||||
session. Schema v9 persists a positive `max_weight_kg` separately from sets;
|
session. Schema v9 introduced the positive `max_weight_kg` separately from sets;
|
||||||
equipment is optional context, so one physical machine may carry independent
|
equipment is optional context, so one physical machine may carry independent
|
||||||
Pec Fly and Rear Delt Fly results. Ordinary training is never promoted
|
Pec Fly and Rear Delt Fly results. Ordinary training is never promoted
|
||||||
implicitly.
|
implicitly.
|
||||||
|
|
@ -312,7 +317,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
NO_ESTIMATE_PERSISTENCE=PASS
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android remains capture-only for this feature.
|
Android remains capture-only for this feature.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=9
|
TRAINLOG_DATABASE_SCHEMA_VERSION=10
|
||||||
DATABASE_SCHEMA_V9=PASS
|
DATABASE_SCHEMA_V10=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,9 +23,15 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
9
|
10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The independent actual-set loads documented in the current desktop, Android
|
||||||
|
and V2 flows use the existing ordered `performed_sets.weight_kg` field and the
|
||||||
|
corresponding durable draft-set field. The per-set column itself pre-existed,
|
||||||
|
but desktop v10 is required: v9 -> v10 rebuilds `performed_sets` solely to
|
||||||
|
widen actual `weight_kg` from finite `> 0` to finite `>= 0`.
|
||||||
|
|
||||||
Supported historical databases are migrated explicitly through the implemented
|
Supported historical databases are migrated explicitly through the implemented
|
||||||
migration chain. A database newer than the running binary understands is
|
migration chain. A database newer than the running binary understands is
|
||||||
rejected.
|
rejected.
|
||||||
|
|
@ -47,6 +53,14 @@ performed set with `reps = 1`, no duration, and a positive weight. The stable
|
||||||
session, occurrence, exercise, position, and equipment identities are retained.
|
session, occurrence, exercise, position, and equipment identities are retained.
|
||||||
Multiple attempts and every other ambiguous shape remain as historical sets.
|
Multiple attempts and every other ambiguous shape remain as historical sets.
|
||||||
|
|
||||||
|
Version 10 rebuilds only `performed_sets`. Its explicit projection preserves
|
||||||
|
every row ID, owning occurrence, position, repetitions-or-duration, and
|
||||||
|
existing `NULL` or positive `weight_kg` value unchanged; it permits a new
|
||||||
|
explicit zero actual load. The migration is transactional. Regression coverage
|
||||||
|
checks lossless migration, rollback after an injected rebuild-name collision,
|
||||||
|
`PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key
|
||||||
|
enforcement, and rejection of negative loads or invalid metric shapes.
|
||||||
|
|
||||||
A schema fixture must represent the real historical structure. Rewriting only
|
A schema fixture must represent the real historical structure. Rewriting only
|
||||||
`user_version` is not an acceptable migration test.
|
`user_version` is not an acceptable migration test.
|
||||||
|
|
||||||
|
|
@ -179,7 +193,12 @@ duration_seconds
|
||||||
Actual repetitions may be zero.
|
Actual repetitions may be zero.
|
||||||
|
|
||||||
Each row is independent; heterogeneous repetition sequences are first-class
|
Each row is independent; heterogeneous repetition sequences are first-class
|
||||||
data.
|
data. Its nullable `weight_kg` is likewise occurrence-set data: blank is
|
||||||
|
distinct from an explicit zero and from a planned target weight. When present,
|
||||||
|
an actual weight is finite and `>= 0`.
|
||||||
|
|
||||||
|
Planned `target_weight_kg` remains distinct planning metadata and, when
|
||||||
|
present, is finite and `> 0`; it is never copied into an actual set.
|
||||||
|
|
||||||
### `continuous_activity`
|
### `continuous_activity`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,30 +92,26 @@ equivalence across different machines.
|
||||||
|
|
||||||
`SETS + REPS` stores one performed-set row per actual set.
|
`SETS + REPS` stores one performed-set row per actual set.
|
||||||
|
|
||||||
Actual repetitions can differ across sets.
|
Each ordered performed set independently owns its repetitions and an optional
|
||||||
|
`weight_kg`. Actual repetitions and actual loads can therefore differ from one
|
||||||
Accepted compact repetition input includes:
|
set to the next. A missing load is not a zero load and is not filled from a
|
||||||
|
planned target. When supplied, an actual load is finite and `>= 0`; an explicit
|
||||||
```text
|
zero is preserved as an observed value.
|
||||||
5x10
|
|
||||||
4,5,6,7,8,9,10,9,8,7,6,5,4
|
|
||||||
4..10..4
|
|
||||||
```
|
|
||||||
|
|
||||||
The pyramid shorthand:
|
|
||||||
|
|
||||||
```text
|
|
||||||
4..10..4
|
|
||||||
```
|
|
||||||
|
|
||||||
expands to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
4,5,6,7,8,9,10,9,8,7,6,5,4
|
|
||||||
```
|
|
||||||
|
|
||||||
Each performed row is the source of truth for actual work.
|
Each performed row is the source of truth for actual work.
|
||||||
|
|
||||||
|
New normal desktop set work is created only as explicit actual rows. Compact
|
||||||
|
performed-repetition expressions are not an active desktop entry form; Android
|
||||||
|
legacy-draft decoding is a separate compatibility behavior documented in
|
||||||
|
`docs/android.md`.
|
||||||
|
|
||||||
|
Existing historical rows retain their stored repetitions, optional loads and
|
||||||
|
order unchanged. Editing or exchanging a session never normalizes heterogeneous
|
||||||
|
actual values into a uniform prescription.
|
||||||
|
|
||||||
|
This per-set capture contract does not introduce volume/tonnage, estimated 1RM,
|
||||||
|
or progression calculations.
|
||||||
|
|
||||||
`SETS + DURATION` likewise stores one actual duration per performed set.
|
`SETS + DURATION` likewise stores one actual duration per performed set.
|
||||||
|
|
||||||
## 5. Planned versus actual
|
## 5. Planned versus actual
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ GATE_1=PASS
|
||||||
GATE_2=PASS
|
GATE_2=PASS
|
||||||
|
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
DESKTOP_SCHEMA_V9=PASS
|
DESKTOP_SCHEMA_V10=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V9=PASS
|
ANDROID_LOCAL_DATABASE_V9=PASS
|
||||||
|
|
||||||
DIRECT_MTP_TRANSPORT=PASS
|
DIRECT_MTP_TRANSPORT=PASS
|
||||||
|
|
@ -28,7 +28,7 @@ BODY_ANALYTICS_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=34/34 PASS
|
DESKTOP_TESTS=36/36 PASS
|
||||||
TUI_NOTCURSES_V1=PASS
|
TUI_NOTCURSES_V1=PASS
|
||||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,17 @@ The companion corroborates explicit `set`/`cleared` state; it does not overwrite
|
||||||
a divergent occurrence. Reimporting either artifact reconciles stable
|
a divergent occurrence. Reimporting either artifact reconciles stable
|
||||||
identities; it neither duplicates sessions nor regenerates occurrence IDs.
|
identities; it neither duplicates sessions nor regenerates occurrence IDs.
|
||||||
|
|
||||||
|
Within an ordered `sets[]` array, `weight_kg` belongs to that individual set,
|
||||||
|
not to the occurrence or its planned target. Each set therefore replays its own
|
||||||
|
repetitions-or-duration and nullable load in its original order. A replay is
|
||||||
|
idempotent: it preserves historic heterogeneous values and does not replace
|
||||||
|
blank loads with zero, a target value, or another set's load.
|
||||||
|
|
||||||
|
An omitted `weight_kg` remains a null/absent actual load. When present, V2
|
||||||
|
requires a finite value `>= 0`, including explicit zero. This does not alter
|
||||||
|
the separate strictly-positive `max_weight_kg`/`max_results` contract, V2's
|
||||||
|
version number, or frozen `TRAINLOG_FORMAT_V1`.
|
||||||
|
|
||||||
Exercise profile fields:
|
Exercise profile fields:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ continuous_detail
|
||||||
reps
|
reps
|
||||||
variable_sets
|
variable_sets
|
||||||
schema_v5_migration
|
schema_v5_migration
|
||||||
|
schema_v9_migration
|
||||||
schema_v7_migration
|
schema_v7_migration
|
||||||
mobile_import_variable_sets
|
mobile_import_variable_sets
|
||||||
mobile_import_multi_occurrence
|
mobile_import_multi_occurrence
|
||||||
|
|
@ -100,12 +101,13 @@ max_results
|
||||||
max_sync
|
max_sync
|
||||||
body_analytics
|
body_analytics
|
||||||
terminal_input_event_type_policy
|
terminal_input_event_type_policy
|
||||||
|
tui_workflows
|
||||||
```
|
```
|
||||||
|
|
||||||
Validated current suite:
|
Validated current suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
34/34 Meson tests PASS
|
36/36 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
The desktop executable is additionally smoke-checked in isolated tmux PTYs at
|
||||||
|
|
@ -120,10 +122,21 @@ Notable regression coverage:
|
||||||
- body-observation stable-identity editing;
|
- body-observation stable-identity editing;
|
||||||
- profile-aware exercise constraints;
|
- profile-aware exercise constraints;
|
||||||
- continuous activity without fake sets;
|
- continuous activity without fake sets;
|
||||||
- repetition shorthand/list/pyramid parsing;
|
- table-only desktop SETS workflow: planning does not create actual rows,
|
||||||
|
explicit add requires an actual metric, and normal zero-row completion is
|
||||||
|
rejected; Android legacy compact-draft decoding remains separately covered;
|
||||||
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration;
|
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration;
|
||||||
- bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation;
|
- bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation;
|
||||||
|
- lossless v9 -> v10 `performed_sets` rebuild: historic NULL and positive
|
||||||
|
weights/IDs/owners/positions/metrics survive, explicit zero is accepted, and
|
||||||
|
injected failure rolls back with integrity, foreign-key and enforcement
|
||||||
|
checks;
|
||||||
- heterogeneous mobile-set import;
|
- heterogeneous mobile-set import;
|
||||||
|
- per-set load persistence and correction: ordered rows retain mixed actual
|
||||||
|
repetitions, nullable loads, positions and assistance semantics through
|
||||||
|
desktop replacement and detail retrieval;
|
||||||
|
- V2 mobile round-trip and idempotent replay preserve each ordered set's own
|
||||||
|
nullable `weight_kg`, without collapsing it to an occurrence target;
|
||||||
- V2 explicit-max Android -> desktop -> Android replay and resumed same-session update;
|
- V2 explicit-max Android -> desktop -> Android replay and resumed same-session update;
|
||||||
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
|
||||||
RELEASE event is consumed without creating a second navigation action.
|
RELEASE event is consumed without creating a second navigation action.
|
||||||
|
|
@ -319,7 +332,7 @@ Coverage proves:
|
||||||
Validated current normal suite:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
34/34 Meson tests PASS
|
36/36 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 12. Body analytics regression
|
## 12. Body analytics regression
|
||||||
|
|
@ -344,7 +357,7 @@ Coverage includes:
|
||||||
Validated current normal suite:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
34/34 Meson tests PASS
|
36/36 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
@ -363,6 +376,35 @@ explicit max creation/edit/finalization without sets, distinct movement values
|
||||||
on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume
|
on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume
|
||||||
and bounded conversion that leaves multiple legacy attempts untouched.
|
and bounded conversion that leaves multiple legacy attempts untouched.
|
||||||
|
|
||||||
|
The current set-row-editor coverage additionally proves that row edits,
|
||||||
|
deletion and append preserve neighbouring rows; French-comma loads, blank
|
||||||
|
loads and explicit zero loads remain distinct; reopening preserves aligned raw
|
||||||
|
row fields; and completed history keeps the ordered per-set values. The
|
||||||
|
instrumentation source exercises row edit, deletion, append and Activity
|
||||||
|
recreation, but this document does not claim that instrumentation was executed
|
||||||
|
for the current documentation checkpoint.
|
||||||
|
|
||||||
|
For the settled implementation, the validation inventory is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Android JVM: ./gradlew testDebugUnitTest
|
||||||
|
Android compilation: ./gradlew assembleDebug
|
||||||
|
Android instrumentation: adb shell am instrument ... (execution is explicit;
|
||||||
|
no device execution is asserted here)
|
||||||
|
Desktop: meson compile -C build
|
||||||
|
meson test -C build --print-errorlogs
|
||||||
|
Frozen JSON/import: python tools/validate_json.py
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
V2 regression: mobile_import_variable_sets and the desktop/Android
|
||||||
|
V2 round-trip/idempotent-replay coverage
|
||||||
|
Sanitizers: clang ASan/UBSan Meson build and test invocation
|
||||||
|
```
|
||||||
|
|
||||||
|
Executed postrepair evidence is: 37 Android unit tests, `assembleDebug`, 36/36
|
||||||
|
Meson tests, valid and invalid JSON checks, import-contract 6/6, and 14/14
|
||||||
|
ASan/UBSan Meson tests. No device, installation, real-store migration, or
|
||||||
|
instrumentation execution is asserted by this checkpoint.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd android
|
cd android
|
||||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||||
|
|
|
||||||
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.
|
The TUI can record sessions directly.
|
||||||
|
|
||||||
Set-based entry supports planned targets and actual work.
|
Set-based entry supports planned targets and actual work. Planned target values
|
||||||
|
remain planning metadata: they are never copied into an actual performed set.
|
||||||
|
|
||||||
For repetition work, compact actual-set input supports:
|
For a normal `SETS` occurrence, creation collects planning metadata only: it
|
||||||
|
does not accept a compact performed-repetitions expression, a performed set
|
||||||
|
count, sequential performed durations, or a pre-table performed load. It starts
|
||||||
|
with zero actual rows, then opens the ordered, keyboard-first actual-set table
|
||||||
|
as the sole path for creating actual work. Each row contains its actual
|
||||||
|
repetitions (or duration) and, when the occurrence has a load mode, its
|
||||||
|
independently optional Charge or Assistance value. The table uses:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
5x10
|
Up/Down select a row
|
||||||
4,5,6,7,8,9,10,9,8,7,6,5,4
|
Left/Right/Tab select metric or load cell
|
||||||
4..10..4
|
Enter edit the selected cell
|
||||||
|
a append a row and enter its required actual metric
|
||||||
|
d/Delete delete the selected set
|
||||||
|
Escape cancel the active cell, or leave the table
|
||||||
|
f/b finish the table
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Adding a row requires an explicit actual repetitions/duration value and creates
|
||||||
|
no actual load; an empty load cell remains absent rather than inheriting the
|
||||||
|
target. An entered actual load is finite and non-negative;
|
||||||
|
the planned target remains a separate, strictly positive planning value. The
|
||||||
|
current-session summary is assembled from each
|
||||||
|
actual row, so mixed repetitions, optional loads and assistance values are not
|
||||||
|
collapsed into one target value.
|
||||||
|
|
||||||
|
Finishing a normal `SETS` draft with zero actual rows is blocked with an
|
||||||
|
explicit diagnostic. This guard does not apply to `MAX` or continuous entries,
|
||||||
|
whose distinct persistence contracts contain no performed-set rows.
|
||||||
|
|
||||||
For timed work, the shared duration parser accepts forms such as:
|
For timed work, the shared duration parser accepts forms such as:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -131,6 +154,11 @@ History is keyboard navigable.
|
||||||
|
|
||||||
`Enter` opens full session detail.
|
`Enter` opens full session detail.
|
||||||
|
|
||||||
|
For set-based occurrences, detail displays an ordered, scrollable table of the
|
||||||
|
persisted rows with series number, repetitions or duration, and Charge or
|
||||||
|
Assistance. It exposes every stored per-set value, including absent loads,
|
||||||
|
rather than only a compact aggregate.
|
||||||
|
|
||||||
Persisted session editing preserves the parent session identity and timestamps
|
Persisted session editing preserves the parent session identity and timestamps
|
||||||
while replacing child exercise/set data transactionally.
|
while replacing child exercise/set data transactionally.
|
||||||
|
|
||||||
|
|
@ -327,7 +355,7 @@ meson test -C build --print-errorlogs
|
||||||
Validated current normal suite:
|
Validated current normal suite:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
34/34 Meson tests PASS
|
36/36 Meson tests PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## 16. Measured max view
|
## 16. Measured max view
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ CREATE TABLE custom_equipment(
|
||||||
equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL,
|
equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL,
|
||||||
equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL
|
equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL
|
||||||
);
|
);
|
||||||
PRAGMA user_version=9;
|
PRAGMA user_version=10;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id
|
||||||
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0));
|
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0));
|
||||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
||||||
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
|
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
|
||||||
PRAGMA user_version=9;
|
PRAGMA user_version=10;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id
|
||||||
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL);
|
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL);
|
||||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
||||||
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
|
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL);
|
||||||
PRAGMA user_version=9;
|
PRAGMA user_version=10;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def payload():
|
def payload():
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
IMPORTER = ROOT / "tools" / "import_mobile_export.py"
|
IMPORTER = ROOT / "tools" / "import_mobile_export.py"
|
||||||
|
EXPORTER = ROOT / "tools" / "export_pc_mobile.py"
|
||||||
|
|
||||||
EXPECTED_REPS = [
|
EXPECTED_REPS = [
|
||||||
4,
|
4,
|
||||||
|
|
@ -55,6 +56,7 @@ CREATE TABLE sessions (
|
||||||
|
|
||||||
CREATE TABLE session_exercises (
|
CREATE TABLE session_exercises (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
entry_id TEXT NOT NULL UNIQUE,
|
||||||
session_row_id INTEGER NOT NULL
|
session_row_id INTEGER NOT NULL
|
||||||
REFERENCES sessions(id) ON DELETE CASCADE,
|
REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
exercise_row_id INTEGER NOT NULL
|
exercise_row_id INTEGER NOT NULL
|
||||||
|
|
@ -68,7 +70,8 @@ CREATE TABLE session_exercises (
|
||||||
target_reps INTEGER,
|
target_reps INTEGER,
|
||||||
target_duration_seconds INTEGER,
|
target_duration_seconds INTEGER,
|
||||||
target_weight_kg REAL,
|
target_weight_kg REAL,
|
||||||
notes TEXT
|
notes TEXT,
|
||||||
|
equipment_id TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE performed_sets (
|
CREATE TABLE performed_sets (
|
||||||
|
|
@ -78,7 +81,7 @@ CREATE TABLE performed_sets (
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
reps INTEGER,
|
reps INTEGER,
|
||||||
duration_seconds INTEGER,
|
duration_seconds INTEGER,
|
||||||
weight_kg REAL
|
weight_kg REAL CHECK(weight_kg >= 0.0)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE continuous_activity (
|
CREATE TABLE continuous_activity (
|
||||||
|
|
@ -112,16 +115,40 @@ CREATE TABLE body_observations (
|
||||||
notes TEXT
|
notes TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
PRAGMA user_version=5;
|
CREATE TABLE max_results (
|
||||||
|
session_exercise_row_id INTEGER PRIMARY KEY,
|
||||||
|
max_weight_kg REAL NOT NULL CHECK(max_weight_kg > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE custom_equipment (
|
||||||
|
equipment_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
label_name TEXT NOT NULL,
|
||||||
|
equipment_type TEXT NOT NULL,
|
||||||
|
load_semantics TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
PRAGMA user_version=10;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
EXPECTED_SETS = [
|
||||||
|
(
|
||||||
|
reps,
|
||||||
|
32.5 if index == 0 else
|
||||||
|
0.0 if index == 1 else
|
||||||
|
None if index in (2, 6) else
|
||||||
|
40.25 + index * 1.5,
|
||||||
|
)
|
||||||
|
for index, reps in enumerate(EXPECTED_REPS)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def payload() -> dict:
|
def payload() -> dict:
|
||||||
return {
|
return {
|
||||||
"format":
|
"format":
|
||||||
"trainlog-mobile-export",
|
"trainlog-mobile-export",
|
||||||
"version":
|
"version":
|
||||||
1,
|
2,
|
||||||
"generated_at":
|
"generated_at":
|
||||||
"2026-09-06T16:00:00+02:00",
|
"2026-09-06T16:00:00+02:00",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
|
|
@ -148,6 +175,10 @@ def payload() -> dict:
|
||||||
"training",
|
"training",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{
|
{
|
||||||
|
"entry_id":
|
||||||
|
"sxe_mobile_pyramid",
|
||||||
|
"position":
|
||||||
|
0,
|
||||||
"exercise_id":
|
"exercise_id":
|
||||||
"ex_mobile_pyramid",
|
"ex_mobile_pyramid",
|
||||||
"name":
|
"name":
|
||||||
|
|
@ -162,12 +193,12 @@ def payload() -> dict:
|
||||||
"none",
|
"none",
|
||||||
"rest_seconds":
|
"rest_seconds":
|
||||||
0,
|
0,
|
||||||
|
"equipment_id":
|
||||||
|
None,
|
||||||
"sets": [
|
"sets": [
|
||||||
{
|
({"reps": reps} if weight is None else
|
||||||
"reps": reps
|
{"reps": reps, "weight_kg": weight})
|
||||||
}
|
for reps, weight in EXPECTED_SETS
|
||||||
for reps
|
|
||||||
in EXPECTED_REPS
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -204,6 +235,64 @@ def run_import(
|
||||||
return result.stdout
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def run_export(output_path: Path, database_path: Path) -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(EXPORTER), str(output_path),
|
||||||
|
"--database", str(database_path)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError("export failed:\n" + result.stdout + result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def database_contents(database_path: Path) -> tuple:
|
||||||
|
"""Capture durable rows so rejected payloads prove transaction atomicity."""
|
||||||
|
with sqlite3.connect(database_path) as connection:
|
||||||
|
return tuple(
|
||||||
|
tuple(connection.execute(
|
||||||
|
f"SELECT * FROM {table} ORDER BY rowid"
|
||||||
|
).fetchall())
|
||||||
|
for table in (
|
||||||
|
"exercises", "sessions", "session_exercises",
|
||||||
|
"performed_sets", "continuous_activity",
|
||||||
|
"body_observations", "max_results",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_weight_rejection(
|
||||||
|
base: Path,
|
||||||
|
database_path: Path,
|
||||||
|
value,
|
||||||
|
suffix: str,
|
||||||
|
) -> None:
|
||||||
|
rejected = payload()
|
||||||
|
rejected["sessions"][0]["session_id"] = f"se_rejected_{suffix}"
|
||||||
|
rejected["sessions"][0]["exercises"][0]["sets"][0]["weight_kg"] = value
|
||||||
|
rejected_path = base / f"invalid-weight-{suffix}.json"
|
||||||
|
rejected_path.write_text(
|
||||||
|
json.dumps(rejected, ensure_ascii=False, allow_nan=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
before = database_contents(database_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(IMPORTER), str(rejected_path),
|
||||||
|
"--database", str(database_path)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 or "weight_kg" not in result.stderr:
|
||||||
|
raise AssertionError(
|
||||||
|
f"invalid set weight accepted ({suffix}):\n"
|
||||||
|
+ result.stdout + result.stderr
|
||||||
|
)
|
||||||
|
if database_contents(database_path) != before:
|
||||||
|
raise AssertionError(f"invalid set weight mutated database ({suffix})")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
with tempfile.TemporaryDirectory(
|
with tempfile.TemporaryDirectory(
|
||||||
prefix="trainlog-mobile-variable-"
|
prefix="trainlog-mobile-variable-"
|
||||||
|
|
@ -285,7 +374,7 @@ def main() -> int:
|
||||||
try:
|
try:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT ps.reps
|
SELECT ps.reps, ps.weight_kg
|
||||||
FROM performed_sets ps
|
FROM performed_sets ps
|
||||||
JOIN session_exercises se
|
JOIN session_exercises se
|
||||||
ON se.id =
|
ON se.id =
|
||||||
|
|
@ -298,14 +387,9 @@ def main() -> int:
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
reps = [
|
if rows != EXPECTED_SETS:
|
||||||
row[0]
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
if reps != EXPECTED_REPS:
|
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
f"reps mismatch: {reps!r}"
|
f"ordered sets mismatch: {rows!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
target = connection.execute(
|
target = connection.execute(
|
||||||
|
|
@ -350,6 +434,74 @@ def main() -> int:
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
exported_path = base / "desktop-v2.json"
|
||||||
|
roundtrip_db = base / "roundtrip.db"
|
||||||
|
run_export(exported_path, database_path)
|
||||||
|
exported = json.loads(exported_path.read_text(encoding="utf-8"))
|
||||||
|
exported_sets = exported["sessions"][0]["exercises"][0]["sets"]
|
||||||
|
expected_json_sets = [
|
||||||
|
({"reps": reps} if weight is None else
|
||||||
|
{"reps": reps, "weight_kg": weight})
|
||||||
|
for reps, weight in EXPECTED_SETS
|
||||||
|
]
|
||||||
|
if exported_sets != expected_json_sets:
|
||||||
|
raise AssertionError(f"V2 export changed sets: {exported_sets!r}")
|
||||||
|
|
||||||
|
with sqlite3.connect(roundtrip_db) as connection:
|
||||||
|
connection.executescript(SCHEMA)
|
||||||
|
imported = run_import(exported_path, roundtrip_db)
|
||||||
|
if "sessions_imported=1" not in imported:
|
||||||
|
raise AssertionError("V2 reimport failed:\n" + imported)
|
||||||
|
replay = run_import(exported_path, roundtrip_db)
|
||||||
|
if "sessions_skipped=1" not in replay:
|
||||||
|
raise AssertionError("V2 replay not idempotent:\n" + replay)
|
||||||
|
with sqlite3.connect(roundtrip_db) as connection:
|
||||||
|
roundtrip_sets = connection.execute(
|
||||||
|
"SELECT reps,weight_kg FROM performed_sets ORDER BY position"
|
||||||
|
).fetchall()
|
||||||
|
if roundtrip_sets != EXPECTED_SETS:
|
||||||
|
raise AssertionError(f"V2 reimport changed sets: {roundtrip_sets!r}")
|
||||||
|
|
||||||
|
# INVARIANT: all validation precedes mutation; every invalid optional
|
||||||
|
# set weight therefore rejects the complete artifact atomically.
|
||||||
|
for invalid_weight, suffix in (
|
||||||
|
(-1.5, "negative"),
|
||||||
|
(True, "bool"),
|
||||||
|
("20", "string"),
|
||||||
|
(float("nan"), "nan"),
|
||||||
|
(float("inf"), "infinity"),
|
||||||
|
):
|
||||||
|
require_weight_rejection(
|
||||||
|
base, database_path, invalid_weight, suffix
|
||||||
|
)
|
||||||
|
|
||||||
|
# A migration-era v9 database cannot represent explicit zero. Reject
|
||||||
|
# before beginning the import instead of collapsing zero into NULL or
|
||||||
|
# leaving a partially inserted graph.
|
||||||
|
v9_database = base / "unmigrated-v9.db"
|
||||||
|
with sqlite3.connect(v9_database) as connection:
|
||||||
|
connection.executescript(
|
||||||
|
SCHEMA.replace(
|
||||||
|
"weight_kg REAL CHECK(weight_kg >= 0.0)",
|
||||||
|
"weight_kg REAL CHECK(weight_kg > 0.0)",
|
||||||
|
).replace("PRAGMA user_version=10", "PRAGMA user_version=9")
|
||||||
|
)
|
||||||
|
before_v9 = database_contents(v9_database)
|
||||||
|
rejected_v9 = subprocess.run(
|
||||||
|
[sys.executable, str(IMPORTER), str(exported_path),
|
||||||
|
"--database", str(v9_database)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if rejected_v9.returncode == 0 or "schéma desktop v10" not in rejected_v9.stderr:
|
||||||
|
raise AssertionError(
|
||||||
|
"zero-bearing V2 artifact did not fail explicitly on v9:\n" +
|
||||||
|
rejected_v9.stdout + rejected_v9.stderr
|
||||||
|
)
|
||||||
|
if database_contents(v9_database) != before_v9:
|
||||||
|
raise AssertionError("zero-bearing V2 artifact mutated v9 database")
|
||||||
|
|
||||||
print(
|
print(
|
||||||
"PASS mobile_import_variable_sets"
|
"PASS mobile_import_variable_sets"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10):
|
||||||
raise ValueError("schema desktop v8 ou v9 requis")
|
raise ValueError("schema desktop v8, v9 ou v10 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ def main():
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10):
|
||||||
raise ValueError("schema desktop v8 ou v9 requis")
|
raise ValueError("schema desktop v8, v9 ou v10 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
"FROM custom_equipment ORDER BY equipment_id")]
|
"FROM custom_equipment ORDER BY equipment_id")]
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in (8, 9):
|
if version not in (8, 9, 10):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ def main():
|
||||||
con = sqlite3.connect(args.database)
|
con = sqlite3.connect(args.database)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] != 9:
|
if con.execute("PRAGMA user_version").fetchone()[0] != 10:
|
||||||
raise ValueError("schema desktop v9 requis")
|
raise ValueError("schema desktop v10 requis")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
known_equipment.update(row[0] for row in con.execute(
|
known_equipment.update(row[0] for row in con.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,8 @@ def main():
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10):
|
||||||
fail("schema desktop v8 ou v9 requis")
|
fail("schema desktop v8, v9 ou v10 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ def main():
|
||||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10):
|
||||||
fail("schema desktop v8 ou v9 requis")
|
fail("schema desktop v8, v9 ou v10 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
for definition in definitions:
|
for definition in definitions:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -153,7 +154,7 @@ def require_positive_number(value, label):
|
||||||
|
|
||||||
parsed = float(value)
|
parsed = float(value)
|
||||||
|
|
||||||
if parsed <= 0.0:
|
if not math.isfinite(parsed) or parsed <= 0.0:
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"{label}: nombre positif attendu"
|
f"{label}: nombre positif attendu"
|
||||||
)
|
)
|
||||||
|
|
@ -161,6 +162,17 @@ def require_positive_number(value, label):
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def require_nonnegative_number(value, label):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ImportFailure(f"{label}: nombre attendu")
|
||||||
|
|
||||||
|
parsed = float(value)
|
||||||
|
if not math.isfinite(parsed) or parsed < 0.0:
|
||||||
|
raise ImportFailure(f"{label}: nombre non négatif attendu")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(value):
|
def normalize_name(value):
|
||||||
folded = unicodedata.normalize(
|
folded = unicodedata.normalize(
|
||||||
"NFC",
|
"NFC",
|
||||||
|
|
@ -367,6 +379,13 @@ def validate_set_item(
|
||||||
label,
|
label,
|
||||||
):
|
):
|
||||||
allowed_weight = {"weight_kg"}
|
allowed_weight = {"weight_kg"}
|
||||||
|
# CONTRACT: absent actual load is omitted/SQL NULL; when present it is a
|
||||||
|
# finite nonnegative observation, including an explicit zero.
|
||||||
|
if "weight_kg" in value:
|
||||||
|
require_nonnegative_number(
|
||||||
|
value["weight_kg"],
|
||||||
|
f"{label}.weight_kg",
|
||||||
|
)
|
||||||
if tracking_mode == "reps":
|
if tracking_mode == "reps":
|
||||||
require_exact_keys(
|
require_exact_keys(
|
||||||
value,
|
value,
|
||||||
|
|
@ -757,9 +776,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in (5, 6, 7, 8, 9):
|
if version not in (5, 6, 7, 8, 9, 10):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v9 attendue, version trouvée: {version}"
|
f"base desktop schema v5 à v10 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1646,6 +1665,17 @@ def run_import(
|
||||||
)
|
)
|
||||||
if has_explicit_max and schema_version < 9:
|
if has_explicit_max and schema_version < 9:
|
||||||
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
|
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
|
||||||
|
has_explicit_zero_set_weight = any(
|
||||||
|
set_item.get("weight_kg") == 0
|
||||||
|
for session in payload["sessions"]
|
||||||
|
for entry in session["exercises"]
|
||||||
|
for set_item in entry.get("sets", [])
|
||||||
|
if "weight_kg" in set_item
|
||||||
|
)
|
||||||
|
if has_explicit_zero_set_weight and schema_version < 10:
|
||||||
|
raise ImportFailure(
|
||||||
|
"weight_kg=0 exige le schéma desktop v10; import annulé"
|
||||||
|
)
|
||||||
|
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,21 @@ def validate_mobile_export_v2(document: Any) -> None:
|
||||||
value = entry["max_weight_kg"]
|
value = entry["max_weight_kg"]
|
||||||
if session.get("session_type") != "max_test" or isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value <= 0:
|
if session.get("session_type") != "max_test" or isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)) or value <= 0:
|
||||||
raise TrainlogSemanticError("mobile export V2: invalid explicit max")
|
raise TrainlogSemanticError("mobile export V2: invalid explicit max")
|
||||||
|
if has_sets:
|
||||||
|
sets = entry["sets"]
|
||||||
|
if not isinstance(sets, list):
|
||||||
|
raise TrainlogSemanticError("mobile export V2: invalid sets")
|
||||||
|
for actual_set in sets:
|
||||||
|
if not isinstance(actual_set, dict):
|
||||||
|
raise TrainlogSemanticError("mobile export V2: invalid set")
|
||||||
|
if "weight_kg" in actual_set:
|
||||||
|
value = actual_set["weight_kg"]
|
||||||
|
if (isinstance(value, bool) or
|
||||||
|
not isinstance(value, (int, float)) or
|
||||||
|
not math.isfinite(float(value)) or value < 0):
|
||||||
|
raise TrainlogSemanticError(
|
||||||
|
"mobile export V2: invalid actual-set weight"
|
||||||
|
)
|
||||||
entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
|
entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
|
||||||
def structural_errors(
|
def structural_errors(
|
||||||
validator: jsonschema.Draft202012Validator,
|
validator: jsonschema.Draft202012Validator,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 9
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 10
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
@ -198,13 +198,20 @@ typedef struct TrainlogPersistedExerciseDetail {
|
||||||
double continuous_distance_km;
|
double continuous_distance_km;
|
||||||
|
|
||||||
size_t actual_set_count;
|
size_t actual_set_count;
|
||||||
|
/* CONTRACT: history consumes this occurrence-owned ordered snapshot;
|
||||||
|
* rendering must not reach into SQLite or impose a global dataset cap. */
|
||||||
|
TrainlogSetInput *actual_sets;
|
||||||
char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U];
|
char actual_summary[TRAINLOG_SET_SUMMARY_MAX + 1U];
|
||||||
} TrainlogPersistedExerciseDetail;
|
} TrainlogPersistedExerciseDetail;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Load one session header plus ordered exercise details.
|
* @brief Load one session header plus ordered exercise details.
|
||||||
*
|
*
|
||||||
* The function is read-only and allocates nothing.
|
* Callers must release a prior successful result before reusing its array.
|
||||||
|
* On success, each copied detail owns actual_sets until released with
|
||||||
|
* trainlog_database_free_session_details(). On failure, the function releases
|
||||||
|
* every partial allocation and, when output_exercise_count is non-NULL, sets
|
||||||
|
* it to zero. A nonzero capacity requires a non-NULL output_exercises array.
|
||||||
*/
|
*/
|
||||||
TrainlogStatus trainlog_database_get_session_details(
|
TrainlogStatus trainlog_database_get_session_details(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
|
|
@ -215,6 +222,12 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
size_t *output_exercise_count
|
size_t *output_exercise_count
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Safe for zero-initialized details and details returned by the loader. */
|
||||||
|
void trainlog_database_free_session_details(
|
||||||
|
TrainlogPersistedExerciseDetail *exercises,
|
||||||
|
size_t exercise_count
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
/* TRAINLOG_BODY_METRIC_HISTORY_API */
|
/* TRAINLOG_BODY_METRIC_HISTORY_API */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,15 @@ test(
|
||||||
test_schema_v5_migration,
|
test_schema_v5_migration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_schema_v9_migration = executable(
|
||||||
|
'test_schema_v9_migration',
|
||||||
|
'tests/test_schema_v9_migration.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('schema_v9_migration', test_schema_v9_migration)
|
||||||
|
|
||||||
test_schema_v7_migration = executable(
|
test_schema_v7_migration = executable(
|
||||||
'test_schema_v7_migration',
|
'test_schema_v7_migration',
|
||||||
'tests/test_schema_v7_migration.c',
|
'tests/test_schema_v7_migration.c',
|
||||||
|
|
@ -484,3 +493,14 @@ test(
|
||||||
'terminal_input_event_type_policy',
|
'terminal_input_event_type_policy',
|
||||||
test_terminal_input,
|
test_terminal_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test_tui_workflows = executable(
|
||||||
|
'test_tui_workflows',
|
||||||
|
'tests/test_tui_workflows.c',
|
||||||
|
'src/theme.c',
|
||||||
|
'src/sync_screen_action.c',
|
||||||
|
dependencies: trainlog_core_dep,
|
||||||
|
c_args: strict_c_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
test('tui_workflows', test_tui_workflows)
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,37 @@ static const char *const MIGRATE_V8_TO_V9_SQL =
|
||||||
"(SELECT session_exercise_row_id FROM max_results);"
|
"(SELECT session_exercise_row_id FROM max_results);"
|
||||||
"PRAGMA user_version = 9;COMMIT;";
|
"PRAGMA user_version = 9;COMMIT;";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WHY: actual load absence and an explicit zero are distinct observations.
|
||||||
|
* CONTRACT: v10 changes only performed_sets.weight_kg from strictly positive
|
||||||
|
* to nonnegative. The explicit projection preserves every row ID, owning
|
||||||
|
* occurrence, position, metric value and NULL/positive load byte-for-byte.
|
||||||
|
*/
|
||||||
|
static const char *const MIGRATE_V9_TO_V10_SQL =
|
||||||
|
"PRAGMA foreign_keys = OFF;"
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"ALTER TABLE performed_sets RENAME TO performed_sets_v9;"
|
||||||
|
"CREATE TABLE performed_sets ("
|
||||||
|
"id INTEGER PRIMARY KEY,"
|
||||||
|
"session_exercise_row_id INTEGER NOT NULL "
|
||||||
|
"REFERENCES session_exercises(id) ON DELETE CASCADE,"
|
||||||
|
"position INTEGER NOT NULL CHECK(position >= 0),"
|
||||||
|
"reps INTEGER CHECK(reps >= 0),"
|
||||||
|
"duration_seconds INTEGER CHECK(duration_seconds > 0),"
|
||||||
|
"weight_kg REAL CHECK(weight_kg >= 0.0),"
|
||||||
|
"UNIQUE(session_exercise_row_id,position),"
|
||||||
|
"CHECK((reps IS NOT NULL AND duration_seconds IS NULL) OR "
|
||||||
|
"(reps IS NULL AND duration_seconds IS NOT NULL))"
|
||||||
|
");"
|
||||||
|
"INSERT INTO performed_sets("
|
||||||
|
"id,session_exercise_row_id,position,reps,duration_seconds,weight_kg"
|
||||||
|
") SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg "
|
||||||
|
"FROM performed_sets_v9;"
|
||||||
|
"DROP TABLE performed_sets_v9;"
|
||||||
|
"PRAGMA user_version = 10;"
|
||||||
|
"COMMIT;"
|
||||||
|
"PRAGMA foreign_keys = ON;";
|
||||||
|
|
||||||
static const char *const MIGRATE_V1_TO_V3_SQL =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
"ALTER TABLE sessions "
|
"ALTER TABLE sessions "
|
||||||
|
|
@ -697,6 +728,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
|
}
|
||||||
} else if (version == 7) {
|
} else if (version == 7) {
|
||||||
/* CONTRACT: v7 is the immediate historic schema and must open through
|
/* CONTRACT: v7 is the immediate historic schema and must open through
|
||||||
* its lossless custom-equipment-table migration. */
|
* its lossless custom-equipment-table migration. */
|
||||||
|
|
@ -704,8 +738,16 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
|
}
|
||||||
} else if (version == 8) {
|
} else if (version == 8) {
|
||||||
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
|
}
|
||||||
|
} else if (version == 9) {
|
||||||
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
status =
|
status =
|
||||||
|
|
@ -833,6 +875,9 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
status = execute_sql(database, MIGRATE_V8_TO_V9_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -842,7 +887,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v9" : "migrate database to schema v9",
|
version == 0 ? "create schema v10" : "migrate database to schema v10",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
@ -853,6 +898,16 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
NULL,
|
NULL,
|
||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
|
/* MIGRATE_V9_TO_V10_SQL disables foreign keys outside its transaction;
|
||||||
|
* a failed statement must not leave the live handle with enforcement
|
||||||
|
* disabled after the rollback. */
|
||||||
|
(void)sqlite3_exec(
|
||||||
|
database->connection,
|
||||||
|
"PRAGMA foreign_keys = ON;",
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -2239,6 +2294,15 @@ static TrainlogStatus insert_performed_set(
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int rc;
|
int rc;
|
||||||
|
|
||||||
|
if (database == NULL || database->connection == NULL || input == NULL ||
|
||||||
|
position > (size_t)INT64_MAX ||
|
||||||
|
(input->has_weight &&
|
||||||
|
(!isfinite(input->weight_kg) || input->weight_kg < 0.0))) {
|
||||||
|
/* CONTRACT: blank is represented by has_weight=false/SQL NULL;
|
||||||
|
* explicit zero is a finite, observed load and must survive exactly. */
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
rc = sqlite3_prepare_v2(
|
rc = sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
"INSERT INTO performed_sets("
|
"INSERT INTO performed_sets("
|
||||||
|
|
@ -2426,6 +2490,40 @@ static TrainlogStatus insert_session_children(
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus validate_actual_set_weights(
|
||||||
|
const TrainlogSessionExerciseInput *exercises,
|
||||||
|
size_t exercise_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
size_t exercise_index;
|
||||||
|
|
||||||
|
if ((exercise_count > 0U && exercises == NULL) ||
|
||||||
|
exercise_count > (size_t)INT64_MAX) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (exercise_index = 0U; exercise_index < exercise_count;
|
||||||
|
++exercise_index) {
|
||||||
|
const TrainlogSessionExerciseInput *exercise =
|
||||||
|
&exercises[exercise_index];
|
||||||
|
size_t set_index;
|
||||||
|
|
||||||
|
if (exercise->set_count > (size_t)INT64_MAX ||
|
||||||
|
(exercise->set_count > 0U && exercise->sets == NULL)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
for (set_index = 0U; set_index < exercise->set_count; ++set_index) {
|
||||||
|
const TrainlogSetInput *set = &exercise->sets[set_index];
|
||||||
|
if (set->has_weight &&
|
||||||
|
(!isfinite(set->weight_kg) || set->weight_kg < 0.0)) {
|
||||||
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_database_insert_session(
|
TrainlogStatus trainlog_database_insert_session(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const TrainlogSessionInput *session
|
const TrainlogSessionInput *session
|
||||||
|
|
@ -2444,6 +2542,14 @@ TrainlogStatus trainlog_database_insert_session(
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status = validate_actual_set_weights(
|
||||||
|
session->exercises,
|
||||||
|
session->exercise_count
|
||||||
|
);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
status = trainlog_database_begin(database);
|
status = trainlog_database_begin(database);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -2500,6 +2606,11 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
||||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status = validate_actual_set_weights(exercises, exercise_count);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
status = trainlog_database_begin(database);
|
status = trainlog_database_begin(database);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -3022,10 +3133,47 @@ static TrainlogStatus detail_fill_sets(
|
||||||
"ORDER BY position ASC;";
|
"ORDER BY position ASC;";
|
||||||
|
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
|
sqlite3_stmt *count_statement = NULL;
|
||||||
size_t used = 0U;
|
size_t used = 0U;
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
int rc;
|
int rc;
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(database->connection,
|
||||||
|
"SELECT COUNT(*) FROM performed_sets "
|
||||||
|
"WHERE session_exercise_row_id = ?1;", -1, &count_statement, NULL);
|
||||||
|
if (rc != SQLITE_OK ||
|
||||||
|
sqlite3_bind_int64(count_statement, 1, session_exercise_row_id) !=
|
||||||
|
SQLITE_OK ||
|
||||||
|
sqlite3_step(count_statement) != SQLITE_ROW) {
|
||||||
|
(void)sqlite3_finalize(count_statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
sqlite3_int64 persisted_count = sqlite3_column_int64(count_statement, 0);
|
||||||
|
if (persisted_count < 0 ||
|
||||||
|
(uint64_t)persisted_count >
|
||||||
|
(uint64_t)(SIZE_MAX / sizeof(*detail->actual_sets))) {
|
||||||
|
(void)sqlite3_finalize(count_statement);
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
count = (size_t)persisted_count;
|
||||||
|
}
|
||||||
|
if (sqlite3_finalize(count_statement) != SQLITE_OK) {
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
count_statement = NULL;
|
||||||
|
|
||||||
|
/* WHY: performed_sets has no schema/domain maximum. Exact occurrence-owned
|
||||||
|
* storage bounds memory to the selected session without limiting history. */
|
||||||
|
if (count > 0U) {
|
||||||
|
detail->actual_sets = calloc(count, sizeof(*detail->actual_sets));
|
||||||
|
if (detail->actual_sets == NULL) {
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detail->actual_set_count = count;
|
||||||
|
count = 0U;
|
||||||
|
|
||||||
rc = sqlite3_prepare_v2(
|
rc = sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
SQL,
|
SQL,
|
||||||
|
|
@ -3034,6 +3182,9 @@ static TrainlogStatus detail_fill_sets(
|
||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3044,6 +3195,9 @@ static TrainlogStatus detail_fill_sets(
|
||||||
);
|
);
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3059,6 +3213,33 @@ static TrainlogStatus detail_fill_sets(
|
||||||
int has_weight =
|
int has_weight =
|
||||||
sqlite3_column_type(statement, 2) != SQLITE_NULL;
|
sqlite3_column_type(statement, 2) != SQLITE_NULL;
|
||||||
|
|
||||||
|
/* INVARIANT: COUNT and SELECT observe the same connection operation;
|
||||||
|
* any mismatch is corruption/concurrent mutation, never truncation. */
|
||||||
|
if (count >= detail->actual_set_count) {
|
||||||
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* INVARIANT: array index is persisted position order. Nullable load
|
||||||
|
* stays explicit instead of inheriting occurrence target metadata. */
|
||||||
|
detail->actual_sets[count].reps =
|
||||||
|
has_reps != 0
|
||||||
|
? sqlite3_column_int(statement, 0)
|
||||||
|
: 0;
|
||||||
|
detail->actual_sets[count].duration_seconds =
|
||||||
|
has_duration != 0
|
||||||
|
? sqlite3_column_int(statement, 1)
|
||||||
|
: 0;
|
||||||
|
detail->actual_sets[count].has_weight =
|
||||||
|
has_weight != 0;
|
||||||
|
detail->actual_sets[count].weight_kg =
|
||||||
|
has_weight != 0
|
||||||
|
? sqlite3_column_double(statement, 2)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
if (count > 0U) {
|
if (count > 0U) {
|
||||||
TrainlogStatus status = detail_append_text(
|
TrainlogStatus status = detail_append_text(
|
||||||
detail->actual_summary,
|
detail->actual_summary,
|
||||||
|
|
@ -3069,6 +3250,9 @@ static TrainlogStatus detail_fill_sets(
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3103,6 +3287,9 @@ static TrainlogStatus detail_fill_sets(
|
||||||
sizeof(duration_text)
|
sizeof(duration_text)
|
||||||
) != TRAINLOG_STATUS_OK) {
|
) != TRAINLOG_STATUS_OK) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3124,12 +3311,18 @@ static TrainlogStatus detail_fill_sets(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (written < 0 ||
|
if (written < 0 ||
|
||||||
(size_t)written >= sizeof(fragment)) {
|
(size_t)written >= sizeof(fragment)) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3143,6 +3336,9 @@ static TrainlogStatus detail_fill_sets(
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3152,14 +3348,25 @@ static TrainlogStatus detail_fill_sets(
|
||||||
|
|
||||||
if (rc != SQLITE_DONE) {
|
if (rc != SQLITE_DONE) {
|
||||||
(void)sqlite3_finalize(statement);
|
(void)sqlite3_finalize(statement);
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
detail->actual_set_count = count;
|
if (count != detail->actual_set_count) {
|
||||||
|
free(detail->actual_sets);
|
||||||
|
detail->actual_sets = NULL;
|
||||||
|
detail->actual_set_count = 0U;
|
||||||
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
if (count == 0U) {
|
if (count == 0U) {
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
|
|
@ -3173,6 +3380,23 @@ static TrainlogStatus detail_fill_sets(
|
||||||
return TRAINLOG_STATUS_OK;
|
return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void trainlog_database_free_session_details(
|
||||||
|
TrainlogPersistedExerciseDetail *exercises,
|
||||||
|
size_t exercise_count
|
||||||
|
)
|
||||||
|
{
|
||||||
|
size_t index;
|
||||||
|
|
||||||
|
if (exercises == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < exercise_count; ++index) {
|
||||||
|
free(exercises[index].actual_sets);
|
||||||
|
exercises[index].actual_sets = NULL;
|
||||||
|
exercises[index].actual_set_count = 0U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_database_get_session_details(
|
TrainlogStatus trainlog_database_get_session_details(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const char *session_id,
|
const char *session_id,
|
||||||
|
|
@ -3219,6 +3443,10 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
size_t total = 0U;
|
size_t total = 0U;
|
||||||
int rc;
|
int rc;
|
||||||
|
|
||||||
|
if (output_exercise_count != NULL) {
|
||||||
|
*output_exercise_count = 0U;
|
||||||
|
}
|
||||||
|
|
||||||
if (database == NULL ||
|
if (database == NULL ||
|
||||||
database->connection == NULL ||
|
database->connection == NULL ||
|
||||||
session_id == NULL ||
|
session_id == NULL ||
|
||||||
|
|
@ -3236,8 +3464,6 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
sizeof(*output_session)
|
sizeof(*output_session)
|
||||||
);
|
);
|
||||||
|
|
||||||
*output_exercise_count = 0U;
|
|
||||||
|
|
||||||
rc = sqlite3_prepare_v2(
|
rc = sqlite3_prepare_v2(
|
||||||
database->connection,
|
database->connection,
|
||||||
HEADER_SQL,
|
HEADER_SQL,
|
||||||
|
|
@ -3389,6 +3615,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
(((TrainlogExerciseDataFields)data_fields) &
|
(((TrainlogExerciseDataFields)data_fields) &
|
||||||
~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
|
~TRAINLOG_EXERCISE_DATA_KNOWN_MASK) != 0U) {
|
||||||
(void)sqlite3_finalize(exercises);
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3404,6 +3631,8 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
(const char *)equipment_id);
|
(const char *)equipment_id);
|
||||||
}
|
}
|
||||||
if (entry_id == NULL) {
|
if (entry_id == NULL) {
|
||||||
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
(void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s",
|
(void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s",
|
||||||
|
|
@ -3506,6 +3735,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
10
|
10
|
||||||
) == SQLITE_NULL) {
|
) == SQLITE_NULL) {
|
||||||
(void)sqlite3_finalize(exercises);
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3548,6 +3778,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
(detail->has_continuous_distance != 0) !=
|
(detail->has_continuous_distance != 0) !=
|
||||||
distance_required) {
|
distance_required) {
|
||||||
(void)sqlite3_finalize(exercises);
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3568,6 +3799,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
|
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
(void)sqlite3_finalize(exercises);
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3580,10 +3812,12 @@ TrainlogStatus trainlog_database_get_session_details(
|
||||||
|
|
||||||
if (rc != SQLITE_DONE) {
|
if (rc != SQLITE_DONE) {
|
||||||
(void)sqlite3_finalize(exercises);
|
(void)sqlite3_finalize(exercises);
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sqlite3_finalize(exercises) != SQLITE_OK) {
|
if (sqlite3_finalize(exercises) != SQLITE_OK) {
|
||||||
|
trainlog_database_free_session_details(output_exercises, copied);
|
||||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,39 @@ typedef struct SyncSilence {
|
||||||
int null_fd;
|
int null_fd;
|
||||||
} SyncSilence;
|
} SyncSilence;
|
||||||
|
|
||||||
|
static void sync_compose_diagnostic(
|
||||||
|
char *output,
|
||||||
|
size_t output_size,
|
||||||
|
const char *prefix,
|
||||||
|
const char *detail
|
||||||
|
)
|
||||||
|
{
|
||||||
|
size_t written = 0U;
|
||||||
|
|
||||||
|
if (output == NULL || output_size == 0U) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTRACT: diagnostics retain their specific context and as much of the
|
||||||
|
* underlying failure as fits. INVARIANT: output is always NUL-terminated,
|
||||||
|
* even when an external tool supplies the maximum-length error. */
|
||||||
|
while (prefix != NULL && prefix[written] != '\0' &&
|
||||||
|
written + 1U < output_size) {
|
||||||
|
output[written] = prefix[written];
|
||||||
|
written += 1U;
|
||||||
|
}
|
||||||
|
if (detail != NULL) {
|
||||||
|
size_t detail_index = 0U;
|
||||||
|
while (detail[detail_index] != '\0' &&
|
||||||
|
written + 1U < output_size) {
|
||||||
|
output[written] = detail[detail_index];
|
||||||
|
written += 1U;
|
||||||
|
detail_index += 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output[written] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
|
TrainlogSyncDirectionPlan trainlog_sync_direction_plan(
|
||||||
TrainlogSyncDirection direction
|
TrainlogSyncDirection direction
|
||||||
)
|
)
|
||||||
|
|
@ -2451,10 +2484,10 @@ void trainlog_sync_build_summary(
|
||||||
report->catalog_published);
|
report->catalog_published);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(void)snprintf(
|
sync_compose_diagnostic(
|
||||||
report->summary,
|
report->summary,
|
||||||
sizeof(report->summary),
|
sizeof(report->summary),
|
||||||
"%s",
|
"",
|
||||||
report->error[0] != '\0'
|
report->error[0] != '\0'
|
||||||
? report->error
|
? report->error
|
||||||
: "Synchronisation échouée."
|
: "Synchronisation échouée."
|
||||||
|
|
@ -2811,9 +2844,12 @@ TrainlogStatus trainlog_sync_run(
|
||||||
strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) {
|
strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) {
|
||||||
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
||||||
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||||
(void)snprintf(output->error, sizeof(output->error),
|
sync_compose_diagnostic(
|
||||||
"Android→PC : définitions équipement : %s",
|
output->error,
|
||||||
useful[0] != '\0' ? useful : "import échoué");
|
sizeof(output->error),
|
||||||
|
"Android→PC : définitions équipement : ",
|
||||||
|
useful[0] != '\0' ? useful : "import échoué"
|
||||||
|
);
|
||||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
@ -2886,10 +2922,10 @@ TrainlogStatus trainlog_sync_run(
|
||||||
sizeof(useful)
|
sizeof(useful)
|
||||||
);
|
);
|
||||||
|
|
||||||
(void)snprintf(
|
sync_compose_diagnostic(
|
||||||
output->error,
|
output->error,
|
||||||
sizeof(output->error),
|
sizeof(output->error),
|
||||||
"Android→PC : %s",
|
"Android→PC : ",
|
||||||
useful[0] != '\0'
|
useful[0] != '\0'
|
||||||
? useful
|
? useful
|
||||||
: "import mobile échoué"
|
: "import mobile échoué"
|
||||||
|
|
@ -2932,11 +2968,14 @@ TrainlogStatus trainlog_sync_run(
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
|
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
|
||||||
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||||
(void)snprintf(output->error, sizeof(output->error),
|
sync_compose_diagnostic(
|
||||||
"Android→PC : import équipement : %s",
|
output->error,
|
||||||
useful[0] != '\0'
|
sizeof(output->error),
|
||||||
? useful
|
"Android→PC : import équipement : ",
|
||||||
: "échec sans diagnostic du script");
|
useful[0] != '\0'
|
||||||
|
? useful
|
||||||
|
: "échec sans diagnostic du script"
|
||||||
|
);
|
||||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
@ -3004,10 +3043,10 @@ outbound:
|
||||||
sizeof(useful)
|
sizeof(useful)
|
||||||
);
|
);
|
||||||
|
|
||||||
(void)snprintf(
|
sync_compose_diagnostic(
|
||||||
output->error,
|
output->error,
|
||||||
sizeof(output->error),
|
sizeof(output->error),
|
||||||
"PC→Android : %s",
|
"PC→Android : ",
|
||||||
useful[0] != '\0'
|
useful[0] != '\0'
|
||||||
? useful
|
? useful
|
||||||
: "export catalogue échoué"
|
: "export catalogue échoué"
|
||||||
|
|
|
||||||
735
tui/src/tui.c
735
tui/src/tui.c
|
|
@ -9,6 +9,7 @@
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <locale.h>
|
#include <locale.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
|
#include <math.h>
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
@ -540,7 +541,25 @@ static bool parse_double_positive(const char *text, double *output)
|
||||||
}
|
}
|
||||||
|
|
||||||
value = strtod(text, &end);
|
value = strtod(text, &end);
|
||||||
if (end == text || *end != '\0' || value <= 0.0) {
|
if (end == text || *end != '\0' || !isfinite(value) || value <= 0.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*output = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parse_double_nonnegative(const char *text, double *output)
|
||||||
|
{
|
||||||
|
char *end = NULL;
|
||||||
|
double value;
|
||||||
|
|
||||||
|
if (text == NULL || output == NULL || text[0] == '\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = strtod(text, &end);
|
||||||
|
if (end == text || *end != '\0' || !isfinite(value) || value < 0.0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,6 +637,36 @@ static bool prompt_optional_double(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool prompt_optional_nonnegative_double(
|
||||||
|
int row,
|
||||||
|
const char *label,
|
||||||
|
bool *present,
|
||||||
|
double *output
|
||||||
|
)
|
||||||
|
{
|
||||||
|
char buffer[64];
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (!prompt_text(row, label, buffer, sizeof(buffer), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[0] == '\0') {
|
||||||
|
*present = false;
|
||||||
|
*output = 0.0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parse_double_nonnegative(buffer, output)) {
|
||||||
|
*present = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line("Nombre non négatif invalide.", TRAINLOG_COLOR_ERROR);
|
||||||
|
trainlog_terminal_render(tui_terminal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */
|
/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */
|
||||||
|
|
||||||
#define EXERCISE_GRAPH_POINTS 12U
|
#define EXERCISE_GRAPH_POINTS 12U
|
||||||
|
|
@ -5181,13 +5230,8 @@ static bool build_session_exercise(
|
||||||
int target_sets = 3;
|
int target_sets = 3;
|
||||||
int target_metric;
|
int target_metric;
|
||||||
int rest_seconds = 60;
|
int rest_seconds = 60;
|
||||||
int actual_sets = 0;
|
|
||||||
int rep_values[MAX_SETS_PER_EXERCISE];
|
|
||||||
size_t rep_count = 0U;
|
|
||||||
char rep_sequence[512];
|
|
||||||
bool target_has_weight = false;
|
bool target_has_weight = false;
|
||||||
double target_weight = 0.0;
|
double target_weight = 0.0;
|
||||||
size_t set_index;
|
|
||||||
|
|
||||||
if (!choose_exercise(
|
if (!choose_exercise(
|
||||||
database,
|
database,
|
||||||
|
|
@ -5357,9 +5401,14 @@ static bool build_session_exercise(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (load_mode != 1) {
|
if (load_mode != 1) {
|
||||||
|
const char *target_load_prompt =
|
||||||
|
load_mode == 3
|
||||||
|
? "Assistance cible kg : "
|
||||||
|
: "Charge cible kg : ";
|
||||||
|
|
||||||
if (!prompt_optional_double(
|
if (!prompt_optional_double(
|
||||||
5,
|
5,
|
||||||
"Charge cible kg : ",
|
target_load_prompt,
|
||||||
&target_has_weight,
|
&target_has_weight,
|
||||||
&target_weight
|
&target_weight
|
||||||
) ||
|
) ||
|
||||||
|
|
@ -5415,53 +5464,6 @@ static bool build_session_exercise(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
exercise.tracking_mode ==
|
|
||||||
TRAINLOG_TRACKING_REPS
|
|
||||||
) {
|
|
||||||
for (;;) {
|
|
||||||
if (!prompt_text(
|
|
||||||
9,
|
|
||||||
"Séries réalisées (5x10 | 4,5,6,... | 4..10..4) : ",
|
|
||||||
rep_sequence,
|
|
||||||
sizeof(rep_sequence),
|
|
||||||
false
|
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
trainlog_reps_parse_sequence(
|
|
||||||
rep_sequence,
|
|
||||||
rep_values,
|
|
||||||
set_capacity,
|
|
||||||
&rep_count
|
|
||||||
) ==
|
|
||||||
TRAINLOG_STATUS_OK
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
status_line(
|
|
||||||
"Séries invalides. Exemples : 5x10 · 4,5,6,7 · 4..10..4",
|
|
||||||
TRAINLOG_COLOR_ERROR
|
|
||||||
);
|
|
||||||
|
|
||||||
trainlog_terminal_render(tui_terminal);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!prompt_int_value(
|
|
||||||
9,
|
|
||||||
"Séries réellement faites",
|
|
||||||
0,
|
|
||||||
(int)set_capacity,
|
|
||||||
target_sets,
|
|
||||||
&actual_sets
|
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output->load_mode =
|
output->load_mode =
|
||||||
load_mode == 1
|
load_mode == 1
|
||||||
? TRAINLOG_LOAD_NONE
|
? TRAINLOG_LOAD_NONE
|
||||||
|
|
@ -5493,118 +5495,7 @@ static bool build_session_exercise(
|
||||||
target_weight;
|
target_weight;
|
||||||
|
|
||||||
output->sets = set_storage;
|
output->sets = set_storage;
|
||||||
output->set_count =
|
output->set_count = 0U;
|
||||||
exercise.tracking_mode ==
|
|
||||||
TRAINLOG_TRACKING_REPS
|
|
||||||
? rep_count
|
|
||||||
: (size_t)actual_sets;
|
|
||||||
|
|
||||||
for (set_index = 0U;
|
|
||||||
set_index < output->set_count;
|
|
||||||
++set_index) {
|
|
||||||
int actual_metric =
|
|
||||||
target_metric;
|
|
||||||
|
|
||||||
(void)memset(
|
|
||||||
&set_storage[set_index],
|
|
||||||
0,
|
|
||||||
sizeof(set_storage[set_index])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
exercise.tracking_mode ==
|
|
||||||
TRAINLOG_TRACKING_DURATION ||
|
|
||||||
target_has_weight
|
|
||||||
) {
|
|
||||||
draw_shell(
|
|
||||||
exercise.name,
|
|
||||||
"Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m"
|
|
||||||
);
|
|
||||||
|
|
||||||
trainlog_terminal_printf(tui_terminal,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
"Série %zu / %zu",
|
|
||||||
set_index + 1U,
|
|
||||||
output->set_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
exercise.tracking_mode ==
|
|
||||||
TRAINLOG_TRACKING_REPS
|
|
||||||
) {
|
|
||||||
actual_metric =
|
|
||||||
rep_values[set_index];
|
|
||||||
|
|
||||||
set_storage[set_index].reps =
|
|
||||||
actual_metric;
|
|
||||||
} else {
|
|
||||||
if (!prompt_duration_value(
|
|
||||||
5,
|
|
||||||
"Durée réalisée",
|
|
||||||
1,
|
|
||||||
86400,
|
|
||||||
target_metric,
|
|
||||||
&actual_metric
|
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_storage[
|
|
||||||
set_index
|
|
||||||
].duration_seconds =
|
|
||||||
actual_metric;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target_has_weight) {
|
|
||||||
char buffer[64];
|
|
||||||
char prompt[128];
|
|
||||||
double actual_weight =
|
|
||||||
target_weight;
|
|
||||||
|
|
||||||
(void)snprintf(
|
|
||||||
prompt,
|
|
||||||
sizeof(prompt),
|
|
||||||
"Charge kg [%.1f] : ",
|
|
||||||
target_weight
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!prompt_text(
|
|
||||||
6,
|
|
||||||
prompt,
|
|
||||||
buffer,
|
|
||||||
sizeof(buffer),
|
|
||||||
true
|
|
||||||
)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer[0] != '\0' &&
|
|
||||||
!parse_double_positive(
|
|
||||||
buffer,
|
|
||||||
&actual_weight
|
|
||||||
)) {
|
|
||||||
status_line(
|
|
||||||
"Charge invalide.",
|
|
||||||
TRAINLOG_COLOR_ERROR
|
|
||||||
);
|
|
||||||
|
|
||||||
wait_key();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_storage[
|
|
||||||
set_index
|
|
||||||
].has_weight =
|
|
||||||
true;
|
|
||||||
|
|
||||||
set_storage[
|
|
||||||
set_index
|
|
||||||
].weight_kg =
|
|
||||||
actual_weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -6165,6 +6056,292 @@ static void draft_bind_input(
|
||||||
: NULL;
|
: NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void draft_format_set_metric(
|
||||||
|
const TrainlogSessionDraftExercise *draft,
|
||||||
|
const TrainlogSetInput *set,
|
||||||
|
char *output,
|
||||||
|
size_t output_size
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
|
||||||
|
if (trainlog_duration_format(
|
||||||
|
set->duration_seconds,
|
||||||
|
output,
|
||||||
|
output_size
|
||||||
|
) != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)snprintf(output, output_size, "%d s",
|
||||||
|
set->duration_seconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(void)snprintf(output, output_size, "%d", set->reps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool draft_edit_set_cell(
|
||||||
|
TrainlogSessionDraftExercise *draft,
|
||||||
|
size_t row,
|
||||||
|
size_t field,
|
||||||
|
bool require_metric_input
|
||||||
|
)
|
||||||
|
{
|
||||||
|
TrainlogSetInput candidate;
|
||||||
|
|
||||||
|
if (draft == NULL || row >= draft->input.set_count) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = draft->sets[row];
|
||||||
|
|
||||||
|
if (field == 0U) {
|
||||||
|
int value = draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||||||
|
? candidate.duration_seconds
|
||||||
|
: candidate.reps;
|
||||||
|
bool accepted;
|
||||||
|
|
||||||
|
if (require_metric_input) {
|
||||||
|
char buffer[64];
|
||||||
|
const char *label =
|
||||||
|
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||||||
|
? "Durée réalisée (requise) : "
|
||||||
|
: "Répétitions réalisées (requises) : ";
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (!prompt_text(6, label, buffer, sizeof(buffer), false)) {
|
||||||
|
accepted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
|
||||||
|
accepted =
|
||||||
|
trainlog_duration_parse(buffer, &value) ==
|
||||||
|
TRAINLOG_STATUS_OK &&
|
||||||
|
value >= 1 && value <= 86400;
|
||||||
|
} else {
|
||||||
|
accepted = parse_int(buffer, 0, 10000, &value);
|
||||||
|
}
|
||||||
|
if (accepted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
status_line("Valeur réalisée invalide.",
|
||||||
|
TRAINLOG_COLOR_ERROR);
|
||||||
|
trainlog_terminal_render(tui_terminal);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accepted = draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||||||
|
? prompt_duration_value(6, "Durée réalisée", 1, 86400,
|
||||||
|
value > 0 ? value : 1, &value)
|
||||||
|
: prompt_int_value(6, "Répétitions réalisées", 0, 10000,
|
||||||
|
value >= 0 ? value : 0, &value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
/* CONTRACT: Escape cancels the active cell; the candidate was
|
||||||
|
* edited off to the side, so the draft remains byte-for-byte. */
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) {
|
||||||
|
candidate.duration_seconds = value;
|
||||||
|
} else {
|
||||||
|
candidate.reps = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bool has_weight = false;
|
||||||
|
double weight = 0.0;
|
||||||
|
|
||||||
|
if (!prompt_optional_nonnegative_double(
|
||||||
|
6,
|
||||||
|
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? "Assistance kg (vide = aucune) : "
|
||||||
|
: "Charge kg (vide = aucune) : ",
|
||||||
|
&has_weight,
|
||||||
|
&weight
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.has_weight = has_weight;
|
||||||
|
candidate.weight_kg = has_weight ? weight : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft->sets[row] = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool draft_append_incomplete_set(
|
||||||
|
TrainlogSessionDraftExercise *draft
|
||||||
|
)
|
||||||
|
{
|
||||||
|
TrainlogSetInput *added;
|
||||||
|
size_t appended_index;
|
||||||
|
|
||||||
|
if (draft == NULL ||
|
||||||
|
draft->input.set_count >= MAX_SETS_PER_EXERCISE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
appended_index = draft->input.set_count;
|
||||||
|
added = &draft->sets[appended_index];
|
||||||
|
(void)memset(added, 0, sizeof(*added));
|
||||||
|
++draft->input.set_count;
|
||||||
|
|
||||||
|
/* INVARIANT: targets are planning metadata. A new actual row exists only
|
||||||
|
* if its metric prompt is accepted; cancellation rolls the append back. */
|
||||||
|
if (!draft_edit_set_cell(draft, appended_index, 0U, true)) {
|
||||||
|
--draft->input.set_count;
|
||||||
|
(void)memset(added, 0, sizeof(*added));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void draft_edit_sets(
|
||||||
|
TrainlogSessionDraftExercise *draft
|
||||||
|
)
|
||||||
|
{
|
||||||
|
size_t selected = 0U;
|
||||||
|
size_t field = 0U;
|
||||||
|
|
||||||
|
if (draft == NULL ||
|
||||||
|
draft->input.recording_mode != TRAINLOG_RECORDING_SETS ||
|
||||||
|
draft->input.has_max_weight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
size_t index;
|
||||||
|
size_t top = 0U;
|
||||||
|
size_t visible;
|
||||||
|
int first_row = 7;
|
||||||
|
int rows = trainlog_terminal_rows(tui_terminal);
|
||||||
|
int key;
|
||||||
|
bool has_load = draft->input.load_mode != TRAINLOG_LOAD_NONE;
|
||||||
|
const char *metric_label =
|
||||||
|
draft->tracking_mode == TRAINLOG_TRACKING_DURATION
|
||||||
|
? "Durée"
|
||||||
|
: "Reps";
|
||||||
|
const char *load_label =
|
||||||
|
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? "Assistance"
|
||||||
|
: "Charge";
|
||||||
|
|
||||||
|
visible = rows > first_row + 3
|
||||||
|
? (size_t)(rows - first_row - 3)
|
||||||
|
: 1U;
|
||||||
|
|
||||||
|
if (draft->input.set_count > 0U &&
|
||||||
|
selected >= draft->input.set_count) {
|
||||||
|
selected = draft->input.set_count - 1U;
|
||||||
|
}
|
||||||
|
if (selected >= visible) {
|
||||||
|
top = selected - visible + 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_shell(
|
||||||
|
draft->name,
|
||||||
|
"↑↓ série ←→/Tab champ Entrée modifier a ajouter d supprimer f/b terminer"
|
||||||
|
);
|
||||||
|
trainlog_terminal_printf(tui_terminal, 3, 4,
|
||||||
|
"Valeurs réellement effectuées — cible indicative, jamais recopiée");
|
||||||
|
trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD);
|
||||||
|
trainlog_terminal_printf(tui_terminal, 5, 4,
|
||||||
|
has_load ? "%-8s %-20s %-20s" : "%-8s %-20s",
|
||||||
|
"Série", metric_label, has_load ? load_label : "");
|
||||||
|
trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD);
|
||||||
|
|
||||||
|
if (draft->input.set_count == 0U) {
|
||||||
|
trainlog_terminal_printf(tui_terminal, first_row, 4,
|
||||||
|
"Aucune série. a = ajouter une série.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index = top;
|
||||||
|
index < draft->input.set_count && index - top < visible;
|
||||||
|
++index) {
|
||||||
|
char metric[64];
|
||||||
|
char weight[64];
|
||||||
|
int row = first_row + (int)(index - top);
|
||||||
|
|
||||||
|
draft_format_set_metric(draft, &draft->sets[index], metric,
|
||||||
|
sizeof(metric));
|
||||||
|
if (draft->sets[index].has_weight) {
|
||||||
|
(void)snprintf(weight, sizeof(weight), "%.2f kg",
|
||||||
|
draft->sets[index].weight_kg);
|
||||||
|
} else {
|
||||||
|
(void)snprintf(weight, sizeof(weight), "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == selected) {
|
||||||
|
trainlog_terminal_style_on(tui_terminal,
|
||||||
|
TRAINLOG_TEXT_REVERSE |
|
||||||
|
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
|
||||||
|
}
|
||||||
|
trainlog_terminal_printf(tui_terminal, row, 4,
|
||||||
|
has_load ? "%c %-6zu %-20.20s %-20.20s"
|
||||||
|
: "%c %-6zu %-20.20s",
|
||||||
|
index == selected ? (field == 0U ? '>' : '*') : ' ',
|
||||||
|
index + 1U, metric, has_load ? weight : "");
|
||||||
|
if (index == selected) {
|
||||||
|
trainlog_terminal_style_off(tui_terminal,
|
||||||
|
TRAINLOG_TEXT_REVERSE |
|
||||||
|
trainlog_theme_style(TRAINLOG_COLOR_ACCENT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trainlog_terminal_render(tui_terminal);
|
||||||
|
key = trainlog_terminal_get_key(tui_terminal);
|
||||||
|
|
||||||
|
if (key == 'f' || key == 'F' || key == 'b' || key == 'B' ||
|
||||||
|
key == 27 || key == TRAINLOG_KEY_ESCAPE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key == TRAINLOG_KEY_UP && draft->input.set_count > 0U) {
|
||||||
|
selected = selected > 0U ? selected - 1U
|
||||||
|
: draft->input.set_count - 1U;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key == TRAINLOG_KEY_DOWN && draft->input.set_count > 0U) {
|
||||||
|
selected = selected + 1U < draft->input.set_count
|
||||||
|
? selected + 1U : 0U;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (has_load && (key == TRAINLOG_KEY_LEFT ||
|
||||||
|
key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB ||
|
||||||
|
key == TRAINLOG_KEY_SHIFT_TAB)) {
|
||||||
|
field = field == 0U ? 1U : 0U;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((key == '\n' || key == TRAINLOG_KEY_ENTER) &&
|
||||||
|
draft->input.set_count > 0U) {
|
||||||
|
(void)draft_edit_set_cell(draft, selected, field, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((key == 'a' || key == 'A') &&
|
||||||
|
draft->input.set_count < MAX_SETS_PER_EXERCISE) {
|
||||||
|
selected = draft->input.set_count;
|
||||||
|
field = 0U;
|
||||||
|
(void)draft_append_incomplete_set(draft);
|
||||||
|
if (draft->input.set_count == 0U ||
|
||||||
|
selected >= draft->input.set_count) {
|
||||||
|
selected = draft->input.set_count > 0U
|
||||||
|
? draft->input.set_count - 1U : 0U;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
|
||||||
|
draft->input.set_count > 0U) {
|
||||||
|
for (index = selected; index + 1U < draft->input.set_count;
|
||||||
|
++index) {
|
||||||
|
draft->sets[index] = draft->sets[index + 1U];
|
||||||
|
}
|
||||||
|
--draft->input.set_count;
|
||||||
|
if (draft->input.set_count > 0U &&
|
||||||
|
selected >= draft->input.set_count) {
|
||||||
|
selected = draft->input.set_count - 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void draft_delete_exercise(
|
static void draft_delete_exercise(
|
||||||
TrainlogSessionDraftExercise *drafts,
|
TrainlogSessionDraftExercise *drafts,
|
||||||
size_t *count,
|
size_t *count,
|
||||||
|
|
@ -6207,6 +6384,9 @@ static void draft_set_summary(
|
||||||
size_t output_size
|
size_t output_size
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
size_t index;
|
||||||
|
size_t used = 0U;
|
||||||
|
|
||||||
if (draft == NULL ||
|
if (draft == NULL ||
|
||||||
output == NULL ||
|
output == NULL ||
|
||||||
output_size == 0U) {
|
output_size == 0U) {
|
||||||
|
|
@ -6227,33 +6407,56 @@ static void draft_set_summary(
|
||||||
"Max %s kg",
|
"Max %s kg",
|
||||||
weight
|
weight
|
||||||
);
|
);
|
||||||
} else if (draft->input.load_mode ==
|
} else if (draft->input.recording_mode ==
|
||||||
TRAINLOG_LOAD_EXTERNAL) {
|
TRAINLOG_RECORDING_CONTINUOUS) {
|
||||||
(void)snprintf(
|
char duration[64];
|
||||||
output,
|
(void)trainlog_duration_format(
|
||||||
output_size,
|
draft->input.continuous_duration_seconds,
|
||||||
"%zu séries · %.1f kg",
|
duration,
|
||||||
draft->input.set_count,
|
sizeof(duration)
|
||||||
draft->input.target_weight_kg
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
draft->input.load_mode ==
|
|
||||||
TRAINLOG_LOAD_ASSISTANCE
|
|
||||||
) {
|
|
||||||
(void)snprintf(
|
|
||||||
output,
|
|
||||||
output_size,
|
|
||||||
"%zu séries · %.1f kg aide",
|
|
||||||
draft->input.set_count,
|
|
||||||
draft->input.target_weight_kg
|
|
||||||
);
|
);
|
||||||
|
(void)snprintf(output, output_size, "Continu · %s", duration);
|
||||||
} else {
|
} else {
|
||||||
(void)snprintf(
|
int written = snprintf(output, output_size, "%zu séries · ",
|
||||||
output,
|
draft->input.set_count);
|
||||||
output_size,
|
if (written < 0) {
|
||||||
"%zu séries",
|
output[0] = '\0';
|
||||||
draft->input.set_count
|
return;
|
||||||
);
|
}
|
||||||
|
used = (size_t)written < output_size
|
||||||
|
? (size_t)written : output_size - 1U;
|
||||||
|
|
||||||
|
/* CONTRACT: the in-progress summary reflects each actual row. It
|
||||||
|
* must not collapse heterogeneous loads back to the target weight. */
|
||||||
|
for (index = 0U; index < draft->input.set_count && used + 1U < output_size;
|
||||||
|
++index) {
|
||||||
|
char metric[64];
|
||||||
|
char fragment[96];
|
||||||
|
draft_format_set_metric(draft, &draft->sets[index], metric,
|
||||||
|
sizeof(metric));
|
||||||
|
if (draft->input.load_mode == TRAINLOG_LOAD_NONE) {
|
||||||
|
written = snprintf(fragment, sizeof(fragment), "%s%s",
|
||||||
|
index > 0U ? " / " : "", metric);
|
||||||
|
} else {
|
||||||
|
written = snprintf(fragment, sizeof(fragment), "%s%s×%s%s",
|
||||||
|
index > 0U ? " / " : "", metric,
|
||||||
|
draft->sets[index].has_weight ? "" : "—",
|
||||||
|
draft->sets[index].has_weight ? "kg" : "");
|
||||||
|
if (draft->sets[index].has_weight) {
|
||||||
|
written = snprintf(fragment, sizeof(fragment),
|
||||||
|
"%s%s×%.2fkg%s",
|
||||||
|
index > 0U ? " / " : "", metric,
|
||||||
|
draft->sets[index].weight_kg,
|
||||||
|
draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? " aide" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (written < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
(void)snprintf(output + used, output_size - used, "%s", fragment);
|
||||||
|
used = strlen(output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6376,7 +6579,7 @@ static bool edit_session_draft(
|
||||||
2,
|
2,
|
||||||
"%.*s",
|
"%.*s",
|
||||||
trainlog_terminal_columns(tui_terminal) - 4,
|
trainlog_terminal_columns(tui_terminal) - 4,
|
||||||
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner"
|
"↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner"
|
||||||
);
|
);
|
||||||
|
|
||||||
trainlog_terminal_style_off(tui_terminal,
|
trainlog_terminal_style_off(tui_terminal,
|
||||||
|
|
@ -6387,7 +6590,7 @@ static bool edit_session_draft(
|
||||||
} else {
|
} else {
|
||||||
draw_shell(
|
draw_shell(
|
||||||
"TRAINLOG — Séance en cours",
|
"TRAINLOG — Séance en cours",
|
||||||
"↑↓ choisir e/Entrée modifier a ajouter d supprimer f enregistrer q/Échap abandonner"
|
"↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6480,6 +6683,8 @@ static bool edit_session_draft(
|
||||||
|
|
||||||
if (key == 'f' ||
|
if (key == 'f' ||
|
||||||
key == 'F') {
|
key == 'F') {
|
||||||
|
size_t draft_index;
|
||||||
|
|
||||||
if (*count == 0U) {
|
if (*count == 0U) {
|
||||||
status_line(
|
status_line(
|
||||||
"Ajoutez au moins un exercice avant d'enregistrer.",
|
"Ajoutez au moins un exercice avant d'enregistrer.",
|
||||||
|
|
@ -6491,6 +6696,28 @@ static bool edit_session_draft(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (draft_index = 0U; draft_index < *count; ++draft_index) {
|
||||||
|
if (drafts[draft_index].input.recording_mode ==
|
||||||
|
TRAINLOG_RECORDING_SETS &&
|
||||||
|
!drafts[draft_index].input.has_max_weight &&
|
||||||
|
drafts[draft_index].input.set_count == 0U) {
|
||||||
|
char message[192];
|
||||||
|
|
||||||
|
selected = draft_index;
|
||||||
|
(void)snprintf(message, sizeof(message),
|
||||||
|
"Ajoutez au moins une série réalisée pour %s.",
|
||||||
|
drafts[draft_index].name);
|
||||||
|
status_line(message, TRAINLOG_COLOR_WARNING);
|
||||||
|
trainlog_terminal_render(tui_terminal);
|
||||||
|
(void)trainlog_terminal_get_key(tui_terminal);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft_index < *count) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6534,6 +6761,7 @@ static bool edit_session_draft(
|
||||||
)) {
|
)) {
|
||||||
selected = *count;
|
selected = *count;
|
||||||
++(*count);
|
++(*count);
|
||||||
|
draft_edit_sets(&drafts[selected]);
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -6544,6 +6772,11 @@ static bool edit_session_draft(
|
||||||
key == '\n' ||
|
key == '\n' ||
|
||||||
key == TRAINLOG_KEY_ENTER) &&
|
key == TRAINLOG_KEY_ENTER) &&
|
||||||
*count > 0U) {
|
*count > 0U) {
|
||||||
|
draft_edit_sets(&drafts[selected]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((key == 'r' || key == 'R') && *count > 0U) {
|
||||||
TrainlogSessionDraftExercise replacement;
|
TrainlogSessionDraftExercise replacement;
|
||||||
|
|
||||||
if (draft_build_exercise(
|
if (draft_build_exercise(
|
||||||
|
|
@ -6932,6 +7165,7 @@ static void screen_new_session(
|
||||||
NULL
|
NULL
|
||||||
)) {
|
)) {
|
||||||
exercise_count = 1U;
|
exercise_count = 1U;
|
||||||
|
draft_edit_sets(&drafts[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!edit_session_draft(
|
if (!edit_session_draft(
|
||||||
|
|
@ -7207,6 +7441,7 @@ static void screen_session_detail(
|
||||||
|
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
size_t selected = 0U;
|
size_t selected = 0U;
|
||||||
|
size_t set_top = 0U;
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
|
|
||||||
status =
|
status =
|
||||||
|
|
@ -7277,7 +7512,7 @@ static void screen_session_detail(
|
||||||
2,
|
2,
|
||||||
"%.*s",
|
"%.*s",
|
||||||
trainlog_terminal_columns(tui_terminal) - 4,
|
trainlog_terminal_columns(tui_terminal) - 4,
|
||||||
"←→/↑↓ exercice précédent/suivant i fiche équipement e modifier b/Échap retour"
|
"←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour"
|
||||||
);
|
);
|
||||||
|
|
||||||
trainlog_terminal_style_off(tui_terminal,
|
trainlog_terminal_style_off(tui_terminal,
|
||||||
|
|
@ -7317,7 +7552,7 @@ static void screen_session_detail(
|
||||||
} else {
|
} else {
|
||||||
draw_shell(
|
draw_shell(
|
||||||
"TRAINLOG — Détail séance",
|
"TRAINLOG — Détail séance",
|
||||||
"←→/↑↓ naviguer i fiche équipement e modifier b/Échap retour"
|
"←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour"
|
||||||
);
|
);
|
||||||
|
|
||||||
trainlog_terminal_printf(tui_terminal,
|
trainlog_terminal_printf(tui_terminal,
|
||||||
|
|
@ -7531,11 +7766,10 @@ static void screen_session_detail(
|
||||||
int actual_row =
|
int actual_row =
|
||||||
decorated ? 23 : 13;
|
decorated ? 23 : 13;
|
||||||
|
|
||||||
int summary_row =
|
int table_header_row =
|
||||||
decorated ? 25 : 15;
|
decorated ? 25 : 15;
|
||||||
|
int table_last_row =
|
||||||
int warning_row =
|
trainlog_terminal_rows(tui_terminal) - 4;
|
||||||
decorated ? 27 : 17;
|
|
||||||
|
|
||||||
if (trainlog_duration_format(
|
if (trainlog_duration_format(
|
||||||
exercise->rest_seconds,
|
exercise->rest_seconds,
|
||||||
|
|
@ -7631,14 +7865,18 @@ static void screen_session_detail(
|
||||||
trainlog_terminal_printf(tui_terminal,
|
trainlog_terminal_printf(tui_terminal,
|
||||||
weight_row,
|
weight_row,
|
||||||
decorated ? 5 : 4,
|
decorated ? 5 : 4,
|
||||||
"Charge cible : %.1f kg",
|
"%s cible : %.1f kg",
|
||||||
|
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? "Assistance" : "Charge",
|
||||||
exercise->target_weight_kg
|
exercise->target_weight_kg
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trainlog_terminal_printf(tui_terminal,
|
trainlog_terminal_printf(tui_terminal,
|
||||||
weight_row,
|
weight_row,
|
||||||
decorated ? 5 : 4,
|
decorated ? 5 : 4,
|
||||||
"Charge cible : —"
|
"%s cible : —",
|
||||||
|
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? "Assistance" : "Charge"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7663,34 +7901,72 @@ static void screen_session_detail(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD);
|
||||||
trainlog_terminal_printf(tui_terminal,
|
trainlog_terminal_printf(tui_terminal,
|
||||||
summary_row,
|
table_header_row,
|
||||||
decorated ? 5 : 4,
|
decorated ? 5 : 4,
|
||||||
"%.*s",
|
"%-8s %-18s %-18s",
|
||||||
trainlog_terminal_columns(tui_terminal) - 10,
|
"Série",
|
||||||
exercise->actual_summary
|
exercise->tracking_mode == TRAINLOG_TRACKING_REPS
|
||||||
|
? "Reps" : "Durée",
|
||||||
|
exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE
|
||||||
|
? "Assistance" : "Charge"
|
||||||
);
|
);
|
||||||
|
trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD);
|
||||||
|
|
||||||
if (exercise->load_mode ==
|
{
|
||||||
TRAINLOG_LOAD_ASSISTANCE) {
|
size_t set_index;
|
||||||
trainlog_terminal_style_on(tui_terminal,
|
size_t visible_sets = table_last_row > table_header_row
|
||||||
trainlog_theme_style(
|
? (size_t)(table_last_row - table_header_row)
|
||||||
TRAINLOG_COLOR_WARNING
|
: 0U;
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
trainlog_terminal_printf(tui_terminal,
|
if (exercise->actual_set_count == 0U) {
|
||||||
warning_row,
|
set_top = 0U;
|
||||||
decorated ? 5 : 4,
|
} else if (set_top >= exercise->actual_set_count) {
|
||||||
"Assistance : plus de kg = davantage d'aide."
|
set_top = exercise->actual_set_count - 1U;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
for (set_index = set_top;
|
||||||
|
set_index < exercise->actual_set_count &&
|
||||||
|
set_index - set_top < visible_sets;
|
||||||
|
++set_index) {
|
||||||
|
char metric[64];
|
||||||
|
char weight[64];
|
||||||
|
const TrainlogSetInput *set =
|
||||||
|
&exercise->actual_sets[set_index];
|
||||||
|
|
||||||
|
if (exercise->tracking_mode ==
|
||||||
|
TRAINLOG_TRACKING_DURATION) {
|
||||||
|
if (trainlog_duration_format(
|
||||||
|
set->duration_seconds,
|
||||||
|
metric,
|
||||||
|
sizeof(metric)
|
||||||
|
) != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)snprintf(metric, sizeof(metric), "%d s",
|
||||||
|
set->duration_seconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(void)snprintf(metric, sizeof(metric), "%d",
|
||||||
|
set->reps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (set->has_weight) {
|
||||||
|
(void)snprintf(weight, sizeof(weight), "%.2f kg",
|
||||||
|
set->weight_kg);
|
||||||
|
} else {
|
||||||
|
(void)snprintf(weight, sizeof(weight), "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
trainlog_terminal_printf(tui_terminal,
|
||||||
|
table_header_row + 1 +
|
||||||
|
(int)(set_index - set_top),
|
||||||
|
decorated ? 5 : 4,
|
||||||
|
"%-8zu %-18.18s %-18.18s",
|
||||||
|
set_index + 1U, metric, weight);
|
||||||
|
}
|
||||||
|
|
||||||
trainlog_terminal_style_off(tui_terminal,
|
|
||||||
trainlog_theme_style(
|
|
||||||
TRAINLOG_COLOR_WARNING
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7700,6 +7976,7 @@ static void screen_session_detail(
|
||||||
if (key == 'b' ||
|
if (key == 'b' ||
|
||||||
key == 'B' ||
|
key == 'B' ||
|
||||||
key == 27) {
|
key == 27) {
|
||||||
|
trainlog_database_free_session_details(exercises, count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7710,6 +7987,7 @@ static void screen_session_detail(
|
||||||
session_id
|
session_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(exercises, count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7723,6 +8001,17 @@ static void screen_session_detail(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (count > 0U && key == TRAINLOG_KEY_PAGE_UP) {
|
||||||
|
set_top = set_top > 0U ? set_top - 1U : 0U;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0U && key == TRAINLOG_KEY_PAGE_DOWN &&
|
||||||
|
set_top + 1U < exercises[selected].actual_set_count) {
|
||||||
|
++set_top;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (count > 0U &&
|
if (count > 0U &&
|
||||||
(key == TRAINLOG_KEY_RIGHT ||
|
(key == TRAINLOG_KEY_RIGHT ||
|
||||||
key == TRAINLOG_KEY_DOWN)) {
|
key == TRAINLOG_KEY_DOWN)) {
|
||||||
|
|
@ -7730,6 +8019,7 @@ static void screen_session_detail(
|
||||||
selected + 1U < count
|
selected + 1U < count
|
||||||
? selected + 1U
|
? selected + 1U
|
||||||
: 0U;
|
: 0U;
|
||||||
|
set_top = 0U;
|
||||||
} else if (
|
} else if (
|
||||||
count > 0U &&
|
count > 0U &&
|
||||||
(key == TRAINLOG_KEY_LEFT ||
|
(key == TRAINLOG_KEY_LEFT ||
|
||||||
|
|
@ -7739,6 +8029,7 @@ static void screen_session_detail(
|
||||||
selected > 0U
|
selected > 0U
|
||||||
? selected - 1U
|
? selected - 1U
|
||||||
: count - 1U;
|
: count - 1U;
|
||||||
|
set_top = 0U;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,11 @@ int main(void)
|
||||||
) == 0
|
) == 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(
|
||||||
|
details,
|
||||||
|
count
|
||||||
|
);
|
||||||
|
|
||||||
trainlog_database_close(
|
trainlog_database_close(
|
||||||
database
|
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_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(version == 9);
|
CHECK(version == 10);
|
||||||
(void)memset(&custom, 0, sizeof(custom));
|
(void)memset(&custom, 0, sizeof(custom));
|
||||||
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
|
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
|
||||||
"%s", "eq_123e4567-e89b-42d3-a456-426614174000");
|
"%s", "eq_123e4567-e89b-42d3-a456-426614174000");
|
||||||
|
|
@ -100,6 +100,7 @@ static bool test_custom_equipment_round_trip(void)
|
||||||
CHECK(count == 1U);
|
CHECK(count == 1U);
|
||||||
CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0);
|
CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0);
|
||||||
CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0);
|
CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0);
|
||||||
|
trainlog_database_free_session_details(detail, count);
|
||||||
CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id,
|
CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id,
|
||||||
used, 2U, &count) == TRAINLOG_STATUS_OK);
|
used, 2U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 1U);
|
CHECK(count == 1U);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <math.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
|
@ -150,7 +151,7 @@ static bool seed_exercise(TrainlogDatabase *database)
|
||||||
static bool test_session_insert(void)
|
static bool test_session_insert(void)
|
||||||
{
|
{
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogSetInput sets[2];
|
TrainlogSetInput sets[3];
|
||||||
TrainlogSessionExerciseInput exercise;
|
TrainlogSessionExerciseInput exercise;
|
||||||
TrainlogSessionInput session;
|
TrainlogSessionInput session;
|
||||||
TrainlogSessionSummary persisted_session;
|
TrainlogSessionSummary persisted_session;
|
||||||
|
|
@ -166,8 +167,12 @@ static bool test_session_insert(void)
|
||||||
(void)memset(sets, 0, sizeof(sets));
|
(void)memset(sets, 0, sizeof(sets));
|
||||||
sets[0].reps = 5;
|
sets[0].reps = 5;
|
||||||
sets[0].has_weight = true;
|
sets[0].has_weight = true;
|
||||||
sets[0].weight_kg = 80.0;
|
sets[0].weight_kg = 32.5;
|
||||||
sets[1] = sets[0];
|
sets[1] = sets[0];
|
||||||
|
sets[1].weight_kg = 0.0;
|
||||||
|
sets[2] = sets[0];
|
||||||
|
sets[2].has_weight = false;
|
||||||
|
sets[2].weight_kg = 0.0;
|
||||||
|
|
||||||
(void)memset(&exercise, 0, sizeof(exercise));
|
(void)memset(&exercise, 0, sizeof(exercise));
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
|
|
@ -178,12 +183,12 @@ static bool test_session_insert(void)
|
||||||
);
|
);
|
||||||
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
||||||
exercise.rest_seconds = 60;
|
exercise.rest_seconds = 60;
|
||||||
exercise.target_sets = 2;
|
exercise.target_sets = 3;
|
||||||
exercise.target_reps = 5;
|
exercise.target_reps = 5;
|
||||||
exercise.target_has_weight = true;
|
exercise.target_has_weight = true;
|
||||||
exercise.target_weight_kg = 80.0;
|
exercise.target_weight_kg = 80.0;
|
||||||
exercise.sets = sets;
|
exercise.sets = sets;
|
||||||
exercise.set_count = 2U;
|
exercise.set_count = 3U;
|
||||||
|
|
||||||
(void)memset(&session, 0, sizeof(session));
|
(void)memset(&session, 0, sizeof(session));
|
||||||
(void)snprintf(
|
(void)snprintf(
|
||||||
|
|
@ -233,6 +238,28 @@ static bool test_session_insert(void)
|
||||||
CHECK(count == 1U);
|
CHECK(count == 1U);
|
||||||
/* Regression: local occurrences are `sxe`, never synchronization runs. */
|
/* Regression: local occurrences are `sxe`, never synchronization runs. */
|
||||||
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
|
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
|
||||||
|
CHECK(persisted_exercises[0].actual_set_count == 3U);
|
||||||
|
CHECK(persisted_exercises[0].actual_sets[0].has_weight);
|
||||||
|
CHECK(persisted_exercises[0].actual_sets[0].weight_kg == 32.5);
|
||||||
|
CHECK(persisted_exercises[0].actual_sets[1].has_weight);
|
||||||
|
CHECK(persisted_exercises[0].actual_sets[1].weight_kg == 0.0);
|
||||||
|
CHECK(!persisted_exercises[0].actual_sets[2].has_weight);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(persisted_exercises, count);
|
||||||
|
|
||||||
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||||
|
"se_negative_weight");
|
||||||
|
sets[0].weight_kg = -0.5;
|
||||||
|
CHECK(trainlog_database_insert_session(database, &session) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||||
|
"se_nonfinite_weight");
|
||||||
|
sets[0].weight_kg = INFINITY;
|
||||||
|
CHECK(trainlog_database_insert_session(database, &session) ==
|
||||||
|
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||||
|
CHECK(trainlog_database_session_count(database, &count) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 1U);
|
||||||
|
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ static bool test_explicit_max_round_trip_and_identity(void)
|
||||||
CHECK(details[2].has_continuous_speed != 0 &&
|
CHECK(details[2].has_continuous_speed != 0 &&
|
||||||
details[2].continuous_speed_kmh == 5.5);
|
details[2].continuous_speed_kmh == 5.5);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(details, count);
|
||||||
|
|
||||||
CHECK(trainlog_database_load_session_editable(database, "se_explicit_max",
|
CHECK(trainlog_database_load_session_editable(database, "se_explicit_max",
|
||||||
&summary, editable, 3U, &count, NULL, 0U, &set_count) ==
|
&summary, editable, 3U, &count, NULL, 0U, &set_count) ==
|
||||||
TRAINLOG_STATUS_OK);
|
TRAINLOG_STATUS_OK);
|
||||||
|
|
@ -124,6 +126,8 @@ static bool test_explicit_max_round_trip_and_identity(void)
|
||||||
CHECK(details[0].max_weight_kg == 101.5);
|
CHECK(details[0].max_weight_kg == 101.5);
|
||||||
CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0);
|
CHECK(strcmp(details[0].entry_id, "sxe_pec") == 0);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(details, count);
|
||||||
|
|
||||||
session.session_type = TRAINLOG_SESSION_TRAINING;
|
session.session_type = TRAINLOG_SESSION_TRAINING;
|
||||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||||
"se_invalid_training_max");
|
"se_invalid_training_max");
|
||||||
|
|
@ -146,9 +150,11 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
|
||||||
"INSERT INTO sessions VALUES(1,'max_test');"
|
"INSERT INTO sessions VALUES(1,'max_test');"
|
||||||
"INSERT INTO session_exercises VALUES(10,1,'sets');"
|
"INSERT INTO session_exercises VALUES(10,1,'sets');"
|
||||||
"INSERT INTO session_exercises VALUES(11,1,'sets');"
|
"INSERT INTO session_exercises VALUES(11,1,'sets');"
|
||||||
|
"INSERT INTO session_exercises VALUES(12,1,'sets');"
|
||||||
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
|
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
|
||||||
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
|
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
|
||||||
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
||||||
|
"INSERT INTO performed_sets VALUES(23,12,0,1,NULL,0.0);"
|
||||||
"PRAGMA user_version=8;";
|
"PRAGMA user_version=8;";
|
||||||
char path[] = "/tmp/trainlog-max-v8-XXXXXX";
|
char path[] = "/tmp/trainlog-max-v8-XXXXXX";
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
|
|
@ -172,6 +178,15 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
|
||||||
CHECK(sqlite3_column_double(statement, 1) == 100.0);
|
CHECK(sqlite3_column_double(statement, 1) == 100.0);
|
||||||
CHECK(sqlite3_step(statement) == SQLITE_DONE);
|
CHECK(sqlite3_step(statement) == SQLITE_DONE);
|
||||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||||
|
CHECK(sqlite3_prepare_v2(raw,
|
||||||
|
"SELECT reps,weight_kg FROM performed_sets "
|
||||||
|
"WHERE session_exercise_row_id=12;",
|
||||||
|
-1, &statement, NULL) == SQLITE_OK);
|
||||||
|
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||||
|
CHECK(sqlite3_column_int(statement, 0) == 1);
|
||||||
|
CHECK(sqlite3_column_double(statement, 1) == 0.0);
|
||||||
|
CHECK(sqlite3_step(statement) == SQLITE_DONE);
|
||||||
|
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||||
CHECK(sqlite3_prepare_v2(raw,
|
CHECK(sqlite3_prepare_v2(raw,
|
||||||
"SELECT position,reps,weight_kg FROM performed_sets "
|
"SELECT position,reps,weight_kg FROM performed_sets "
|
||||||
"WHERE session_exercise_row_id=11 ORDER BY position;",
|
"WHERE session_exercise_row_id=11 ORDER BY position;",
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void)
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
|
|
||||||
CHECK(version == 9);
|
CHECK(version == 10);
|
||||||
|
|
||||||
CHECK(
|
CHECK(
|
||||||
trainlog_database_get_session_details(
|
trainlog_database_get_session_details(
|
||||||
|
|
@ -212,6 +212,7 @@ static bool test_v4_to_current_preserves_session(void)
|
||||||
) == 0
|
) == 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(details, detail_count);
|
||||||
trainlog_database_close(
|
trainlog_database_close(
|
||||||
database
|
database
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void)
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(version == 9);
|
CHECK(version == 10);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
CHECK(verify_preserved_values(path));
|
CHECK(verify_preserved_values(path));
|
||||||
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v8: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v10: SQLite rc=",
|
||||||
strlen("migrate database to schema v8: SQLite rc=")) == 0);
|
strlen("migrate database to schema v10: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
|
|
||||||
static bool test_newer_schema_has_application_diagnostic(void)
|
static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
{
|
{
|
||||||
char path[] = "/tmp/trainlog-schema-v10-XXXXXX";
|
char path[] = "/tmp/trainlog-schema-v11-XXXXXX";
|
||||||
char diagnostic[256];
|
char diagnostic[256];
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=10;", NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, "PRAGMA user_version=11;", NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 10 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 11 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
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)
|
static bool test_session_details(void)
|
||||||
{
|
{
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogSetInput sets[3];
|
TrainlogSetInput sets[80];
|
||||||
TrainlogSessionExerciseInput exercise;
|
TrainlogSessionExerciseInput exercise;
|
||||||
TrainlogSessionInput session;
|
TrainlogSessionInput session;
|
||||||
TrainlogSessionSummary summary;
|
TrainlogSessionSummary summary;
|
||||||
|
|
@ -52,15 +52,22 @@ static bool test_session_details(void)
|
||||||
|
|
||||||
(void)memset(sets, 0, sizeof(sets));
|
(void)memset(sets, 0, sizeof(sets));
|
||||||
|
|
||||||
|
for (size_t index = 0U; index < 80U; ++index) {
|
||||||
|
sets[index].reps = (int)index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
sets[0].reps = 5;
|
sets[0].reps = 5;
|
||||||
sets[0].has_weight = true;
|
sets[0].has_weight = true;
|
||||||
sets[0].weight_kg = 80.0;
|
sets[0].weight_kg = 78.25;
|
||||||
|
|
||||||
sets[1] = sets[0];
|
sets[1] = sets[0];
|
||||||
|
sets[1].reps = 4;
|
||||||
|
sets[1].has_weight = false;
|
||||||
|
sets[1].weight_kg = 0.0;
|
||||||
|
|
||||||
sets[2].reps = 3;
|
sets[2].reps = 3;
|
||||||
sets[2].has_weight = true;
|
sets[2].has_weight = true;
|
||||||
sets[2].weight_kg = 80.0;
|
sets[2].weight_kg = 81.75;
|
||||||
|
|
||||||
(void)memset(
|
(void)memset(
|
||||||
&exercise,
|
&exercise,
|
||||||
|
|
@ -78,14 +85,14 @@ static bool test_session_details(void)
|
||||||
"%s", "leg_press");
|
"%s", "leg_press");
|
||||||
|
|
||||||
exercise.load_mode =
|
exercise.load_mode =
|
||||||
TRAINLOG_LOAD_EXTERNAL;
|
TRAINLOG_LOAD_ASSISTANCE;
|
||||||
exercise.rest_seconds = 60;
|
exercise.rest_seconds = 60;
|
||||||
exercise.target_sets = 3;
|
exercise.target_sets = 3;
|
||||||
exercise.target_reps = 5;
|
exercise.target_reps = 5;
|
||||||
exercise.target_has_weight = true;
|
exercise.target_has_weight = true;
|
||||||
exercise.target_weight_kg = 80.0;
|
exercise.target_weight_kg = 80.0;
|
||||||
exercise.sets = sets;
|
exercise.sets = sets;
|
||||||
exercise.set_count = 3U;
|
exercise.set_count = 80U;
|
||||||
|
|
||||||
(void)memset(
|
(void)memset(
|
||||||
&session,
|
&session,
|
||||||
|
|
@ -147,14 +154,21 @@ static bool test_session_details(void)
|
||||||
CHECK(details[0].target_reps == 5);
|
CHECK(details[0].target_reps == 5);
|
||||||
CHECK(details[0].rest_seconds == 60);
|
CHECK(details[0].rest_seconds == 60);
|
||||||
CHECK(strcmp(details[0].equipment_id, "leg_press") == 0);
|
CHECK(strcmp(details[0].equipment_id, "leg_press") == 0);
|
||||||
CHECK(details[0].actual_set_count == 3U);
|
CHECK(details[0].actual_set_count == 80U);
|
||||||
CHECK(
|
CHECK(details[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
|
||||||
strcmp(
|
CHECK(details[0].actual_sets[0].reps == 5);
|
||||||
details[0].actual_summary,
|
CHECK(details[0].actual_sets[0].has_weight);
|
||||||
"5@80.0 / 5@80.0 / 3@80.0"
|
CHECK(details[0].actual_sets[0].weight_kg > 78.24);
|
||||||
) == 0
|
CHECK(details[0].actual_sets[0].weight_kg < 78.26);
|
||||||
);
|
CHECK(details[0].actual_sets[1].reps == 4);
|
||||||
|
CHECK(!details[0].actual_sets[1].has_weight);
|
||||||
|
CHECK(details[0].actual_sets[2].reps == 3);
|
||||||
|
CHECK(details[0].actual_sets[2].weight_kg > 81.74);
|
||||||
|
CHECK(details[0].actual_sets[2].weight_kg < 81.76);
|
||||||
|
CHECK(details[0].actual_sets[64].reps == 65);
|
||||||
|
CHECK(details[0].actual_sets[79].reps == 80);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(details, count);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,10 +498,115 @@ static bool test_remove_exercise_from_session(void)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool test_per_set_correction_sequence(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogSetInput created[3];
|
||||||
|
TrainlogSetInput corrected[3];
|
||||||
|
TrainlogSetInput loaded_sets[8];
|
||||||
|
TrainlogSessionExerciseInput exercise;
|
||||||
|
TrainlogSessionInput session;
|
||||||
|
TrainlogSessionSummary loaded_session;
|
||||||
|
TrainlogEditableExerciseRecord records[2];
|
||||||
|
TrainlogPersistedExerciseDetail details[2];
|
||||||
|
size_t exercise_count = 0U;
|
||||||
|
size_t set_count = 0U;
|
||||||
|
size_t detail_count = 0U;
|
||||||
|
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(add_exercises(database));
|
||||||
|
|
||||||
|
(void)memset(created, 0, sizeof(created));
|
||||||
|
created[0].reps = 10;
|
||||||
|
created[0].has_weight = true;
|
||||||
|
created[0].weight_kg = 52.25;
|
||||||
|
created[1].reps = 9;
|
||||||
|
created[1].has_weight = true;
|
||||||
|
created[1].weight_kg = 57.5;
|
||||||
|
created[2].reps = 8;
|
||||||
|
created[2].has_weight = false;
|
||||||
|
|
||||||
|
bind_reps_exercise(&exercise, created, 3U, 55.0);
|
||||||
|
exercise.load_mode = TRAINLOG_LOAD_ASSISTANCE;
|
||||||
|
/* bind_reps_exercise initializes uniform values; restore the deliberately
|
||||||
|
* heterogeneous actual rows after occurrence metadata is initialized. */
|
||||||
|
created[0].reps = 10;
|
||||||
|
created[0].weight_kg = 52.25;
|
||||||
|
created[1].reps = 9;
|
||||||
|
created[1].weight_kg = 57.5;
|
||||||
|
created[2].reps = 8;
|
||||||
|
created[2].has_weight = false;
|
||||||
|
created[2].weight_kg = 0.0;
|
||||||
|
|
||||||
|
(void)memset(&session, 0, sizeof(session));
|
||||||
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
|
||||||
|
"se_per_set_edit");
|
||||||
|
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
|
||||||
|
"2026-09-09T08:00:00+02:00");
|
||||||
|
session.session_type = TRAINLOG_SESSION_TRAINING;
|
||||||
|
session.exercises = &exercise;
|
||||||
|
session.exercise_count = 1U;
|
||||||
|
CHECK(trainlog_database_insert_session(database, &session) ==
|
||||||
|
TRAINLOG_STATUS_OK);
|
||||||
|
|
||||||
|
CHECK(trainlog_database_load_session_editable(database,
|
||||||
|
"se_per_set_edit", &loaded_session, records, 2U, &exercise_count,
|
||||||
|
loaded_sets, 8U, &set_count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(set_count == 3U);
|
||||||
|
CHECK(loaded_sets[1].weight_kg > 57.49);
|
||||||
|
CHECK(!loaded_sets[2].has_weight);
|
||||||
|
|
||||||
|
/* CONTRACT: model the row editor sequence: edit row 2, delete row 1,
|
||||||
|
* preserve the untouched row 3, then append a new (originally row 4)
|
||||||
|
* blank-load set. Replacement persists positions 0..N-1 in this order. */
|
||||||
|
corrected[0] = loaded_sets[1];
|
||||||
|
corrected[0].reps = 11;
|
||||||
|
corrected[0].weight_kg = 58.75;
|
||||||
|
corrected[1] = loaded_sets[2];
|
||||||
|
(void)memset(&corrected[2], 0, sizeof(corrected[2]));
|
||||||
|
corrected[2].reps = 7;
|
||||||
|
corrected[2].has_weight = false;
|
||||||
|
|
||||||
|
exercise.sets = corrected;
|
||||||
|
exercise.set_count = 3U;
|
||||||
|
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s",
|
||||||
|
records[0].entry_id);
|
||||||
|
CHECK(trainlog_database_replace_session_exercises(database,
|
||||||
|
"se_per_set_edit", &exercise, 1U) == TRAINLOG_STATUS_OK);
|
||||||
|
|
||||||
|
CHECK(trainlog_database_load_session_editable(database,
|
||||||
|
"se_per_set_edit", &loaded_session, records, 2U, &exercise_count,
|
||||||
|
loaded_sets, 8U, &set_count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(set_count == 3U);
|
||||||
|
CHECK(loaded_sets[0].reps == 11);
|
||||||
|
CHECK(loaded_sets[0].weight_kg > 58.74);
|
||||||
|
CHECK(loaded_sets[0].weight_kg < 58.76);
|
||||||
|
CHECK(loaded_sets[1].reps == 8);
|
||||||
|
CHECK(!loaded_sets[1].has_weight);
|
||||||
|
CHECK(loaded_sets[2].reps == 7);
|
||||||
|
CHECK(!loaded_sets[2].has_weight);
|
||||||
|
CHECK(records[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
|
||||||
|
|
||||||
|
CHECK(trainlog_database_get_session_details(database, "se_per_set_edit",
|
||||||
|
&loaded_session, details, 2U, &detail_count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(detail_count == 1U);
|
||||||
|
CHECK(details[0].actual_set_count == 3U);
|
||||||
|
CHECK(details[0].actual_sets[0].reps == 11);
|
||||||
|
CHECK(details[0].actual_sets[1].reps == 8);
|
||||||
|
CHECK(details[0].actual_sets[2].reps == 7);
|
||||||
|
CHECK(details[0].load_mode == TRAINLOG_LOAD_ASSISTANCE);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(details, detail_count);
|
||||||
|
trainlog_database_close(database);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
CHECK(test_load_replace_and_rollback());
|
CHECK(test_load_replace_and_rollback());
|
||||||
CHECK(test_remove_exercise_from_session());
|
CHECK(test_remove_exercise_from_session());
|
||||||
|
CHECK(test_per_set_correction_sequence());
|
||||||
|
|
||||||
(void)printf(
|
(void)printf(
|
||||||
"PASS session_edit\n"
|
"PASS session_edit\n"
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,14 @@ int main(void)
|
||||||
report.exercises_imported = 1U;
|
report.exercises_imported = 1U;
|
||||||
trainlog_sync_build_summary(&report);
|
trainlog_sync_build_summary(&report);
|
||||||
CHECK(strstr(report.summary, "+1 exercice(s)") != NULL);
|
CHECK(strstr(report.summary, "+1 exercice(s)") != NULL);
|
||||||
|
|
||||||
|
report.success = false;
|
||||||
|
memset(report.error, 'x', TRAINLOG_SYNC_ERROR_MAX);
|
||||||
|
report.error[TRAINLOG_SYNC_ERROR_MAX] = '\0';
|
||||||
|
trainlog_sync_build_summary(&report);
|
||||||
|
CHECK(strlen(report.summary) == TRAINLOG_SYNC_SUMMARY_MAX);
|
||||||
|
CHECK(report.summary[TRAINLOG_SYNC_SUMMARY_MAX] == '\0');
|
||||||
|
CHECK(report.summary[0] == 'x');
|
||||||
puts("PASS sync direction plan");
|
puts("PASS sync direction plan");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
) == 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trainlog_database_free_session_details(detail, detail_count);
|
||||||
trainlog_database_close(
|
trainlog_database_close(
|
||||||
database
|
database
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue