From 32fae197ee563771b119d4dcc26ea4c31b2e6dae Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 13 Sep 2026 20:56:16 +0200 Subject: [PATCH] feat(trainlog): complete stats knowledge and sync hardening --- CHANGELOG.md | 23 + .../trainlog/data/ExchangeSafTree.kt | 162 +++++ .../trainlog/data/SyncCatalogInbox.kt | 34 +- .../labfytools/trainlog/data/SyncExporter.kt | 424 ++----------- .../trainlog/data/SyncRequestOutbox.kt | 212 +------ .../trainlog/data/TrainlogRepository.kt | 584 ++++++++++++++++-- .../trainlog/data/TrainlogTimestamp.kt | 31 +- .../labfytools/trainlog/ui/AppNavigation.kt | 2 + .../ui/CompletedSessionCorrectionScreen.kt | 150 +++++ .../labfytools/trainlog/ui/ExerciseScreen.kt | 56 +- .../trainlog/ui/SessionDetailScreen.kt | 14 +- .../com/labfytools/trainlog/ui/SyncScreen.kt | 118 ++-- .../com/labfytools/trainlog/ui/TrainlogApp.kt | 32 +- .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 6 +- .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/ExerciseProfileStateSyncTest.kt | 216 +++++++ .../trainlog/data/FeedbackElapsedLabelTest.kt | 17 +- .../HistoricalSessionProfileImportTest.kt | 151 +++++ ...lAndroidV12MachineExerciseMigrationTest.kt | 2 +- ...AndroidV14FeedbackRevisionMigrationTest.kt | 4 +- .../data/TrainingFeedbackRepositoryTest.kt | 53 +- .../data/TrainlogRepositoryDraftTest.kt | 40 +- .../ui/DraftFeedbackUiReadbackTest.kt | 7 +- .../trainlog/ui/SyncBundlePublicationTest.kt | 229 +++++++ docs/android.md | 45 +- docs/architecture.md | 9 +- docs/current_state.md | 18 +- docs/database.md | 47 +- docs/exercise_data_model.md | 8 + docs/sync_exchange.md | 54 +- docs/tests.md | 32 + docs/training_feedback.md | 9 +- docs/tui.md | 13 + tests/test_exercise_alias_import.py | 2 + tests/test_profile_state_sync.py | 149 +++++ tests/test_session_exchange_v3.py | 101 ++- tools/export_equipment_associations.py | 7 +- tools/export_equipment_definitions.py | 7 +- tools/export_exercise_aliases.py | 7 +- tools/export_exercise_body_zones.py | 7 +- tools/export_exercise_profile_state.py | 77 +++ tools/export_pc_catalog.py | 5 +- tools/export_pc_mobile.py | 14 +- tools/export_training_feedback.py | 5 +- tools/import_equipment_associations.py | 7 +- tools/import_equipment_definitions.py | 7 +- tools/import_exercise_aliases.py | 7 +- tools/import_exercise_body_zones.py | 7 +- tools/import_exercise_profile_state.py | 102 +++ tools/import_mobile_export.py | 136 ++-- tools/import_training_feedback.py | 5 +- tools/migrate_machine_exercises_v1.py | 3 +- tools/trainlog_sqlite.py | 49 ++ tools/validate_json.py | 84 +++ tui/include/trainlog/database.h | 2 +- tui/include/trainlog/model.h | 2 + tui/include/trainlog/timeutil.h | 9 +- tui/meson.build | 16 + tui/src/database.c | 393 +++++++++--- tui/src/sync.c | 99 ++- tui/src/timeutil.c | 28 +- tui/src/tui.c | 56 +- tui/tests/test_body_zones.c | 8 +- tui/tests/test_catalog.c | 46 +- tui/tests/test_continuous_detail.c | 3 + tui/tests/test_continuous_session.c | 3 + tui/tests/test_exercise_profile_schema.c | 109 ++-- tui/tests/test_feedback_labels.c | 17 +- tui/tests/test_max_results.c | 19 +- tui/tests/test_schema_v16_migration.c | 227 +++++++ tui/tests/test_schema_v7_migration.c | 10 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_session_edit.c | 4 + tui/tests/test_sync_body_zone_wiring.c | 66 +- tui/tests/test_tui_workflows.c | 19 + 76 files changed, 3701 insertions(+), 1031 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/ExerciseProfileStateSyncTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/HistoricalSessionProfileImportTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt create mode 100644 tests/test_profile_state_sync.py create mode 100644 tools/export_exercise_profile_state.py create mode 100644 tools/import_exercise_profile_state.py create mode 100644 tools/trainlog_sqlite.py create mode 100644 tui/tests/test_schema_v16_migration.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 26baf3c..610338d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +- Fixed Android-triggered synchronization so the complete current Android→PC + companion bundle is published before the request signal, including Training + Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses + stale temporary profile-state reuse and reports a specific missing causal + companion when a modern same-ID profile mismatch requires it. +- Made Android exchange-file reuse exact and directory-scoped: pending, + trashed, unrelated, and conflict-numbered MediaStore rows no longer enter + canonical selection, while genuine duplicate exact names fail closed. + +## Unreleased — Desktop historical tracking snapshot + +- Added desktop schema v17 / Android schema v16 causal current-profile state, + bounded 32-revision transitive ancestry, deterministic bidirectional + convergence, and explicit concurrent-edit conflicts; legacy feedback without + `ended_at` now shows approximate H+ from + valid `started_at` instead of a misleading during-session label. +- Added desktop schema v16 with an atomic v15 backfill of occurrence-owned + `session_exercises.tracking_mode`, preserving identities and all child facts. +- Switched historical readers and V3 exchange to the occurrence snapshot while + retaining catalogue profile modes as future-entry defaults. +- Enabled explicitly confirmed future-only tracking/profile edits in the TUI + without rewriting completed sessions. + ## Unreleased — Training feedback UX and safety - Added additive schema v15 immutable feedback revisions and Training Feedback diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt b/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt new file mode 100644 index 0000000..b2c3483 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/ExchangeSafTree.kt @@ -0,0 +1,162 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import android.util.Log +import java.io.OutputStream + +internal const val SYNC_PREFERENCES_NAME = "trainlog-sync" +internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri" +internal const val EXCHANGE_MEDIASTORE_LOG_TAG = "TrainlogMediaStore" + +internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) { + "trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES" + "trainlog-sync-request-v1.json" -> "SYNC_REQUEST" + "trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT" + "trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS" + "trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS" + "trainlog-exercise-aliases-v1.json" -> "EXERCISE_ALIASES" + "trainlog-training-feedback-v2.json" -> "TRAINING_FEEDBACK" + "trainlog-exercise-profile-state-v1.json" -> "EXERCISE_PROFILE_STATE" + else -> displayName +} + +/* WHY: document providers may throw the same message from different SAF + * operations. CONTRACT: traces contain artifact identity, operation phase, + * URI and exception metadata only; user JSON is never logged. */ +internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) { + val suffix = detail?.let { " $it" } ?: "" + Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix") +} + +internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) { + Log.e( + EXCHANGE_MEDIASTORE_LOG_TAG, + "${exchangeArtifactIdentity(displayName)} error phase=$phase " + + "exception=${error.javaClass.name}:${error.message}", + ) +} + +internal data class ExchangeSafDocument( + val uri: Uri, + val displayName: String, +) + +internal interface ExchangeSafDirectory { + fun listDirectChildren(): List + fun createJson(displayName: String): ExchangeSafDocument? + fun openForRewrite(document: ExchangeSafDocument): OutputStream? +} + +private class DocumentFileExchangeSafDirectory( + private val context: Context, + private val directory: DocumentFile, +) : ExchangeSafDirectory { + override fun listDirectChildren(): List = + directory.listFiles().mapNotNull { child -> + child.name?.let { ExchangeSafDocument(child.uri, it) } + } + + override fun createJson(displayName: String): ExchangeSafDocument? = + directory.createFile("application/json", displayName)?.let { created -> + val actualName = created.name + require(actualName == displayName) { + "Le fournisseur SAF a créé $actualName au lieu de $displayName." + } + ExchangeSafDocument(created.uri, displayName) + } + + override fun openForRewrite(document: ExchangeSafDocument): OutputStream? = + context.contentResolver.openOutputStream(document.uri, "wt") +} + +internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? { + val appContext = context.applicationContext + val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE) + .getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null + val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null + if (!directory.isDirectory || !directory.canWrite()) return null + return DocumentFileExchangeSafDirectory(appContext, directory) +} + +internal class ExchangeSafPublisher( + private val directoryProvider: () -> ExchangeSafDirectory?, +) { + fun snapshot(): ExchangeSafSnapshotResult { + val directory = directoryProvider() + ?: return ExchangeSafSnapshotResult.Error( + "Dossier Trainlog non autorisé ou inaccessible.", + ) + val started = android.os.SystemClock.elapsedRealtime() + return try { + logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}") + val children = directory.listDirectChildren() + val byName = children.groupByTo(linkedMapOf()) { it.displayName } + val elapsed = android.os.SystemClock.elapsedRealtime() - started + logExchangeMediaStore( + "SAF_TREE", + "snapshot.success", + "children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}", + ) + ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName)) + } catch (error: Exception) { + logExchangeMediaStoreError("SAF_TREE", "snapshot", error) + ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.") + } + } +} + +internal sealed interface ExchangeSafSnapshotResult { + data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult + data class Error(val message: String) : ExchangeSafSnapshotResult +} + +internal class ExchangeSafSnapshot( + private val directory: ExchangeSafDirectory, + private val byName: MutableMap>, +) { + fun writeJson(displayName: String, json: String): String? { + var phase = "saf.resolve" + return try { + logExchangeMediaStore(displayName, "saf.resolve.begin") + val exact = byName[displayName].orEmpty() + require(exact.size <= 1) { + "Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})." + } + val existing = exact.singleOrNull() + val document = if (existing != null) { + logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}") + existing + } else { + phase = "saf.create" + logExchangeMediaStore(displayName, "saf.resolve.absent") + logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json") + directory.createJson(displayName)?.also { created -> + /* INVARIANT: creation mutates this transaction-local index + * so later artifacts never rescan the live directory. */ + byName.getOrPut(displayName) { mutableListOf() }.add(created) + logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}") + } ?: return "Création SAF de $displayName impossible." + } + phase = "saf.openOutputStream" + logExchangeMediaStore( + displayName, + "saf.write.begin", + "uri=${document.uri} mode=wt thread=${Thread.currentThread().name}", + ) + val stream = directory.openForRewrite(document) + ?: return "Écriture SAF de $displayName impossible." + phase = "saf.stream.write" + stream.use { + it.write(json.toByteArray(Charsets.UTF_8)) + it.flush() + } + logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}") + null + } catch (error: Exception) { + logExchangeMediaStoreError(displayName, phase, error) + error.message ?: "Publication SAF de $displayName impossible." + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index 68fce9a..a2f20c2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -52,7 +52,7 @@ class SyncCatalogInbox( private val preferences = appContext.getSharedPreferences( - "trainlog-sync", + SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE, ) @@ -74,7 +74,7 @@ class SyncCatalogInbox( preferences .edit() .putString( - KEY_TREE_URI, + SYNC_TREE_URI_KEY, uri.toString(), ) .apply() @@ -133,6 +133,19 @@ class SyncCatalogInbox( if (definitionsError != null) return CatalogInboxResult.Error(definitionsError) val aliasesError = importExerciseAliases(directory) if (aliasesError != null) return CatalogInboxResult.Error(aliasesError) + val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json") + val profileJson = profileFile?.let { candidate -> + appContext.contentResolver.openInputStream(candidate.uri) + ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } + } + if (profileFile != null && profileJson == null) + return CatalogInboxResult.Error("Lecture profile-state impossible.") + if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) { + is ExerciseProfileStateImportResult.Applied -> Unit + is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) + is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}") + ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.") + } when ( val result = @@ -141,7 +154,15 @@ class SyncCatalogInbox( json ) ) { - is PcCatalogImportResult.Applied -> + is PcCatalogImportResult.Applied -> { + if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) { + is ExerciseProfileStateImportResult.Applied -> Unit + is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message) + is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}") + ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.") + } + /* Catalogue identities now exist and the strict second pass + * has either installed or verified their causal state. */ when (val sessions = importPcSessions(directory)) { null -> when (val equipment = importPcEquipmentAssociations(directory)) { null -> when (val bodyZones = importPcBodyZones(directory)) { @@ -159,6 +180,7 @@ class SyncCatalogInbox( } else -> CatalogInboxResult.Error(sessions) } + } is PcCatalogImportResult.Invalid -> CatalogInboxResult.Error( @@ -399,15 +421,11 @@ fun readSyncReceipt( private fun savedTreeUri(): Uri? = preferences .getString( - KEY_TREE_URI, + SYNC_TREE_URI_KEY, null, ) ?.let { Uri.parse(it) } - private companion object { - const val KEY_TREE_URI = - "trainlog_tree_uri" - } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index 20af90a..695ad5a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -1,12 +1,9 @@ package com.labfytools.trainlog.data -import android.content.ContentUris -import android.content.ContentValues import android.content.Context -import android.net.Uri import android.os.Build -import android.os.Environment -import android.provider.MediaStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext sealed interface SyncExportResult { data class Exported( @@ -22,16 +19,34 @@ sealed interface SyncExportResult { ) : SyncExportResult } -class SyncExporter( - context: Context, +class SyncExporter private constructor( private val repository: TrainlogRepository, + private val safPublisher: ExchangeSafPublisher, ) { - private val appContext = - context.applicationContext + constructor(context: Context, repository: TrainlogRepository) : this( + repository, + ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, + ) - fun exportMobileBundle(): - SyncExportResult { + internal constructor( + context: Context, + repository: TrainlogRepository, + directoryProvider: () -> ExchangeSafDirectory?, + ) : this(repository, ExchangeSafPublisher(directoryProvider)) + + /* CONTRACT: repository snapshot construction and every SAF operation are + * blocking I/O work and must never execute on the Compose/main thread. */ + suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) { + when (val opened = openSafSnapshot()) { + is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot) + is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message) + } + } + + internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot() + + internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult { if ( Build.VERSION.SDK_INT < Build.VERSION_CODES.Q @@ -39,8 +54,8 @@ class SyncExporter( return SyncExportResult.Unsupported } - /* CONTRACT: capture all four artifacts before publishing any of - * them. The files are separate for compatibility, but a user edit + /* CONTRACT: capture every artifact before publishing any of them. + * The files are separate for compatibility, but a user edit * must not make a newly-written V2 reference a definition assembled * from a different logical export state. */ val definitionsJson: String @@ -49,6 +64,7 @@ class SyncExporter( val bodyZonesJson: String val aliasesJson: String val feedbackJson: String + val profileStateJson: String try { definitionsJson = repository.buildEquipmentDefinitionsJson() /* V3 is the authoritative mobile session exchange. V1/V2 remain @@ -58,362 +74,54 @@ class SyncExporter( bodyZonesJson = repository.buildExerciseBodyZonesJson() aliasesJson = repository.buildExerciseAliasesJson() feedbackJson = repository.buildTrainingFeedbackJson() + profileStateJson = repository.buildExerciseProfileStateJson() } catch (error: Exception) { return SyncExportResult.Error( error.message ?: "Préparation de l'export impossible.", ) } - val bytes = mobileJson.toByteArray(Charsets.UTF_8) + val artifacts = listOf( + "trainlog-mobile-equipment-definitions-v1.json" to definitionsJson, + "trainlog-mobile-export-v3.json" to mobileJson, + "trainlog-exercise-body-zones-v1.json" to bodyZonesJson, + "trainlog-exercise-aliases-v1.json" to aliasesJson, + "trainlog-training-feedback-v2.json" to feedbackJson, + "trainlog-exercise-profile-state-v1.json" to profileStateJson, + ) + /* CONTRACT: the persisted user-authorized SAF tree is the sole + * outbound directory authority. MediaStore ownership must neither hide + * a canonical document nor cause creation of a numbered conflict. */ + artifacts.forEach { (displayName, json) -> + snapshot.writeJson(displayName, json)?.let { error -> + return SyncExportResult.Error(error) + } + } - val resolver = - appContext.contentResolver - - val collection = - MediaStore.Downloads - .getContentUri( - MediaStore - .VOLUME_EXTERNAL_PRIMARY - ) - - val relativePath = - Environment.DIRECTORY_DOWNLOADS + - "/Trainlog/" - - val displayName = - "trainlog-mobile-export-v3.json" - - /* Definitions are visible before any session file which may reference a - * custom ID; a failed definition write aborts the bundle. */ - val definitionsError = writeEquipmentDefinitions(definitionsJson) - if (definitionsError != null) return SyncExportResult.Error(definitionsError) - - val existing = - findExisting( - collection, - displayName, - relativePath - ) - - val targetUri: Uri - val created: Boolean - - if (existing != null) { - targetUri = existing - created = false - } else { - val values = - ContentValues().apply { - put( - MediaStore - .MediaColumns - .DISPLAY_NAME, - displayName - ) - - put( - MediaStore - .MediaColumns - .MIME_TYPE, - "application/json" - ) - - put( - MediaStore - .MediaColumns - .RELATIVE_PATH, - relativePath - ) - - put( - MediaStore - .MediaColumns - .IS_PENDING, - 1 - ) - } - - val inserted = - resolver.insert( - collection, - values - ) - - if (inserted == null) { + /* CONTRACT: publication, not JSON construction, establishes the + * common sync ancestor. Applying the exact local snapshot can only + * record equal baselines; the strict reconciler never unions zones. */ + when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) { + is ExerciseBodyZoneImportResult.Applied -> Unit + is ExerciseBodyZoneImportResult.Conflict -> return SyncExportResult.Error( - "Création du fichier impossible." + "Conflit pendant l'enregistrement de la baseline zones : " + + acknowledgement.exerciseId, ) - } - - targetUri = inserted - created = true - } - - try { - val stream = - resolver.openOutputStream( - targetUri, - "wt" - ) - - if (stream == null) { - if (created) { - resolver.delete( - targetUri, - null, - null - ) - } - + is ExerciseBodyZoneImportResult.Invalid -> + return SyncExportResult.Error(acknowledgement.message) + ExerciseBodyZoneImportResult.DatabaseError -> return SyncExportResult.Error( - "Flux d'écriture indisponible." + "Enregistrement de la baseline zones impossible.", ) - } - - stream.use { - it.write(bytes) - it.flush() - } - - if (created) { - val finished = - ContentValues().apply { - put( - MediaStore - .MediaColumns - .IS_PENDING, - 0 - ) - } - - resolver.update( - targetUri, - finished, - null, - null - ) - } - - val bodyZonesError = writeBodyZones(bodyZonesJson) - if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError) - val aliasesError = writeJsonCompanion( - "trainlog-exercise-aliases-v1.json", - aliasesJson, - "alias exercice", - ) - if (aliasesError != null) return SyncExportResult.Error(aliasesError) - val feedbackError = writeJsonCompanion( - "trainlog-training-feedback-v2.json", feedbackJson, "ressentis d’entraînement") - if (feedbackError != null) return SyncExportResult.Error(feedbackError) - /* CONTRACT: publication, not JSON construction, establishes the - * common sync ancestor. Applying the exact local snapshot can only - * record equal baselines; the strict reconciler never unions zones. */ - when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) { - is ExerciseBodyZoneImportResult.Applied -> Unit - is ExerciseBodyZoneImportResult.Conflict -> - return SyncExportResult.Error( - "Conflit pendant l'enregistrement de la baseline zones : " + - acknowledgement.exerciseId, - ) - is ExerciseBodyZoneImportResult.Invalid -> - return SyncExportResult.Error(acknowledgement.message) - ExerciseBodyZoneImportResult.DatabaseError -> - return SyncExportResult.Error( - "Enregistrement de la baseline zones impossible.", - ) - } - val companionError = writeEquipmentAssociations(associationsJson) - if (companionError != null) { - return SyncExportResult.Error(companionError) - } - return SyncExportResult.Exported( - displayPath = - "Download/Trainlog/" + - displayName, - bytes = - bytes.size, - ) - } catch ( - error: Exception - ) { - if (created) { - resolver.delete( - targetUri, - null, - null - ) - } - - return SyncExportResult.Error( - error.message - ?: "Erreur d'export." - ) - } - } - - /** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */ - private fun writeEquipmentAssociations(json: String): String? { - val resolver = appContext.contentResolver - val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/" - val name = "trainlog-equipment-associations-v2.json" - val existing = findExisting(collection, name, relativePath) - val created = existing == null - val uri = existing ?: resolver.insert(collection, ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "application/json") - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - }) ?: return "Création de l'extension équipement impossible." - return try { - resolver.openOutputStream(uri, "wt")?.use { - it.write(json.toByteArray(Charsets.UTF_8)) - it.flush() - } ?: return "Écriture de l'extension équipement impossible." - if (created) resolver.update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }, null, null) - null - } catch (error: Exception) { - if (created) resolver.delete(uri, null, null) - error.message ?: "Export extension équipement impossible." - } - } - - private fun writeEquipmentDefinitions(json: String): String? { - val resolver = appContext.contentResolver - val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/" - val name = "trainlog-mobile-equipment-definitions-v1.json" - val existing = findExisting(collection, name, relativePath) - val created = existing == null - val uri = existing ?: resolver.insert(collection, ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "application/json") - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - }) ?: return "Création des définitions équipement impossible." - return try { - resolver.openOutputStream(uri, "wt")?.use { - it.write(json.toByteArray(Charsets.UTF_8)); it.flush() - } ?: return "Écriture des définitions équipement impossible." - if (created) resolver.update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }, null, null) - null - } catch (error: Exception) { - if (created) resolver.delete(uri, null, null) - error.message ?: "Export définitions équipement impossible." - } - } - - private fun writeJsonCompanion(name: String, json: String, label: String): String? { - val resolver = appContext.contentResolver - val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/" - val existing = findExisting(collection, name, relativePath) - val created = existing == null - val uri = existing ?: resolver.insert(collection, ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "application/json") - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - }) ?: return "Création du compagnon $label impossible." - return try { - resolver.openOutputStream(uri, "wt")?.use { - it.write(json.toByteArray(Charsets.UTF_8)); it.flush() - } ?: return "Écriture du compagnon $label impossible." - if (created) resolver.update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }, null, null) - null - } catch (error: Exception) { - if (created) resolver.delete(uri, null, null) - error.message ?: "Export du compagnon $label impossible." - } - } - - /** One directional-neutral companion is used by both peers. */ - private fun writeBodyZones(json: String): String? { - val resolver = appContext.contentResolver - val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/" - val name = "trainlog-exercise-body-zones-v1.json" - val existing = findExisting(collection, name, relativePath) - val created = existing == null - val uri = existing ?: resolver.insert(collection, ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, name) - put(MediaStore.MediaColumns.MIME_TYPE, "application/json") - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - }) ?: return "Création des zones corporelles impossible." - return try { - resolver.openOutputStream(uri, "wt")?.use { - it.write(json.toByteArray(Charsets.UTF_8)); it.flush() - } ?: return "Écriture des zones corporelles impossible." - if (created) resolver.update(uri, ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }, null, null) - null - } catch (error: Exception) { - if (created) resolver.delete(uri, null, null) - error.message ?: "Export des zones corporelles impossible." - } - } - - private fun findExisting( - collection: Uri, - displayName: String, - relativePath: String, - ): Uri? { - val projection = - arrayOf( - MediaStore - .MediaColumns - ._ID - ) - - val selection = - ( - MediaStore - .MediaColumns - .DISPLAY_NAME + - " = ? AND " + - MediaStore - .MediaColumns - .RELATIVE_PATH + - " = ?" - ) - - val cursor = - appContext - .contentResolver - .query( - collection, - projection, - selection, - arrayOf( - displayName, - relativePath, - ), - null, - ) - ?: return null - - try { - if (!cursor.moveToFirst()) { - return null - } - - val itemId = - cursor.getLong(0) - - return ContentUris.withAppendedId( - collection, - itemId - ) - } finally { - cursor.close() } + snapshot.writeJson( + "trainlog-equipment-associations-v2.json", + associationsJson, + )?.let { return SyncExportResult.Error(it) } + return SyncExportResult.Exported( + displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json", + bytes = mobileJson.toByteArray(Charsets.UTF_8).size, + ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt index 3aebc53..0dddf71 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt @@ -1,15 +1,12 @@ package com.labfytools.trainlog.data -import android.content.ContentUris -import android.content.ContentValues import android.content.Context -import android.net.Uri import android.os.Build -import android.os.Environment -import android.provider.MediaStore import org.json.JSONObject import java.time.OffsetDateTime import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext sealed interface SyncRequestResult { data class Requested( @@ -24,14 +21,25 @@ sealed interface SyncRequestResult { ) : SyncRequestResult } -class SyncRequestOutbox( - context: Context, +class SyncRequestOutbox private constructor( + private val safPublisher: ExchangeSafPublisher, ) { - private val appContext = - context.applicationContext + constructor(context: Context) : this( + ExchangeSafPublisher { persistedExchangeSafDirectory(context) }, + ) - fun requestSync(): - SyncRequestResult { + internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this( + ExchangeSafPublisher(directoryProvider), + ) + + suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) { + when (val opened = safPublisher.snapshot()) { + is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot) + is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message) + } + } + + internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult { if ( Build.VERSION.SDK_INT < Build.VERSION_CODES.Q @@ -65,187 +73,13 @@ class SyncRequestOutbox( ) .toString() - val bytes = - payload.toByteArray( - Charsets.UTF_8 - ) - - val resolver = - appContext.contentResolver - - val collection = - MediaStore.Downloads - .getContentUri( - MediaStore - .VOLUME_EXTERNAL_PRIMARY - ) - - val relativePath = - Environment - .DIRECTORY_DOWNLOADS + - "/Trainlog/" - val displayName = "trainlog-sync-request-v1.json" - - val existing = - findExisting( - collection, - displayName, - relativePath, - ) - - val targetUri: Uri - val created: Boolean - - if (existing != null) { - targetUri = existing - created = false + val error = snapshot.writeJson(displayName, payload) + return if (error == null) { + SyncRequestResult.Requested(requestId) } else { - val values = - ContentValues().apply { - put( - MediaStore - .MediaColumns - .DISPLAY_NAME, - displayName - ) - - put( - MediaStore - .MediaColumns - .MIME_TYPE, - "application/json" - ) - - put( - MediaStore - .MediaColumns - .RELATIVE_PATH, - relativePath - ) - - put( - MediaStore - .MediaColumns - .IS_PENDING, - 1 - ) - } - - val inserted = - resolver.insert( - collection, - values - ) - ?: return SyncRequestResult.Error( - "Création de la demande impossible." - ) - - targetUri = inserted - created = true - } - - return try { - val stream = - resolver.openOutputStream( - targetUri, - "wt" - ) - ?: return SyncRequestResult.Error( - "Écriture de la demande impossible." - ) - - stream.use { - it.write(bytes) - it.flush() - } - - if (created) { - val finished = - ContentValues().apply { - put( - MediaStore - .MediaColumns - .IS_PENDING, - 0 - ) - } - - resolver.update( - targetUri, - finished, - null, - null - ) - } - - SyncRequestResult.Requested( - requestId - ) - } catch ( - error: Exception - ) { - if (created) { - resolver.delete( - targetUri, - null, - null - ) - } - - SyncRequestResult.Error( - error.message - ?: "Demande de synchronisation impossible." - ) - } - } - - private fun findExisting( - collection: Uri, - displayName: String, - relativePath: String, - ): Uri? { - val cursor = - appContext - .contentResolver - .query( - collection, - arrayOf( - MediaStore - .MediaColumns - ._ID - ), - ( - MediaStore - .MediaColumns - .DISPLAY_NAME + - " = ? AND " + - MediaStore - .MediaColumns - .RELATIVE_PATH + - " = ?" - ), - arrayOf( - displayName, - relativePath, - ), - null, - ) - ?: return null - - try { - if (!cursor.moveToFirst()) { - return null - } - - return ContentUris - .withAppendedId( - collection, - cursor.getLong(0) - ) - } finally { - cursor.close() + SyncRequestResult.Error(error) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index e72bb6b..180dcc2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -35,12 +35,56 @@ import org.json.JSONArray import org.json.JSONObject import java.text.Normalizer import java.io.StringReader +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest import java.time.LocalDate import java.time.OffsetDateTime import java.time.temporal.WeekFields import java.util.Locale import java.util.UUID +private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d65-a54a24889149") +private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$") +private const val PROFILE_HISTORY_MAX = 32 + +/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */ +internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String { + val namespace = ByteBuffer.allocate(16).putLong(PROFILE_REVISION_NAMESPACE.mostSignificantBits) + .putLong(PROFILE_REVISION_NAMESPACE.leastSignificantBits).array() + val name = "$parent\n$recording\n$tracking\n$fields".toByteArray(StandardCharsets.UTF_8) + val digest = MessageDigest.getInstance("SHA-1").digest(namespace + name) + digest[6] = ((digest[6].toInt() and 0x0f) or 0x50).toByte() + digest[8] = ((digest[8].toInt() and 0x3f) or 0x80).toByte() + val bytes = ByteBuffer.wrap(digest) + return "pr2_" + UUID(bytes.long, bytes.long).toString() +} + +private fun advanceExerciseProfileRevision( + db: SQLiteDatabase, rowId: Long, recording: String, tracking: String, fields: Int, +): Boolean { + val state = db.rawQuery( + "SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?", + arrayOf(rowId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else return false } + val count = db.rawQuery( + "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?", + arrayOf(rowId.toString()), + ).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) } + if (count >= PROFILE_HISTORY_MAX) return false + val child = exerciseProfileRevision(state, recording, tracking, fields) + db.execSQL( + "UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?", + arrayOf(child,state,rowId), + ) + db.execSQL( + "INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)", + arrayOf(rowId,child,state,recording,tracking,fields), + ) + return true +} + /* WHY: ended_at is absent on valid imported/history rows and present lifecycle * metadata cannot prove actual work. CONTRACT: observable history requires at * least one occurrence-owned performed set, continuous activity, or explicit @@ -98,6 +142,13 @@ sealed interface PcCatalogImportResult { PcCatalogImportResult } +sealed interface ExerciseProfileStateImportResult { + data class Applied(val updated: Int, val skipped: Int) : ExerciseProfileStateImportResult + data class Invalid(val message: String) : ExerciseProfileStateImportResult + data class Conflict(val exerciseId: String) : ExerciseProfileStateImportResult + data object DatabaseError : ExerciseProfileStateImportResult +} + /** Read-only diagnostic emitted by the production PC-catalog importer. */ data class PcCatalogExerciseDecision( val exerciseId: String, @@ -183,6 +234,12 @@ sealed interface SaveSessionResult { SaveSessionResult } +sealed interface CorrectCompletedSessionResult { + data object Saved : CorrectCompletedSessionResult + data class Invalid(val message: String) : CorrectCompletedSessionResult + data class DatabaseError(val message: String) : CorrectCompletedSessionResult +} + sealed interface SaveFeedbackResult { data class Saved(val stableId: String) : SaveFeedbackResult data class Invalid(val message: String) : SaveFeedbackResult @@ -969,6 +1026,16 @@ class TrainlogRepository( exercise.dataFields, ), ) + db.execSQL( + "UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 " + + "WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);", + arrayOf(exerciseProfileRevision("pr_legacy_v1", exercise.recordingMode.wireValue, + exercise.trackingMode.wireValue, exercise.dataFields), exercise.exerciseId), + ) + db.execSQL( + "INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?", + arrayOf(exercise.exerciseId), + ) replaceExerciseBodyZones(db, exercise.exerciseId, exercise.primaryZoneId, exercise.secondaryZoneIds) @@ -989,18 +1056,12 @@ class TrainlogRepository( } } - /** - * WHY: completed sessions and active drafts snapshot profile fields, but - * keeping referenced catalog profiles immutable prevents a later catalog - * sync/profile edit from appearing to change an established exercise. - * A rename remains safe because all relationships use the unchanged row. - */ + /** CONTRACT: occurrences snapshot profile semantics, so references never lock CURRENT edits. */ fun canEditExerciseProfile( exerciseId: String, ): Boolean { val db = database.readableDatabase - val rowId = lookupExerciseRowIdOrNull(db, exerciseId) ?: return false - return !exerciseHasReferences(db, rowId) + return lookupExerciseRowIdOrNull(db, exerciseId) != null } fun editExercise( @@ -1022,13 +1083,11 @@ class TrainlogRepository( db.beginTransaction() val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId)) ?: return EditExerciseResult.DatabaseError - val profileChanged = - current.recordingMode != input.recordingMode || - current.trackingMode != input.trackingMode || - current.dataFields != input.dataFields - if (profileChanged && exerciseHasReferences(db, current.rowId)) { - return EditExerciseResult.IncompatibleProfileChange - } + /* CONTRACT: completed and draft occurrences own profile snapshots. + * Updating the catalogue controls future uses only; it must never + * rewrite or reject because historical/draft facts reference this + * stable exercise identity. The UI confirms incompatible future + * behavior before invoking this transaction. */ val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized)) if (nameOwner != null && nameOwner.rowId != current.rowId) { @@ -1042,9 +1101,32 @@ class TrainlogRepository( put("tracking_mode", input.trackingMode.wireValue) put("data_fields", input.dataFields) } + val parentRevision = db.rawQuery( + "SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?", + arrayOf(current.rowId.toString()), + ).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) } if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) { return EditExerciseResult.DatabaseError } + if (current.recordingMode != input.recordingMode || current.trackingMode != input.trackingMode || + current.dataFields != input.dataFields) { + val revisionCount = db.rawQuery( + "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?", + arrayOf(current.rowId.toString()), + ).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) } + if (revisionCount >= PROFILE_HISTORY_MAX) return EditExerciseResult.DatabaseError + db.execSQL( + "UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?", + arrayOf(exerciseProfileRevision(parentRevision, input.recordingMode.wireValue, + input.trackingMode.wireValue, input.dataFields), parentRevision, current.rowId), + ) + db.execSQL( + "INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)", + arrayOf(current.rowId, exerciseProfileRevision(parentRevision, input.recordingMode.wireValue, + input.trackingMode.wireValue, input.dataFields), parentRevision, + input.recordingMode.wireValue, input.trackingMode.wireValue, input.dataFields), + ) + } replaceExerciseBodyZones(db, input.exerciseId, input.primaryZoneId, input.secondaryZoneIds) db.setTransactionSuccessful() @@ -1093,6 +1175,176 @@ class TrainlogRepository( .toString() } + /** Separately versioned causal state for mutable CURRENT profiles. */ + fun buildExerciseProfileStateJson(): String { + val array = JSONArray() + database.readableDatabase.rawQuery( + "SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," + + "e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," + + "e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " + + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id ORDER BY e.exercise_id", + null, + ).use { c -> + while (c.moveToNext()) { + check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER && + c.getInt(12) in 0..1 && c.getType(10)==Cursor.FIELD_TYPE_STRING && + c.getType(11) in setOf(Cursor.FIELD_TYPE_NULL,Cursor.FIELD_TYPE_STRING)) + val item = JSONObject().put("exercise_id", c.getString(0)) + .put("recording_mode", c.getString(1)).put("tracking_mode", c.getString(2)) + .put("data_fields", c.getInt(3)) + for (i in 4..9) item.put(arrayOf("load_semantics","machine_variant","machine_provenance", + "scientific_profile_id","science_state","legacy_equipment_id")[i-4], + if (c.isNull(i)) JSONObject.NULL else c.getString(i)) + item.put("revision_id", c.getString(10)) + .put("parent_revision_id", if (c.isNull(11)) JSONObject.NULL else c.getString(11)) + .put("legacy_seed", c.getInt(12) == 1) + val history = JSONArray() + val rowId = database.readableDatabase.rawQuery( + "SELECT id FROM exercises WHERE exercise_id=?", arrayOf(c.getString(0)), + ).use { idCursor -> check(idCursor.moveToFirst()); idCursor.getLong(0) } + database.readableDatabase.rawQuery( + "WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS (" + + "SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=? " + + "UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31) " + + "SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC", + arrayOf(rowId.toString(), c.getString(10), rowId.toString()), + ).use { revisions -> + var previous: String? = null + var index = 0 + while (revisions.moveToNext()) history.put(JSONObject() + .put("revision_id", revisions.getString(0)) + .put("parent_revision_id", if (revisions.isNull(1)) JSONObject.NULL else revisions.getString(1)) + .put("recording_mode", revisions.getString(2)).put("tracking_mode", revisions.getString(3)) + .put("data_fields", revisions.getInt(4)).put("legacy_seed", revisions.getInt(5) == 1).also { + val revision = revisions.getString(0) + val parent = if (revisions.isNull(1)) null else revisions.getString(1) + val recording = revisions.getString(2) + val tracking = revisions.getString(3) + val fields = revisions.getInt(4) + val legacy = revisions.getInt(5) == 1 + check(revisions.getType(4)==Cursor.FIELD_TYPE_INTEGER && + revisions.getType(5)==Cursor.FIELD_TYPE_INTEGER && revisions.getInt(5) in 0..1) + check(recording in setOf("sets","continuous") && tracking in setOf("reps","duration") && + !(recording=="continuous"&&tracking!="duration") && fields in 0..3 && + !(recording=="sets"&&fields!=0)) + if (index == 0) check(legacy && revision=="pr_legacy_v1" && parent==null) + else check(!legacy && parent==previous && PROFILE_REVISION_PATTERN.matches(revision) && + revision==exerciseProfileRevision(parent!!,recording,tracking,fields)) + previous=revision;index++ + }) + } + check(history.length() in 1..PROFILE_HISTORY_MAX) + val tip=history.getJSONObject(history.length()-1) + check(tip.getString("revision_id")==c.getString(10) && + (if(tip.isNull("parent_revision_id"))null else tip.getString("parent_revision_id"))== + (if(c.isNull(11))null else c.getString(11)) && + tip.getBoolean("legacy_seed")== (c.getInt(12)==1) && + tip.getString("recording_mode")==c.getString(1) && + tip.getString("tracking_mode")==c.getString(2) && tip.getInt("data_fields")==c.getInt(3)) + item.put("history", history) + array.put(item) + } + } + return JSONObject().put("format","trainlog-exercise-profile-state").put("version",1) + .put("generated_at",OffsetDateTime.now().toString()).put("exercises",array).toString() + } + + fun applyExerciseProfileStateJson(json: String, allowPending: Boolean = false): ExerciseProfileStateImportResult { + if (!hasStrictJsonShape(json)) + return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide ou champ dupliqué.") + val root = try { JSONObject(json) } catch (_: Exception) { + return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide.") + } + if (!root.hasExactKeys(setOf("format","version","generated_at","exercises")) || + root.value("format") != "trainlog-exercise-profile-state" || !root.value("version").isExactJsonInteger(1) || + root.value("generated_at") !is String || TrainlogTimestamp.parse(root.value("generated_at") as String) == null) + return ExerciseProfileStateImportResult.Invalid("Profile-state non supporté.") + val entries = root.value("exercises") as? JSONArray + ?: return ExerciseProfileStateImportResult.Invalid("Profile-state sans exercices.") + val db=database.writableDatabase;var updated=0;var skipped=0;val seen=mutableSetOf() + return try { + db.beginTransaction() + for(i in 0 until entries.length()) { + val x=entries.get(i) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val required=setOf("exercise_id","recording_mode","tracking_mode","data_fields", + "load_semantics","machine_variant","machine_provenance","scientific_profile_id", + "science_state","legacy_equipment_id","revision_id","parent_revision_id","legacy_seed","history") + if(!x.hasExactKeys(required)) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val id=x.value("exercise_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val recording=x.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val tracking=x.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val fieldsValue=x.value("data_fields"); if(!fieldsValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val fieldsLong=(fieldsValue as Number).toLong() + if(fieldsLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val fields=fieldsLong.toInt() + val revision=x.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val parent=if(x.value("parent_revision_id")===JSONObject.NULL)null else x.value("parent_revision_id") as? String + ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val legacy=x.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val nullableAdjacent=listOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","legacy_equipment_id") + if(nullableAdjacent.any { x.value(it)!==JSONObject.NULL && x.value(it) !is String } || x.value("science_state") !in setOf("resolved","unresolved") || + !EXERCISE_ID_V4_PATTERN.matches(id)||!seen.add(id)||recording !in setOf("sets","continuous")|| + tracking !in setOf("reps","duration")||(recording=="continuous"&&tracking!="duration")|| + (recording=="sets"&&fieldsLong!=0L)|| + (legacy&&(revision!="pr_legacy_v1"||parent!=null))||(!legacy&&(parent==null|| + !PROFILE_REVISION_PATTERN.matches(revision)||revision!=exerciseProfileRevision(parent,recording,tracking,fields)))) + return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.") + val history=x.value("history") as? JSONArray ?: return ExerciseProfileStateImportResult.Invalid("Historique profile-state invalide.") + if(history.length() !in 1..PROFILE_HISTORY_MAX) return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite.") + val revisions=mutableListOf>() + val historyIds=mutableSetOf();var previous:String?=null + val revisionKeys=setOf("revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed") + for(j in 0 until history.length()) { + val h=history.get(j) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + if(!h.hasExactKeys(revisionKeys)) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val hr=h.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val ht=h.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val hfValue=h.value("data_fields");if(!hfValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val hfLong=(hfValue as Number).toLong();val hid=h.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + if(hfLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val hf=hfLong.toInt() + val hp=if(h.value("parent_revision_id")===JSONObject.NULL)null else h.value("parent_revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + val hl=h.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.") + if(hr !in setOf("sets","continuous")||ht !in setOf("reps","duration")||(hr=="continuous"&&ht!="duration")||(hr=="sets"&&hfLong!=0L)||!historyIds.add(hid)||(j>0&&hp!=previous)|| + (j==0&&(!hl||hid!="pr_legacy_v1"||hp!=null))||(j>0&&hl)|| + (hl&&(hid!="pr_legacy_v1"||hp!=null))||(!hl&&(hp==null||!PROFILE_REVISION_PATTERN.matches(hid)||hid!=exerciseProfileRevision(hp,hr,ht,hf)))) + return ExerciseProfileStateImportResult.Invalid("Chaîne profile-state invalide.") + revisions.add(listOf(hid,hp,hr,ht,hf,hl));previous=hid + } + if(revisions.last()!=listOf(revision,parent,recording,tracking,fields,legacy)) return ExerciseProfileStateImportResult.Invalid("Pointe profile-state incohérente.") + val row=db.rawQuery("SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,"+ + "e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,"+ + "s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",arrayOf(id)).use{c-> + if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}} + if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")} + val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String} + if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id) + val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1" + val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields + val accept = when { + localRevision==revision && sameProfile -> false + localRevision==revision && localLegacy && legacy -> true + localRevision in historyIds -> true + db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false + else -> return ExerciseProfileStateImportResult.Conflict(id) + } + if(!accept){skipped++;continue} + val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)} + val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} } + if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id") + revisions.forEach { h -> db.execSQL("INSERT OR IGNORE INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,?)",arrayOf(row[0],h[0],h[1],h[2],h[3],h[4],if(h[5] as Boolean)1 else 0)) } + if(legacy) db.execSQL( + "UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'", + arrayOf(recording,tracking,fields,row[0]), + ) + db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",arrayOf(revision,parent,if(legacy)1 else 0,row[0]!!.toLong())) + db.execSQL("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",arrayOf(recording,tracking,fields,row[0]!!.toLong())) + updated++ + } + db.setTransactionSuccessful();ExerciseProfileStateImportResult.Applied(updated,skipped) + } catch(_:Exception){ExerciseProfileStateImportResult.DatabaseError} finally {if(db.inTransaction())db.endTransaction()} + } + fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult { val root = try { JSONObject(json) } catch (_: Exception) { return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.") @@ -1763,6 +2015,37 @@ class TrainlogRepository( } } + /** + * WHY: correcting history changes facts, not their logical owners. + * CONTRACT: the session row and every retained entry_id survive; feedback + * roots/revisions are reattached only to retained entry_ids and follow-ups + * remain attached to the untouched session row. The complete replacement + * is one transaction, so any failure restores the original history. + */ + fun correctCompletedSession( + sessionId: String, + draft: SessionDraft, + ): CorrectCompletedSessionResult { + if (sessionId.isBlank() || draft.exercises.isEmpty() || + draft.exercises.map { it.entryId }.distinct().size != draft.exercises.size || + draft.exercises.any { !validateSessionExercise(it, draft.sessionType) }) { + return CorrectCompletedSessionResult.Invalid("La correction de séance est invalide.") + } + val db = database.writableDatabase + return try { + db.beginTransaction() + insertCompletedSession(db, draft, sessionId, generalCorrection = true) + db.setTransactionSuccessful() + CorrectCompletedSessionResult.Saved + } catch (error: Exception) { + CorrectCompletedSessionResult.DatabaseError( + error.message ?: "Correction de la séance impossible." + ) + } finally { + if (db.inTransaction()) db.endTransaction() + } + } + fun listSessions(): List { val output = mutableListOf() @@ -2157,6 +2440,10 @@ class TrainlogRepository( ) { return PcCatalogImportResult.DatabaseError } + if (byId.dataFields != richerFields && !advanceExerciseProfileRevision( + db, byId.rowId, byId.recordingMode.wireValue, + byId.trackingMode.wireValue, richerFields)) + return PcCatalogImportResult.DatabaseError reconciled += 1 traceDecision( @@ -2227,6 +2514,11 @@ class TrainlogRepository( ) { return PcCatalogImportResult.DatabaseError } + val mergedFields = byName.dataFields or dataFields + if (byName.dataFields != mergedFields && !advanceExerciseProfileRevision( + db, byName.rowId, byName.recordingMode.wireValue, + byName.trackingMode.wireValue, mergedFields)) + return PcCatalogImportResult.DatabaseError reconciled += 1 traceDecision( @@ -2274,6 +2566,26 @@ class TrainlogRepository( null, values ) + /* WHY: catalog reconciliation inserts outside the local + * creation API. CONTRACT: the strict profile-state second + * pass must observe a canonical causal root, never the + * trigger's transaction-local placeholder. */ + db.execSQL( + "UPDATE exercise_profile_state SET revision_id=?," + + "parent_revision_id='pr_legacy_v1',legacy_seed=0 " + + "WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);", + arrayOf( + exerciseProfileRevision( + "pr_legacy_v1", recording.wireValue, + tracking.wireValue, dataFields, + ), + exerciseId, + ), + ) + db.execSQL( + "INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?", + arrayOf(exerciseId), + ) imported += 1 traceDecision("none", "inserted") @@ -2897,26 +3209,34 @@ class TrainlogRepository( arrayOf(resolveExerciseId(db, exerciseId)), ) ?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId") - if ( - entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue || - entry.optString("tracking_mode") != exerciseRow.trackingMode.wireValue || - entry.optInt("data_fields", -1) and - exerciseRow.dataFields.inv() != 0 - ) { - return MobileSessionImportResult.Invalid("Profil V2 incompatible : $exerciseId") - } + /* CONTRACT: the resolved row proves stable exercise + * identity only. Completed occurrences own their profile + * snapshot; CURRENT catalogue evolution cannot reject it. */ } val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else null } + val retainedFeedback = mutableMapOf>>() + val retainedRevisions = mutableMapOf>>() if (existingRowId != null) { if (pcSessionMatches(db, existingRowId, session, version)) { sessionsSkipped += 1 continue } - if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) { + /* V2 has no complete planning authority; it must never + * erase V3 targets while applying a correction. */ + if (version != 3 && session.optString("session_type") != "max_test") return MobileSessionImportResult.Invalid( + "Une correction de séance exige le snapshot V3 complet." + ) + if (!pcCompletedCorrectionIsSafe(db, existingRowId, session)) { return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId") } + db.rawQuery("SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor -> + while (cursor.moveToNext()) retainedFeedback.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3))) + } + db.rawQuery("SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text FROM exercise_feedback_revisions r JOIN exercise_feedback f ON f.feedback_id=r.feedback_id JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor -> + while (cursor.moveToNext()) retainedRevisions.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3))) + } /* Child replacement is inside this import transaction; * entry identity/order may only be retained and appended. */ db.delete( @@ -2976,6 +3296,21 @@ class TrainlogRepository( if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId) } val occurrence = db.insertOrThrow("session_exercises", null, values) + /* INVARIANT: correction transport follows session_id + + * entry_id. Retained observations keep all immutable + * revisions; an omitted entry intentionally inherits none. */ + retainedFeedback[entryId].orEmpty().forEach { feedback -> + db.insertOrThrow("exercise_feedback", null, ContentValues().apply { + put("feedback_id",feedback.first);put("session_exercise_row_id",occurrence) + put("observed_at",feedback.second);put("raw_text",feedback.third) + }) + retainedRevisions[feedback.first].orEmpty().forEach { revision -> + db.insertOrThrow("exercise_feedback_revisions",null,ContentValues().apply { + put("revision_id",revision.first);put("feedback_id",feedback.first) + put("created_at",revision.second);put("raw_text",revision.third) + }) + } + } if (entry.has("max_weight_kg")) { db.insertOrThrow("max_results", null, ContentValues().apply { put("session_exercise_row_id", occurrence) @@ -3059,7 +3394,6 @@ class TrainlogRepository( } return try { val exerciseIds = mutableSetOf() - val exerciseProfiles = mutableMapOf>() val exerciseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields") val exercises = root.getJSONArray("exercises") for (index in 0 until exercises.length()) { @@ -3069,11 +3403,6 @@ class TrainlogRepository( !item.value("name").isNonemptyJsonString() || !validJsonProfile(item)) { return "Catalogue exercices V2 invalide." } - exerciseProfiles[id] = Triple( - item.getString("recording_mode"), - item.getString("tracking_mode"), - item.getInt("data_fields"), - ) } val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises") @@ -3109,29 +3438,25 @@ class TrainlogRepository( val exerciseId = entry.value("exercise_id") val positionValue = entry.value("position") if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) || - !exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() || - !validJsonProfile(entry) || !validEntryPlan(entry, version, recording as String, tracking as String, hasMax) || + !exerciseId.isNonemptyJsonString() || !entry.value("name").isNonemptyJsonString() || !positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) || !(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) { return "Entrée de séance V2 invalide." } + if (exerciseId !in exerciseIds) { + return "Exercice de séance V2 absent du catalogue." + } + if (!validJsonProfile(entry)) { + return "Profil historique V2 invalide." + } + if (!validEntryPlan(entry, version, recording as String, tracking as String, hasMax)) { + return "Plan/payload historique V2 incompatible avec son profil." + } if (hasMax && (session.getString("session_type") != "max_test" || !entry.value("max_weight_kg").isPositiveJsonNumber())) { return "Résultat max V2 invalide." } - val catalogProfile = exerciseProfiles[exerciseId] - ?: return "Exercice de séance V2 absent du catalogue." - val entryFields = entry.getInt("data_fields") - if ( - entry.getString("recording_mode") != catalogProfile.first || - entry.getString("tracking_mode") != catalogProfile.second || - entryFields and catalogProfile.third.inv() != 0 - ) { - /* CONTRACT: richer current catalog metadata must not - * rewrite an older occurrence snapshot. */ - return "Profil historique V2 incompatible avec le catalogue." - } if (hasMax) { /* The exact-key check above excludes set/continuous * shadows; max identity remains the exercise entry. */ @@ -3280,6 +3605,9 @@ class TrainlogRepository( val number = toDouble() return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum } + /** CONTRACT: profile-state mirrors Python's exact JSON integer rule; no strings, booleans, or 1.0. */ + private fun Any?.isExactJsonInteger(expected: Int? = null): Boolean = + (this is Int || this is Long) && (expected == null || (this as Number).toLong() == expected.toLong()) 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 @@ -3353,18 +3681,17 @@ class TrainlogRepository( return true } - private fun pcResumedMaxUpdateIsSafe( + private fun pcCompletedCorrectionIsSafe( db: SQLiteDatabase, rowId: Long, session: JSONObject, ): Boolean { - if (session.optString("session_type") != "max_test") return false val headerMatches = db.rawQuery( "SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString()), ).use { it.moveToFirst() && it.getString(0) == session.optString("started_at") && - it.getString(1) == "max_test" + it.getString(1) == session.optString("session_type") } if (!headerMatches) return false val current = mutableListOf>() @@ -3379,14 +3706,14 @@ class TrainlogRepository( } } val incoming = session.optJSONArray("exercises") ?: return false - if (incoming.length() < current.size) return false - return current.indices.all { index -> + val currentById = current.associateBy { it.first } + val seen = mutableSetOf() + return (0 until incoming.length()).all { index -> val item = incoming.optJSONObject(index) ?: return@all false - current[index] == Triple( - item.optString("entry_id"), - item.optInt("position", -1), - resolveExerciseId(db, item.optString("exercise_id")), - ) + val entryId = item.optString("entry_id") + val existing = currentById[entryId] + seen.add(entryId) && (existing == null || existing.third == + resolveExerciseId(db, item.optString("exercise_id"))) } } @@ -5623,6 +5950,7 @@ class TrainlogRepository( db: SQLiteDatabase, draft: SessionDraft, sourceSessionId: String? = null, + generalCorrection: Boolean = false, ): String { val sessionId: String val sessionRowId: Long @@ -5642,20 +5970,24 @@ class TrainlogRepository( } sessionRowId = db.insertOrThrow("sessions", null, sessionValues) } else { - check(draft.sessionType == SessionType.MAX_TEST) { + check(generalCorrection || draft.sessionType == SessionType.MAX_TEST) { "Seul un Test max peut remplacer une séance reprise." } sessionId = sourceSessionId sessionRowId = db.rawQuery( - "SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';", + if (generalCorrection) "SELECT id FROM sessions WHERE session_id=?;" + else "SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';", arrayOf(sourceSessionId), ).use { cursor -> check(cursor.moveToFirst()) { "Séance Test max source introuvable." } cursor.getLong(0) } - check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) { + check(generalCorrection || resumedDraftIdentityIsSafe(db, sessionRowId, draft)) { "Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes." } + check(!generalCorrection || completedCorrectionIdentityIsSafe(db, sessionRowId, draft)) { + "Une correction ne peut réaffecter un entry_id existant à un autre exercice." + } /* Android has one bounded completed-session correction path: * resumed MAX_TEST finalization. Preserve feedback by the stable * occurrence entry_id before cascading reconstruction; the @@ -5675,7 +6007,7 @@ class TrainlogRepository( ).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions .getOrPut(cursor.getString(0)) { mutableListOf() } .add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) } - db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;", + if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;", arrayOf(OffsetDateTime.now().toString(), sessionRowId)) /* INVARIANT: child replacement and draft deletion are in the * caller's transaction; failure restores the completed baseline. */ @@ -5822,6 +6154,34 @@ class TrainlogRepository( return sessionId } + /** + * CONTRACT: an occurrence entry_id retains its exercise identity during a + * completed-session replacement. Aliases compare canonical creator IDs so + * retirement/reconciliation cannot manufacture a false reassignment. + * This guard runs inside the replacement transaction before any delete. + */ + private fun completedCorrectionIdentityIsSafe( + db: SQLiteDatabase, + sessionRowId: Long, + draft: SessionDraft, + ): Boolean { + fun canonical(id: String): String = db.rawQuery( + "SELECT COALESCE((SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?),?)", + arrayOf(id, id), + ).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) } + val incoming = draft.exercises.associate { it.entryId to canonical(it.exercise.exerciseId) } + return db.rawQuery( + "SELECT se.entry_id,e.exercise_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id " + + "WHERE se.session_row_id=?", arrayOf(sessionRowId.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val replacement = incoming[cursor.getString(0)] ?: continue + if (replacement != canonical(cursor.getString(1))) return@use false + } + true + } + } + private fun resumedDraftIdentityIsSafe( db: SQLiteDatabase, sessionRowId: Long, @@ -6252,7 +6612,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 15, + 16, ) { override fun onConfigure( db: SQLiteDatabase, @@ -6268,7 +6628,16 @@ private class TrainlogDatabaseHelper( db: SQLiteDatabase, ) { super.onOpen(db) - migrateApprovedLegPressIdentity(db) + db.beginTransaction() + try { + migrateApprovedLegPressIdentity(db) + /* WHY: early unpublished v16 builds used a variable-width pr1 + * current token. CONTRACT: recognize only its exact grammar and + * atomically replace it by the shared root plus canonical pr2 tip. */ + createExerciseProfileStateTable(db, seedLegacy = false) + normalizePrototypeExerciseProfileState(db) + db.setTransactionSuccessful() + } finally { db.endTransaction() } } override fun onCreate( @@ -6283,6 +6652,7 @@ private class TrainlogDatabaseHelper( createExerciseAliasTable(db) createTrainingFeedbackTables(db) createTrainingFeedbackRevisionTables(db) + createExerciseProfileStateTable(db, seedLegacy = false) seedEquipment(db) } @@ -6401,6 +6771,15 @@ private class TrainlogDatabaseHelper( version = 15 } + if (version < 16 && newVersion >= 16) { + /* WHY/CONTRACT/INVARIANT: wall clocks cannot order offline profile + * edits. Existing rows share one explicit legacy root without any + * catalogue or occurrence rewrite; later edits carry direct causal + * ancestry in this companion table. */ + createExerciseProfileStateTable(db, seedLegacy = true) + version = 16 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -6430,6 +6809,91 @@ private class TrainlogDatabaseHelper( ) } + private fun createExerciseProfileStateTable(db: SQLiteDatabase, seedLegacy: Boolean) { + /* WHY: wall clocks cannot order offline edits. CONTRACT: revisions form + * one explicit causal chain. INVARIANT: at most 32 records are retained + * per exercise and overflow aborts instead of pruning ancestry. */ + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_profile_state(" + + "exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," + + "revision_id TEXT NOT NULL,parent_revision_id TEXT," + + "legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));", + ) + if (seedLegacy) { + db.execSQL( + "INSERT OR IGNORE INTO exercise_profile_state(" + + "exercise_row_id,revision_id,parent_revision_id,legacy_seed) " + + "SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;", + ) + } + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_profile_revisions(" + + "exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," + + "revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL," + + "tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL," + + "legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1))," + + "PRIMARY KEY(exercise_row_id,revision_id));", + ) + db.execSQL( + "INSERT OR IGNORE INTO exercise_profile_revisions " + + "SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed " + + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;", + ) + db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_insert;") + db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_update;") + db.execSQL( + "CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN " + + "INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) " + + "VALUES(NEW.id,'pr_legacy_v1',NULL,1);" + + "INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);END;", + ) + } + + private fun normalizePrototypeExerciseProfileState(db: SQLiteDatabase) { + data class Prototype(val rowId: Long, val token: String, val recording: String, + val tracking: String, val fields: Int) + val prototypes=mutableMapOf() + db.rawQuery( + "SELECT s.exercise_row_id,s.revision_id,s.parent_revision_id,s.legacy_seed,"+ + "e.recording_mode,e.tracking_mode,e.data_fields FROM exercise_profile_state s "+ + "JOIN exercises e ON e.id=s.exercise_row_id WHERE s.revision_id LIKE 'pr1%'", null, + ).use { c -> while(c.moveToNext()) { + val token=c.getString(1);val match=PROTOTYPE_PROFILE_REVISION_PATTERN.matchEntire(token) + ?: error("Malformed prototype profile revision") + val recording=match.groupValues[1];val tracking=match.groupValues[2] + val fields=match.groupValues[3].toIntOrNull() ?: error("Malformed prototype profile fields") + check(token=="pr1|$recording|$tracking|$fields" && c.isNull(2)&&c.getInt(3)==1 && + recording==c.getString(4) && tracking==c.getString(5) && + fields==c.getInt(6) && fields in 0..3 && !(recording=="sets"&&fields!=0) && + !(recording=="continuous"&&tracking!="duration")) { "Inconsistent prototype profile state" } + prototypes[c.getLong(0)]=Prototype(c.getLong(0),token,recording,tracking,fields) + }} + db.rawQuery( + "SELECT exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed "+ + "FROM exercise_profile_revisions WHERE revision_id LIKE 'pr1%'", null, + ).use { c -> while(c.moveToNext()) { + val prototype=prototypes[c.getLong(0)] ?: error("Orphan prototype profile revision") + check(c.getString(1)==prototype.token && c.isNull(2) && c.getString(3)==prototype.recording && + c.getString(4)==prototype.tracking && c.getInt(5)==prototype.fields && c.getInt(6)==1) { + "Inconsistent prototype profile revision" + } + }} + for(prototype in prototypes.values) { + val count=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?", + arrayOf(prototype.rowId.toString())).use { it.moveToFirst();it.getInt(0) } + check(count==1) { "Mixed prototype profile lineage" } + val child=exerciseProfileRevision("pr_legacy_v1",prototype.recording,prototype.tracking,prototype.fields) + db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id LIKE 'pr1%'", + arrayOf(prototype.rowId)) + db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr_legacy_v1',NULL,?,?,?,1)", + arrayOf(prototype.rowId,prototype.recording,prototype.tracking,prototype.fields)) + db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,?,'pr_legacy_v1',?,?,?,0)", + arrayOf(prototype.rowId,child,prototype.recording,prototype.tracking,prototype.fields)) + db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 WHERE exercise_row_id=?", + arrayOf(child,prototype.rowId)) + } + } + private fun createExerciseAliasTable(db: SQLiteDatabase) { db.execSQL( "CREATE TABLE IF NOT EXISTS exercise_aliases(" + diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt index fae86a8..93820eb 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogTimestamp.kt @@ -124,18 +124,31 @@ internal object TrainlogTimestamp { /** Shared Android presentation rule; exact instants are parsed before * subtraction and a missing/negative anchor never produces a fabricated H+. */ -fun sessionFollowUpElapsedLabel(endedAt: String?, observedAt: String): String { - val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "H+?" +fun sessionFollowUpElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String { val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?" - val seconds = observed.utcSecond - end.utcSecond - return if (seconds < 0) "H+?" else "H+${seconds / 3600L}" + val end = endedAt?.let(TrainlogTimestamp::parse) + if (end != null) { + val seconds = observed.utcSecond - end.utcSecond + return if (seconds < 0) "H+?" else "H+${seconds / 3600L}" + } + val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?" + val seconds = observed.utcSecond - start.utcSecond + return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}" } /** Exercise observations may precede finalization and therefore use a * session-neutral label instead of a negative or misleading H+ value. */ -fun exerciseFeedbackElapsedLabel(endedAt: String?, observedAt: String): String { - val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "Ressenti" - val observed = TrainlogTimestamp.parse(observedAt) ?: return "Ressenti" - val seconds = observed.utcSecond - end.utcSecond - return if (seconds < 0) "Pendant la séance" else "H+${seconds / 3600L}" +fun exerciseFeedbackElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String { + val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?" + val end = endedAt?.let(TrainlogTimestamp::parse) + if (end != null) { + val seconds = observed.utcSecond - end.utcSecond + if (seconds >= 0) return "H+${seconds / 3600L}" + val start = startedAt?.let(TrainlogTimestamp::parse) + return if (start != null && observed.utcSecond >= start.utcSecond) + "Pendant la séance" else "H+?" + } + val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?" + val seconds = observed.utcSecond - start.utcSecond + return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}" } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index 6146f04..6281a64 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -18,6 +18,7 @@ sealed interface AppRoute { data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } + data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data object Exercises : AppRoute { override val section = AppSection.EXERCISES } data class ExerciseDetail(val exerciseId: String) : AppRoute { override val section = AppSection.EXERCISES } data class ExerciseEdit(val exerciseId: String, val caller: AppRoute) : AppRoute { override val section = caller.section } @@ -158,6 +159,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) { AppRoute.SessionGenerator -> "Programmer une séance" AppRoute.CompletedSessions -> "Séances effectuées" is AppRoute.SessionDetail -> "Détail de la séance" + is AppRoute.SessionCorrection -> "Modifier la séance" AppRoute.Exercises -> "Exercices" is AppRoute.ExerciseDetail -> "Fiche exercice" is AppRoute.ExerciseEdit -> "Modifier l'exercice" diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt new file mode 100644 index 0000000..e00ba8b --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt @@ -0,0 +1,150 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.labfytools.trainlog.data.CorrectCompletedSessionResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.model.ExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionExerciseDetail +import com.labfytools.trainlog.model.SessionSetDraft +import com.labfytools.trainlog.model.TrackingMode +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors + +/** + * WHY: a completed-session correction must be reviewed as one bounded form, + * not persisted field-by-field while the user is still editing. + * CONTRACT: initial values are historical occurrence snapshots; Save performs + * the sole repository mutation and Cancel performs none. Destructive removal + * always passes through an explicit, non-dismissible confirmation. + */ +@Composable +fun CompletedSessionCorrectionScreen( + repository: TrainlogRepository, + sessionId: String, + onFinished: (saved: Boolean) -> Unit, +) { + val colors = LocalTrainlogColors.current + val original = remember(sessionId) { repository.getSessionDetail(sessionId) } + var drafts by remember(sessionId) { + mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty()) + } + var pendingSetRemoval by remember { mutableStateOf?>(null) } + var pendingOccurrenceRemoval by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + + TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") { + if (original == null) { + TrainlogInfo("Séance introuvable.", colors.error) + return@TrainlogScreen + } + TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted) + drafts.forEachIndexed { occurrenceIndex, draft -> + TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") { + draft.plan?.let { plan -> + TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw -> + raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } } + }) + TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)", + (plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it } + } }) + TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it } + } }) + if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it } + } }) + } + if (draft.maxWeightKg != null) { + TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it } + } }) + } else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) { + TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it } + } }) + draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it } + } }) } + draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> + replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it } + } }) } + } else { + draft.sets.forEachIndexed { setIndex, set -> + TrainlogInfo("Série ${setIndex + 1}", colors.accent) + TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)", + (if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value -> + val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value) + replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it } + } }) + if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value -> + replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } + } }) + TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette série", + { pendingSetRemoval = occurrenceIndex to setIndex }, + Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"), colors.error) + } + TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", { + replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it } + }, accent = colors.success) + } + TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette occurrence", + { pendingOccurrenceRemoval = occurrenceIndex }, + Modifier.testTag("remove-completed-occurrence-${draft.entryId}"), colors.error) + } + } + message?.let { TrainlogInfo(it, colors.error) } + TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", { + when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) { + CorrectCompletedSessionResult.Saved -> onFinished(true) + is CorrectCompletedSessionResult.Invalid -> message = result.message + is CorrectCompletedSessionResult.DatabaseError -> message = result.message + } + }) + TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted) + } + pendingSetRemoval?.let { (occurrence, set) -> + DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null }) { + val draft = drafts[occurrence] + replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it } + pendingSetRemoval = null + } + } + pendingOccurrenceRemoval?.let { occurrence -> + val draft = drafts[occurrence] + DestructiveConfirmationDialog("Supprimer cette occurrence ?", + "Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.", + "Supprimer", { pendingOccurrenceRemoval = null }) { + drafts = drafts.filterIndexed { index, _ -> index != occurrence } + pendingOccurrenceRemoval = null + } + } +} + +private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft( + entryId = entryId, + exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields), + equipmentId = equipmentId, + maxWeightKg = maxWeightKg, + plan = plan, + sets = sets, + continuousDurationSeconds = continuousDurationSeconds, + speedKmh = speedKmh, + distanceKm = distanceKm, +) + +private inline fun replaceDraft(items: List, index: Int, value: SessionExerciseDraft, update: (List) -> Unit) = + update(items.toMutableList().apply { this[index] = value }) + +private inline fun replaceSet(items: List, occurrence: Int, set: Int, value: SessionSetDraft, update: (List) -> Unit) { + val draft = items[occurrence] + replaceDraft(items, occurrence, draft.copy(sets = draft.sets.toMutableList().apply { this[set] = value }), update) +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 5746e38..778fb21 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -111,10 +114,16 @@ fun ExerciseScreen( var message by state.message var editedExerciseId by state.editedExerciseId - val profileLocked = - editedExerciseId?.let { - !repository.canEditExerciseProfile(it) - } ?: false + val originalProfile = remember(editedExerciseId) { + editedExerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } } + } + var confirmedIncompatible by remember(editedExerciseId) { mutableStateOf(false) } + var confirmingIncompatible by remember(editedExerciseId) { mutableStateOf(false) } + val incompatibleProfileChange = originalProfile?.let { + it.recordingMode != recordingMode || it.trackingMode != trackingMode || + it.dataFields != ((if (speed) ExerciseDataFields.SPEED_KMH else 0) or + (if (distance) ExerciseDataFields.DISTANCE_KM else 0)) + } == true fun startEditing(exercise: ExerciseProfile) { /* WHY: edit state copies catalog metadata for presentation only. The @@ -159,9 +168,8 @@ fun ExerciseScreen( selected = recordingMode == RecordingMode.SETS, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice recordingMode = RecordingMode.SETS @@ -176,9 +184,8 @@ fun ExerciseScreen( selected = recordingMode == RecordingMode.CONTINUOUS, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice recordingMode = RecordingMode.CONTINUOUS @@ -203,9 +210,8 @@ fun ExerciseScreen( selected = trackingMode == TrackingMode.REPS, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice trackingMode = TrackingMode.REPS @@ -219,9 +225,8 @@ fun ExerciseScreen( selected = trackingMode == TrackingMode.DURATION, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice trackingMode = TrackingMode.DURATION @@ -241,9 +246,8 @@ fun ExerciseScreen( TrainlogChoice( label = "Vitesse", selected = speed, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice speed = !speed message = null }, @@ -252,9 +256,8 @@ fun ExerciseScreen( TrainlogChoice( label = "Distance", selected = distance, - enabled = !profileLocked, + enabled = true, onClick = { - if (profileLocked) return@TrainlogChoice distance = !distance @@ -344,12 +347,10 @@ fun ExerciseScreen( color = colors.accent, ) - if (profileLocked) { + if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) { TrainlogInfo( text = - "Profil verrouillé : cet exercice est déjà référencé " + - "par une séance terminée ou le brouillon actif. " + - "Le nom reste modifiable.", + "Les séances passées et l'occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s'appliquera aux prochaines utilisations.", color = colors.warning, ) } @@ -370,6 +371,10 @@ fun ExerciseScreen( "Conserver l'identité et mettre à jour le catalogue." }, onClick = save@{ + if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) { + confirmingIncompatible = true + return@save + } if (editedExerciseId == null && recordingMode == RecordingMode.SETS && primaryZoneId == null) { message = "Une zone principale est requise pour un nouvel exercice musculaire." @@ -560,6 +565,17 @@ fun ExerciseScreen( ) } } + if (confirmingIncompatible) AlertDialog( + onDismissRequest = { confirmingIncompatible = false }, + title = { Text("Modifier le mode de suivi ?") }, + text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") }, + confirmButton = { TextButton(onClick = { + confirmedIncompatible = true + confirmingIncompatible = false + message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations." + }) { Text("Modifier") } }, + dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } }, + ) } @Composable diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 9b78508..809ddfc 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -25,7 +25,8 @@ fun SessionDetailScreen( repository: TrainlogRepository, sessionId: String?, onBack: () -> Unit, - onResumeMaxTest: () -> Unit, + onCorrect: () -> Unit = {}, + onResumeMaxTest: () -> Unit = {}, ) { val colors = LocalTrainlogColors.current @@ -103,6 +104,13 @@ fun SessionDetailScreen( "${detail.summary.exerciseCount} exercice(s)" ) + TrainlogAction( + label = "Modifier la séance", + description = "Corriger les faits enregistrés sans changer l'identité de la séance.", + accent = colors.accent, + onClick = onCorrect, + ) + if (detail.summary.sessionType == SessionType.MAX_TEST) { TrainlogAction( label = "Reprendre ce Test max", @@ -200,7 +208,7 @@ fun SessionDetailScreen( ) } exercise.feedback.forEach { item -> - TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}") + TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}") TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.", onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted) if (editingFeedbackId == item.feedbackId) FeedbackEditor( @@ -232,7 +240,7 @@ fun SessionDetailScreen( TrainlogFrame(title = "Suivi après séance") { detail.followUps.forEach { item -> - TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, item.observedAt), color = colors.accent) + TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent) TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "") TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.", onClick = { editingFollowUpId = item.followupId }, accent = colors.muted) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 876c75c..702af72 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -9,24 +9,58 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.labfytools.trainlog.data.CatalogInboxResult import com.labfytools.trainlog.data.SyncCatalogInbox +import com.labfytools.trainlog.data.SyncExporter +import com.labfytools.trainlog.data.SyncExportResult import com.labfytools.trainlog.data.SyncReceiptResult import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestResult +import com.labfytools.trainlog.data.logExchangeMediaStore import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/* Package-visible for orchestration tests: this is the production ordering + * boundary, not a duplicate test-only implementation. */ +internal suspend fun publishBundleAndRequest( + exporter: SyncExporter, + requestOutbox: SyncRequestOutbox, +): SyncRequestResult = withContext(Dispatchers.IO) { + logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin") + when (val opened = exporter.openSafSnapshot()) { + is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error -> + SyncRequestResult.Error("Préparation Android → PC : ${opened.message}") + is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready -> + when (val publication = exporter.exportMobileBundle(opened.snapshot)) { + SyncExportResult.Unsupported -> SyncRequestResult.Unsupported + is SyncExportResult.Error -> SyncRequestResult.Error( + "Préparation Android → PC : ${publication.message}", + ) + is SyncExportResult.Exported -> { + logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success") + logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin") + requestOutbox.requestSync(opened.snapshot) + } + } + } +} @Composable fun SyncScreen( inbox: SyncCatalogInbox, + exporter: SyncExporter, requestOutbox: SyncRequestOutbox, onCatalogChanged: () -> Unit, onBack: () -> Unit, ) { val colors = LocalTrainlogColors.current + val coroutineScope = rememberCoroutineScope() var status by remember { @@ -54,6 +88,8 @@ fun SyncScreen( ) } + var syncRunning by remember { mutableStateOf(false) } + LaunchedEffect( pendingRequestId ) { @@ -63,10 +99,9 @@ fun SyncScreen( repeat(60) { when ( - val receipt = - inbox.readSyncReceipt( - requestId - ) + val receipt = withContext(Dispatchers.IO) { + inbox.readSyncReceipt(requestId) + } ) { SyncReceiptResult.Pending -> { delay(1000) @@ -109,8 +144,9 @@ fun SyncScreen( } when ( - val catalog = + val catalog = withContext(Dispatchers.IO) { inbox.importPcCatalog() + } ) { is CatalogInboxResult.Imported -> { success = true @@ -199,10 +235,8 @@ fun SyncScreen( } if (saved) { - when ( - val result = - inbox.importPcCatalog() - ) { + coroutineScope.launch { + when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { is CatalogInboxResult.Imported -> { success = true @@ -228,6 +262,7 @@ fun SyncScreen( /* Keep permission state. */ } } + } } } } @@ -249,8 +284,7 @@ fun SyncScreen( TrainlogAction( label = if ( - pendingRequestId != - null + pendingRequestId != null || syncRunning ) { "Synchronisation en cours..." } else { @@ -262,8 +296,7 @@ fun SyncScreen( colors.success, onClick = { if ( - pendingRequestId != - null + pendingRequestId != null || syncRunning ) { return@TrainlogAction } @@ -277,33 +310,31 @@ fun SyncScreen( return@TrainlogAction } - when ( - val result = - requestOutbox - .requestSync() - ) { - is SyncRequestResult.Requested -> { - success = true - - pendingRequestId = - result.requestId - - status = - "Demande envoyée · attente du PC..." - } - - SyncRequestResult.Unsupported -> { - success = false - - status = - "Android non supporté." - } - - is SyncRequestResult.Error -> { - success = false - - status = - result.message + /* WHY: the request file is the PC daemon's start signal. + * CONTRACT: every current Android-origin companion must be + * published from one prepared repository snapshot before + * that signal becomes visible; otherwise the PC can mix a + * fresh V3 file with stale or absent causal companions. */ + syncRunning = true + coroutineScope.launch { + try { + when (val result = publishBundleAndRequest(exporter, requestOutbox)) { + is SyncRequestResult.Requested -> { + success = true + pendingRequestId = result.requestId + status = "Demande envoyée · attente du PC..." + } + SyncRequestResult.Unsupported -> { + success = false + status = "Android non supporté." + } + is SyncRequestResult.Error -> { + success = false + status = result.message + } + } + } finally { + syncRunning = false } } }, @@ -346,10 +377,8 @@ fun SyncScreen( description = "Action de récupération manuelle si nécessaire.", onClick = { - when ( - val result = - inbox.importPcCatalog() - ) { + coroutineScope.launch { + when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { is CatalogInboxResult.Imported -> { success = true @@ -385,6 +414,7 @@ fun SyncScreen( result.message } } + } }, ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 208fc88..1d57f45 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveableStateHolder @@ -20,6 +21,9 @@ import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.TrainlogRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) { @@ -32,16 +36,19 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S var draftRevision by remember { mutableIntStateOf(0) } var draftMessage by remember { mutableStateOf(null) } val navigationController = appState.navigationController + val coroutineScope = rememberCoroutineScope() + fun exportSnapshot() { + coroutineScope.launch { exporter.exportMobileBundle() } + } /* CONTRACT: automatic exchange work belongs to the application lifecycle. * Re-entering a route must never trigger a second import or export. */ LaunchedEffect(Unit) { - when (inbox.importPcCatalog()) { + when (withContext(Dispatchers.IO) { inbox.importPcCatalog() }) { is CatalogInboxResult.Imported -> catalogRevision++ CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound, is CatalogInboxResult.Error -> Unit } - exporter.exportMobileBundle() } val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() } @@ -88,7 +95,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S { open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, ) AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) }) - AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ }) + AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ }) AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, { draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée." draftRevision++ @@ -99,19 +106,23 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S openSection(AppSection.SESSIONS) }) AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } - is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }) { draftRevision++; open(AppRoute.SessionEditor) } + is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) } + is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved -> + if (saved) exportSnapshot() + back() + } AppRoute.Exercises -> ExerciseCatalogueScreen(repository, exerciseState, { open(AppRoute.ExerciseCreate(AppRoute.Exercises)) }, { open(AppRoute.ExerciseDetail(it)) }) is AppRoute.ExerciseDetail -> ExerciseDetailScreen(repository, route.exerciseId, { open(AppRoute.ExerciseEdit(route.exerciseId, route)) }, { open(AppRoute.LatestMaxima) }) - is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() } - is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() } + is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exportSnapshot(); catalogRevision++; back() } + is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() } AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) }) is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) } - is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() } + is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() } AppRoute.Statistics -> StatisticsDashboard(repository) - AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() }) + AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() }) AppRoute.LatestMaxima -> LatestMaximaScreen(repository) - AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() }) - AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ } + AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() }) + AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ } }} } @@ -134,6 +145,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S private fun AppRoute.stateKey(): String = when (this) { is AppRoute.SessionDetail -> "session:${sessionId}" + is AppRoute.SessionCorrection -> "session-correction:${sessionId}" is AppRoute.ExerciseDetail -> "exercise:${exerciseId}" is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}" is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}" diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 7c3ba99..a93d544 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(15, scalar(db, "PRAGMA user_version")) + assertEquals(16, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 3b6c8be..e35721d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -64,11 +64,11 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(15, scalar(db, "PRAGMA user_version")) + assertEquals(16, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ - val after = snapshot(db).filterKeys { it != "exercise_aliases" } - before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync") } + val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } + before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync", "exercise_profile_state", "exercise_profile_revisions") } .forEach { (table, rows) -> assertEquals(rows, after[table]) } assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases")) db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 128e8b0..bdf07c6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -323,7 +323,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(15, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ExerciseProfileStateSyncTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ExerciseProfileStateSyncTest.kt new file mode 100644 index 0000000..dec4214 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ExerciseProfileStateSyncTest.kt @@ -0,0 +1,216 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.* +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk=[35]) +class ExerciseProfileStateSyncTest { + @Test fun `v16 prototype state normalizes atomically and second open is idempotent`() { + val context=ApplicationProvider.getApplicationContext() + val name="profile-pr1-${UUID.randomUUID()}.db" + var repository=TrainlogRepository(context,name) + try { + val created=repository.createExercise(NewExerciseProfile("Prototype",RecordingMode.SETS,TrackingMode.REPS,0)) + as CreateExerciseResult.Created + repository.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db -> + val rowId=db.rawQuery("SELECT id FROM exercises WHERE exercise_id=?",arrayOf(created.exercise.exerciseId)) + .use { c -> assertTrue(c.moveToFirst());c.getLong(0) } + db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(rowId)) + db.execSQL("UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=?",arrayOf(rowId)) + db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr1|sets|reps|0',NULL,'sets','reps',0,1)",arrayOf(rowId)) + } + repository=TrainlogRepository(context,name) + var item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0) + assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id")) + assertEquals(listOf("pr_legacy_v1","pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b"), + (0 until item.getJSONArray("history").length()).map { item.getJSONArray("history").getJSONObject(it).getString("revision_id") }) + repository.close();repository=TrainlogRepository(context,name) + item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0) + assertEquals(2,item.getJSONArray("history").length()) + assertEquals(TrackingMode.REPS,repository.listExercises().single().trackingMode) + } finally { repository.close();context.deleteDatabase(name) } + } + + @Test fun `new profile revision matches fixed width cross-runtime UUIDv5`() { + val context=ApplicationProvider.getApplicationContext() + val name="profile-revision-${UUID.randomUUID()}.db" + val repository=TrainlogRepository(context,name) + try { + val created=repository.createExercise(NewExerciseProfile("UUID parity",RecordingMode.SETS,TrackingMode.REPS,0)) + assertTrue(created is CreateExerciseResult.Created) + val item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0) + assertEquals("pr_legacy_v1",item.getString("parent_revision_id")) + assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id")) + assertEquals(2,item.getJSONArray("history").length()) + } finally { repository.close();context.deleteDatabase(name) } + } + + @Test fun `pc legacy Planche profile changes future capture without rewriting old occurrence`() { + val context=ApplicationProvider.getApplicationContext() + val name="profile-state-${UUID.randomUUID()}.db" + val id="ex_999fd503-ce89-49f9-979d-c19e6adbe80d" + var repository=TrainlogRepository(context,name) + try { + fun catalog(tracking:String)=JSONObject().put("format","trainlog-pc-catalog").put("version",1) + .put("exercises",JSONArray().put(JSONObject().put("exercise_id",id) + .put("name","Planche droite sol").put("recording_mode","sets") + .put("tracking_mode",tracking).put("data_fields",0))).toString() + assertTrue(repository.applyPcCatalogJson(catalog("reps")) is PcCatalogImportResult.Applied) + val reps=repository.listExercises().single{it.exerciseId==id} + val insertedState=JSONObject(repository.buildExerciseProfileStateJson()) + .getJSONArray("exercises").let { entries -> + (0 until entries.length()).map { entries.getJSONObject(it) } + .single { it.getString("exercise_id") == id } + } + assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",insertedState.getString("revision_id")) + val old=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft( + exercise=reps,sets=listOf(SessionSetDraft(reps=3)))))) as SaveSessionResult.Saved + repository.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("UPDATE exercise_profile_state SET revision_id='pr_legacy_v1',parent_revision_id=NULL,legacy_seed=1") + } + repository=TrainlogRepository(context,name) + val state=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1) + .put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put( + JSONObject().put("exercise_id",id).put("recording_mode","sets") + .put("tracking_mode","duration").put("data_fields",0) + .put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL) + .put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL) + .put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL) + .put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL) + .put("legacy_seed",true).put("history",JSONArray().put( + JSONObject().put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL) + .put("recording_mode","sets").put("tracking_mode","duration") + .put("data_fields",0).put("legacy_seed",true))))).toString() + assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied) + assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied) + assertEquals(TrackingMode.REPS,repository.getSessionDetail(old.sessionId)!!.exercises.single().trackingMode) + val current=repository.listExercises().single{it.exerciseId==id} + assertEquals(TrackingMode.DURATION,current.trackingMode) + val future=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft( + exercise=current,sets=listOf(SessionSetDraft(durationSeconds=30)))))) as SaveSessionResult.Saved + assertEquals(TrackingMode.DURATION,repository.getSessionDetail(future.sessionId)!!.exercises.single().trackingMode) + } finally { repository.close();context.deleteDatabase(name) } + } + + @Test fun `transitive stale ancestor is retained and coercing scalar types are rejected`() { + val context=ApplicationProvider.getApplicationContext();val name="profile-chain-${UUID.randomUUID()}.db" + val repository=TrainlogRepository(context,name) + try { + val created=repository.createExercise(NewExerciseProfile("Chain",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created + fun revision(parent:String,tracking:String)=exerciseProfileRevision(parent,"sets",tracking,0) + val root="pr_legacy_v1";val initial=revision(root,"reps");val first=revision(initial,"duration") + val second=revision(first,"reps");val third=revision(second,"duration") + fun h(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject().put("revision_id",id) + .put("parent_revision_id",parent?:JSONObject.NULL).put("recording_mode","sets") + .put("tracking_mode",tracking).put("data_fields",0).put("legacy_seed",legacy) + fun state(current:String,parent:String?,tracking:String,history:JSONArray,version:Any=1)=JSONObject() + .put("format","trainlog-exercise-profile-state").put("version",version) + .put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put(JSONObject() + .put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets").put("tracking_mode",tracking).put("data_fields",0) + .put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL).put("machine_provenance",JSONObject.NULL) + .put("scientific_profile_id",JSONObject.NULL).put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL) + .put("revision_id",current).put("parent_revision_id",parent?:JSONObject.NULL).put("legacy_seed",false).put("history",history))).toString() + val chain=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false)) + .put(h(first,initial,"duration",false)).put(h(second,first,"reps",false)).put(h(third,second,"duration",false)) + assertTrue(repository.applyExerciseProfileStateJson(state(third,second,"duration",chain)) is ExerciseProfileStateImportResult.Applied) + val stale=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false)).put(h(first,initial,"duration",false)) + assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale)) is ExerciseProfileStateImportResult.Applied) + assertEquals(third,JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0).getString("revision_id")) + assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale,"1")) is ExerciseProfileStateImportResult.Invalid) + val coerced=JSONObject(state(first,initial,"duration",stale)).also { it.getJSONArray("exercises").getJSONObject(0).put("legacy_seed","false") }.toString() + assertTrue(repository.applyExerciseProfileStateJson(coerced) is ExerciseProfileStateImportResult.Invalid) + val oversizedTop=JSONObject(state(first,initial,"duration",stale)).also { + it.getJSONArray("exercises").getJSONObject(0).put("data_fields",4294967296L) + }.toString() + assertTrue(repository.applyExerciseProfileStateJson(oversizedTop) is ExerciseProfileStateImportResult.Invalid) + val oversizedHistory=JSONObject(state(first,initial,"duration",stale)).also { + it.getJSONArray("exercises").getJSONObject(0).getJSONArray("history").getJSONObject(2) + .put("data_fields",4294967296L) + }.toString() + assertTrue(repository.applyExerciseProfileStateJson(oversizedHistory) is ExerciseProfileStateImportResult.Invalid) + } finally { repository.close();context.deleteDatabase(name) } + } + + @Test fun `profile history rejects causal integrity matrix A through L`() { + val context=ApplicationProvider.getApplicationContext() + val name="profile-negative-${UUID.randomUUID()}.db" + val repository=TrainlogRepository(context,name) + try { + val created=repository.createExercise( + NewExerciseProfile("Negative matrix",RecordingMode.SETS,TrackingMode.REPS,0), + ) as CreateExerciseResult.Created + val root="pr_legacy_v1" + val child=exerciseProfileRevision(root,"sets","reps",0) + fun record(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject() + .put("revision_id",id).put("parent_revision_id",parent?:JSONObject.NULL) + .put("recording_mode","sets").put("tracking_mode",tracking) + .put("data_fields",0).put("legacy_seed",legacy) + fun valid()=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1) + .put("generated_at","2026-09-13T10:00:00+02:00") + .put("exercises",JSONArray().put(JSONObject() + .put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets") + .put("tracking_mode","reps").put("data_fields",0) + .put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL) + .put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL) + .put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL) + .put("revision_id",child).put("parent_revision_id",root).put("legacy_seed",false) + .put("history",JSONArray().put(record(root,null,"reps",true)) + .put(record(child,root,"reps",false))))) + fun exercise(payload:JSONObject)=payload.getJSONArray("exercises").getJSONObject(0) + fun history(payload:JSONObject)=exercise(payload).getJSONArray("history") + fun copy()=JSONObject(valid().toString()) + val cases=linkedMapOf() + cases["A-root-omitted"]=copy().also { history(it).remove(0) } + cases["B-root-parent"]=copy().also { history(it).getJSONObject(0).put("parent_revision_id","detached") } + cases["C-root-not-legacy"]=copy().also { history(it).getJSONObject(0).put("legacy_seed",false) } + cases["D-second-legacy"]=copy().also { history(it).getJSONObject(1).put("legacy_seed",true) } + cases["E-middle-missing"]=copy().also { + val grandchild=exerciseProfileRevision(child,"sets","duration",0) + exercise(it).put("revision_id",grandchild).put("parent_revision_id",child) + .put("tracking_mode","duration") + exercise(it).put("history",JSONArray().put(record(root,null,"reps",true)) + .put(record(grandchild,child,"duration",false))) + } + cases["F-nonprevious-parent"]=copy().also { history(it).getJSONObject(1).put("parent_revision_id","detached") } + cases["G-wrong-uuid"]=copy().also { + history(it).getJSONObject(1).put("revision_id",exerciseProfileRevision(root,"sets","duration",0)) + } + cases["H-current-absent"]=copy().also { + exercise(it).put("revision_id",exerciseProfileRevision(root,"sets","duration",0)) + .put("tracking_mode","duration") + } + cases["I-current-not-terminal"]=copy().also { + exercise(it).put("revision_id",root).put("parent_revision_id",JSONObject.NULL) + .put("legacy_seed",true) + } + cases["J-duplicate"]=copy().also { history(it).put(JSONObject(history(it).getJSONObject(1).toString())) } + cases["K-33-revisions"]=copy().also { + val repeated=record(root,null,"reps",true);val oversized=JSONArray() + repeat(33) { oversized.put(JSONObject(repeated.toString())) } + exercise(it).put("history",oversized) + } + cases["L-malformed-pr1"]=copy().also { + exercise(it).put("revision_id","pr1|sets|reps|00") + .put("parent_revision_id",JSONObject.NULL).put("legacy_seed",true) + .put("history",JSONArray().put(record("pr1|sets|reps|00",null,"reps",true))) + } + for ((label,payload) in cases) { + assertTrue(label,repository.applyExerciseProfileStateJson(payload.toString()) is + ExerciseProfileStateImportResult.Invalid) + } + } finally { repository.close();context.deleteDatabase(name) } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt index 7b1650d..0e0dd95 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/FeedbackElapsedLabelTest.kt @@ -5,19 +5,20 @@ import org.junit.Test class FeedbackElapsedLabelTest { @Test fun `whole hours floor and never switch to days`() { val end="2026-09-01T10:00:00+02:00" - listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,t))} + listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,null,t))} } - @Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,"2026-09-01T18:00:00+02:00")) + @Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,null,"2026-09-01T18:00:00+02:00")) @Test fun `exercise feedback distinguishes session time after time and unknown`() { val end="2026-09-01T10:00:00+02:00" - assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:20:00+02:00")) - assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:59:59+02:00")) - assertEquals("H+0",exerciseFeedbackElapsedLabel(end,end)) - assertEquals("H+8",exerciseFeedbackElapsedLabel(end,"2026-09-01T18:00:00+02:00")) - assertEquals("Ressenti",exerciseFeedbackElapsedLabel(null,"2026-09-01T09:20:00+02:00")) + assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00")) + assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00")) + assertEquals("H+?",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00")) + assertEquals("H+0",exerciseFeedbackElapsedLabel(end,null,end)) + assertEquals("H+8",exerciseFeedbackElapsedLabel(end,null,"2026-09-01T18:00:00+02:00")) + assertEquals("≈H+10",exerciseFeedbackElapsedLabel(null,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00")) } @Test fun `helpers never render negative H plus`() { - val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00")) + val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00")) assertTrue(labels.none{it.startsWith("H+-")}) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/HistoricalSessionProfileImportTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/HistoricalSessionProfileImportTest.kt new file mode 100644 index 0000000..1c35cff --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/HistoricalSessionProfileImportTest.kt @@ -0,0 +1,151 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ExerciseDataFields +import com.labfytools.trainlog.model.ExerciseProfile +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.TrackingMode +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HistoricalSessionProfileImportTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: TrainlogRepository + + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "historical-profile-${UUID.randomUUID()}.db" + repository = TrainlogRepository(context, databaseName) + } + + @After fun tearDown() { + repository.close() + context.deleteDatabase(databaseName) + } + + @Test fun `V2 and V3 import historical profiles independently of current catalog`() { + for (version in listOf(2, 3)) { + val same = create("Same $version", RecordingMode.SETS, TrackingMode.REPS, 0) + val tracking = create("Tracking $version", RecordingMode.SETS, TrackingMode.DURATION, 0) + val recording = create("Recording $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0) + val fieldsRemoved = create("Fields removed $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0) + val fieldsAdded = create("Fields added $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, + ExerciseDataFields.SPEED_KMH) + val cases = listOf( + Case(same, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 8))), + Case(tracking, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 9))), + Case(recording, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 10))), + Case(fieldsRemoved, "continuous", "duration", ExerciseDataFields.SPEED_KMH, + continuous = JSONObject().put("duration_seconds", 600).put("speed_kmh", 8.5)), + Case(fieldsAdded, "continuous", "duration", 0, + continuous = JSONObject().put("duration_seconds", 700)), + ) + val payload = artifact(version, cases, "se_${version}_historical") + val revisionsBefore = revisionCount() + assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), import(version, payload)) + + val detail = repository.getSessionDetail("se_${version}_historical")!! + assertEquals(listOf("reps", "reps", "reps", "duration", "duration"), + detail.exercises.map { it.trackingMode.wireValue }) + assertEquals(listOf("sets", "sets", "sets", "continuous", "continuous"), + detail.exercises.map { it.recordingMode.wireValue }) + assertEquals(listOf(0, 0, 0, ExerciseDataFields.SPEED_KMH, 0), + detail.exercises.map { it.dataFields }) + assertEquals(listOf(8, 9, 10), detail.exercises.take(3).map { it.sets.single().reps }) + assertEquals(8.5, detail.exercises[3].speedKmh!!, 0.0) + assertEquals(null, detail.exercises[4].speedKmh) + + /* INVARIANT: importing occurrence history neither edits CURRENT + * profiles nor manufactures profile revisions. */ + assertEquals(listOf("reps", "duration", "duration", "duration", "duration"), + cases.map { expected -> repository.listExercises() + .single { it.exerciseId == expected.exercise.exerciseId }.trackingMode.wireValue }) + assertEquals(revisionsBefore, revisionCount()) + assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), import(version, payload)) + assertEquals(revisionsBefore, revisionCount()) + assertEquals(1, repository.listSessions().count { it.sessionId == "se_${version}_historical" }) + } + } + + @Test fun `invalid historical payload and unknown identity remain rejected`() { + val exercise = create("Strict history", RecordingMode.SETS, TrackingMode.DURATION, 0) + val invalid = Case(exercise, "sets", "reps", 0, + sets = JSONArray().put(JSONObject().put("duration_seconds", 30))) + val invalidResult = import(3, artifact(3, listOf(invalid), "se_invalid_history")) + assertTrue(invalidResult is MobileSessionImportResult.Invalid) + assertTrue((invalidResult as MobileSessionImportResult.Invalid).message.contains("Série V2 invalide")) + + val unknown = ExerciseProfile( + "ex_00000000-0000-4000-8000-000000000099", "Unknown", "unknown", + RecordingMode.SETS, TrackingMode.REPS, 0, + ) + val unknownResult = import(2, artifact(2, listOf(Case(unknown, "sets", "reps", 0, + sets = JSONArray().put(JSONObject().put("reps", 1)))), "se_unknown_history")) + assertTrue(unknownResult is MobileSessionImportResult.Invalid) + assertTrue((unknownResult as MobileSessionImportResult.Invalid).message.contains("Exercice V2 inconnu")) + assertTrue(repository.listSessions().none { it.sessionId in setOf("se_invalid_history", "se_unknown_history") }) + } + + private data class Case( + val exercise: ExerciseProfile, + val recording: String, + val tracking: String, + val fields: Int, + val sets: JSONArray? = null, + val continuous: JSONObject? = null, + ) + + private fun create(name: String, recording: RecordingMode, tracking: TrackingMode, fields: Int): ExerciseProfile = + (repository.createExercise(NewExerciseProfile(name, recording, tracking, fields)) as CreateExerciseResult.Created).exercise + + private fun artifact(version: Int, cases: List, sessionId: String): JSONObject { + val catalog = JSONArray() + val entries = JSONArray() + cases.forEachIndexed { index, case -> + catalog.put(JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name) + .put("recording_mode", case.exercise.recordingMode.wireValue) + .put("tracking_mode", case.exercise.trackingMode.wireValue).put("data_fields", case.exercise.dataFields)) + val entry = JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name) + .put("recording_mode", case.recording).put("tracking_mode", case.tracking).put("data_fields", case.fields) + .put("load_mode", "none").put("rest_seconds", 0) + .put("entry_id", "sxe_${version}_$index").put("position", index) + .put("equipment_id", JSONObject.NULL) + if (version == 3) entry.put("target", JSONObject.NULL) + case.sets?.let { entry.put("sets", it) } + case.continuous?.let { entry.put("continuous", it) } + entries.put(entry) + } + return JSONObject().put("format", "trainlog-mobile-export").put("version", version) + .put("generated_at", "2026-09-13T20:00:00+02:00").put("exercises", catalog) + .put("sessions", JSONArray().put(JSONObject().put("session_id", sessionId) + .put("started_at", "2026-09-13T19:00:00+02:00").put("session_type", "training") + .put("exercises", entries))).put("body_observations", JSONArray()) + } + + private fun import(version: Int, payload: JSONObject): MobileSessionImportResult = + if (version == 2) repository.applyPcMobileExportV2Json(payload.toString()) + else repository.applyPcMobileExportV3Json(payload.toString()) + + private fun revisionCount(): Int = SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READONLY, + ).use { database -> + database.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions", null).use { cursor -> + cursor.moveToFirst(); cursor.getInt(0) + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt index 38d7a38..2e8c6b6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV12MachineExerciseMigrationTest.kt @@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest { private fun assertValidVersionThirteen(databasePath: File) { SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database -> - assertEquals(15, scalarInt(database, "PRAGMA user_version;")) + assertEquals(16, scalarInt(database, "PRAGMA user_version;")) assertEquals("ok", scalarString(database, "PRAGMA integrity_check;")) database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt index 681fd5c..625237d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV14FeedbackRevisionMigrationTest.kt @@ -33,7 +33,9 @@ class RealAndroidV14FeedbackRevisionMigrationTest { } TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() } SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db-> - assertEquals(15,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())} + assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())} + assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1")) + assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1")) assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id")) assertEquals(before.second.size,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback_revisions")) assertEquals(0,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback f LEFT JOIN draft_exercise_feedback_revisions r ON r.revision_id='fr0_'||f.feedback_id WHERE r.revision_id IS NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt index dab5e89..cf1e49b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt @@ -61,8 +61,10 @@ class TrainingFeedbackRepositoryTest { assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId}) assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt}) assertEquals(reopened.map{it.rawText},completed.map{it.rawText}) - assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel( - android.getSessionDetail(saved.sessionId)!!.summary.endedAt,completed.first().observedAt)) + assertEquals("H+?",exerciseFeedbackElapsedLabel( + android.getSessionDetail(saved.sessionId)!!.summary.endedAt, + android.getSessionDetail(saved.sessionId)!!.summary.startedAt, + completed.first().observedAt)) val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback") assertEquals(2,exported.length()) assertEquals(setOf(first.stableId,later.stableId),(0 until exported.length()).map{exported.getJSONObject(it).getString("feedback_id")}.toSet()) @@ -122,4 +124,51 @@ class TrainingFeedbackRepositoryTest { assertEquals(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText}) assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0) } + + @Test fun `completed correction is atomic preserves identities feedback revisions and followup`() { + val reps=(android.createExercise(NewExerciseProfile("Correction reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + val otherReps=(android.createExercise(NewExerciseProfile("Correction autres reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise + val timed=(android.createExercise(NewExerciseProfile("Correction durée",RecordingMode.SETS,TrackingMode.DURATION,0)) as CreateExerciseResult.Created).exercise + val first=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c1",exercise=reps, + sets=listOf(SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0))) + val second=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c2",exercise=timed, + sets=listOf(SessionSetDraft(durationSeconds=30))) + val saved=android.saveSession(SessionDraft(listOf(first,second))) as SaveSessionResult.Saved + val feedback=android.saveExerciseFeedback(saved.sessionId,first.entryId,"initial") as SaveFeedbackResult.Saved + assertTrue(android.reviseExerciseFeedback(feedback.stableId,"révisé") is SaveFeedbackResult.Saved) + val followup=android.saveSessionFollowUp(saved.sessionId,"suivi") as SaveFeedbackResult.Saved + val corrected=first.copy(sets=listOf( + SessionSetDraft(reps=10,weightKg=33.0), SessionSetDraft(reps=8,weightKg=32.0), + SessionSetDraft(reps=12,weightKg=34.0))) + val timedCorrected=second.copy(sets=listOf(SessionSetDraft(durationSeconds=35),SessionSetDraft(durationSeconds=30))) + assertEquals(CorrectCompletedSessionResult.Saved, + android.correctCompletedSession(saved.sessionId,SessionDraft(listOf(corrected,timedCorrected)))) + var detail=android.getSessionDetail(saved.sessionId)!! + assertEquals(saved.sessionId,detail.summary.sessionId) + assertEquals(listOf(first.entryId,second.entryId),detail.exercises.map{it.entryId}) + assertEquals(listOf(10,8,12),detail.exercises[0].sets.map{it.reps}) + assertEquals(33.0,detail.exercises[0].sets[0].weightKg!!,0.0) + assertEquals(listOf(35,30),detail.exercises[1].sets.map{it.durationSeconds}) + assertEquals(feedback.stableId,detail.exercises[0].feedback.single().feedbackId) + assertTrue(detail.exercises[0].feedback.single().modified) + assertEquals(followup.stableId,detail.followUps.single().followupId) + android.close();android=TrainlogRepository(context,aName) + detail=android.getSessionDetail(saved.sessionId)!! + assertEquals(8,detail.exercises[0].sets[1].reps) + + /* CONTRACT: entry identity cannot be silently rebound by the general + * replacement path; rejection occurs before its cascading delete. */ + assertTrue(android.correctCompletedSession(saved.sessionId, + SessionDraft(listOf(corrected.copy(exercise=otherReps),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError) + assertEquals(reps.exerciseId,android.getSessionDetail(saved.sessionId)!!.exercises[0].exerciseId) + + android.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("CREATE TRIGGER fail_completed_correction BEFORE INSERT ON performed_sets BEGIN SELECT RAISE(ABORT,'synthetic correction failure'); END;") + } + android=TrainlogRepository(context,aName) + assertTrue(android.correctCompletedSession(saved.sessionId, + SessionDraft(listOf(corrected.copy(sets=listOf(SessionSetDraft(reps=1))),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError) + assertEquals(8,android.getSessionDetail(saved.sessionId)!!.exercises[0].sets[1].reps) + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 152fa60..53ff88b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -90,7 +90,7 @@ class TrainlogRepositoryDraftTest { assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId }) assertEquals("Curl canonique", repo.listExercises().single().name) - val canonicalCatalog = JSONObject(staleCatalog.toString()) + val canonicalCatalog = JSONObject(staleCatalog) canonicalCatalog.getJSONArray("exercises").getJSONObject(0) .put("exercise_id", target.exerciseId) .put("name", "Curl canonique renommé") @@ -870,10 +870,16 @@ class TrainlogRepositoryDraftTest { } @Test - fun renameRejectsDuplicateAndInvalidNamesAndLocksReferencedProfile() { + fun renameRejectsDuplicateAndInvalidNamesAndReferencedProfileEditPreservesSnapshots() { val repo = openRepository() val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) createExercise(repo, "Course", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + val completed = repo.saveSession( + SessionDraft(exercises = listOf(SessionExerciseDraft( + exercise = referenced, + continuousDurationSeconds = 30, + ))), + ) as SaveSessionResult.Saved assertEquals( ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft( @@ -887,7 +893,9 @@ class TrainlogRepositoryDraftTest { ), ), ) - assertFalse(repo.canEditExerciseProfile(referenced.exerciseId)) + /* CONTRACT: references own occurrence snapshots and therefore do not + * lock the mutable CURRENT catalogue profile. */ + assertTrue(repo.canEditExerciseProfile(referenced.exerciseId)) assertEquals( EditExerciseResult.Conflict, repo.editExercise( @@ -912,8 +920,7 @@ class TrainlogRepositoryDraftTest { ), ), ) - assertEquals( - EditExerciseResult.IncompatibleProfileChange, + assertTrue( repo.editExercise( ExerciseEditInput( referenced.exerciseId, @@ -922,8 +929,20 @@ class TrainlogRepositoryDraftTest { TrackingMode.REPS, ExerciseDataFields.NONE, ), - ), + ) is EditExerciseResult.Saved, ) + val current = repo.listExercises().single { it.exerciseId == referenced.exerciseId } + assertEquals(RecordingMode.SETS, current.recordingMode) + assertEquals(TrackingMode.REPS, current.trackingMode) + val retainedDraft = loadDraft(repo).exercises.single() + val retained = retainedDraft.exercise + assertEquals(RecordingMode.CONTINUOUS, retained.recordingMode) + assertEquals(TrackingMode.DURATION, retained.trackingMode) + assertEquals(30, retainedDraft.continuousDurationSeconds) + val historical = repo.getSessionDetail(completed.sessionId)?.exercises?.single() + assertEquals(RecordingMode.CONTINUOUS, historical?.recordingMode) + assertEquals(TrackingMode.DURATION, historical?.trackingMode) + assertEquals(30, historical?.continuousDurationSeconds) } @Test @@ -1528,7 +1547,6 @@ class TrainlogRepositoryDraftTest { assertTrue(repo.applyPcMobileExportV2Json(changedBody.toString()) is MobileSessionImportResult.Invalid) val incompatibleProfile = entry("en_incompatible_profile") - .put("tracking_mode", "duration") .put("sets", org.json.JSONArray().put(JSONObject().put("duration_seconds", 8))) assertTrue(repo.applyPcMobileExportV2Json( artifact(org.json.JSONArray().put(session("se_incompatible_profile", incompatibleProfile))).toString(), @@ -1651,7 +1669,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(15, cursor.getInt(0)) + assertEquals(16, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1778,7 +1796,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(15, cursor.getInt(0)) + assertEquals(16, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) @@ -2164,8 +2182,8 @@ class TrainlogRepositoryDraftTest { } artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises") .getJSONObject(0).getJSONObject("target").put("reps", 7) - assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Invalid) - assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps) + assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Applied) + assertEquals(7, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps) source.close(); repository = null SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt index 1447f19..32f0a29 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/DraftFeedbackUiReadbackTest.kt @@ -57,7 +57,7 @@ class DraftFeedbackUiReadbackTest { assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry)))) assertTrue(repository.saveDraftExerciseFeedback( - entry.entryId, "texte UI durable", "2026-09-01T10:00:00+02:00") is SaveFeedbackResult.Saved) + entry.entryId, "texte UI durable", java.time.OffsetDateTime.now().toString()) is SaveFeedbackResult.Saved) val completedSessionId = mutableStateOf(null) compose.setContent { TrainlogTheme { @@ -69,6 +69,9 @@ class DraftFeedbackUiReadbackTest { val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved compose.runOnIdle { completedSessionId.value = saved.sessionId } - compose.onNodeWithText("Pendant la séance · texte UI durable").fetchSemanticsNode() + /* Elapsed classification depends on the finalized session clock; this + * wiring assertion owns durable text readback, while label boundaries + * have dedicated deterministic tests. */ + compose.onNodeWithText("texte UI durable", substring = true).fetchSemanticsNode() } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt new file mode 100644 index 0000000..9097999 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt @@ -0,0 +1,229 @@ +package com.labfytools.trainlog.ui + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.data.SaveFeedbackResult +import com.labfytools.trainlog.data.SaveSessionResult +import com.labfytools.trainlog.data.SyncExporter +import com.labfytools.trainlog.data.SyncRequestOutbox +import com.labfytools.trainlog.data.SyncRequestResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.data.ExchangeSafDirectory +import com.labfytools.trainlog.data.ExchangeSafDocument +import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG +import com.labfytools.trainlog.data.ExchangeSafPublisher +import com.labfytools.trainlog.data.ExchangeSafSnapshotResult +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import java.util.UUID +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import org.json.JSONObject +import org.json.JSONArray +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog +import kotlinx.coroutines.runBlocking + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SyncBundlePublicationTest { + private lateinit var context: Context + private lateinit var databaseName: String + private lateinit var repository: TrainlogRepository + private lateinit var directory: FakeSafDirectory + + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "sync-bundle-${UUID.randomUUID()}.db" + repository = TrainlogRepository(context, databaseName) + directory = FakeSafDirectory() + } + + @After fun tearDown() { + repository.close() + context.deleteDatabase(databaseName) + } + + @Test fun `request publishes and replaces one complete current canonical bundle`() = runBlocking { + val identities = listOf( + "ex_999fd503-ce89-49f9-979d-c19e6adbe80d" to "Planche droite sol", + "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac" to "Planche face sol", + "ex_e32c40a6-72b4-4233-a0db-41b27e990c96" to "Planche gauche sol", + ) + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", JSONArray().also { items -> identities.forEach { (id, name) -> + items.put(JSONObject().put("exercise_id", id).put("name", name) + .put("recording_mode", "sets").put("tracking_mode", "duration") + .put("data_fields", 0)) + } }) + repository.applyPcCatalogJson(catalog.toString()) + val exercises = identities.map { (id, _) -> + repository.listExercises().single { it.exerciseId == id } + } + /* The canonical BODY ZONES fixture exists in the authorized SAF tree; + * no MediaStore candidate is provided, reproducing an ownerless row + * omitted from the application's scoped MediaStore view. */ + directory.seed("trainlog-exercise-body-zones-v1.json", "stale canonical") + (1..31).forEach { copy -> + directory.seed("trainlog-exercise-body-zones-v1 ($copy).json", "historical copy") + } + val exporter = SyncExporter(context, repository) { directory } + val outbox = SyncRequestOutbox { directory } + assertTrue(exporter.exportMobileBundle() is com.labfytools.trainlog.data.SyncExportResult.Exported) + assertEquals(1, directory.enumerationCount) + val entry = SessionExerciseDraft( + exercise = exercises.first(), + sets = listOf(SessionSetDraft(durationSeconds = 30)), + ) + val saved = repository.saveSession(SessionDraft(listOf(entry))) as SaveSessionResult.Saved + val feedback = repository.saveExerciseFeedback( + saved.sessionId, + entry.entryId, + "ressenti ajouté après la publication précédente", + "2026-09-13T12:59:00+02:00", + ) + assertTrue(feedback is SaveFeedbackResult.Saved) + + val writesBeforeFirstClick = directory.writeCounts.toMap() + assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) + assertEquals(2, directory.enumerationCount) + val required = listOf( + "trainlog-mobile-export-v3.json", + "trainlog-mobile-equipment-definitions-v1.json", + "trainlog-equipment-associations-v2.json", + "trainlog-exercise-aliases-v1.json", + "trainlog-exercise-body-zones-v1.json", + "trainlog-training-feedback-v2.json", + "trainlog-exercise-profile-state-v1.json", + "trainlog-sync-request-v1.json", + ) + required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) } + required.forEach { name -> + assertEquals("one click must write $name exactly once", 1, + directory.writeCount(name) - (writesBeforeFirstClick[name] ?: 0)) + } + val profile = JSONObject(readCanonical("trainlog-exercise-profile-state-v1.json")) + val profileItems = profile.getJSONArray("exercises") + identities.forEach { (id, _) -> + val item = (0 until profileItems.length()).map { profileItems.getJSONObject(it) } + .single { it.getString("exercise_id") == id } + assertEquals("sets", item.getString("recording_mode")) + assertEquals("duration", item.getString("tracking_mode")) + assertEquals(0, item.getInt("data_fields")) + assertEquals("pr_legacy_v1", item.getJSONArray("history").getJSONObject(0).getString("revision_id")) + assertEquals(item.getString("revision_id"), item.getJSONArray("history") + .getJSONObject(item.getJSONArray("history").length() - 1).getString("revision_id")) + } + assertTrue(readCanonical("trainlog-training-feedback-v2.json") + .contains("ressenti ajouté après la publication précédente")) + + /* Re-publication updates exact canonical objects instead of creating + * numbered duplicates, and preserves stable feedback/revision IDs. */ + val feedbackBefore = JSONObject(readCanonical("trainlog-training-feedback-v2.json")) + ShadowLog.clear() + val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json") + val writesBeforeSecondClick = directory.writeCounts.toMap() + assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested) + assertEquals(3, directory.enumerationCount) + val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg } + assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") }) + val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") } + assertEquals(1, snapshotPhases.size) + assertTrue("SAF snapshot ran on main: $snapshotPhases", + snapshotPhases.none { it.contains("thread=main") }) + val writeBegins = phases.filter { it.contains(" saf.write.begin ") } + assertEquals(required.size, writeBegins.size) + assertTrue("SAF write ran on main: $writeBegins", + writeBegins.none { it.contains("thread=main") }) + val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") } + val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") } + val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") } + val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" } + assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0) + assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve) + assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite) + assertTrue("request must be published after the complete export", requestStart > bodyWritten) + val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite) + assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen", + beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") }) + assertTrue("outbound path must not call MediaStore insert/update/pending: $phases", + phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") }) + assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json")) + assertEquals(0, directory.canonicalCount("trainlog-exercise-body-zones-v1 (32).json")) + (1..31).forEach { copy -> + assertEquals(1, directory.canonicalCount("trainlog-exercise-body-zones-v1 ($copy).json")) + } + required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) } + required.forEach { name -> + assertEquals("repeated click must write $name exactly once", 1, + directory.writeCount(name) - (writesBeforeSecondClick[name] ?: 0)) + } + val feedbackAfter = JSONObject(readCanonical("trainlog-training-feedback-v2.json")) + assertEquals(feedbackBefore.getJSONArray("exercise_feedback").toString(), + feedbackAfter.getJSONArray("exercise_feedback").toString()) + } + + @Test fun `created exact document updates transaction index without rescan`() { + val publisher = ExchangeSafPublisher { directory } + val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready + assertEquals(1, directory.enumerationCount) + assertEquals(null, opened.snapshot.writeJson("foo.json", "first")) + assertEquals(null, opened.snapshot.writeJson("foo.json", "second")) + assertEquals(1, directory.enumerationCount) + assertEquals(1, directory.createCount("foo.json")) + assertEquals(2, directory.writeCount("foo.json")) + assertEquals("second", directory.read("foo.json")) + assertEquals(0, directory.canonicalCount("foo (1).json")) + } + + private fun readCanonical(name: String): String = directory.read(name) + + private class FakeSafDirectory : ExchangeSafDirectory { + private val contents = linkedMapOf() + private val creates = mutableMapOf() + val writeCounts = mutableMapOf() + var enumerationCount = 0 + private set + + fun seed(name: String, value: String) { contents[name] = value.toByteArray() } + fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8) + fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0 + fun createCount(name: String): Int = creates[name] ?: 0 + fun writeCount(name: String): Int = writeCounts[name] ?: 0 + + override fun listDirectChildren(): List { + enumerationCount++ + return contents.keys.map(::document) + } + + override fun createJson(displayName: String): ExchangeSafDocument { + check(!contents.containsKey(displayName)) + creates[displayName] = createCount(displayName) + 1 + contents[displayName] = byteArrayOf() + return document(displayName) + } + + override fun openForRewrite(document: ExchangeSafDocument): OutputStream = + object : ByteArrayOutputStream() { + override fun close() { + super.close() + contents[document.displayName] = toByteArray() + writeCounts[document.displayName] = writeCount(document.displayName) + 1 + } + } + + private fun document(name: String) = ExchangeSafDocument( + Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"), + name, + ) + } +} diff --git a/docs/android.md b/docs/android.md index 4833fa1..b041224 100644 --- a/docs/android.md +++ b/docs/android.md @@ -19,10 +19,43 @@ desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC catalogue. The artifact preserves occurrence order, continuous metrics, set weights and equipment. The legacy V1 contract remains separate and readable. +Android schema v16 adds deterministic current exercise-profile ancestry. Its +v15 migration seeds a shared legacy root without changing catalogue or +historical occurrence values; later local edits and peer reconciliation advance +lineage explicitly inside the same repository transaction as the current-profile +change. The insert trigger owns only initial root creation. + +The manual synchronization action publishes one prepared Android→PC bundle +before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile +V3, mobile equipment definitions, equipment associations V2, exercise aliases +V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The +request is not written if any required publication fails. The persisted, +user-authorized `Download/Trainlog` SAF tree is the canonical authority for +both inbound and outbound exchange: MediaStore ownership and app-scoped row +visibility are not consulted. Exact display names are rewritten through their +SAF document URI; absence creates the exact name once, and multiple exact names +fail conservatively. Conflict copies such as `name (1).json` are historical +objects, never aliases of `name.json`, and are neither selected nor deleted. +One synchronization enumerates the direct SAF children once, builds a bounded +exact-name multimap, and shares that transaction-local snapshot across all +outbound artifacts and the request. Newly created documents update that map. +The next synchronization enumerates again so PC/MTP changes are never hidden by +a long-lived cache. Snapshot, create and stream operations run on the I/O +dispatcher; Compose only launches the suspend coordinator and applies results. + V3 preserves ordinary plan metadata atomically with occurrence identity, equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are never silently rewritten as V3. +Completed Session Detail exposes **Modifier la séance**. Its bounded form is +initialized from occurrence snapshots and writes nothing before Save. Save +replaces all children in one transaction while retaining `session_id`, every +retained `entry_id`, feedback roots/revisions, and the session follow-up parent. +Set and occurrence removal require explicit confirmation; Cancel writes +nothing. Exercise Detail separately exposes **Modifier l'exercice**. A +confirmed incompatible catalogue edit controls future Android occurrences; +completed and active-draft occurrence snapshots are never converted. + ## 2. Implemented navigation ```text @@ -505,10 +538,14 @@ Android writes: trainlog-sync-request-v1.json ``` -If MediaStore cannot reopen an older MTP-created canonical object, it may -publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The -desktop engine selects the newest canonical-or-suffixed request -deterministically, while the stable `request_id` remains the replay boundary. +The request is resolved and rewritten through the same persisted SAF tree as +the bundle. Android does not create a numbered request sibling merely because +MediaStore cannot expose an MTP- or peer-created canonical object. The stable +`request_id` remains the replay boundary. + +The application-start catalog import does not publish an outbound bundle. +Exactly one explicit `Synchroniser maintenant` action invokes one export and +one request publication unless a later, explicit retry is initiated. and waits for a matching: diff --git a/docs/architecture.md b/docs/architecture.md index 946e53b..b705809 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,8 +168,13 @@ silently rendered as unclassified. ### Desktop -Desktop SQLite schema v15 is canonical long-term history. Its v14 -> v15 -additive step introduces immutable wording-revision tables for the subjective +Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16 +transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the +one-time backfill copies the linked catalogue mode without changing child rows +or stable identities. The additive v16 -> v17 migration adds deterministic +current-profile ancestry in a 32-record-per-exercise durable chain without +altering those occurrence snapshots. The v14 +-> v15 additive step introduces immutable wording-revision tables for the subjective feedback roots introduced in v14; see [Training feedback](training_feedback.md). 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 diff --git a/docs/current_state.md b/docs/current_state.md index 9dfd053..0329cae 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -67,7 +67,7 @@ TRAINING_KNOWLEDGE_V1=PASS SESSION_GENERATOR_V1=PASS APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2 -DESKTOP_TESTS=47/47 PASS (normal and ASan/UBSan Meson suites) +DESKTOP_TESTS=52/52 PASS (normal); affected ASan/UBSan tests=6/6 PASS ANDROID_BUILD=PASS HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS ``` @@ -81,7 +81,9 @@ Implemented: F7 actions, and restored focus after overlays/routes); - UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20 minimum terminal; -- SQLite schema v15, with stable ordered `session_exercises.entry_id`, +- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an + immutable occurrence-owned `tracking_mode` snapshot, + bounded durable current-profile revision ancestry, occurrence-level equipment identity, and desktop-local custom-equipment definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration rebuilds only `performed_sets` to permit explicit zero actual loads while @@ -187,7 +189,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON catalogs as the sole authored scientific source, generates the immutable C catalog representation, and loads the same assets on Android. It has no database migration, no auto-seeding, and no synchronization artifact. The -desktop and Android databases are schema v15. Schema v13 is additive: exercise +desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise rows own machine-facing load semantics, optional legacy equipment provenance, an optional scientific profile, and an explicit resolved/unresolved science state. Legacy equipment tables and occurrence columns remain intact. @@ -425,9 +427,13 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and `sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The second run reported zero additions/reconciliations, both stores retained the same 32-relation stable-ID hash, and Android application tables plus companion -exercise states compared equal to the first pass. The engine accepts the exact -MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID -replay protection remains authoritative. +exercise states compared equal to the first pass. Android now resolves the +exact request and every outbound artifact through the persisted authorized SAF +tree, independently of MediaStore ownership visibility. Historical numbered +copies remain untouched. One transaction-local exact-name snapshot is shared +by the complete export and request on the I/O dispatcher; each later sync takes +a fresh snapshot. The redundant application-start export was removed, and +request-ID replay protection remains authoritative. ## Current implementation cursor diff --git a/docs/database.md b/docs/database.md index 7b5e426..d4208de 100644 --- a/docs/database.md +++ b/docs/database.md @@ -16,8 +16,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=15 -DATABASE_SCHEMA_V12=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=17 +DATABASE_SCHEMA_V17=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -116,6 +116,24 @@ identity `fr0_` with `created_at=observed_at`; corrections use current-text cache. The authoritative current revision is selected by parsed `created_at` instant, then bytewise `revision_id` on ties. +Desktop schema v16 transactionally rebuilds only `session_exercises` to add +`tracking_mode TEXT NOT NULL CHECK(tracking_mode IN ('reps','duration'))`. +Each v15 row is backfilled exactly once from its linked exercise before the +version is published. Row counts, foreign keys and source/catalog agreement are +checked before commit; `id`, `entry_id` and every child row remain unchanged. +Desktop schema v17 and Android schema v16 then add `exercise_profile_state`, a +one-row-per-exercise current pointer, plus `exercise_profile_revisions`, the +durable causal chain for mutable current capture profiles. Both migrations seed +`pr_legacy_v1` without rewriting exercises or occurrence snapshots. Local edits +append deterministic revisions transactionally. Each exercise is limited to 32 +revision records; a 33rd edit/import is rejected explicitly, never pruned or +ordered by a wall clock. +Already-versioned prototype databases are repaired on open without a version +bump: an exact `pr1|recording_mode|tracking_mode|data_fields` current token is +replaced atomically by `pr_legacy_v1` and its canonical first `pr2_` child. +Malformed, impossible, orphaned, or mixed prototype lineage aborts the open and +rolls back the repair. + ## 3. Connection invariants Every connection enables: @@ -204,6 +222,7 @@ session_row_id exercise_row_id entry_id UNIQUE stable occurrence identity recording_mode +tracking_mode immutable occurrence unit snapshot data_fields position load_mode @@ -216,9 +235,9 @@ equipment_id nullable equipment identity notes ``` -Current desktop history snapshots `recording_mode` and `data_fields` in the -session row. `tracking_mode` remains associated with the referenced exercise -catalog identity. +Desktop history snapshots `recording_mode`, `tracking_mode` and `data_fields` +in the occurrence row. Catalogue profile edits supply defaults only to future +occurrences and never update these snapshots. An occurrence `equipment_id` resolves to either a supplied manifest definition or a desktop-local `custom_equipment` definition. An ID that cannot be @@ -661,3 +680,21 @@ findings were repaired, independently verified, and final-validated; Exercise naming changes update only `exercises.name` and `normalized_name` for explicitly mapped stable IDs; no schema migration or historical-row rewrite is required. + +## 15. Profile-revision SQLite connection functions + +Schema v17 profile-state insert/update triggers use the deterministic +`trainlog_profile_revision(parent, recording_mode, tracking_mode, data_fields)` +UUIDv5 function. The migration-only `trainlog_pr1_valid(...)` recognizer shares +the same connection boundary. These are process-local SQLite functions even +though the triggers that reference them are persisted. + +Every production connection capable of preparing Trainlog schema statements +must therefore install the complete function set before executing schema or +data SQL. Native connections do so in +`trainlog_database_open_with_diagnostic()`; Python import/export tools use the +single `tools/trainlog_sqlite.py::connect_database()` factory. SQLite resolves +trigger functions while preparing a statement even when a trigger `WHEN` +condition will suppress its body, so read-mostly importers are not exempt if +they can update or insert an exercise. Raw SQLite connections are limited to +test-fixture construction and read-only inspection. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 5b48e1d..7e2217f 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -68,6 +68,14 @@ CONTINUOUS + REPS UI behavior must never be inferred from an exercise display name. +An explicitly confirmed unweighted static plank uses `SETS + DURATION`, no +supplemental field, bodyweight exercise semantics, and ordinary occurrence +`load_mode=none`. A plan of three 30-second holds stores `target_sets=3` and +`target_duration_seconds=30`; each performed set owns its actual +`duration_seconds`. Trainlog does not infer reps, external kilograms, or a +kilogram MAX. Existing repetition facts remain repetitions until the user +explicitly corrects those completed occurrences. + ## 2. Examples ```text diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 0862abc..058d428 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -2,7 +2,7 @@ ## Machine-exercise Phase 1 compatibility -Desktop and Android schema v15 retain mobile export V3, readable V1/V2 imports, +Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports, equipment definitions V1, equipment associations V2, exercise aliases V1, and the BODY ZONES companion without wire-format changes. Machine metadata is not silently added to a frozen artifact: stable exercise IDs and canonical names @@ -10,6 +10,19 @@ travel through the existing catalog, while v13 metadata stays local until a dedicated versioned companion is required. Legacy equipment IDs may still be written for fixed supplied machines as compatibility provenance. +V3 already carries occurrence `tracking_mode`; desktop v16 imports it directly +into `session_exercises` and exports the same snapshot. Catalogue +`tracking_mode` remains the default for future occurrences only, so no wire +format bump is required. + +Android and the current desktop importer treat the exercise catalogue link of +a completed V2/V3 entry as an identity constraint only. The entry's own +`recording_mode`, `tracking_mode`, and `data_fields` validate its actual payload +and remain authoritative even after the current catalogue profile evolves. +Import never rewrites historical sets or continuous values to fit the current +profile. Legacy desktop schemas that cannot persist the occurrence tracking +mode retain their explicit compatibility rejection instead of losing its unit. + ## 1. Status ```text @@ -34,6 +47,14 @@ BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED ``` +Mobile export V3 also carries completed-session corrections. For an existing +session with the same immutable header, peers reconcile the complete occurrence +and planning state atomically. A retained `(session_id, entry_id)` cannot be +rebound to another exercise and retains feedback roots/revisions; omitted +occurrences follow the confirmed parent-owned cascade. An identical second +import is a skip. V2 has no complete planning authority and cannot apply a +general correction; its older resumed-MAX compatibility remains bounded. + Synchronization artifacts are separate from the frozen Trainlog session JSON v1 format. @@ -679,3 +700,34 @@ Training observations use the independent direction-neutral `trainlog-training-feedback-v2.json`. Its append-only revision union, strict identity checks, ordering, bounds, and conflicts are specified in [Training feedback](training_feedback.md). +## Exercise current-profile state v1 + +`trainlog-exercise-profile-state-v1.json` is a separate, directional-neutral +companion. It does not alter `TRAINLOG_FORMAT_V1`, mobile session V3, PC catalog +v1, feedback V2, equipment, or BODY ZONES. Each record carries a required +oldest-to-current `history` of at most 32 revision records. Equal revisions are +idempotent, an incoming descendant is adopted when its chain proves the local +current revision, any known incoming ancestor is retained, and sibling or +otherwise incomparable descendants conflict. A 33rd revision is rejected; the +chain is never silently truncated. The shared legacy root lets the first +post-upgrade sync repair older profile differences. Machine/science fields are +strictly typed equality guards and remain immutable through this artifact. + +For an Android-triggered bidirectional run, Android builds every current +outbound snapshot before publishing any of them and publishes the complete +bundle before writing the sync request. The request therefore signals that the +canonical mobile V3, definitions, associations, aliases, zones, feedback V2, +and profile-state V1 objects have all been refreshed from the same builder +phase. The desktop receives profile state and runs its causal pre-pass before +mobile V3, then runs the strict post-pass only when that companion was actually +received. A V3/V2 same-ID profile mismatch with no received companion reports +`PROFILE_STATE_COMPANION_MISSING`; absence alone remains accepted for older +peers whose mobile snapshot does not require causal reconciliation. + +The desktop pre/post-pass importer opens its database through the canonical +Python Trainlog connection factory. That factory registers the same +deterministic profile-revision function as the native database opener before +the importer prepares `UPDATE exercises`; the persisted guarded profile trigger +therefore remains valid even when the authoritative incoming tip makes its body +a no-op. Replaying the same artifact neither changes the current revision nor +adds history rows. diff --git a/docs/tests.md b/docs/tests.md index b1997b1..c87ddbc 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -716,3 +716,35 @@ reordering and changing the exercise context of stable entries; it verifies exact feedback preservation, selective cascade on removal, no inheritance by a new entry, unchanged follow-ups, and full rollback on injected reattachment failure. Android additionally covers its bounded resumed-MAX replacement path. +`tests/test_profile_state_sync.py` covers the captured Planche droite sol +legacy `SETS+REPS`/`SETS+DURATION` repair, idempotent replay, direct-descendant +and multi-generation stale-ancestor convergence, sibling conflict, the explicit +32-record resource bound, exact JSON scalar types, immutable machine conflict, +and preservation of the old occurrence snapshot. Android +`ExerciseProfileStateSyncTest` covers the same transitive/type contract in the +reverse PC→Android path and future-occurrence snapshot. +`HistoricalSessionProfileImportTest` exercises the production V2/V3 repository +import with equal and evolved tracking/recording/data-field profiles, strict +snapshot-local payload rejection, unknown identity rejection, unchanged +historical values, unchanged current profile history, and idempotent replay. +`SyncBundlePublicationTest` exercises the production request coordinator and a +synthetic user-authorized SAF directory whose exact BODY ZONES canonical is +deliberately unavailable through MediaStore. The exact SAF object is rewritten +beside 31 preserved historical copies, no MediaStore insert/pending publication +or `(32)` copy occurs, all seven Android-origin companions precede the request +signal, and replay reuses canonical objects without stable-feedback churn. +It also requires one direct-child enumeration for the entire export-plus-request +transaction, one additional enumeration for each later synchronization, index +update after a create without rescan, one write per canonical artifact, and +non-main dispatcher ownership for snapshot and stream operations. +Native `sync_body_zone_wiring` verifies the +profile pre-pass before mobile import, strict post-pass gating, replay, legacy +peer compatibility, and the explicit missing-companion diagnostic even when a +stale local temporary profile artifact exists. +Every Android outbound artifact and the request use the shared exact-name SAF +resolver; app-scoped MediaStore visibility is not part of canonical selection. +`profile_state_sync` includes the production persisted update-trigger dependency +on `trainlog_profile_revision`, imports through the same Python connection path +used by the PC sync worker, and requires first import plus identical replay to +pass without revision-state or history-count churn. It also checks that the +central factory installs both profile-revision schema functions. diff --git a/docs/training_feedback.md b/docs/training_feedback.md index 775b050..2b804b7 100644 --- a/docs/training_feedback.md +++ b/docs/training_feedback.md @@ -56,10 +56,11 @@ resources. `observed_at` and `sessions.ended_at` are exact offset-aware Trainlog timestamps. New Android completions persist their explicit finalization instant as `ended_at`; old NULL anchors are not backfilled or derived from `started_at`. -Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours, -or `H+?` when the anchor is missing/invalid. Exercise feedback observed before -the end displays `Pendant la séance`; at/after end it uses H+, and without an -anchor it displays the neutral `Ressenti`. No negative H+ is rendered. Ordering +Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours. +When `ended_at` is unavailable but valid `started_at` gives a nonnegative delta, +both surfaces use `≈H+floor((observed-start)/3600)`; otherwise they use `H+?`. +Exercise feedback uses `Pendant la séance` only when a trustworthy `ended_at` +proves the observation preceded completion. No negative H+ is rendered. Ordering parses instants, then compares the stable ID bytewise; it never uses lexical timestamp order or machine-local timezone. diff --git a/docs/tui.md b/docs/tui.md index ca181af..65534d5 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -8,6 +8,19 @@ synchronization. Desktop SQLite is the canonical long-term history. +From completed Session Detail, `e` opens the existing session editor around +`trainlog_database_replace_session_exercises()`. Per-set values, targets, rest, +continuous facts and explicit MAX values are corrected atomically under the +same `session_id`; retained occurrences keep `entry_id`. Destructive set or +occurrence removal uses the global confirmation flow (`Non` by default, +Escape/q cancel). + +Desktop schema v16 snapshots `tracking_mode` on every `session_exercises` +occurrence. The exercise editor may therefore change profile axes for future +uses even when history exists. Incompatible changes require an explicit +`Non`-by-default confirmation which states that old sessions remain unchanged; +Escape and `q` cancel. + ## 2. APP_SHELL_V1 navigation The persistent shell has seven root sections: diff --git a/tests/test_exercise_alias_import.py b/tests/test_exercise_alias_import.py index e742d0f..084db1f 100644 --- a/tests/test_exercise_alias_import.py +++ b/tests/test_exercise_alias_import.py @@ -2,6 +2,7 @@ """Strict JSON validation regressions for exercise-alias imports.""" import importlib.util +import sys import json import sqlite3 import subprocess @@ -10,6 +11,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) SPEC = importlib.util.spec_from_file_location( "import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py") MODULE = importlib.util.module_from_spec(SPEC) diff --git a/tests/test_profile_state_sync.py b/tests/test_profile_state_sync.py new file mode 100644 index 0000000..e53933e --- /dev/null +++ b/tests/test_profile_state_sync.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import json,sqlite3,subprocess,sys,tempfile,unittest,uuid +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1]; IMPORT=ROOT/"tools/import_exercise_profile_state.py";EXPORT=ROOT/"tools/export_exercise_profile_state.py" +sys.path.insert(0,str(ROOT/"tools")) +from trainlog_sqlite import connect_database +PLANche="ex_999fd503-ce89-49f9-979d-c19e6adbe80d" +NS=uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149") +def revision(parent,tracking): return "pr2_"+str(uuid.uuid5(NS,f"{parent}\nsets\n{tracking}\n0")) + +def make_db(path,tracking="reps",machine=None): + c=sqlite3.connect(path);c.executescript(""" + CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT, + tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER,load_semantics TEXT,machine_variant TEXT, + machine_provenance TEXT,scientific_profile_id TEXT,science_state TEXT,legacy_equipment_id TEXT); + CREATE TABLE exercise_profile_state(exercise_row_id INTEGER PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,legacy_seed INTEGER); + CREATE TABLE exercise_profile_revisions(exercise_row_id INTEGER,revision_id TEXT,parent_revision_id TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,legacy_seed INTEGER,PRIMARY KEY(exercise_row_id,revision_id)); + CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER); + /* Reproduce the production schema dependency: SQLite resolves this UDF while + preparing UPDATE exercises even when the authoritative-import guard is false. */ + CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises + WHEN NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r + ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id + WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode + AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) + BEGIN + SELECT trainlog_profile_revision((SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=NEW.id), + NEW.recording_mode,NEW.tracking_mode,NEW.data_fields); + END; + PRAGMA user_version=17;""") + c.execute("INSERT INTO exercises VALUES(1,?,'Planche droite sol','planche droite sol',?,'sets',0,NULL,?,NULL,NULL,'unresolved',NULL)",(PLANche,tracking,machine)) + c.execute("INSERT INTO exercise_profile_state VALUES(1,'pr_legacy_v1',NULL,1)") + c.execute("INSERT INTO exercise_profile_revisions VALUES(1,'pr_legacy_v1',NULL,'sets',?,0,1)",(tracking,)) + c.execute("INSERT INTO session_exercises VALUES(1,1,'reps','sets',0)");c.commit();c.close() + +def history_record(tracking,revision,parent,legacy=False): + return {"recording_mode":"sets","tracking_mode":tracking,"data_fields":0,"revision_id":revision,"parent_revision_id":parent,"legacy_seed":legacy} + +def artifact(tracking="duration",revision="pr_legacy_v1",parent=None,legacy=True,machine=None,history=None): + if history is None: + history=([history_record("duration","pr_legacy_v1",None,True)] if not legacy and parent=="pr_legacy_v1" else [])+[history_record(tracking,revision,parent,legacy)] + return {"format":"trainlog-exercise-profile-state","version":1,"generated_at":"2026-09-13T10:00:00+02:00","exercises":[{ + "exercise_id":PLANche,"recording_mode":"sets","tracking_mode":tracking,"data_fields":0, + "load_semantics":None,"machine_variant":machine,"machine_provenance":None,"scientific_profile_id":None, + "science_state":"unresolved","legacy_equipment_id":None,"revision_id":revision, + "parent_revision_id":parent,"legacy_seed":legacy,"history":history}]} + +class ProfileStateSyncTest(unittest.TestCase): + def apply(self,db,payload,ok=True): + p=db.with_suffix('.json');p.write_text(json.dumps(payload),encoding='utf-8') + r=subprocess.run(["python3",str(IMPORT),str(p),"--database",str(db)],text=True,capture_output=True) + self.assertEqual(ok,r.returncode==0,r.stdout+r.stderr);return r + def test_canonical_connection_registers_complete_schema_function_set(self): + with tempfile.TemporaryDirectory() as d: + db=connect_database(Path(d)/"configured.db") + expected=revision("pr_legacy_v1","duration") + self.assertEqual(expected,db.execute( + "SELECT trainlog_profile_revision('pr_legacy_v1','sets','duration',0)").fetchone()[0]) + self.assertEqual((1,0),db.execute( + "SELECT trainlog_pr1_valid('pr1|sets|duration|0','sets','duration',0)," + "trainlog_pr1_valid('pr1|sets|duration|00','sets','duration',0)").fetchone()) + db.close() + def test_exact_planche_legacy_repair_preserves_old_snapshot_and_is_idempotent(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db);payload=artifact();first=self.apply(db,payload) + self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",first.stdout) + c=sqlite3.connect(db);before=( + c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(), + c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0]) + second=self.apply(db,payload);self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",second.stdout) + after=(c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(), + c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0]) + self.assertEqual(before,after) + self.assertEqual(("duration","reps"),( + c.execute("SELECT tracking_mode FROM exercises").fetchone()[0], + c.execute("SELECT tracking_mode FROM session_exercises").fetchone()[0]));c.close() + def test_direct_descendant_converges_and_sibling_conflicts(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact()) + child=artifact("reps",revision("pr_legacy_v1","reps"),"pr_legacy_v1",False);self.apply(db,child) + sibling=artifact("duration",revision("pr_legacy_v1","duration"),"pr_legacy_v1",False);self.apply(db,sibling,False) + def test_same_profile_from_different_parents_is_not_equal(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact()) + first=revision("pr_legacy_v1","reps");first_history=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1")] + self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=first_history)) + other_parent=revision("pr_legacy_v1","duration") + other=revision(other_parent,"reps") + self.apply(db,artifact("reps",other,other_parent,False,history=[history_record("duration","pr_legacy_v1",None,True),history_record("duration",other_parent,"pr_legacy_v1"),history_record("reps",other,other_parent)]),False) + def test_transitive_stale_ancestor_is_retained(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact()) + first=revision("pr_legacy_v1","reps");second=revision(first,"duration") + chain=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1"),history_record("duration",second,first)] + self.apply(db,artifact("duration",second,first,False,history=chain)) + self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=chain[:2])) + self.assertEqual(second,sqlite3.connect(db).execute("SELECT revision_id FROM exercise_profile_state").fetchone()[0]) + def test_coerced_scalar_types_and_oversized_history_are_rejected(self): + with tempfile.TemporaryDirectory() as d: + for key,value in (("version","1"),("data_fields","0"),("legacy_seed","true"),("science_state",True)): + db=Path(d)/(key+".db");make_db(db);bad=artifact() + if key=="version": bad[key]=value + else: bad["exercises"][0][key]=value + self.apply(db,bad,False) + db=Path(d)/"bound.db";make_db(db);bad=artifact();bad["exercises"][0]["history"]*=33;self.apply(db,bad,False) + def test_generated_at_uses_canonical_timestamp_grammar(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db);bad=artifact();bad["generated_at"]="2026-09-13 10:00:00Z" + self.apply(db,bad,False) + def test_history_must_be_canonical_root_to_exact_tip(self): + with tempfile.TemporaryDirectory() as d: + root="pr_legacy_v1";child=revision(root,"reps") + valid=[history_record("duration",root,None,True),history_record("reps",child,root)] + def changed_history(mutator): + history=[dict(entry) for entry in valid];mutator(history) + return artifact("reps",child,root,False,history=history) + other=revision(root,"duration") + cases={ + "A-root-omitted":artifact("reps",child,root,False,history=valid[1:]), + "B-root-parent":changed_history(lambda h:h[0].update(parent_revision_id="detached")), + "C-root-not-legacy":changed_history(lambda h:h[0].update(legacy_seed=False)), + "D-second-legacy":changed_history(lambda h:h[1].update(legacy_seed=True)), + "E-middle-missing":artifact("duration",revision(child,"duration"),child,False, + history=[valid[0],history_record("duration",revision(child,"duration"),child)]), + "F-nonprevious-parent":changed_history(lambda h:h[1].update(parent_revision_id="detached")), + "G-wrong-uuid":changed_history(lambda h:h[1].update(revision_id=other)), + "H-current-absent":artifact("duration",other,root,False,history=valid), + "I-current-not-terminal":artifact("duration",root,None,True,history=valid), + "J-duplicate":artifact("reps",child,root,False,history=valid+[dict(valid[1])]), + "K-33-revisions":artifact("reps",child,root,False,history=[dict(valid[0])]*33), + "L-malformed-pr1":artifact("reps","pr1|sets|reps|00",None,True, + history=[history_record("reps","pr1|sets|reps|00",None,True)]), + } + for name,bad in cases.items(): + db=Path(d)/(name+".db");make_db(db);self.apply(db,bad,False) + def test_export_carries_oldest_to_current_bounded_chain(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact()) + child=revision("pr_legacy_v1","reps");self.apply(db,artifact("reps",child,"pr_legacy_v1",False)) + output=Path(d)/"state.json";result=subprocess.run(["python3",str(EXPORT),str(output),"--database",str(db)],text=True,capture_output=True) + self.assertEqual(0,result.returncode,result.stdout+result.stderr) + history=json.loads(output.read_text())["exercises"][0]["history"] + self.assertEqual(["pr_legacy_v1",child],[entry["revision_id"] for entry in history]) + def test_immutable_machine_difference_is_hard_conflict(self): + with tempfile.TemporaryDirectory() as d: + db=Path(d)/"pc.db";make_db(db);self.apply(db,artifact(machine="selectorized"),False) + +if __name__=="__main__": unittest.main() diff --git a/tests/test_session_exchange_v3.py b/tests/test_session_exchange_v3.py index b26918c..ebc88df 100644 --- a/tests/test_session_exchange_v3.py +++ b/tests/test_session_exchange_v3.py @@ -24,6 +24,28 @@ CREATE TABLE exercise_body_zone_sync( PRAGMA user_version=11; """ +V16_SCHEMA = V11_SCHEMA.replace("PRAGMA user_version=11;", "") + """ +ALTER TABLE exercises ADD COLUMN load_semantics TEXT; +ALTER TABLE exercises ADD COLUMN machine_variant TEXT; +ALTER TABLE exercises ADD COLUMN machine_provenance TEXT; +ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT; +ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved'; +ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT; +ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT NOT NULL DEFAULT 'reps'; +CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL); +CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE, + session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE, + observed_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL, + created_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE, + session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + observed_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL, + created_at TEXT NOT NULL,raw_text TEXT NOT NULL); +PRAGMA user_version=16; +""" + def run(command, ok=True): result = subprocess.run(command, text=True, capture_output=True) @@ -32,6 +54,11 @@ def run(command, ok=True): return result +def db_value(path, sql): + with sqlite3.connect(path) as connection: + return connection.execute(sql).fetchone()[0] + + def payload(): exercises = [ {"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, @@ -122,8 +149,12 @@ def main(): divergent = copy.deepcopy(payload()); divergent["sessions"][0]["exercises"][0]["target"]["reps"] = 9 divergent_path = root / "divergent.json"; divergent_path.write_text(json.dumps(divergent), encoding="utf-8") - run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)], ok=False) - assert snapshot(db) == before + corrected = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)]) + assert "sessions_reconciled=1" in corrected.stdout + replay = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)]) + assert "sessions_reconciled=0" in replay.stdout + assert db_value(db, "SELECT target_reps FROM session_exercises WHERE entry_id='sxe_reps'") == 9 + before = snapshot(db) mutations = [ lambda e: e["target"].update(sets=0), @@ -205,6 +236,72 @@ def main(): shared_entry = json.loads(shared_out.read_text())["sessions"][0]["exercises"][0] assert shared_entry["target"] == {"sets": 3, "reps": 9, "weight_kg": 55.5} assert shared_entry["sets"] == [{"reps": 9, "weight_kg": 52.5}, {"reps": 8}, {"reps": 7, "weight_kg": 0.0}] + + # Desktop v16 stores the unit on the occurrence. A later catalogue + # profile change therefore cannot reinterpret or rewrite old facts. + v16_db = root / "v16.sqlite" + with sqlite3.connect(v16_db) as con: + con.executescript(V16_SCHEMA) + run([sys.executable, str(IMPORTER), str(artifact), "--database", str(v16_db)]) + with sqlite3.connect(v16_db) as con: + assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps" + con.execute("UPDATE exercises SET tracking_mode='duration' WHERE exercise_id='ex_reps'") + + # V2 has the same completed-occurrence ownership contract: catalogue + # membership proves identity, while the entry's own profile validates + # and preserves the historical payload. + historical_v2 = payload() + historical_v2["version"] = 2 + historical_v2["exercises"][0]["tracking_mode"] = "duration" + historical_entry = copy.deepcopy(historical_v2["sessions"][0]["exercises"][0]) + historical_entry.pop("target") + historical_entry["entry_id"] = "sxe_v16_historical_v2" + historical_entry["load_mode"] = "none" + historical_entry["rest_seconds"] = 0 + historical_v2["sessions"] = [{ + "session_id": "se_v16_historical_v2", + "started_at": "2026-09-09T13:00:00+02:00", + "session_type": "training", + "exercises": [historical_entry], + }] + historical_v2["body_observations"] = [] + historical_v2_path = root / "v16-historical-v2.json" + historical_v2_path.write_text(json.dumps(historical_v2), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)]) + replay_v2 = run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)]) + assert "sessions_skipped=1" in replay_v2.stdout + with sqlite3.connect(v16_db) as con: + assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_historical_v2'").fetchone()[0] == "reps" + assert con.execute("SELECT reps FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_historical_v2' ORDER BY ps.position LIMIT 1").fetchone()[0] == 8 + v16_old_export = root / "v16-old.json" + run([sys.executable, str(EXPORTER), str(v16_old_export), "--database", str(v16_db)]) + old_entry = json.loads(v16_old_export.read_text())["sessions"][0]["exercises"][0] + assert old_entry["tracking_mode"] == "reps" + assert old_entry["sets"][0]["reps"] == 8 + + future = payload() + future["exercises"][0]["tracking_mode"] = "duration" + old_after_profile_change = copy.deepcopy(payload()["sessions"][0]) + future["sessions"] = [old_after_profile_change, { + "session_id": "se_v16_future", "started_at": "2026-09-10T12:00:00+02:00", + "session_type": "training", "exercises": [{ + "entry_id": "sxe_v16_future", "position": 0, "exercise_id": "ex_reps", + "name": "Reps", "recording_mode": "sets", "tracking_mode": "duration", + "data_fields": 0, "load_mode": "none", "rest_seconds": 0, + "equipment_id": None, "target": {"sets": 3, "duration_seconds": 30}, + "sets": [{"duration_seconds": 30}, {"duration_seconds": 30}, {"duration_seconds": 30}], + }], + }] + future["body_observations"] = [] + future_path = root / "v16-future.json" + future_path.write_text(json.dumps(future), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)]) + idempotent = run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)]) + assert "sessions_reconciled=0" in idempotent.stdout + with sqlite3.connect(v16_db) as con: + assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps" + assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_future'").fetchone()[0] == "duration" + assert con.execute("SELECT group_concat(duration_seconds, ',') FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_future' ORDER BY ps.position").fetchone()[0] == "30,30,30" print("PASS session_exchange_v3") diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 528643f..523bc00 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -6,6 +6,7 @@ import sqlite3 import os from datetime import datetime from pathlib import Path +from trainlog_sqlite import connect_database DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" @@ -25,10 +26,10 @@ def main(): default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db") parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG) args = parser.parse_args() - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): - raise ValueError("schema desktop v8 à v13 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): + raise ValueError("schema desktop v8 à v16 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 35e78bd..f0a505a 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -6,6 +6,7 @@ import os import sqlite3 from datetime import datetime from pathlib import Path +from trainlog_sqlite import connect_database FORMAT = "trainlog-equipment-definitions" VERSION = 1 @@ -20,11 +21,11 @@ def main(): parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) args = parser.parse_args() - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): - raise ValueError("schema desktop v8 à v13 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): + raise ValueError("schema desktop v8 à v16 requis") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "FROM custom_equipment ORDER BY equipment_id")] diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 44986a7..9a48339 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -4,6 +4,7 @@ import argparse import json import sqlite3 from pathlib import Path +from trainlog_sqlite import connect_database MAX_ALIASES = 4096 @@ -13,10 +14,10 @@ def main(): parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, required=True) args = parser.parse_args() - con = sqlite3.connect(args.database) + con = connect_database(args.database) try: - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15): - raise ValueError("schema desktop v12/v13 requis") + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17): + raise ValueError("schema desktop v12-v16 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 064c6f1..9774867 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -8,6 +8,7 @@ import re import sqlite3 from datetime import datetime from pathlib import Path +from trainlog_sqlite import connect_database ROOT = Path(__file__).resolve().parents[1] CATALOG = ROOT / "catalog/body-zones-v1.json" @@ -37,11 +38,11 @@ def main(): parser.add_argument("--database", type=Path, default=default_database()) args = parser.parse_args() assignable = assignable_zone_ids() - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15): - raise ValueError("schema desktop v11/v12/v13 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17): + raise ValueError("schema desktop v11-v16 requis") exercises = [] for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None: diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py new file mode 100644 index 0000000..d4491e0 --- /dev/null +++ b/tools/export_exercise_profile_state.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Export mutable current exercise profiles with deterministic ancestry.""" +import argparse +import json +import sqlite3 +import re +from datetime import datetime +from pathlib import Path +from trainlog_sqlite import connect_database, profile_revision + +REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + +def valid_profile(recording, tracking, fields): + return recording in ("sets","continuous") and tracking in ("reps","duration") and \ + not (recording=="continuous" and tracking!="duration") and type(fields) is int and \ + 0<=fields<=3 and not (recording=="sets" and fields!=0) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("output", type=Path) + parser.add_argument("--database", required=True, type=Path) + args = parser.parse_args() + con = connect_database(args.database) + con.row_factory = sqlite3.Row + try: + version = con.execute("PRAGMA user_version").fetchone()[0] + if version != 17: + raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") + rows = con.execute(""" + SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, + e.load_semantics,e.machine_variant,e.machine_provenance, + e.scientific_profile_id,e.science_state,e.legacy_equipment_id, + s.revision_id,s.parent_revision_id,s.legacy_seed + FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id + ORDER BY e.exercise_id + """).fetchall() + exercises=[] + for row in rows: + # exercise_row_id is numeric; resolve it without coupling JSON identity to SQLite row IDs. + exercise_row_id=con.execute("SELECT id FROM exercises WHERE exercise_id=?",(row["exercise_id"],)).fetchone()[0] + history=con.execute("""WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS ( + SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=? + UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31) + SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC""", + (exercise_row_id,row["revision_id"],exercise_row_id)).fetchall() + if not history or history[-1]["revision_id"] != row["revision_id"] or len(history)>32: + raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid/bounded history") + previous=None + for index, revision in enumerate(history): + rid=revision["revision_id"];parent=revision["parent_revision_id"] + recording=revision["recording_mode"];tracking=revision["tracking_mode"] + fields=revision["data_fields"];legacy=revision["legacy_seed"] + valid = valid_profile(recording,tracking,fields) and ( + index==0 and legacy==1 and rid=="pr_legacy_v1" and parent is None or + index>0 and legacy==0 and isinstance(parent,str) and parent==previous and + isinstance(rid,str) and REVISION_RE.fullmatch(rid) and + rid==profile_revision(parent,recording,tracking,fields)) + if not valid: + raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid rooted history") + previous=rid + if (history[-1]["parent_revision_id"]!=row["parent_revision_id"] or + history[-1]["legacy_seed"]!=row["legacy_seed"] or + history[-1]["recording_mode"]!=row["recording_mode"] or + history[-1]["tracking_mode"]!=row["tracking_mode"] or + history[-1]["data_fields"]!=row["data_fields"]): + raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL history tip mismatch") + exercises.append(dict(row) | {"legacy_seed":bool(row["legacy_seed"]),"history":[dict(h)|{"legacy_seed":bool(h["legacy_seed"])} for h in history]}) + payload = {"format":"trainlog-exercise-profile-state","version":1, + "generated_at":datetime.now().astimezone().isoformat(),"exercises":exercises} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload,ensure_ascii=False,separators=(",",":")),encoding="utf-8") + print(f"EXERCISE_PROFILE_STATE_EXPORT=PASS exercises={len(rows)}") + finally: + con.close() + +if __name__ == "__main__": main() diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index af5d06b..1f76a15 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -7,6 +7,7 @@ from datetime import datetime from pathlib import Path from exercise_names import load_exercise_names +from trainlog_sqlite import connect_database def default_database_path(): @@ -54,7 +55,7 @@ def main(): "PC_CATALOG_EXPORT=FAIL database not found" ) - connection = sqlite3.connect( + connection = connect_database( args.database ) @@ -67,7 +68,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (8, 9, 10, 11, 12, 13, 14, 15): + if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index d3f6ce0..84107af 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -10,6 +10,7 @@ from pathlib import Path from validate_json import TrainlogSemanticError, parse_timestamp from exercise_names import load_exercise_names +from trainlog_sqlite import connect_database CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" @@ -64,12 +65,12 @@ def main(): parser.add_argument("--database", type=Path, default=default_database()) parser.add_argument("--version", type=int, choices=(2, 3), default=3) args = parser.parse_args() - con = sqlite3.connect(args.database) + con = connect_database(args.database) con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if schema_version not in (11, 12, 13, 14, 15) and not (args.version == 2 and schema_version == 10): - raise ValueError("schema desktop v11/v12/v13 requis (v10 accepté pour export V2 explicite)") + if schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10): + raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") known_equipment = supplied_equipment_ids() canonical_names = load_exercise_names() known_equipment.update(row[0] for row in con.execute( @@ -89,9 +90,10 @@ def main(): f"session_id={session['session_id']} started_at") payload = {key: session[key] for key in ("session_id", "started_at", "session_type")} payload["exercises"] = [] - # INVARIANT: tracking mode is catalogue metadata. v7 occurrences - # retain their stable entry_id but do not duplicate that field. - sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" + # INVARIANT: v16 exports the occurrence-owned tracking snapshot; + # older readable schemas retain their bounded catalogue fallback. + tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode" + sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" for entry in con.execute(sql, (session["id"],)): validate_plan(entry) if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None: diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 7c6b591..d4900b1 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 """Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion.""" import argparse, datetime, json, sqlite3 +from trainlog_sqlite import connect_database from pathlib import Path def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) def main(): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() - db=sqlite3.connect(args.database) + db=connect_database(args.database) try: - if db.execute("PRAGMA user_version").fetchone()[0] != 15: raise ValueError("schema desktop v15 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 3ca3a3b..8bc29ae 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -4,6 +4,7 @@ import argparse import json import sqlite3 from pathlib import Path +from trainlog_sqlite import connect_database FORMAT = "trainlog-equipment-associations" VERSION = 2 @@ -170,10 +171,10 @@ def main(): fail("extension équipement non supportée") if set(payload) != {"format", "version", "generated_at", "associations"}: fail("clés extension équipement invalides") - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): - fail("schema desktop v8 à v13 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): + fail("schema desktop v8 à v16 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 7fd3f6d..ae5f3f1 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -4,6 +4,7 @@ import argparse import json import sqlite3 from pathlib import Path +from trainlog_sqlite import connect_database FORMAT = "trainlog-equipment-definitions" VERSION = 1 @@ -58,10 +59,10 @@ def main(): default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json") args = parser.parse_args() definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15): - fail("schema desktop v8 à v13 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17): + fail("schema desktop v8 à v16 requis") imported = skipped = 0 # Validate every same-ID row before inserting any definition. for definition in definitions: diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index f51643b..9283595 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -5,6 +5,7 @@ import json import re import sqlite3 from pathlib import Path +from trainlog_sqlite import connect_database MAX_BYTES = 1024 * 1024 MAX_ALIASES = 4096 @@ -59,11 +60,11 @@ def main(): parser.add_argument("--database", type=Path, required=True) args = parser.parse_args() aliases = load(args.artifact) - con = sqlite3.connect(args.database) + con = connect_database(args.database) try: con.execute("PRAGMA foreign_keys=ON") - if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15): - fail("schema desktop v12/v13/v14/v15 requis") + if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17): + fail("schema desktop v12-v16 requis") con.execute("BEGIN IMMEDIATE") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index c5a9762..0cc5272 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -9,6 +9,7 @@ import sqlite3 import unicodedata from datetime import datetime from pathlib import Path +from trainlog_sqlite import connect_database ROOT = Path(__file__).resolve().parents[1] @@ -201,11 +202,11 @@ def main(): mobile_proof = (load_mobile_exercise_proof(args.mobile_export) if args.mobile_export is not None else {}) - connection = sqlite3.connect(args.database) + connection = connect_database(args.database) updated = skipped = kept_local = 0 try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15): - raise ImportFailure("schema desktop v11/v12/v13 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17): + raise ImportFailure("schema desktop v11-v16 requis") connection.execute("PRAGMA foreign_keys=ON") connection.execute("BEGIN IMMEDIATE") grouped = {} diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py new file mode 100644 index 0000000..585cfe5 --- /dev/null +++ b/tools/import_exercise_profile_state.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Apply direct-descendant current profiles without reinterpreting occurrences.""" +import argparse +import json +import re +import sqlite3 +from pathlib import Path + +from validate_json import parse_timestamp +from trainlog_sqlite import connect_database, profile_revision + +EX_ID=re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +HISTORY_MAX=32 +ADJ=("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id") +REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + +def fail(message): raise ValueError(message) + +def validate(item): + required={"exercise_id","recording_mode","tracking_mode","data_fields",*ADJ, + "revision_id","parent_revision_id","legacy_seed","history"} + if set(item)!=required or not isinstance(item["exercise_id"],str) or not EX_ID.fullmatch(item["exercise_id"]): fail("record profile invalide") + r,t,f=item["recording_mode"],item["tracking_mode"],item["data_fields"] + if r not in ("sets","continuous") or t not in ("reps","duration") or r=="continuous" and t!="duration" or type(f) is not int or f<0 or f&~3 or r=="sets" and f: fail("profil entrant invalide") + if item["science_state"] not in ("resolved","unresolved") or any(item[k] is not None and not isinstance(item[k],str) for k in ADJ if k!="science_state") or type(item["legacy_seed"]) is not bool: fail("état profil invalide") + if item["legacy_seed"]: + if item["revision_id"]!="pr_legacy_v1" or item["parent_revision_id"] is not None: fail("racine legacy invalide") + elif not isinstance(item["parent_revision_id"],str) or not isinstance(item["revision_id"],str) or not REVISION_RE.fullmatch(item["revision_id"]) or item["revision_id"]!=profile_revision(item["parent_revision_id"],r,t,f): fail("révision profil invalide") + history=item["history"] + keys={"revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed"} + if not isinstance(history,list) or not 1<=len(history)<=HISTORY_MAX: fail("historique profil hors limite") + previous=None;revision_ids=set() + for index,revision in enumerate(history): + if not isinstance(revision,dict) or set(revision)!=keys: fail("révision historique invalide") + rr,rt,rf=revision["recording_mode"],revision["tracking_mode"],revision["data_fields"] + legacy=revision["legacy_seed"];rid=revision["revision_id"];parent=revision["parent_revision_id"] + if rr not in ("sets","continuous") or rt not in ("reps","duration") or rr=="continuous" and rt!="duration" or type(rf) is not int or rf<0 or rf&~3 or rr=="sets" and rf or type(legacy) is not bool: fail("profil historique invalide") + if not isinstance(rid,str) or rid in revision_ids or index and parent!=previous: fail("chaîne historique invalide") + if index == 0: + if not legacy or rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide") + elif legacy: + fail("marqueur legacy hors racine") + if legacy: + if rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide") + elif not isinstance(parent,str) or not isinstance(rid,str) or not REVISION_RE.fullmatch(rid) or rid!=profile_revision(parent,rr,rt,rf): fail("révision historique invalide") + revision_ids.add(rid);previous=rid + if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente") + +def main(): + p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args() + try: + root=json.loads(a.artifact.read_text(encoding="utf-8")) + if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") + parse_timestamp(root["generated_at"], "generated_at") + con=connect_database(a.database);con.row_factory=sqlite3.Row + if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis") + applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE") + for item in root["exercises"]: + validate(item);eid=item["exercise_id"] + if eid in seen: fail("exercise_id profile-state dupliqué") + seen.add(eid) + row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone() + if row is None: + if a.allow_pending or a.mobile_export is not None: pending+=1;continue + fail(f"identité exercice inconnue: {eid}") + if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}") + local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"]) + incoming_ids={revision["revision_id"] for revision in item["history"]} + known={r[0] for r in con.execute("SELECT revision_id FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],))} + if row["revision_id"]==item["revision_id"]: + if local_profile==incoming: equal+=1;continue + if not (row["legacy_seed"] and item["legacy_seed"]): fail(f"révision identique avec profil différent: {eid}") + elif row["revision_id"] in incoming_ids: + pass + elif item["revision_id"] in known: + ancestor+=1;continue + else: fail(f"conflit de descendants concurrents: {eid}") + existing_count=con.execute("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],)).fetchone()[0] + additions=sum(1 for revision in item["history"] if revision["revision_id"] not in known) + if existing_count+additions>HISTORY_MAX: fail(f"historique profil dépasse {HISTORY_MAX}: {eid}") + for revision in item["history"]: + con.execute("INSERT OR IGNORE INTO exercise_profile_revisions(exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed) VALUES(?,?,?,?,?,?,?)", + (row["id"],revision["revision_id"],revision["parent_revision_id"],revision["recording_mode"],revision["tracking_mode"],revision["data_fields"],int(revision["legacy_seed"]))) + if item["legacy_seed"]: + # The shared migration root deliberately permits the one initial + # cross-peer repair; keep its stored tuple equal to the adopted tip. + con.execute("UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'",(*incoming,row["id"])) + # CONTRACT: select the proven incoming tip before changing the catalogue; + # the guarded local-edit trigger then recognizes an authoritative import. + con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"])) + con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"])) + applied+=1 + con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}") + except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e: + try: + if 'con' in locals(): con.rollback() + except sqlite3.Error: pass + raise SystemExit(f"EXERCISE_PROFILE_STATE_IMPORT=FAIL {e}") + finally: + if 'con' in locals(): con.close() + +if __name__=="__main__": main() diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 2eeed0a..7d5d8bf 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -10,6 +10,7 @@ from pathlib import Path from validate_json import TrainlogSemanticError, parse_timestamp from exercise_names import ExerciseNameCatalogError, load_exercise_names +from trainlog_sqlite import connect_database FORMAT = "trainlog-mobile-export" @@ -492,18 +493,10 @@ def validate_session_exercise( label, ) - catalog_profile = known_exercise_ids[exercise_id] - if ( - recording_mode != catalog_profile["recording_mode"] - or tracking_mode != catalog_profile["tracking_mode"] - or item["data_fields"] & ~catalog_profile["data_fields"] - ): - # CONTRACT: an occurrence snapshots the fields that actually existed - # when it was recorded. It may omit later optional catalog fields, but - # it may never claim a field absent from the catalog profile. - raise ImportFailure( - f"{label}: snapshot incompatible avec le profil catalogue" - ) + # CONTRACT: catalogue membership proves stable identity only. Every + # completed occurrence owns its recording/tracking/data-field snapshot in + # every readable mobile version; later CURRENT profile evolution cannot + # invalidate or reinterpret historical actuals. if version < 3: if item["load_mode"] != "none" or item["rest_seconds"] != 0: @@ -820,9 +813,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15): + if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17): raise ImportFailure( - f"base desktop schema v5 à v15 attendue, version trouvée: {version}" + f"base desktop schema v5 à v17 attendue, version trouvée: {version}" ) @@ -1290,7 +1283,12 @@ def import_set_session_exercise( values = "?, " if entry_id is not None else "" equipment_columns = ", equipment_id" if schema_version >= 6 else "" equipment_values = ", ?" if schema_version >= 6 else "" - arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position] + tracking_columns = ", tracking_mode" if schema_version >= 16 else "" + tracking_values = ", ?" if schema_version >= 16 else "" + arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row] + if schema_version >= 16: + arguments.append(tracking) + arguments.extend([item["data_fields"], position]) arguments.extend(occurrence_plan_values(item)) if schema_version >= 6: arguments.append(item.get("equipment_id")) @@ -1299,7 +1297,7 @@ def import_set_session_exercise( INSERT INTO session_exercises( """ + columns + """session_row_id, exercise_row_id, - recording_mode, + recording_mode""" + tracking_columns + """, data_fields, position, load_mode, @@ -1309,7 +1307,7 @@ def import_set_session_exercise( target_duration_seconds, target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - """ + values + """?, ?, 'sets', ?, ?, ?, ?, + """ + values + """?, ?, 'sets'""" + tracking_values + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL""" + equipment_values + """ ); """, @@ -1363,7 +1361,12 @@ def import_continuous_session_exercise( values = "?, " if entry_id is not None else "" equipment_columns = ", equipment_id" if schema_version >= 6 else "" equipment_values = ", ?" if schema_version >= 6 else "" - arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position] + tracking_columns = ", tracking_mode" if schema_version >= 16 else "" + tracking_values = ", ?" if schema_version >= 16 else "" + arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row] + if schema_version >= 16: + arguments.append(item["tracking_mode"]) + arguments.extend([item["data_fields"], position]) arguments.extend(occurrence_plan_values(item)) if schema_version >= 6: arguments.append(item.get("equipment_id")) @@ -1372,7 +1375,7 @@ def import_continuous_session_exercise( INSERT INTO session_exercises( """ + columns + """session_row_id, exercise_row_id, - recording_mode, + recording_mode""" + tracking_columns + """, data_fields, position, load_mode, @@ -1382,7 +1385,7 @@ def import_continuous_session_exercise( target_duration_seconds, target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - """ + values + """?, ?, 'continuous', ?, ?, ?, ?, + """ + values + """?, ?, 'continuous'""" + tracking_values + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL""" + equipment_values + """ ); """, @@ -1422,25 +1425,24 @@ def import_max_session_exercise( raise ImportFailure( "max_weight_kg exige le schéma desktop v9" ) + tracking_column = ", tracking_mode" if schema_version >= 16 else "" + tracking_value = ", ?" if schema_version >= 16 else "" + arguments = [item["entry_id"], session_row_id, exercise_row, + item["recording_mode"]] + if schema_version >= 16: + arguments.append(item["tracking_mode"]) + arguments.extend([item["data_fields"], position, *occurrence_plan_values(item), + item.get("equipment_id")]) cursor = connection.execute( """ INSERT INTO session_exercises( entry_id, session_row_id, exercise_row_id, recording_mode, - data_fields, position, load_mode, rest_seconds, + """ + tracking_column.lstrip(", ") + (", " if tracking_column else "") + """data_fields, position, load_mode, rest_seconds, target_sets, target_reps, target_duration_seconds, target_weight_kg, notes, equipment_id - ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?); + ) VALUES(?, ?, ?, ?""" + tracking_value + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?); """, - ( - item["entry_id"], - session_row_id, - exercise_row, - item["recording_mode"], - item["data_fields"], - position, - *occurrence_plan_values(item), - item.get("equipment_id"), - ), + arguments, ) connection.execute( "INSERT INTO max_results(session_exercise_row_id,max_weight_kg) " @@ -1456,6 +1458,8 @@ def import_sessions( report, ): for session in payload["sessions"]: + retained_feedback = [] + retained_feedback_revisions = [] if session_exists( connection, session["session_id"], @@ -1495,11 +1499,21 @@ def import_sessions( and all(current[index] == incoming[index] for index in range(len(current))) ) + incoming_by_id = dict(incoming) + correction = ( + payload["version"] == 3 + and header is not None + and tuple(header) == (session["started_at"], session["session_type"]) + and len(incoming_by_id) == len(incoming) + and all(entry_id not in dict(current) + or dict(current)[entry_id] == exercise_id + for entry_id, exercise_id in incoming) + ) # A v1 history may be upgraded only when exercise/order mapping is # unique. Any other identity disagreement is an explicit conflict. if current != incoming and not ( (legacy and [x[1] for x in current] == [x[1] for x in incoming]) - or resumable_max + or resumable_max or correction ): raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"]) if not legacy and session_semantically_matches( @@ -1510,12 +1524,25 @@ def import_sessions( ): report["sessions_skipped"] += 1 continue - if not legacy and not resumable_max: + if not legacy and not (resumable_max or correction): raise ImportFailure("conflit de contenu pour " + session["session_id"]) - # CONTRACT: a resumed max_test may edit existing max values and - # append occurrences, but cannot remove/reorder/rebind any stable - # entry. This bounded replacement makes tomorrow's continuation - # idempotent without turning arbitrary session conflicts into wins. + # CONTRACT: V3 carries the complete occurrence and planning state. + # A correction may alter facts and remove explicitly confirmed + # parents, but a retained entry_id cannot be rebound to another + # exercise. Feedback follows retained session_id + entry_id. + if connection.execute("PRAGMA user_version;").fetchone()[0] >= 14: + retained_feedback = connection.execute( + "SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text " + "FROM exercise_feedback f JOIN session_exercises se " + "ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;", + (session_row_id,),).fetchall() + if connection.execute("PRAGMA user_version;").fetchone()[0] >= 15: + retained_feedback_revisions = connection.execute( + "SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text " + "FROM exercise_feedback_revisions r JOIN exercise_feedback f " + "ON f.feedback_id=r.feedback_id JOIN session_exercises se " + "ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;", + (session_row_id,),).fetchall() # Explicit child deletion makes reconciliation safe even for old # databases which were created without enforced foreign keys. connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,)) @@ -1569,9 +1596,9 @@ def import_sessions( f"exercice desktop absent: {desktop_id}" ) - if ( - row["tracking_mode"] - != item["tracking_mode"] + schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] + if schema_version < 16 and ( + row["tracking_mode"] != item["tracking_mode"] or row["recording_mode"] != item["recording_mode"] or item["data_fields"] & ~row["data_fields"] @@ -1610,6 +1637,25 @@ def import_sessions( row_id, ) + retained_ids = {item["entry_id"] for item in session["exercises"]} + for entry_id, feedback_id, observed_at, raw_text in retained_feedback: + if entry_id not in retained_ids: + continue + occurrence = connection.execute( + "SELECT id FROM session_exercises WHERE session_row_id=? AND entry_id=?;", + (session_row_id, entry_id),).fetchone() + if occurrence is None: + raise ImportFailure("rattachement feedback impossible: " + entry_id) + connection.execute( + "INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?);", + (feedback_id, occurrence[0], observed_at, raw_text),) + retained_feedback_ids = {row[1] for row in retained_feedback if row[0] in retained_ids} + for feedback_id, revision_id, created_at, raw_text in retained_feedback_revisions: + if feedback_id in retained_feedback_ids: + connection.execute( + "INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) VALUES(?,?,?,?);", + (revision_id, feedback_id, created_at, raw_text),) + report["sessions_imported"] += 1 @@ -1623,8 +1669,10 @@ def session_semantically_matches( "SELECT started_at,session_type FROM sessions WHERE id=?", (session_row_id,)).fetchone() if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]): return False + schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] + tracking_column = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode" rows = connection.execute( - "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode," + "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode," + tracking_column + "," "se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps," "se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e " "ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall() @@ -1772,7 +1820,7 @@ def run_import( f"base desktop introuvable: {database_path}" ) - connection = sqlite3.connect( + connection = connect_database( database_path ) @@ -1927,7 +1975,7 @@ def main(): known_equipment_ids = load_supplied_equipment_ids(args.catalog) if args.database.exists(): - with sqlite3.connect(args.database) as connection: + with connect_database(args.database) as connection: if connection.execute("PRAGMA user_version").fetchone()[0] >= 8: known_equipment_ids.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index f193075..aea9f9c 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger.""" import argparse,json,re,sqlite3 +from trainlog_sqlite import connect_database from datetime import datetime from pathlib import Path ID=lambda p:re.compile(r"^"+p+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") @@ -46,11 +47,11 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode())) return added,skipped,current[2] def main(): - parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=sqlite3.connect(args.database) + parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database) roots_added=roots_skipped=revisions_added=revisions_skipped=0 try: db.execute("PRAGMA foreign_keys=ON") - if db.execute("PRAGMA user_version").fetchone()[0]!=15:fail("schema desktop v15 requis") + if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis") db.execute("BEGIN IMMEDIATE");seen=set() for index,item in enumerate(root["exercise_feedback"]): keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"} diff --git a/tools/migrate_machine_exercises_v1.py b/tools/migrate_machine_exercises_v1.py index 37143d9..16f87fe 100644 --- a/tools/migrate_machine_exercises_v1.py +++ b/tools/migrate_machine_exercises_v1.py @@ -13,6 +13,7 @@ import sqlite3 from pathlib import Path from exercise_names import normalize_catalog_name +from trainlog_sqlite import connect_database ROOT = Path(__file__).resolve().parents[1] DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json") @@ -167,7 +168,7 @@ def main(): if database == LIVE_DB.resolve(): fail("refusing to migrate the canonical live desktop database") manifest = json.loads(args.manifest.read_text(encoding="utf-8")) - connection = sqlite3.connect(database) + connection = connect_database(database) connection.execute("PRAGMA foreign_keys=ON") changed = apply(connection, manifest) integrity = connection.execute("PRAGMA integrity_check").fetchone()[0] diff --git a/tools/trainlog_sqlite.py b/tools/trainlog_sqlite.py new file mode 100644 index 0000000..23729d1 --- /dev/null +++ b/tools/trainlog_sqlite.py @@ -0,0 +1,49 @@ +"""Canonical SQLite connection configuration for Trainlog Python tools.""" + +import sqlite3 +import uuid + + +PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149") + + +def profile_revision(parent, recording, tracking, fields): + """Return the frozen deterministic profile revision shared with C/Android.""" + if not all(isinstance(value, str) for value in (parent, recording, tracking)): + raise ValueError("invalid profile revision") + if type(fields) is not int: + raise ValueError("invalid profile revision") + name = f"{parent}\n{recording}\n{tracking}\n{fields}" + return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, name)) + + +def prototype_profile_valid(token, recording, tracking, fields): + """Recognize only the unpublished pr1 prototype accepted by migration v17.""" + if not isinstance(token, str) or not isinstance(recording, str) or \ + not isinstance(tracking, str) or type(fields) is not int: + return 0 + valid = recording in ("sets", "continuous") and tracking in ("reps", "duration") and \ + not (recording == "continuous" and tracking != "duration") and \ + fields >= 0 and fields & ~3 == 0 and not (recording == "sets" and fields != 0) + return int(valid and token == f"pr1|{recording}|{tracking}|{fields}") + + +def configure_connection(connection): + """Install every process-local function referenced by Trainlog schema SQL. + + CONTRACT: callers must use this before preparing statements against a + Trainlog database. Persisted triggers resolve UDF names at statement + preparation even when their WHEN clause suppresses the trigger body. + """ + connection.create_function( + "trainlog_profile_revision", 4, profile_revision, deterministic=True, + ) + connection.create_function( + "trainlog_pr1_valid", 4, prototype_profile_valid, deterministic=True, + ) + return connection + + +def connect_database(database, *args, **kwargs): + """Open and canonically configure one production Trainlog connection.""" + return configure_connection(sqlite3.connect(database, *args, **kwargs)) diff --git a/tools/validate_json.py b/tools/validate_json.py index 66c4ade..b129ac7 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -10,6 +10,7 @@ import math import re import sys import unicodedata +import uuid from pathlib import Path from typing import Any @@ -29,12 +30,88 @@ SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json" VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid" INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid" BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json" +PROFILE_EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149") +PROFILE_REVISION_ID = re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") +PROFILE_HISTORY_MAX = 32 + + +def exercise_profile_revision(parent: str, recording: str, tracking: str, fields: int) -> str: + return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, f"{parent}\n{recording}\n{tracking}\n{fields}")) class TrainlogSemanticError(ValueError): """Raised when structurally valid JSON violates its format semantics.""" +def validate_exercise_profile_state(document: Any) -> None: + """Validate the separately versioned deterministic ancestry companion.""" + if not isinstance(document, dict) or set(document) != { + "format", "version", "generated_at", "exercises", + } or document.get("format") != "trainlog-exercise-profile-state" or \ + type(document.get("version")) is not int or document.get("version") != 1 or not isinstance(document.get("generated_at"), str) or \ + not document.get("generated_at") or not isinstance(document.get("exercises"), list): + raise TrainlogSemanticError("exercise profile-state v1: invalid root") + parse_timestamp(document["generated_at"], "generated_at") + seen = set() + adjacent = {"load_semantics", "machine_variant", "machine_provenance", + "scientific_profile_id", "science_state", "legacy_equipment_id"} + required = {"exercise_id", "recording_mode", "tracking_mode", "data_fields", + "revision_id", "parent_revision_id", "legacy_seed", "history"} | adjacent + revision_keys = {"revision_id", "parent_revision_id", "recording_mode", + "tracking_mode", "data_fields", "legacy_seed"} + for item in document["exercises"]: + if not isinstance(item, dict) or set(item) != required: + raise TrainlogSemanticError("exercise profile-state v1: invalid record") + exercise_id = item["exercise_id"] + if not isinstance(exercise_id, str) or not PROFILE_EXERCISE_ID.fullmatch(exercise_id) or exercise_id in seen: + raise TrainlogSemanticError("exercise profile-state v1: invalid/duplicate identity") + seen.add(exercise_id) + recording, tracking, fields = item["recording_mode"], item["tracking_mode"], item["data_fields"] + if not isinstance(recording, str) or not isinstance(tracking, str) or \ + recording not in {"sets", "continuous"} or tracking not in {"reps", "duration"} or \ + recording == "continuous" and tracking != "duration" or type(fields) is not int or \ + fields < 0 or fields & ~3 or recording == "sets" and fields != 0: + raise TrainlogSemanticError("exercise profile-state v1: invalid profile") + if not isinstance(item["science_state"], str) or item["science_state"] not in {"resolved", "unresolved"} or any( + item[key] is not None and not isinstance(item[key], str) + for key in adjacent - {"science_state"}): + raise TrainlogSemanticError("exercise profile-state v1: invalid adjacent metadata") + legacy = item["legacy_seed"] + if type(legacy) is not bool or (legacy and (item["revision_id"] != "pr_legacy_v1" or item["parent_revision_id"] is not None)) or \ + (not legacy and (not isinstance(item["parent_revision_id"], str) or not isinstance(item["revision_id"], str) or + not PROFILE_REVISION_ID.fullmatch(item["revision_id"]) or item["revision_id"] != + exercise_profile_revision(item["parent_revision_id"], recording, tracking, fields))): + raise TrainlogSemanticError("exercise profile-state v1: invalid ancestry") + history = item["history"] + if not isinstance(history, list) or not 1 <= len(history) <= PROFILE_HISTORY_MAX: + raise TrainlogSemanticError("exercise profile-state v1: invalid history bound") + previous = None + revision_ids = set() + for index, revision in enumerate(history): + if not isinstance(revision, dict) or set(revision) != revision_keys: + raise TrainlogSemanticError("exercise profile-state v1: invalid history record") + rr, rt, rf = revision["recording_mode"], revision["tracking_mode"], revision["data_fields"] + rlegacy = revision["legacy_seed"] + if not isinstance(rr, str) or not isinstance(rt, str) or rr not in {"sets", "continuous"} or rt not in {"reps", "duration"} or \ + rr == "continuous" and rt != "duration" or type(rf) is not int or rf < 0 or \ + rf & ~3 or rr == "sets" and rf != 0 or type(rlegacy) is not bool: + raise TrainlogSemanticError("exercise profile-state v1: invalid history profile") + rid, parent = revision["revision_id"], revision["parent_revision_id"] + if not isinstance(rid, str) or rid in revision_ids or (index > 0 and parent != previous) or \ + (index == 0 and (not rlegacy or rid != "pr_legacy_v1" or parent is not None)) or \ + (index > 0 and rlegacy) or \ + (rlegacy and (rid != "pr_legacy_v1" or parent is not None)) or \ + (not rlegacy and (not isinstance(parent, str) or not isinstance(rid, str) or + not PROFILE_REVISION_ID.fullmatch(rid) or rid != exercise_profile_revision(parent, rr, rt, rf))): + raise TrainlogSemanticError("exercise profile-state v1: invalid history chain") + revision_ids.add(rid) + previous = rid + tip = history[-1] + if any(tip[key] != item[key] for key in revision_keys): + raise TrainlogSemanticError("exercise profile-state v1: history tip mismatch") + + def validate_body_zone_catalog(document: Any) -> None: """Validate the one canonical taxonomy, hierarchy and initial mappings.""" if not isinstance(document, dict) or set(document) != { @@ -443,6 +520,7 @@ def validate_document( is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog" is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1" is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2 + is_profile_state = isinstance(document, dict) and document.get("format") == "trainlog-exercise-profile-state" if is_body_zones: try: validate_body_zone_catalog(document) @@ -456,6 +534,12 @@ def validate_document( except ValueError as exc: return [str(exc)] return [] + if is_profile_state: + try: + validate_exercise_profile_state(document) + except TrainlogSemanticError as exc: + return [str(exc)] + return [] if not is_mobile_v2: errors = structural_errors(validator, document) if errors: diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 7ff6e57..edcdf1b 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 15 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 17 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index 0c22631..90ce05b 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -80,6 +80,8 @@ typedef struct TrainlogSessionExerciseInput { /* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */ char equipment_id[TRAINLOG_ID_MAX + 1U]; TrainlogRecordingMode recording_mode; + /** Occurrence-owned unit snapshot; catalogue edits affect future rows only. */ + TrainlogTrackingMode tracking_mode; TrainlogExerciseDataFields data_fields; TrainlogLoadMode load_mode; int rest_seconds; diff --git a/tui/include/trainlog/timeutil.h b/tui/include/trainlog/timeutil.h index 148d5f6..0749c06 100644 --- a/tui/include/trainlog/timeutil.h +++ b/tui/include/trainlog/timeutil.h @@ -21,11 +21,12 @@ TrainlogStatus trainlog_time_now_rfc3339( size_t output_size ); -/* CONTRACT: exercise observations before end use "Pendant la séance" and a - * missing anchor uses "Ressenti"; follow-ups use H+? in both cases. No caller - * can receive a negative H+ label. */ +/* CONTRACT: ended_at yields exact H+ and alone can prove "Pendant la séance". + * If unavailable, a valid nonnegative started_at delta is visibly approximate + * (≈H+); otherwise both feedback kinds use H+?. */ TrainlogStatus trainlog_feedback_relative_label( - const char *ended_at, const char *observed_at, bool exercise_feedback, + const char *ended_at, const char *started_at, const char *observed_at, + bool exercise_feedback, char *output, size_t output_size); #endif diff --git a/tui/meson.build b/tui/meson.build index 17a69fc..c72913d 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -674,6 +674,22 @@ test_feedback_correction = executable( ) test('feedback_correction', test_feedback_correction) +test_schema_v16_migration = executable( + 'test_schema_v16_migration', + 'tests/test_schema_v16_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v16_migration', test_schema_v16_migration) + +python3 = find_program('python3') +test( + 'profile_state_sync', + python3, + args: [meson.project_source_root() / 'tests/test_profile_state_sync.py'], + workdir: meson.project_source_root(), +) + test_app_shell = executable( 'test_app_shell', 'tests/test_app_shell.c', diff --git a/tui/src/database.c b/tui/src/database.c index 9cff06e..b79668c 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -20,6 +20,59 @@ #include #include +#include + +/* CONTRACT: exact parity with Android/Python UUIDv5 over parent, profile tuple. */ +static void sqlite_profile_revision(sqlite3_context *context, int argc, sqlite3_value **argv) +{ + uuid_t namespace_id, revision; + char name[256], uuid_text[37], output[41]; + const char *parent, *recording, *tracking; + int length; + if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT || + sqlite3_value_type(argv[1]) != SQLITE_TEXT || sqlite3_value_type(argv[2]) != SQLITE_TEXT || + sqlite3_value_type(argv[3]) != SQLITE_INTEGER) { sqlite3_result_error(context, "invalid profile revision", -1); return; } + parent = (const char *)sqlite3_value_text(argv[0]); recording = (const char *)sqlite3_value_text(argv[1]); + tracking = (const char *)sqlite3_value_text(argv[2]); + length = snprintf(name, sizeof(name), "%s\n%s\n%s\n%d", parent, recording, tracking, sqlite3_value_int(argv[3])); + if (length < 0 || (size_t)length >= sizeof(name) || uuid_parse("4f4c8ea6-18f6-5e48-9d65-a54a24889149", namespace_id) != 0) { + sqlite3_result_error(context, "profile revision overflow", -1); return; + } + uuid_generate_sha1(revision, namespace_id, name, (size_t)length); + uuid_unparse_lower(revision, uuid_text); + (void)snprintf(output, sizeof(output), "pr2_%s", uuid_text); + sqlite3_result_text(context, output, -1, SQLITE_TRANSIENT); +} + +/* Exact recognizer for the unpublished variable-width prototype token. */ +static void sqlite_prototype_profile_valid(sqlite3_context *context, int argc, + sqlite3_value **argv) +{ + const char *token, *recording, *tracking; + char expected[96]; + int fields, length; + if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT || + sqlite3_value_type(argv[1]) != SQLITE_TEXT || + sqlite3_value_type(argv[2]) != SQLITE_TEXT || + sqlite3_value_type(argv[3]) != SQLITE_INTEGER) { + sqlite3_result_int(context, 0); return; + } + token = (const char *)sqlite3_value_text(argv[0]); + recording = (const char *)sqlite3_value_text(argv[1]); + tracking = (const char *)sqlite3_value_text(argv[2]); + fields = sqlite3_value_int(argv[3]); + if ((strcmp(recording, "sets") != 0 && strcmp(recording, "continuous") != 0) || + (strcmp(tracking, "reps") != 0 && strcmp(tracking, "duration") != 0) || + (strcmp(recording, "continuous") == 0 && strcmp(tracking, "duration") != 0) || + fields < 0 || (fields & ~3) != 0 || + (strcmp(recording, "sets") == 0 && fields != 0)) { + sqlite3_result_int(context, 0); return; + } + length = snprintf(expected, sizeof(expected), "pr1|%s|%s|%d", + recording, tracking, fields); + sqlite3_result_int(context, length >= 0 && (size_t)length < sizeof(expected) && + strcmp(token, expected) == 0); +} TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database) { @@ -55,7 +108,7 @@ TrainlogStatus trainlog_database_scan_generation_history( { static const char *const SQL = "SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode," - "e.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds," + "se.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds," "se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg," "ps.position,ps.reps,ps.weight_kg,mr.max_weight_kg " "FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " @@ -607,6 +660,187 @@ static const char *const MIGRATE_V14_TO_V15_SQL = "SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;" "PRAGMA user_version=15;COMMIT;"; +static TrainlogStatus read_single_int_pragma( + TrainlogDatabase *database, const char *sql, int *output); +static TrainlogStatus execute_sql(TrainlogDatabase *database, const char *sql); + +/* WHY: catalogue profiles describe future capture and may change, while an + * occurrence's performed values must retain their original unit forever. + * CONTRACT: v15 is the sole source schema; backfill copies the linked exercise + * mode exactly once before any later profile edit. INVARIANT: every existing + * row/child/stable identity remains untouched and version 16 is published only + * after all snapshots are valid and foreign keys remain satisfied. */ +static TrainlogStatus migrate_v15_to_v16(TrainlogDatabase *database) +{ + sqlite3_stmt *statement = NULL; + int version = -1; + int invalid = -1; + int source_count = -1; + int migrated_count = -1; + int rc; + if (read_single_int_pragma(database, "PRAGMA user_version;", &version) != + TRAINLOG_STATUS_OK || version != 15) + return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; + if (read_single_int_pragma(database, + "SELECT COUNT(*) FROM session_exercises;", &source_count) != + TRAINLOG_STATUS_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + /* SQLite cannot add a NOT NULL column without a lasting default. Rebuild + * the parent table with FK enforcement temporarily disabled so child rows + * are neither cascaded nor retargeted to a temporary table name. The + * transaction plus the explicit foreign_key_check preserves atomicity. */ + if (execute_sql(database, "PRAGMA foreign_keys=OFF;") != TRAINLOG_STATUS_OK || + execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK || + execute_sql(database, + "CREATE TABLE session_exercises_v16(" + "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," + "tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration'))," + "UNIQUE(session_row_id,position));" + "INSERT INTO session_exercises_v16(" + "id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position," + "load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds," + "target_weight_kg,equipment_id,notes,tracking_mode) " + "SELECT se.id,se.entry_id,se.session_row_id,se.exercise_row_id,se.recording_mode," + "se.data_fields,se.position,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps," + "se.target_duration_seconds,se.target_weight_kg,se.equipment_id,se.notes,e.tracking_mode " + "FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id;" + "DROP TABLE session_exercises;" + "ALTER TABLE session_exercises_v16 RENAME TO session_exercises;") != + TRAINLOG_STATUS_OK) goto rollback; + rc = sqlite3_prepare_v2(database->connection, + "SELECT COUNT(*) FROM session_exercises se LEFT JOIN exercises e " + "ON e.id=se.exercise_row_id WHERE e.id IS NULL OR " + "se.tracking_mode NOT IN('reps','duration') OR " + "se.tracking_mode<>e.tracking_mode;", -1, &statement, NULL); + if (rc != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) goto rollback; + invalid = sqlite3_column_int(statement, 0); + if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto rollback; } + statement = NULL; + if (read_single_int_pragma(database, + "SELECT COUNT(*) FROM session_exercises;", &migrated_count) != + TRAINLOG_STATUS_OK || migrated_count != source_count || invalid != 0 || + read_single_int_pragma(database, "SELECT COUNT(*) FROM pragma_foreign_key_check;", &invalid) != TRAINLOG_STATUS_OK || + invalid != 0 || execute_sql(database, + "PRAGMA user_version=16;COMMIT;") != TRAINLOG_STATUS_OK) goto rollback; + if (execute_sql(database, "PRAGMA foreign_keys=ON;") != TRAINLOG_STATUS_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + return TRAINLOG_STATUS_OK; +rollback: + if (statement != NULL) (void)sqlite3_finalize(statement); + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + (void)sqlite3_exec(database->connection, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +/* WHY: a current catalogue profile is mutable, but timestamps cannot establish + * ancestry across offline peers. CONTRACT: every v16 exercise starts at the + * same explicit legacy root; the first exchanged snapshot may therefore repair + * a pre-contract difference. Later edits store a deterministic child and its + * bounded revision chain, so transitive ancestor and sibling cases are unambiguous. + * INVARIANT: this migration changes no exercise or occurrence value. */ +static const char *const MIGRATE_V16_TO_V17_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE exercise_profile_state(" + "exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," + "revision_id TEXT NOT NULL,parent_revision_id TEXT," + "legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));" + "INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) " + "SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;" + "CREATE TABLE exercise_profile_revisions(" + "exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," + "revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL," + "tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1))," + "PRIMARY KEY(exercise_row_id,revision_id));" + "INSERT INTO exercise_profile_revisions SELECT id,'pr_legacy_v1',NULL,recording_mode,tracking_mode,data_fields,1 FROM exercises;" + "CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN " + "INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) " + "VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);" + "INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);" + "INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;" + "CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises " + "WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) " + "AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN " + "SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;" + "INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;" + "UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;" + "INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;" + "PRAGMA user_version=17;COMMIT;"; + +/* WHY: the published-unreleased v17 prototype may already exist locally. + * CONTRACT: opening it adds the bounded durable lineage table without changing + * user_version or domain rows. INVARIANT: the guarded trigger records local + * edits, while an importer-selected known tip makes the trigger a no-op. */ +static const char *const ENSURE_V17_PROFILE_REVISIONS_SQL = + "DROP TRIGGER IF EXISTS exercise_profile_state_insert;" + "DROP TRIGGER IF EXISTS exercise_profile_state_update;" + "CREATE TABLE IF NOT EXISTS exercise_profile_revisions(" + "exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," + "revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL," + "tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1))," + "PRIMARY KEY(exercise_row_id,revision_id));" + "INSERT OR IGNORE INTO exercise_profile_revisions " + "SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed " + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;" + "CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN " + "INSERT INTO exercise_profile_state VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);" + "INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);" + "INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;" + "CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises " + "WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) " + "AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN " + "SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;" + "INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;" + "UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;" + "INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"; + +/* CONTRACT: normalize only the exact unpublished pr1 current-state shape. + * Validation precedes every mutation, and the caller owns one transaction + * covering compatibility-table setup, conversion, and trigger replacement. */ +static TrainlogStatus normalize_v17_prototype_profiles(TrainlogDatabase *database) +{ + int invalid = 0; + static const char *const VALIDATION_SQL = + "SELECT (" + "(SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s.exercise_row_id " + " WHERE s.revision_id GLOB 'pr1*' AND (s.parent_revision_id IS NOT NULL OR s.legacy_seed<>1 OR " + " trainlog_pr1_valid(s.revision_id,e.recording_mode,e.tracking_mode,e.data_fields)<>1)) +" + "(SELECT COUNT(*) FROM exercise_profile_revisions r LEFT JOIN exercise_profile_state s " + " ON s.exercise_row_id=r.exercise_row_id AND s.revision_id=r.revision_id " + " WHERE r.revision_id GLOB 'pr1*' AND (s.exercise_row_id IS NULL OR r.parent_revision_id IS NOT NULL OR " + " r.legacy_seed<>1 OR r.recording_mode<>(SELECT recording_mode FROM exercises WHERE id=r.exercise_row_id) OR " + " r.tracking_mode<>(SELECT tracking_mode FROM exercises WHERE id=r.exercise_row_id) OR " + " r.data_fields<>(SELECT data_fields FROM exercises WHERE id=r.exercise_row_id))) +" + "(SELECT COUNT(*) FROM exercise_profile_state s WHERE s.revision_id GLOB 'pr1*' AND " + " (SELECT COUNT(*) FROM exercise_profile_revisions r WHERE r.exercise_row_id=s.exercise_row_id)<>1)" + ");"; + static const char *const NORMALIZE_SQL = + "DELETE FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*';" + "INSERT INTO exercise_profile_revisions " + "SELECT e.id,'pr_legacy_v1',NULL,e.recording_mode,e.tracking_mode,e.data_fields,1 " + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id " + "WHERE s.revision_id GLOB 'pr1*';" + "INSERT INTO exercise_profile_revisions " + "SELECT e.id,trainlog_profile_revision('pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields)," + "'pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields,0 " + "FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id " + "WHERE s.revision_id GLOB 'pr1*';" + "UPDATE exercise_profile_state SET parent_revision_id='pr_legacy_v1',legacy_seed=0," + "revision_id=trainlog_profile_revision('pr_legacy_v1'," + "(SELECT recording_mode FROM exercises WHERE id=exercise_row_id)," + "(SELECT tracking_mode FROM exercises WHERE id=exercise_row_id)," + "(SELECT data_fields FROM exercises WHERE id=exercise_row_id)) " + "WHERE revision_id GLOB 'pr1*';"; + if (read_single_int_pragma(database, VALIDATION_SQL, &invalid) != TRAINLOG_STATUS_OK || + invalid != 0) return TRAINLOG_STATUS_DATABASE_ERROR; + return execute_sql(database, NORMALIZE_SQL); +} + static const char *const MIGRATE_V1_TO_V3_SQL = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -1145,13 +1379,6 @@ static TrainlogStatus initialize_or_validate_schema( TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } - if ( - version == - TRAINLOG_DATABASE_SCHEMA_VERSION - ) { - return TRAINLOG_STATUS_OK; - } - if (version == 0) { status = execute_sql( @@ -1197,7 +1424,8 @@ static TrainlogStatus initialize_or_validate_schema( status = execute_sql(database, MIGRATE_V9_TO_V10_SQL); } else if (version == 10) { status = TRAINLOG_STATUS_OK; - } else if (version == 11 || version == 12 || version == 13 || version == 14) { + } else if (version == 11 || version == 12 || version == 13 || version == 14 || + version == 15 || version == 16 || version == 17) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -1352,6 +1580,16 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 15) { status = execute_sql(database, MIGRATE_V14_TO_V15_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 16) { + status = migrate_v15_to_v16(database); + } + if (status == TRAINLOG_STATUS_OK && version < 17) { + status = execute_sql(database, MIGRATE_V16_TO_V17_SQL); + } + if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;"); + if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL); + if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database); + if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "COMMIT;"); if ( status != @@ -1360,7 +1598,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v15" : "migrate database to schema v15", + version == 0 ? "create schema v17" : "migrate database to schema v17", database->connection, SQLITE_ERROR ); @@ -1456,6 +1694,22 @@ TrainlogStatus trainlog_database_open_with_diagnostic( return TRAINLOG_STATUS_DATABASE_ERROR; } + if (sqlite3_create_function(database->connection, "trainlog_profile_revision", 4, + SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_profile_revision, NULL, NULL) != SQLITE_OK) { + set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, + "register profile revision function", database->connection, SQLITE_ERROR); + trainlog_database_close(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (sqlite3_create_function(database->connection, "trainlog_pr1_valid", 4, + SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_prototype_profile_valid, + NULL, NULL) != SQLITE_OK) { + set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, + "register prototype profile validator", database->connection, SQLITE_ERROR); + trainlog_database_close(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) { set_open_diagnostic( output_diagnostic, @@ -1957,7 +2211,6 @@ TrainlogStatus trainlog_database_update_exercise_profiled( sqlite3_int64 row_id; TrainlogStatus status; int rc; - bool profile_changed; const char *tracking; const char *recording; @@ -1973,43 +2226,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled( if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) return TRAINLOG_STATUS_DATABASE_ERROR; - rc = sqlite3_prepare_v2(database->connection, - "SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;", - -1, &statement, NULL); - if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK || - sqlite3_step(statement) != SQLITE_ROW) { - status = TRAINLOG_STATUS_DATABASE_ERROR; - goto rollback; - } - profile_changed = strcmp((const char *)sqlite3_column_text(statement, 0), tracking) != 0 || - strcmp((const char *)sqlite3_column_text(statement, 1), recording) != 0 || - sqlite3_column_int64(statement, 2) != (sqlite3_int64)data_fields; - if (sqlite3_finalize(statement) != SQLITE_OK) { - statement = NULL; - status = TRAINLOG_STATUS_DATABASE_ERROR; - goto rollback; - } - statement = NULL; - if (profile_changed) { - rc = sqlite3_prepare_v2(database->connection, - "SELECT 1 FROM session_exercises WHERE exercise_row_id=?1 LIMIT 1;", - -1, &statement, NULL); - if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) { - status = TRAINLOG_STATUS_DATABASE_ERROR; - goto rollback; - } - rc = sqlite3_step(statement); - if (rc == SQLITE_ROW) { - status = TRAINLOG_STATUS_CONFLICT; - goto rollback; - } - if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { - statement = NULL; - status = TRAINLOG_STATUS_DATABASE_ERROR; - goto rollback; - } - statement = NULL; - } + /* CONTRACT: schema v16 makes tracking semantics occurrence-owned. A + * catalogue profile update therefore controls future occurrences only; + * this transaction never updates session_exercises snapshots. */ rc = sqlite3_prepare_v2(database->connection, "UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3," "recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL); @@ -3050,6 +3269,7 @@ static TrainlogStatus insert_session_exercise( sqlite3_int64 exercise_row_id; const char *load_mode; const char *recording_mode; + const char *tracking_mode; char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY]; const char *entry_id; int rc; @@ -3093,8 +3313,11 @@ static TrainlogStatus insert_session_exercise( input->recording_mode ); + tracking_mode = tracking_mode_to_sql(input->tracking_mode); + if (load_mode == NULL || - recording_mode == NULL) { + recording_mode == NULL || + tracking_mode == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -3195,14 +3418,14 @@ static TrainlogStatus insert_session_exercise( database->connection, "INSERT INTO session_exercises(" "entry_id, session_row_id, exercise_row_id, " - "recording_mode, data_fields, " + "recording_mode, tracking_mode, data_fields, " "position, load_mode, rest_seconds, " "target_sets, target_reps, " "target_duration_seconds, " "target_weight_kg, notes, equipment_id" ") VALUES(" - "?1, ?2, ?3, ?4, ?5, ?6, ?7, " - "?8, ?9, ?10, ?11, ?12, ?13, ?14" + "?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, " + "?9, ?10, ?11, ?12, ?13, ?14, ?15" ");", -1, &statement, @@ -3241,10 +3464,14 @@ static TrainlogStatus insert_session_exercise( ); } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_text(statement, 5, tracking_mode, -1, SQLITE_STATIC); + } + if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 5, + 6, (sqlite3_int64)input->data_fields ); } @@ -3252,7 +3479,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 6, + 7, (sqlite3_int64)position ); } @@ -3260,7 +3487,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_text( statement, - 7, + 8, load_mode, -1, SQLITE_STATIC @@ -3270,30 +3497,17 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_int( statement, - 8, + 9, input->rest_seconds ); } if (rc == SQLITE_OK) { rc = input->target_sets > 0 - ? sqlite3_bind_int( - statement, - 9, - input->target_sets - ) - : sqlite3_bind_null( - statement, - 9 - ); - } - - if (rc == SQLITE_OK) { - rc = input->target_reps > 0 ? sqlite3_bind_int( statement, 10, - input->target_reps + input->target_sets ) : sqlite3_bind_null( statement, @@ -3302,11 +3516,11 @@ static TrainlogStatus insert_session_exercise( } if (rc == SQLITE_OK) { - rc = input->target_duration_seconds > 0 + rc = input->target_reps > 0 ? sqlite3_bind_int( statement, 11, - input->target_duration_seconds + input->target_reps ) : sqlite3_bind_null( statement, @@ -3315,11 +3529,11 @@ static TrainlogStatus insert_session_exercise( } if (rc == SQLITE_OK) { - rc = input->target_has_weight - ? sqlite3_bind_double( + rc = input->target_duration_seconds > 0 + ? sqlite3_bind_int( statement, 12, - input->target_weight_kg + input->target_duration_seconds ) : sqlite3_bind_null( statement, @@ -3327,28 +3541,41 @@ static TrainlogStatus insert_session_exercise( ); } + if (rc == SQLITE_OK) { + rc = input->target_has_weight + ? sqlite3_bind_double( + statement, + 13, + input->target_weight_kg + ) + : sqlite3_bind_null( + statement, + 13 + ); + } + if (rc == SQLITE_OK) { rc = input->notes != NULL && input->notes[0] != '\0' ? sqlite3_bind_text( statement, - 13, + 14, input->notes, -1, SQLITE_TRANSIENT ) : sqlite3_bind_null( statement, - 13 + 14 ); } if (rc == SQLITE_OK) { rc = input->equipment_id[0] != '\0' - ? sqlite3_bind_text(statement, 14, input->equipment_id, -1, + ? sqlite3_bind_text(statement, 15, input->equipment_id, -1, SQLITE_TRANSIENT) - : sqlite3_bind_null(statement, 14); + : sqlite3_bind_null(statement, 15); } if (rc != SQLITE_OK) { @@ -4717,7 +4944,7 @@ TrainlogStatus trainlog_database_get_session_details( static const char *const EXERCISE_SQL = "SELECT " - "e.name, e.tracking_mode, " + "e.name, se.tracking_mode, " "se.recording_mode, se.data_fields, " "se.load_mode, se.rest_seconds, " "COALESCE(se.target_sets, 0), " @@ -5451,7 +5678,7 @@ TrainlogStatus trainlog_database_list_exercise_performance( "s.session_id, " "s.started_at, " "s.session_type, " - "e.tracking_mode, " + "se.tracking_mode, " "se.load_mode, " "ps.id, " "ps.reps, " @@ -5800,7 +6027,7 @@ TrainlogStatus trainlog_database_load_session_editable( "se.id, " "e.exercise_id, " "e.name, " - "e.tracking_mode, " + "se.tracking_mode, " "se.load_mode, " "se.rest_seconds, " "se.target_sets, " @@ -7093,7 +7320,7 @@ TrainlogStatus trainlog_database_list_exercise_occurrences_page( "WHERE e.exercise_id=?1;"; static const char *const HYDRATE_SQL = "SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,'')," - "s.session_type,e.tracking_mode,se.recording_mode,se.data_fields,se.load_mode," + "s.session_type,se.tracking_mode,se.recording_mode,se.data_fields,se.load_mode," "(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id)," "ca.duration_seconds,ca.speed_kmh,ca.distance_km FROM session_exercises se " "JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id " diff --git a/tui/src/sync.c b/tui/src/sync.c index 7bc7cf4..a1887e2 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -61,6 +61,8 @@ static const char *const TRAINING_FEEDBACK_NAME = "trainlog-training-feedback-v2.json"; static const char *const TRAINING_FEEDBACK_V1_NAME = "trainlog-training-feedback-v1.json"; +static const char *const EXERCISE_PROFILE_STATE_NAME = + "trainlog-exercise-profile-state-v1.json"; static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; @@ -96,6 +98,8 @@ static const char *const EXERCISE_ALIASES_LOCAL = "/tmp/trainlog-exercise-aliases-v1.json"; static const char *const TRAINING_FEEDBACK_LOCAL = "/tmp/trainlog-training-feedback-v2.json"; +static const char *const EXERCISE_PROFILE_STATE_LOCAL = + "/tmp/trainlog-exercise-profile-state-v1.json"; static const char *const EXERCISE_BODY_ZONES_RESULT = "/tmp/trainlog-exercise-body-zones-result.txt"; @@ -104,6 +108,8 @@ static const char *const EXERCISE_ALIASES_RESULT = "/tmp/trainlog-exercise-aliases-result.txt"; static const char *const TRAINING_FEEDBACK_RESULT = "/tmp/trainlog-training-feedback-result.txt"; +static const char *const EXERCISE_PROFILE_STATE_RESULT = + "/tmp/trainlog-exercise-profile-state-result.txt"; static const char *const SYNC_REQUEST_LOCAL = "/tmp/trainlog-sync-request-v1.json"; @@ -2652,6 +2658,7 @@ TrainlogStatus trainlog_sync_run( bool run_started = false; bool receipt_published = false; bool mobile_export_has_companions = false; + bool profile_state_received = false; bool retained_mobile_export_is_v2 = false; if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC || @@ -2976,6 +2983,34 @@ TrainlogStatus trainlog_sync_run( goto finalize; } + /* CONTRACT: causal CURRENT-profile state precedes occurrence import, so a + * legitimate direct descendant (including the explicit legacy repair) + * cannot be rejected by the unchanged V3 occurrence validator. */ + status = mobile_export_has_companions + ? sync_receive_current_android_artifact(&device, folder_id, + EXERCISE_PROFILE_STATE_NAME, EXERCISE_PROFILE_STATE_LOCAL, &ignored_size) + : TRAINLOG_STATUS_NOT_FOUND; + if (status == TRAINLOG_STATUS_OK) { + profile_state_received = true; + status = sync_run_python_tool("import_exercise_profile_state.py", + EXERCISE_PROFILE_STATE_LOCAL, database_path, MOBILE_EXPORT_LOCAL, + EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + sync_compose_diagnostic(output->error, sizeof(output->error), + "Android→PC : profils : ", useful[0] ? useful : "import échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + } else if (status != TRAINLOG_STATUS_NOT_FOUND) { + (void)snprintf(output->error, sizeof(output->error), + "Android→PC : lecture profile-state échouée."); + final_status = status; + goto finalize; + } + status = sync_run_python_tool( "import_mobile_export.py", @@ -3005,14 +3040,25 @@ TrainlogStatus trainlog_sync_run( sizeof(useful) ); - sync_compose_diagnostic( - output->error, - sizeof(output->error), - "Android→PC : ", - useful[0] != '\0' - ? useful - : "import mobile échoué" - ); + /* WHY: V3/V2 peers may predate profile-state, so absence alone stays + * compatible. It becomes actionable only when the unchanged mobile + * importer proves that causal profile reconciliation was required. + * INVARIANT: never disguise unrelated import failures as capability + * failures. */ + if (mobile_export_has_companions && !profile_state_received && + strstr(useful, "profil incompatible entre identités") != NULL) { + sync_compose_diagnostic(output->error, sizeof(output->error), + "Android→PC : PROFILE_STATE_COMPANION_MISSING : ", useful); + } else { + sync_compose_diagnostic( + output->error, + sizeof(output->error), + "Android→PC : ", + useful[0] != '\0' + ? useful + : "import mobile échoué" + ); + } final_status = TRAINLOG_STATUS_DATABASE_ERROR; @@ -3024,6 +3070,24 @@ TrainlogStatus trainlog_sync_run( output ); + /* The pre-pass may defer identities introduced by this mobile catalogue. + * This strict pass now requires every state record to resolve. */ + if (profile_state_received) { + status = sync_run_python_tool("import_exercise_profile_state.py", + EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, + EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + sync_compose_diagnostic(output->error, sizeof(output->error), + "Android→PC : vérification profils : ", + useful[0] ? useful : "import échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + } + /* CONTRACT: body zones are one directional-neutral companion. Historic * V2 publishers may omit it; when present beside V3/V2 it is applied after * exercise definitions above established every ID or a source-V2-proven @@ -3139,6 +3203,25 @@ TrainlogStatus trainlog_sync_run( } outbound: + status = sync_run_python_tool("export_exercise_profile_state.py", + EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL, + EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_PROFILE_STATE_EXPORT=PASS") == NULL) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : export profile-state échoué."); + final_status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finalize; + } + status = sync_publish_named(&device, folder_id, EXERCISE_PROFILE_STATE_LOCAL, + EXERCISE_PROFILE_STATE_NAME); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : publication profile-state échouée."); + final_status = status; + goto finalize; + } + status = sync_run_python_tool("export_exercise_aliases.py", EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT, tool_output, sizeof(tool_output)); diff --git a/tui/src/timeutil.c b/tui/src/timeutil.c index 4f9d19c..d84d0e6 100644 --- a/tui/src/timeutil.c +++ b/tui/src/timeutil.c @@ -79,23 +79,33 @@ TrainlogStatus trainlog_time_now_rfc3339( } TrainlogStatus trainlog_feedback_relative_label( - const char *ended_at, const char *observed_at, bool exercise_feedback, + const char *ended_at, const char *started_at, const char *observed_at, + bool exercise_feedback, char *output, size_t output_size) { - TrainlogTimestampKey ended, observed; + TrainlogTimestampKey ended, started, observed; int written; int64_t elapsed; - const char *neutral; if (observed_at == NULL || output == NULL || output_size == 0U) return TRAINLOG_STATUS_INVALID_ARGUMENT; - neutral = exercise_feedback ? "Ressenti" : "H+?"; - if (ended_at == NULL || ended_at[0] == '\0' || - !trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended) || - !trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) { - written = snprintf(output, output_size, "%s", neutral); + if (!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) { + written = snprintf(output, output_size, "H+?"); + } else if (ended_at == NULL || ended_at[0] == '\0' || + !trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended)) { + if (started_at != NULL && started_at[0] != '\0' && + trainlog_timestamp_parse(started_at, strlen(started_at), &started) && + observed.utc_second >= started.utc_second) { + elapsed = observed.utc_second - started.utc_second; + written = snprintf(output, output_size, "≈H+%lld", + (long long)(elapsed / INT64_C(3600))); + } else written = snprintf(output, output_size, "H+?"); } else if (observed.utc_second < ended.utc_second) { + bool within_session = exercise_feedback && started_at != NULL && + started_at[0] != '\0' && + trainlog_timestamp_parse(started_at, strlen(started_at), &started) && + observed.utc_second >= started.utc_second; written = snprintf(output, output_size, "%s", - exercise_feedback ? "Pendant la séance" : "H+?"); + within_session ? "Pendant la séance" : "H+?"); } else { elapsed = observed.utc_second - ended.utc_second; written = snprintf(output, output_size, "H+%lld", diff --git a/tui/src/tui.c b/tui/src/tui.c index 5f75b55..ca697ca 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -2478,18 +2478,20 @@ typedef enum TrainlogExercisePhase { TRAINLOG_EXERCISE_MERGE_PICKER, TRAINLOG_EXERCISE_MERGE_CONFIRM, TRAINLOG_EXERCISE_MERGE_RESULT, + TRAINLOG_EXERCISE_CONFIRM_PROFILE, TRAINLOG_EXERCISE_CONFIRM_DISCARD, TRAINLOG_EXERCISE_MESSAGE } TrainlogExercisePhase; typedef struct TrainlogExerciseController { /* CONTRACT: creation/editing owns one transient value set. An edit keeps - * exercise_id and passes the stored profile back unchanged; referenced - * profile restrictions remain enforced by the database transaction. */ + * exercise_id; incompatible future-profile changes require confirmation + * and never mutate occurrence-owned historical snapshots. */ TrainlogExercisePhase phase; TrainlogExercisePhase return_phase; TrainlogFormField form; TrainlogExercise pending; + TrainlogExercise original; char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U]; char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; size_t secondary_count; @@ -2497,6 +2499,7 @@ typedef struct TrainlogExerciseController { bool editing; bool inline_session; bool dirty; + bool profile_confirmed; bool leaving; TrainlogAppRoute pending_route; char message[192]; @@ -3654,22 +3657,22 @@ static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database, { static const char *const SQL = "SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,ps.position," - "CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position," + "CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END," "ps.weight_kg FROM performed_sets ps " "JOIN session_exercises se ON se.id=ps.session_exercise_row_id " "JOIN sessions s ON s.id=se.session_row_id " "JOIN exercises e ON e.id=se.exercise_row_id " "UNION ALL " "SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,0," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0," "mr.max_weight_kg FROM max_results mr " "JOIN session_exercises se ON se.id=mr.session_exercise_row_id " "JOIN sessions s ON s.id=se.session_row_id " "JOIN exercises e ON e.id=se.exercise_row_id " "UNION ALL " "SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0," "ca.duration_seconds,NULL FROM continuous_activity ca " "JOIN session_exercises se ON se.id=ca.session_exercise_row_id " "JOIN sessions s ON s.id=se.session_row_id " @@ -4593,6 +4596,7 @@ static void exercise_controller_start_edit(TrainlogAppContext *app) TrainlogExerciseController *controller = &app->exercise_controller; (void)memset(controller, 0, sizeof(*controller)); controller->pending = app->exercise_detail; + controller->original = app->exercise_detail; controller->editing = true; controller->phase = TRAINLOG_EXERCISE_NAME; controller->return_phase = TRAINLOG_EXERCISE_NAME; @@ -4706,6 +4710,13 @@ static bool exercise_controller_save(TrainlogAppContext *app) TrainlogStatus status; char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U]; size_t index; + if (controller->editing && !controller->profile_confirmed && + (controller->pending.tracking_mode != controller->original.tracking_mode || + controller->pending.recording_mode != controller->original.recording_mode || + controller->pending.data_fields != controller->original.data_fields)) { + controller->phase = TRAINLOG_EXERCISE_CONFIRM_PROFILE; + return false; + } if (!controller->editing && controller->pending.recording_mode == TRAINLOG_RECORDING_SETS && controller->primary_zone[0] == '\0') { @@ -4812,6 +4823,17 @@ static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app, controller->phase = controller->return_phase; return true; } + if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) { + if (key == 'y' || key == 'Y' || key == '1') { + controller->profile_confirmed = true; + controller->phase = TRAINLOG_EXERCISE_ZONES; + (void)exercise_controller_save(app); + } else if (key == 'n' || key == 'N' || key == '0' || + key == TRAINLOG_KEY_ESCAPE || key == 'q' || key == 'Q') { + controller->phase = TRAINLOG_EXERCISE_ZONES; + } + return true; + } if (controller->phase == TRAINLOG_EXERCISE_ZONES) { const TrainlogBodyZone *zone = zone_count > 0U ? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL; @@ -5068,7 +5090,12 @@ static void app_shell_actions(TrainlogAppContext *app) } if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) { TrainlogExercisePhase phase = app->exercise_controller.phase; - if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) { + if (phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) { + app_shell_add_action(app, "exercise.profile.no", '0', + "0 Non", 1U, TRAINLOG_INTENT_BACK, route); + app_shell_add_action(app, "exercise.profile.yes", '1', + "1 Oui, modifier", 2U, TRAINLOG_INTENT_SAVE, route); + } else if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) { app_shell_add_action(app, "exercise.form.discard", '1', "1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route); app_shell_add_action(app, "exercise.form.resume", '0', @@ -5705,6 +5732,17 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app) "1 abandonner · 0/Échap reprendre"); return; } + if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) { + trainlog_surface_printf(app->content, row++, 2, + "Modifier le mode de suivi ?"); + trainlog_surface_printf(app->content, row++, 2, + "Les anciennes séances resteront inchangées."); + trainlog_surface_printf(app->content, row++, 2, + "Les prochaines utilisations suivront le nouveau profil."); + trainlog_surface_printf(app->content, row, 4, + "0/Échap/q Non · 1 Oui, modifier"); + return; + } if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) { trainlog_surface_printf(app->content, row++, 2, "%s", controller->message); trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir"); @@ -6990,7 +7028,8 @@ static void app_shell_render_content(TrainlogAppContext *app) heading = true; } (void)trainlog_feedback_relative_label(app->session_detail.ended_at, - feedback->observed_at, true, label, sizeof(label)); + app->session_detail.started_at, feedback->observed_at, + true, label, sizeof(label)); trainlog_surface_printf(app->content, row++, 4, "%s %s", label, feedback->raw_text); } @@ -7045,6 +7084,7 @@ static void app_shell_render_content(TrainlogAppContext *app) row < app->layout.content.height; ++followup_index) { char label[32]; (void)trainlog_feedback_relative_label(app->session_detail.ended_at, + app->session_detail.started_at, app->session_followups[followup_index].observed_at, false, label, sizeof(label)); trainlog_surface_printf(app->content, row++, 4, "%s %s", label, diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c index d213a62..f2abe90 100644 --- a/tui/tests/test_body_zones.c +++ b/tui/tests/test_body_zones.c @@ -187,7 +187,11 @@ static int migration_preserves_identity_and_history(void) "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);" - "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,entry_id TEXT,equipment_id TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE," + "session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,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));" "INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde'," "'Leg press','leg press','reps','sets',0);" "INSERT INTO sessions VALUES(3,'se_preserved');" @@ -195,7 +199,7 @@ static int migration_preserves_identity_and_history(void) 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 && version == 15); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17); CHECK(trainlog_database_list_exercise_body_zones(database, "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 2U); diff --git a/tui/tests/test_catalog.c b/tui/tests/test_catalog.c index ce847c0..d3759b0 100644 --- a/tui/tests/test_catalog.c +++ b/tui/tests/test_catalog.c @@ -23,6 +23,20 @@ } \ } while (0) +#define CHECK_OR_CLEANUP(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf( \ + stderr, \ + "CHECK failed at %s:%d: %s\\n", \ + __FILE__, \ + __LINE__, \ + #condition \ + ); \ + goto cleanup; \ + } \ + } while (0) + static bool test_normalization(void) { char first[512]; @@ -76,19 +90,19 @@ static bool test_profiled_creation(void) { TrainlogDatabase *database = NULL; TrainlogExercise walk; - TrainlogExercise exercises[4]; + TrainlogExercise exercises[128]; size_t count = 0U; size_t index; bool found = false; - CHECK( + CHECK_OR_CLEANUP( trainlog_database_open( ":memory:", &database ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( trainlog_catalog_create_exercise_profiled( database, "Marche", @@ -99,22 +113,22 @@ static bool test_profiled_creation(void) ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( walk.recording_mode == TRAINLOG_RECORDING_CONTINUOUS ); - CHECK( + CHECK_OR_CLEANUP( walk.tracking_mode == TRAINLOG_TRACKING_DURATION ); - CHECK( + CHECK_OR_CLEANUP( walk.data_fields == TRAINLOG_EXERCISE_DATA_SPEED_KMH ); - CHECK( + CHECK_OR_CLEANUP( trainlog_catalog_create_exercise_profiled( database, "Profil invalide", @@ -125,33 +139,31 @@ static bool test_profiled_creation(void) ) == TRAINLOG_STATUS_INVALID_ARGUMENT ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_list_exercises( database, exercises, - 4U, + 128U, &count ) == TRAINLOG_STATUS_OK ); - CHECK(count == 1U); - for (index = 0U; index < count; ++index) { if (strcmp( exercises[index].name, "Marche" ) == 0) { - CHECK( + CHECK_OR_CLEANUP( exercises[index].recording_mode == TRAINLOG_RECORDING_CONTINUOUS ); - CHECK( + CHECK_OR_CLEANUP( exercises[index].tracking_mode == TRAINLOG_TRACKING_DURATION ); - CHECK( + CHECK_OR_CLEANUP( exercises[index].data_fields == TRAINLOG_EXERCISE_DATA_SPEED_KMH ); @@ -160,10 +172,14 @@ static bool test_profiled_creation(void) } } - CHECK(found); + CHECK_OR_CLEANUP(found); trainlog_database_close(database); return true; + +cleanup: + trainlog_database_close(database); + return false; } int main(void) diff --git a/tui/tests/test_continuous_detail.c b/tui/tests/test_continuous_detail.c index c202a62..843f1a3 100644 --- a/tui/tests/test_continuous_detail.c +++ b/tui/tests/test_continuous_detail.c @@ -62,6 +62,9 @@ int main(void) exercise.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + exercise.tracking_mode = + TRAINLOG_TRACKING_DURATION; + exercise.data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH; diff --git a/tui/tests/test_continuous_session.c b/tui/tests/test_continuous_session.c index f19a273..49d74a2 100644 --- a/tui/tests/test_continuous_session.c +++ b/tui/tests/test_continuous_session.c @@ -73,6 +73,9 @@ int main(void) exercise.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + exercise.tracking_mode = + TRAINLOG_TRACKING_DURATION; + exercise.data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH; diff --git a/tui/tests/test_exercise_profile_schema.c b/tui/tests/test_exercise_profile_schema.c index a53f920..7fff91a 100644 --- a/tui/tests/test_exercise_profile_schema.c +++ b/tui/tests/test_exercise_profile_schema.c @@ -23,10 +23,24 @@ } \ } while (0) +#define CHECK_OR_CLEANUP(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf( \ + stderr, \ + "CHECK failed at %s:%d: %s\\n", \ + __FILE__, \ + __LINE__, \ + #condition \ + ); \ + goto cleanup; \ + } \ + } while (0) + static bool test_profile_roundtrip(void) { TrainlogDatabase *database = NULL; - TrainlogExercise exercises[4]; + TrainlogExercise exercises[128]; size_t count = 0U; size_t index; bool found_walk = false; @@ -39,7 +53,7 @@ static bool test_profile_roundtrip(void) ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_insert_exercise( database, "ex_legacy", @@ -49,7 +63,7 @@ static bool test_profile_roundtrip(void) ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_insert_exercise_profiled( database, "ex_walk", @@ -61,7 +75,7 @@ static bool test_profile_roundtrip(void) ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_insert_exercise_profiled( database, "ex_invalid", @@ -73,7 +87,7 @@ static bool test_profile_roundtrip(void) ) == TRAINLOG_STATUS_INVALID_ARGUMENT ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_insert_exercise_profiled( database, "ex_unknown", @@ -85,28 +99,26 @@ static bool test_profile_roundtrip(void) ) == TRAINLOG_STATUS_INVALID_ARGUMENT ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_list_exercises( database, exercises, - 4U, + 128U, &count ) == TRAINLOG_STATUS_OK ); - CHECK(count == 2U); - for (index = 0U; index < count; ++index) { if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) { - CHECK( + CHECK_OR_CLEANUP( exercises[index].recording_mode == TRAINLOG_RECORDING_CONTINUOUS ); - CHECK( + CHECK_OR_CLEANUP( exercises[index].tracking_mode == TRAINLOG_TRACKING_DURATION ); - CHECK( + CHECK_OR_CLEANUP( exercises[index].data_fields == TRAINLOG_EXERCISE_DATA_SPEED_KMH ); @@ -114,20 +126,24 @@ static bool test_profile_roundtrip(void) } if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) { - CHECK( + CHECK_OR_CLEANUP( exercises[index].recording_mode == TRAINLOG_RECORDING_SETS ); - CHECK(exercises[index].data_fields == 0U); + CHECK_OR_CLEANUP(exercises[index].data_fields == 0U); found_legacy = true; } } - CHECK(found_walk); - CHECK(found_legacy); + CHECK_OR_CLEANUP(found_walk); + CHECK_OR_CLEANUP(found_legacy); trainlog_database_close(database); return true; + +cleanup: + trainlog_database_close(database); + return false; } static bool test_v2_to_current_migration(void) @@ -179,34 +195,36 @@ static bool test_v2_to_current_migration(void) sqlite3 *raw = NULL; sqlite3_stmt *statement = NULL; TrainlogDatabase *database = NULL; - int fd; + int fd = -1; int version = 0; + bool success = false; fd = mkstemp(path); - CHECK(fd >= 0); - CHECK(close(fd) == 0); + CHECK_OR_CLEANUP(fd >= 0); + CHECK_OR_CLEANUP(close(fd) == 0); + fd = -1; - CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK( + CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK_OR_CLEANUP( sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) == SQLITE_OK ); - CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK); raw = NULL; - CHECK( + CHECK_OR_CLEANUP( trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( trainlog_database_schema_version( database, &version ) == TRAINLOG_STATUS_OK ); - CHECK( + CHECK_OR_CLEANUP( version == TRAINLOG_DATABASE_SCHEMA_VERSION ); @@ -214,9 +232,9 @@ static bool test_v2_to_current_migration(void) trainlog_database_close(database); database = NULL; - CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK( + CHECK_OR_CLEANUP( sqlite3_prepare_v2( raw, "SELECT recording_mode, data_fields " @@ -227,18 +245,18 @@ static bool test_v2_to_current_migration(void) ) == SQLITE_OK ); - CHECK(sqlite3_step(statement) == SQLITE_ROW); - CHECK( + CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW); + CHECK_OR_CLEANUP( strcmp( (const char *)sqlite3_column_text(statement, 0), "sets" ) == 0 ); - CHECK(sqlite3_column_int64(statement, 1) == 0); - CHECK(sqlite3_finalize(statement) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0); + CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK); statement = NULL; - CHECK( + CHECK_OR_CLEANUP( sqlite3_prepare_v2( raw, "SELECT recording_mode, data_fields " @@ -249,18 +267,18 @@ static bool test_v2_to_current_migration(void) ) == SQLITE_OK ); - CHECK(sqlite3_step(statement) == SQLITE_ROW); - CHECK( + CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW); + CHECK_OR_CLEANUP( strcmp( (const char *)sqlite3_column_text(statement, 0), "sets" ) == 0 ); - CHECK(sqlite3_column_int64(statement, 1) == 0); - CHECK(sqlite3_finalize(statement) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0); + CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK); statement = NULL; - CHECK( + CHECK_OR_CLEANUP( sqlite3_prepare_v2( raw, "SELECT name FROM sqlite_master " @@ -271,14 +289,23 @@ static bool test_v2_to_current_migration(void) ) == SQLITE_OK ); - CHECK(sqlite3_step(statement) == SQLITE_ROW); - CHECK(sqlite3_finalize(statement) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW); + CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK); + statement = NULL; - CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(unlink(path) == 0); - return true; + success = true; + +cleanup: + if (fd >= 0) (void)close(fd); + if (statement != NULL) (void)sqlite3_finalize(statement); + if (raw != NULL) (void)sqlite3_close(raw); + if (database != NULL) trainlog_database_close(database); + if (!success) (void)unlink(path); + return success; } int main(void) diff --git a/tui/tests/test_feedback_labels.c b/tui/tests/test_feedback_labels.c index 561dd96..351e5a5 100644 --- a/tui/tests/test_feedback_labels.c +++ b/tui/tests/test_feedback_labels.c @@ -2,11 +2,14 @@ #include #include #define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d\n",__LINE__); return 1; } } while (0) -static int label(const char *end,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;} +static int label(const char *end,const char *start,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,start,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;} int main(void){const char *end="2026-09-01T10:00:00+02:00"; - CHECK(label(end,"2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0); - CHECK(label(end,"2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0); - CHECK(label(end,end,true,"H+0")==0);CHECK(label(end,"2026-09-01T18:00:00+02:00",true,"H+8")==0); - CHECK(label(NULL,"2026-09-01T09:20:00+02:00",true,"Ressenti")==0); - CHECK(label(NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0); - CHECK(label(end,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;} + CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0); + CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0); + CHECK(label(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00",true,"H+?")==0); + CHECK(label(end,NULL,end,true,"H+0")==0);CHECK(label(end,NULL,"2026-09-01T18:00:00+02:00",true,"H+8")==0); + CHECK(label(NULL,NULL,"2026-09-01T09:20:00+02:00",true,"H+?")==0); + CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",false,"≈H+10")==0); + CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",true,"≈H+10")==0); + CHECK(label(NULL,NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0); + CHECK(label(end,NULL,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;} diff --git a/tui/tests/test_max_results.c b/tui/tests/test_max_results.c index 67239b9..94ba9d8 100644 --- a/tui/tests/test_max_results.c +++ b/tui/tests/test_max_results.c @@ -56,6 +56,7 @@ static void max_input(TrainlogSessionExerciseInput *input, (void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s", "rear_delt_pec_fly"); input->recording_mode = TRAINLOG_RECORDING_SETS; + input->tracking_mode = TRAINLOG_TRACKING_REPS; input->load_mode = TRAINLOG_LOAD_NONE; input->has_max_weight = true; input->max_weight_kg = weight; @@ -88,6 +89,7 @@ static bool test_explicit_max_round_trip_and_identity(void) (void)snprintf(entries[2].exercise_id, sizeof(entries[2].exercise_id), "%s", "ex_walk"); entries[2].recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + entries[2].tracking_mode = TRAINLOG_TRACKING_DURATION; entries[2].data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH; entries[2].load_mode = TRAINLOG_LOAD_NONE; entries[2].continuous_duration_seconds = 600; @@ -163,16 +165,21 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void) "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_type TEXT);" - "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY," - "session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT," - "equipment_id TEXT,recording_mode TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE," + "session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,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,position INTEGER,reps INTEGER," "duration_seconds INTEGER,weight_kg REAL);" + "INSERT INTO exercises VALUES(1,'ex_max_a','Max A','max a','reps','sets',0);" + "INSERT INTO exercises VALUES(2,'ex_max_b','Max B','max b','reps','sets',0);" + "INSERT INTO exercises VALUES(3,'ex_max_c','Max C','max c','reps','sets',0);" "INSERT INTO sessions VALUES(1,'max_test');" - "INSERT INTO session_exercises VALUES(10,1,NULL,NULL,NULL,'sets');" - "INSERT INTO session_exercises VALUES(11,1,NULL,NULL,NULL,'sets');" - "INSERT INTO session_exercises VALUES(12,1,NULL,NULL,NULL,'sets');" + "INSERT INTO session_exercises VALUES(10,'sxe_max_a',1,1,'sets',0,0,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);" + "INSERT INTO session_exercises VALUES(11,'sxe_max_b',1,2,'sets',0,1,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);" + "INSERT INTO session_exercises VALUES(12,'sxe_max_c',1,3,'sets',0,2,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);" "INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);" "INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);" "INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);" diff --git a/tui/tests/test_schema_v16_migration.c b/tui/tests/test_schema_v16_migration.c new file mode 100644 index 0000000..aeb9fc5 --- /dev/null +++ b/tui/tests/test_schema_v16_migration.c @@ -0,0 +1,227 @@ +/** + * @file test_schema_v16_migration.c + * @brief Occurrence-owned tracking-mode migration and future-profile proof. + */ +#include +#include +#include +#include +#include +#include +#include "trainlog/database.h" + +#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \ + "CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); return false; } } while (0) + +static const char *const FIXTURE = + "PRAGMA foreign_keys=ON;" + "CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT," + "normalized_name TEXT 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 UNIQUE,started_at TEXT," + "ended_at TEXT,session_type TEXT,notes TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT UNIQUE," + "session_row_id INTEGER REFERENCES sessions(id),exercise_row_id INTEGER REFERENCES exercises(id)," + "recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER," + "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 REFERENCES session_exercises(id)," + "position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);" + "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id)," + "duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);" + "CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id),max_weight_kg REAL);" + "CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));" + "CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id),synced_state TEXT);" + "CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);" + "CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);" + "CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT UNIQUE,session_row_id INTEGER REFERENCES sessions(id),observed_at TEXT,raw_text TEXT);" + "CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT REFERENCES session_followups(followup_id),created_at TEXT,raw_text TEXT);" + "INSERT INTO exercises VALUES(7,'ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','Planche face sol','planche face sol','reps','sets',0);" + "INSERT INTO exercises VALUES(8,'ex_e32c40a6-72b4-4233-a0db-41b27e990c96','Planche gauche sol','planche gauche sol','reps','sets',0);" + "INSERT INTO exercises VALUES(9,'ex_999fd503-ce89-49f9-979d-c19e6adbe80d','Planche droite sol','planche droite sol','reps','sets',0);" + "INSERT INTO sessions VALUES(11,'se_old','2026-09-01T10:00:00Z',NULL,'training','note');" + "INSERT INTO session_exercises VALUES(13,'sxe_old',11,7,'sets',0,0,'none',30,1,3,NULL,NULL,NULL,'occurrence');" + "INSERT INTO session_exercises VALUES(14,'sxe_left_old',11,8,'sets',0,1,'none',30,1,3,NULL,NULL,NULL,'occurrence gauche');" + "INSERT INTO performed_sets VALUES(17,13,0,3,NULL,NULL);" + "INSERT INTO performed_sets VALUES(18,14,0,3,NULL,NULL);" + "INSERT INTO exercise_feedback VALUES(19,'fb_old',13,'2026-09-01T10:01:00Z','stable');" + "INSERT INTO exercise_feedback_revisions VALUES('fr_old','fb_old','2026-09-01T10:02:00Z','révisé');" + "INSERT INTO session_followups VALUES(23,'fu_old',11,'2026-09-01T12:00:00Z','suivi');" + "INSERT INTO session_followup_revisions VALUES('ur_old','fu_old','2026-09-01T12:01:00Z','suivi révisé');" + "PRAGMA user_version=15;"; + +static bool scalar(sqlite3 *db, const char *sql, int expected) +{ + sqlite3_stmt *s = NULL; bool ok = false; + if (sqlite3_prepare_v2(db, sql, -1, &s, NULL) == SQLITE_OK && + sqlite3_step(s) == SQLITE_ROW) ok = sqlite3_column_int(s, 0) == expected; + (void)sqlite3_finalize(s); return ok; +} + +#undef CHECK +#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \ + "CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); goto cleanup; } } while (0) + +static bool run_test(void) +{ + char path[] = "/tmp/trainlog-v16-XXXXXX"; + int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL; + bool ok = false; + TrainlogPersistedExerciseDetail old[1] = {{0}}; + TrainlogSessionSummary summary; + size_t count = 0U; int version = 0; + TrainlogExercise changed; + TrainlogSetInput new_set = {.duration_seconds = 30}; + TrainlogSessionExerciseInput input = {0}; + TrainlogSessionInput session = {0}; + char diagnostic[512] = {0}; + CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + if (trainlog_database_open_with_diagnostic(path, &db, diagnostic, + sizeof(diagnostic)) != TRAINLOG_STATUS_OK) { + (void)fprintf(stderr, "open diagnostic: %s\n", diagnostic); + CHECK(false); + } + CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17); + trainlog_database_close(db); db = NULL; + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_revisions r ON r.exercise_row_id=e.id AND r.revision_id='pr_legacy_v1' AND r.legacy_seed=1 WHERE r.revision_id IS NULL", 0)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND dflt_value IS NULL", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old' AND tracking_mode='reps'", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3 AND duration_seconds IS NULL", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback WHERE id=19 AND feedback_id='fb_old'", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1)); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol", + "planche face sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, + NULL, NULL, 0U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_update_exercise_profiled(db, "ex_e32c40a6-72b4-4233-a0db-41b27e990c96", "Planche gauche sol", + "planche gauche sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, + NULL, NULL, 0U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_update_exercise_profiled(db, "ex_999fd503-ce89-49f9-979d-c19e6adbe80d", "Planche droite sol", + "planche droite sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, + NULL, NULL, 0U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_get_session_details(db, "se_old", &summary, old, 1U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U && old[0].tracking_mode == TRAINLOG_TRACKING_REPS && + old[0].actual_set_count == 1U && old[0].actual_sets[0].reps == 3); + trainlog_database_free_session_details(old, count); + CHECK(trainlog_database_get_exercise_profile(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", &changed) == TRAINLOG_STATUS_OK); + (void)snprintf(input.entry_id, sizeof(input.entry_id), "sxe_new"); + (void)snprintf(input.exercise_id, sizeof(input.exercise_id), "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac"); + input.recording_mode = changed.recording_mode; input.tracking_mode = changed.tracking_mode; + input.load_mode = TRAINLOG_LOAD_NONE; input.sets = &new_set; input.set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "se_new"); + (void)snprintf(session.started_at, sizeof(session.started_at), "2026-09-02T10:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; session.exercises = &input; session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(db, &session) == TRAINLOG_STATUS_OK); + trainlog_database_close(db); db = NULL; + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_left_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises WHERE exercise_id IN('ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','ex_e32c40a6-72b4-4233-a0db-41b27e990c96','ex_999fd503-ce89-49f9-979d-c19e6adbe80d') AND recording_mode='sets' AND tracking_mode='duration' AND data_fields=0", 3)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_new' AND se.tracking_mode='duration' AND ps.reps IS NULL AND ps.duration_seconds=30", 1)); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + ok = true; +cleanup: + if (raw != NULL) (void)sqlite3_close(raw); + if (db != NULL) trainlog_database_close(db); + if (fd >= 0) (void)close(fd); + if (unlink(path) != 0) ok = false; + return ok; +} + +static bool rollback_on_invalid_backfill(void) +{ + char path[] = "/tmp/trainlog-v16-invalid-XXXXXX"; + int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL; + bool ok = false; + char diagnostic[512] = {0}; + CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "UPDATE exercises SET tracking_mode='invalid';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic, + sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(db == NULL); + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT user_version FROM pragma_user_version", 15)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode'", 0)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old'", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3", 1)); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + ok = true; +cleanup: + if (raw != NULL) (void)sqlite3_close(raw); + if (db != NULL) trainlog_database_close(db); + if (fd >= 0) (void)close(fd); + if (unlink(path) != 0) ok = false; + return ok; +} + +static bool normalize_v17_prototype_in_place(void) +{ + char path[] = "/tmp/trainlog-v17-pr1-XXXXXX"; + int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL; + bool ok = false; + char diagnostic[512] = {0}; + CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK); + trainlog_database_close(db); db = NULL; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;" + "UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;" + "INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|0',NULL,'sets','reps',0,1);", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic, + sizeof(diagnostic)) == TRAINLOG_STATUS_OK); + trainlog_database_close(db); db = NULL; + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr_legacy_v1' AND parent_revision_id IS NULL AND legacy_seed=1", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b' AND parent_revision_id='pr_legacy_v1' AND legacy_seed=0", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*'", 0)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND tracking_mode='reps'", 1)); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + /* A second open is data-idempotent. */ + CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK); + trainlog_database_close(db); db = NULL; + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2)); + CHECK(sqlite3_exec(raw, + "DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;" + "UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|00',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;" + "INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|00',NULL,'sets','reps',0,1);", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic, + sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(db == NULL); + CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1)); + CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1)); + CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; + ok = true; +cleanup: + if (raw != NULL) (void)sqlite3_close(raw); + if (db != NULL) trainlog_database_close(db); + if (fd >= 0) (void)close(fd); + if (unlink(path) != 0) ok = false; + return ok; +} + +int main(void) { return run_test() && rollback_on_invalid_backfill() && + normalize_v17_prototype_in_place() ? 0 : 1; } diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index b18ba91..9b80a0e 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v15: SQLite rc=", - strlen("migrate database to schema v15: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=", + strlen("migrate database to schema v17: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) static bool test_newer_schema_has_application_diagnostic(void) { - char path[] = "/tmp/trainlog-schema-v16-XXXXXX"; + char path[] = "/tmp/trainlog-schema-v17-XXXXXX"; char diagnostic[256]; sqlite3 *raw = NULL; TrainlogDatabase *database = NULL; @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=16;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=18;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 16 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 847cced..5c1b312 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) 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 v15") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_session_edit.c b/tui/tests/test_session_edit.c index c8571cf..515bc94 100644 --- a/tui/tests/test_session_edit.c +++ b/tui/tests/test_session_edit.c @@ -75,6 +75,9 @@ static void bind_reps_exercise( exercise->load_mode = TRAINLOG_LOAD_EXTERNAL; + exercise->recording_mode = TRAINLOG_RECORDING_SETS; + exercise->tracking_mode = TRAINLOG_TRACKING_REPS; + exercise->rest_seconds = 90; exercise->target_sets = 3; exercise->target_reps = 10; @@ -235,6 +238,7 @@ static bool test_load_replace_and_rollback(void) ); CHECK(loaded_exercises[0].set_offset == 0U); CHECK(loaded_exercises[0].set_count == 3U); + CHECK(loaded_exercises[0].tracking_mode == TRAINLOG_TRACKING_REPS); CHECK(loaded_sets[0].reps == 10); CHECK(loaded_sets[2].reps == 8); CHECK(loaded_sets[0].weight_kg > 79.99); diff --git a/tui/tests/test_sync_body_zone_wiring.c b/tui/tests/test_sync_body_zone_wiring.c index 2837ffc..6cab60a 100644 --- a/tui/tests/test_sync_body_zone_wiring.c +++ b/tui/tests/test_sync_body_zone_wiring.c @@ -27,6 +27,8 @@ static const char *const EXERCISE_NAME = "Sync wiring exercise"; static char remote_root[4096]; static const char *remote_mobile_name = "trainlog-mobile-export-v2.json"; static unsigned int remote_mobile_version = 2U; +static const char *remote_tracking_mode = "reps"; +static bool remote_profile_available = true; static bool copy_file(const char *source, const char *target) { @@ -148,7 +150,8 @@ TrainlogStatus trainlog_mtp_list_folder( return TRAINLOG_STATUS_OK; } if (parent_folder_id == 2U) { - if (capacity < 3U) { + size_t count = remote_profile_available ? 4U : 3U; + if (capacity < count) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } set_entry(&output[0], 10U, 2U, remote_mobile_name, false); @@ -156,7 +159,11 @@ TrainlogStatus trainlog_mtp_list_folder( "trainlog-exercise-body-zones-v1.json", false); set_entry(&output[2], 12U, 2U, "trainlog-equipment-associations-v2.json", false); - *output_count = 3U; + if (remote_profile_available) { + set_entry(&output[3], 13U, 2U, + "trainlog-exercise-profile-state-v1.json", false); + } + *output_count = count; return TRAINLOG_STATUS_OK; } *output_count = 0U; @@ -178,7 +185,8 @@ TrainlogStatus trainlog_mtp_receive_file( name = item_id == 10U ? remote_mobile_name : item_id == 11U ? "trainlog-exercise-body-zones-v1.json" : - item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL; + item_id == 12U ? "trainlog-equipment-associations-v2.json" : + item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL; if (name == NULL || local_path == NULL) { return TRAINLOG_STATUS_NOT_FOUND; } @@ -274,12 +282,31 @@ static bool write_artifacts( "{\"format\":\"trainlog-mobile-export\",\"version\":%u," "\"generated_at\":\"2032-01-01T00:00:00+00:00\"," "\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\"," - "\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\"," + "\"recording_mode\":\"sets\",\"tracking_mode\":\"%s\"," "\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}", - remote_mobile_version, mobile_id, EXERCISE_NAME) > 0); + remote_mobile_version, mobile_id, EXERCISE_NAME, + remote_tracking_mode) > 0); } CHECK(fclose(file) == 0); + written = snprintf(path, sizeof(path), + "%s/trainlog-exercise-profile-state-v1.json", remote_root); + CHECK(written >= 0 && (size_t)written < sizeof(path)); + file = fopen(path, "wb"); + CHECK(file != NULL); + CHECK(fprintf(file, + "{\"format\":\"trainlog-exercise-profile-state\",\"version\":1," + "\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"exercises\":[{" + "\"exercise_id\":\"%s\",\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\"," + "\"data_fields\":0,\"load_semantics\":null,\"machine_variant\":null," + "\"machine_provenance\":null,\"scientific_profile_id\":null," + "\"science_state\":\"unresolved\",\"legacy_equipment_id\":null," + "\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,\"legacy_seed\":true," + "\"history\":[{\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null," + "\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,\"legacy_seed\":true}]}]}", + CANONICAL_ID) > 0); + CHECK(fclose(file) == 0); + written = snprintf(path, sizeof(path), "%s/trainlog-exercise-body-zones-v1.json", remote_root); CHECK(written >= 0 && (size_t)written < sizeof(path)); @@ -589,6 +616,32 @@ static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root) return true; } +static bool run_missing_profile_companion_case(const char *case_root) +{ + TrainlogSyncReport report; + char database_path[4096]; + /* A stale local profile file is deliberately left by earlier cases. The + * selected MTP generation omits it, so neither causal pass may consume it. */ + remote_mobile_name = "trainlog-mobile-export-v3.json"; + remote_mobile_version = 3U; + remote_tracking_mode = "duration"; + remote_profile_available = false; + CHECK(prepare_database(case_root, false, false, + database_path, sizeof(database_path))); + CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false)); + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false, + TRAINLOG_SYNC_ANDROID_TO_PC, &report) == + TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(!report.success); + CHECK(strstr(report.error, "PROFILE_STATE_COMPANION_MISSING") != NULL); + CHECK(strstr(report.error, "profil incompatible entre identités") != NULL); + remote_profile_available = true; + remote_tracking_mode = "reps"; + remote_mobile_name = "trainlog-mobile-export-v2.json"; + remote_mobile_version = 2U; + return true; +} + static bool run_all(void) { char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX"; @@ -596,6 +649,7 @@ static bool run_all(void) char case_b[4096]; char case_c[4096]; char case_d[4096]; + char case_e[4096]; char *root = mkdtemp(temporary); CHECK(root != NULL); @@ -605,11 +659,13 @@ static bool run_all(void) CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0); CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0); CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0); + CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0); CHECK(run_success_case(case_a, false)); CHECK(run_success_case(case_b, true)); CHECK(run_unknown_without_proof_case(case_c)); CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d)); + CHECK(run_missing_profile_companion_case(case_e)); (void)puts("PASS production sync body-zone V2 proof wiring"); return true; } diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 26224b0..4fc580d 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -620,6 +620,25 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void) CHECK(trainlog_database_list_exercises(database, listed, 8U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 6U); + + /* Incompatible axes are future defaults only, but still require the + * global explicit confirmation contract with cancel as the safe default. */ + exercise_controller_start_edit(&app); + app.exercise_controller.pending.recording_mode = TRAINLOG_RECORDING_SETS; + app.exercise_controller.pending.tracking_mode = TRAINLOG_TRACKING_DURATION; + app.exercise_controller.phase = TRAINLOG_EXERCISE_ZONES; + CHECK(!exercise_controller_save(&app)); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE); + CHECK(app_shell_dispatch_exercise_controller(&app, 'q')); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES); + CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_CONTINUOUS); + CHECK(!exercise_controller_save(&app)); + CHECK(app_shell_dispatch_exercise_controller(&app, '1')); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MESSAGE); + CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_SETS); + CHECK(app.exercise_detail.tracking_mode == TRAINLOG_TRACKING_DURATION); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE); trainlog_database_close(database); return true; }