diff --git a/.gitignore b/.gitignore index c9a8c99..8ec0f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ local.properties # Local user data /data/ exports/ +.local-backups/ # Python helpers __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index de239a8..6235beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Detailed implementation chronology remains available in Git history and ### Added +- desktop equipment v8: a supplied catalogue generated from + `catalog/equipment-v1.json`, browse/search/detail views, and local custom + equipment creation/selection; occurrence links resolve supplied or local + definitions and show unknown historic references explicitly; + - multi-occurrence session V2: stable per-occurrence `entry_id`, repeated catalogue exercises in one session, per-set actual weights, and occurrence equipment associations across Android, desktop, import and export; @@ -16,6 +21,23 @@ Detailed implementation chronology remains available in Git history and Android-local custom equipment creation, and explicit rejection of unknown equipment identities rather than silent association loss; +- `trainlog-equipment-definitions` v1, with directional Android-to-PC and + PC-to-Android filenames, strict stable definition fields, additive + reconciliation, reserved supplied-manifest IDs, and explicit divergence + conflicts; +- explicit TUI/shared-engine synchronization actions: `a` Android -> PC, `p` + PC -> Android, and `b` inbound then outbound bidirectional synchronization; + each opens one direction-specific confirmation, `Enter` runs it once, `Esc` + cancels without an operation, `r` only refreshes device status, and the + former `s` shortcut is inert; +- bounded V2 exercise reconciliation for distinct IDs with equal normalized + names: modes and represented invariants must match, field masks must be + comparable, desktop identity stays canonical, and the richer bit-mask union + is retained without changing historical occurrence snapshots; +- regression coverage for identical/different-ID profiles, compatible + subset/superset masks, incompatible collisions, the real Marche identity + pair, full Android -> PC import and stable PC ↔ Android replay; + - Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename, explicit invalid/conflict/profile/database results, and profile locking once completed history or an active draft references the exercise; @@ -27,7 +49,7 @@ Detailed implementation chronology remains available in Git history and - native Kotlin/Compose Android capture client; - Android local exercise, session, continuous-activity, and body persistence; -- C17/ncursesw desktop TUI with direct session entry and durable SQLite history; +- C17/Notcurses desktop TUI with direct session entry and durable SQLite history; - profile-aware exercise model using recording mode, tracking mode, and supplemental fields; - continuous activity persistence without synthetic sets; @@ -52,6 +74,12 @@ Detailed implementation chronology remains available in Git history and ### Changed +- desktop and Android schema v8 preserve existing equipment references while + permitting custom definitions with `load_semantics = none`; Android's v7 -> + v8 migration is non-destructive; +- custom definitions reconcile before V2 mobile/association artifacts in each + direction. The V2 mobile and association shapes are unchanged; + - desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows while adding durable occurrence identities and occurrence-level equipment; - the active Android↔PC completed-session exchange is V2; frozen V1 artifacts @@ -60,9 +88,9 @@ Detailed implementation chronology remains available in Git history and - synchronization invokes each local helper with the explicit XDG-resolved desktop database path and records the concrete equipment-import failure; -- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata - in place and rejects a different-ID normalized-name collision, preserving - synchronization identity and preventing renamed duplicates; +- same-ID Android ↔ PC catalog reconciliation updates display-name metadata in + place. Compatible different-ID normalized-name collisions now merge through + the explicit V2 policy; incompatible collisions still reject atomically; - Android local SQLite v3 -> v4 additive migration for structured active drafts; - completed-session insertion and draft clearing are atomic; drafts remain @@ -83,11 +111,24 @@ Detailed implementation chronology remains available in Git history and ### Fixed +- synchronization summaries no longer add exercise reconciliations to the + `exercices ajoutés` value. A compatible different-ID lookup that makes no + persistent change is reported as an idempotent skip, while real insertions + and real reconciliation mutations retain distinct counters. A production + PC-exporter/Android-importer regression proves zero additions and exact + business-table stability on the second and third imports; - equipment companion import previously omitted its required `--database` argument and blocked synchronization after a successful session import; - PC catalogue/mobile export paths now accept schema v7 and preserve catalogue tracking metadata; Android completed-session equipment editing now targets the stable occurrence `entry_id`, not an ambiguous catalogue exercise ID; +- Android scoped-storage suffix selection now covers equipment associations as + well as mobile/definition artifacts; a historic V1 snapshot cannot consume a + neighboring V2 equipment companion; +- desktop-generated session occurrences now receive `sxe_` rather + than the synchronization-run `sy_` prefix; public generator capacity and C17 + regression coverage include the three-character occurrence prefix; +- TUI footer help now advertises every active direct function key through F5; - in-progress Android workout loss when leaving the foreground or recreating the Activity/process; @@ -100,7 +141,7 @@ Detailed implementation chronology remains available in Git history and - synchronization failures that previously surfaced only as `error=unknown`; - Android folder-selection UX so a wrong SAF folder can be changed without clearing application data; -- libmtp terminal output leaking into ncurses rendering. +- libmtp terminal output leaking into terminal rendering. ### Validation @@ -109,21 +150,23 @@ Current validated baseline: ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V7=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_SCHEMA_V8=PASS +DESKTOP_TESTS=32/32 PASS ANDROID_BUILD=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V7=PASS -ANDROID_HOST_TESTS=8/8 PASS -ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS +ANDROID_LOCAL_DATABASE_V8=PASS +ANDROID_TEST_DEBUG_UNIT=PASS ANDROID_SESSION_DRAFT_V1=PASS USB_MTP_DETECTION=PASS -MTP_ROUNDTRIP=PASS +MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS +CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED +REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS +REAL_DATABASE_APPLICATION=BLOCKED_BY_SANDBOX_WRITE_BOUNDARY -ANDROID_TO_PC_MTP=PASS -PC_TO_ANDROID_MTP_PUBLISH=PASS +ANDROID_TO_PC_MTP=HISTORICAL_PASS +PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS COMMON_SYNC_ENGINE=PASS TRAINLOG_SYNCD=PASS ANDROID_TRIGGERED_SYNC=PASS @@ -132,6 +175,8 @@ TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS MULTI_OCCURRENCE_SESSION_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS +EQUIPMENT_DEFINITIONS_V1=PASS +EXERCISE_RECONCILIATION_V2=PASS ``` ### Measured max v1 diff --git a/README.md b/README.md index c045953..9881557 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ desktop. ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V7=PASS +DESKTOP_SCHEMA_V8=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V7=PASS +ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_SESSION_DRAFT_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS @@ -34,8 +34,10 @@ ANDROID_SYNC_RECEIPT=PASS BIDIRECTIONAL_SYNC_V1=PASS MULTI_OCCURRENCE_SESSION_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS +EQUIPMENT_DEFINITIONS_V1=PASS +EXERCISE_RECONCILIATION_V2=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS ANDROID_BUILD=PASS ``` @@ -109,6 +111,14 @@ occurrence, as do its actual per-set loads. `external` records an applied or machine-displayed load; `assistance` records assistance and is not interpreted as increasing strength. +During V2 synchronization, a different-ID normalized-name collision is merged +only when recording/tracking modes match, every other known invariant is +compatible, and one `data_fields` mask contains the other. The desktop identity +is retained as canonical, the bit-mask union preserves the richer capability, +and historical occurrence snapshots remain unchanged; absent optional values +stay absent. Incomparable profiles remain explicit conflicts. Name equality +alone is never sufficient. + ## Repository layout ```text @@ -147,9 +157,10 @@ export or desktop synchronization as completed sessions. Schema v4 migrates additively from v3, preserving existing capture data. See [Android behavior](docs/android.md) and [validation](docs/tests.md). -The current Android schema is v7. Its additive v4 -> v7 chain adds the shared -equipment catalogue, per-occurrence equipment links and durable occurrence -identities without recreating completed history or the active draft. +The current Android schema is v8. Its additive v4 -> v8 chain adds the shared +equipment catalogue, per-occurrence equipment links, durable occurrence +identities, and custom-equipment definition support without recreating completed +history or the active draft. Exercises can be renamed in place from Android. The `ex_` identity is unchanged; completed history, an active draft, and synchronization therefore @@ -272,5 +283,5 @@ BODY_ANALYTICS_V1=PASS BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS ``` diff --git a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt index ff9eb56..7c2aca7 100644 --- a/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/labfytools/trainlog/ui/SessionDraftUiInstrumentedTest.kt @@ -3,15 +3,21 @@ package com.labfytools.trainlog.ui import android.content.Context import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.labfytools.trainlog.data.ActiveDraftLoadResult import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.CreateExerciseResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraftForm @@ -29,6 +35,10 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SessionDraftUiInstrumentedTest { + private lateinit var marcheExerciseId: String + private lateinit var elevationExerciseId: String + private lateinit var marcheTenExerciseId: String + private val context: Context = ApplicationProvider.getApplicationContext() @@ -159,6 +169,133 @@ class SessionDraftUiInstrumentedTest { } } + @Test + fun exerciseSearchFiltersPrefixIgnoringCaseAccentsAndExtraSpaces() { + resumeAndOpenExerciseSearch() + + compose.onNodeWithTag("exercise-search-input") + .performTextInput("m") + compose.onNodeWithTag("exercise-search-result-$marcheExerciseId") + .performScrollTo() + + compose.onNodeWithTag("exercise-search-input") + .performTextClearance() + compose.onNodeWithTag("exercise-search-input") + .performTextInput(" MAR ") + compose.onNodeWithTag("exercise-search-result-$marcheExerciseId") + .performScrollTo() + + compose.onNodeWithTag("exercise-search-input") + .performTextClearance() + compose.onNodeWithTag("exercise-search-input") + .performTextInput("ele") + compose.onNodeWithTag("exercise-search-result-$elevationExerciseId") + .performScrollTo() + } + + @Test + fun exerciseSearchShowsFullCatalogueAndNoResultState() { + resumeAndOpenExerciseSearch() + + compose.onNodeWithTag("exercise-search-input") + .performTextInput("marche") + compose.onNodeWithTag("exercise-search-results") + .performScrollToIndex(10) + + compose.onNodeWithTag("exercise-search-result-$marcheTenExerciseId") + .performScrollTo() + + compose.onNodeWithTag("exercise-search-input") + .performTextClearance() + compose.onNodeWithTag("exercise-search-input") + .performTextInput("inexistant") + compose.onNodeWithText("Aucun exercice trouvé").assertIsDisplayed() + } + + @Test + fun cancellingSearchKeepsTheConfirmedExerciseAndRawValues() { + resumeAndOpenExerciseSearch() + compose.onNodeWithTag("exercise-search-input") + .performTextInput("mar") + compose.onNodeWithText("Annuler la recherche").performClick() + + compose.onNodeWithTag("exercise-picker-open").assertIsDisplayed() + compose.onNodeWithText("4,5,6,") + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun selectingAnExerciseUsesItsIdentityAndAllowsASecondPassage() { + resumeAndOpenExerciseSearch() + selectMarche() + assertMarcheFormSelected() + compose.onNodeWithText("Ajouter à la séance") + .performScrollTo() + .performClick() + + compose.onNodeWithTag("exercise-picker-open").performClick() + selectMarche() + assertMarcheFormSelected() + compose.onNodeWithText("Ajouter à la séance") + .performScrollTo() + .performClick() + + TrainlogRepository(context, DRAFT_UI_TEST_DATABASE_NAME).useForTest { repository -> + val draft = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + val marches = draft.exercises.filter { it.exercise.name == "Marche" } + assertEquals(2, marches.size) + assertTrue(marches.map { it.entryId }.distinct().size == 2) + } + } + + @Test + fun changingAnEditedEntryReplacesOnlyThatEntry() { + compose.onNodeWithText("Reprendre la séance en cours").performClick() + compose.onNodeWithText("Modifier Test UI") + .performScrollTo() + .performClick() + resumeAndOpenExerciseSearch(alreadyResumed = true) + selectMarche() + assertMarcheFormSelected() + compose.onNodeWithText("Ajouter à la séance") + .performScrollTo() + .performClick() + + TrainlogRepository(context, DRAFT_UI_TEST_DATABASE_NAME).useForTest { repository -> + val draft = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals(1, draft.exercises.size) + assertEquals("Marche", draft.exercises.single().exercise.name) + assertTrue(draft.exercises.single().entryId.startsWith("sxe_")) + } + } + + private fun resumeAndOpenExerciseSearch(alreadyResumed: Boolean = false) { + if (!alreadyResumed) { + compose.onNodeWithText("Reprendre la séance en cours").performClick() + } + compose.onNodeWithTag("exercise-picker-open") + .performScrollTo() + .performClick() + } + + private fun selectMarche() { + compose.onNodeWithTag("exercise-search-input") + .performTextClearance() + compose.onNodeWithTag("exercise-search-input") + .performTextInput("marche") + compose.onNodeWithTag("exercise-search-result-$marcheExerciseId") + .performScrollTo() + .performClick() + } + + private fun assertMarcheFormSelected() { + compose.waitForIdle() + compose.onNodeWithText("SAISIE — Marche") + .performScrollTo() + .assertIsDisplayed() + } + private fun seedDraft() { context.deleteDatabase( DRAFT_UI_TEST_DATABASE_NAME @@ -178,6 +315,24 @@ class SessionDraftUiInstrumentedTest { dataFields = 0, ) ) as CreateExerciseResult.Created + marcheExerciseId = + createExercise(repository, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) + .exerciseId + elevationExerciseId = + createExercise(repository, "Élévation", RecordingMode.SETS, TrackingMode.REPS) + .exerciseId + (1..10).forEach { number -> + val exercise = + createExercise( + repository, + "Marche %02d".format(number), + RecordingMode.SETS, + TrackingMode.REPS, + ) + if (number == 10) { + marcheTenExerciseId = exercise.exerciseId + } + } assertEquals( ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft( @@ -202,6 +357,25 @@ class SessionDraftUiInstrumentedTest { ) } } + + private fun createExercise( + repository: TrainlogRepository, + name: String, + recordingMode: RecordingMode, + trackingMode: TrackingMode, + ): ExerciseProfile { + val result = + repository.createExercise( + NewExerciseProfile( + name = name, + recordingMode = recordingMode, + trackingMode = trackingMode, + dataFields = 0, + ), + ) + assertTrue(result is CreateExerciseResult.Created) + return (result as CreateExerciseResult.Created).exercise + } } private inline fun TrainlogRepository.useForTest( diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt b/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt index e5de256..bb74329 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt @@ -6,6 +6,7 @@ import java.text.Normalizer import java.util.Locale enum class EquipmentLoadSemantics { + NONE, EXTERNAL, ASSISTANCE, BODYWEIGHT, 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 8f71e97..5f62060 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 @@ -129,6 +129,9 @@ class SyncCatalogInbox( it.readText() } + val definitionsError = importPcEquipmentDefinitions(directory) + if (definitionsError != null) return CatalogInboxResult.Error(definitionsError) + when ( val result = repository @@ -183,6 +186,20 @@ class SyncCatalogInbox( } catch (error: Exception) { error.message ?: "Import séances V2 impossible." } } + private fun importPcEquipmentDefinitions(directory: DocumentFile): String? { + val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null + return try { + val json = appContext.contentResolver.openInputStream(file.uri) + ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } + ?: return "Lecture définitions équipement impossible." + when (val result = repository.applyPcEquipmentDefinitionsJson(json)) { + is EquipmentDefinitionImportResult.Applied -> null + is EquipmentDefinitionImportResult.Invalid -> result.message + EquipmentDefinitionImportResult.DatabaseError -> "Erreur base locale définitions équipement." + } + } catch (error: Exception) { error.message ?: "Import définitions équipement impossible." } + } + private fun importPcEquipmentAssociations(directory: DocumentFile): String? { val file = directory.findFile("trainlog-equipment-associations-v2.json") ?: directory.findFile("trainlog-equipment-associations-v1.json") 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 9d5ac4d..d2115fc 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 @@ -39,14 +39,26 @@ class SyncExporter( return SyncExportResult.Unsupported } - /* V2 is the authoritative mobile session exchange. V1 remains - * readable by desktop for historic devices but is not published here. */ - val json = repository.buildMobileExportV2Json() - - val bytes = - json.toByteArray( - Charsets.UTF_8 + /* CONTRACT: capture all three artifacts 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 + val mobileJson: String + val associationsJson: String + try { + definitionsJson = repository.buildEquipmentDefinitionsJson() + /* V2 is the authoritative mobile session exchange. V1 remains + * readable by desktop for historic devices but is not published. */ + mobileJson = repository.buildMobileExportV2Json() + associationsJson = repository.buildEquipmentAssociationsJson() + } catch (error: Exception) { + return SyncExportResult.Error( + error.message ?: "Préparation de l'export impossible.", ) + } + + val bytes = mobileJson.toByteArray(Charsets.UTF_8) val resolver = appContext.contentResolver @@ -65,6 +77,11 @@ class SyncExporter( val displayName = "trainlog-mobile-export-v2.json" + /* Definitions are visible before any v2 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, @@ -171,7 +188,7 @@ class SyncExporter( ) } - val companionError = writeEquipmentAssociations() + val companionError = writeEquipmentAssociations(associationsJson) if (companionError != null) { return SyncExportResult.Error(companionError) } @@ -201,7 +218,7 @@ class SyncExporter( } /** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */ - private fun writeEquipmentAssociations(): String? { + 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/" @@ -216,7 +233,7 @@ class SyncExporter( }) ?: return "Création de l'extension équipement impossible." return try { resolver.openOutputStream(uri, "wt")?.use { - it.write(repository.buildEquipmentAssociationsJson().toByteArray(Charsets.UTF_8)) + it.write(json.toByteArray(Charsets.UTF_8)) it.flush() } ?: return "Écriture de l'extension équipement impossible." if (created) resolver.update(uri, ContentValues().apply { @@ -229,6 +246,33 @@ class SyncExporter( } } + 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 findExisting( collection: Uri, displayName: String, 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 b25fb2c..7bfa263 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 @@ -8,6 +8,7 @@ import android.database.sqlite.SQLiteOpenHelper import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationSummary +import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseProfile import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.NewExerciseProfile @@ -67,14 +68,37 @@ sealed interface PcCatalogImportResult { PcCatalogImportResult } +/** Read-only diagnostic emitted by the production PC-catalog importer. */ +data class PcCatalogExerciseDecision( + val exerciseId: String, + val name: String, + val recordingMode: String, + val trackingMode: String, + val dataFields: Int, + val lookup: String, + val decision: String, +) + sealed interface EquipmentAssociationImportResult { data class Applied(val updated: Int) : EquipmentAssociationImportResult data class Invalid(val message: String) : EquipmentAssociationImportResult data object DatabaseError : EquipmentAssociationImportResult } +sealed interface EquipmentDefinitionImportResult { + data class Applied(val imported: Int, val skipped: Int) : EquipmentDefinitionImportResult + data class Invalid(val message: String) : EquipmentDefinitionImportResult + data object DatabaseError : EquipmentDefinitionImportResult +} + sealed interface MobileSessionImportResult { - data class Applied(val sessions: Int) : MobileSessionImportResult + /** CONTRACT: counters describe persistent mutations, not artifact size. */ + data class Applied( + val sessionsAdded: Int, + val sessionsSkipped: Int, + val bodyObservationsAdded: Int, + val bodyObservationsSkipped: Int, + ) : MobileSessionImportResult data class Invalid(val message: String) : MobileSessionImportResult data object DatabaseError : MobileSessionImportResult } @@ -936,6 +960,7 @@ class TrainlogRepository( fun applyPcCatalogJson( json: String, + exerciseDecisionObserver: ((PcCatalogExerciseDecision) -> Unit)? = null, ): PcCatalogImportResult { val root = try { @@ -1043,6 +1068,34 @@ class TrainlogRepository( "data_fields" ) + fun traceDecision(lookup: String, decision: String) { + exerciseDecisionObserver?.invoke( + PcCatalogExerciseDecision( + exerciseId = exerciseId, + name = name, + recordingMode = recording.wireValue, + trackingMode = tracking.wireValue, + dataFields = dataFields, + lookup = lookup, + decision = decision, + ) + ) + } + + if ( + !NewExerciseProfile( + name = name, + recordingMode = recording, + trackingMode = tracking, + dataFields = dataFields, + ).validate() + ) { + traceDecision("not-run:invalid-profile", "conflict") + return PcCatalogImportResult.Invalid( + "Profil catalogue PC invalide pour $name." + ) + } + val byId = findExerciseRow( db, @@ -1054,15 +1107,19 @@ class TrainlogRepository( if (byId != null) { if ( - byId.recordingMode != - recording || - byId.trackingMode != - tracking || - byId.dataFields != - dataFields + !exerciseProfilesAreReconcilable( + byId, + recording, + tracking, + dataFields, + ) ) { + traceDecision( + "exercise_id:${byId.exerciseId}", + "conflict", + ) return PcCatalogImportResult.Invalid( - "Conflit de profil catalogue PC." + "Conflit de profil catalogue PC pour $name." ) } @@ -1075,18 +1132,66 @@ class TrainlogRepository( "normalized_name = ?", arrayOf(normalized), ) - if ( + val mergedNameOwner = nameOwner != null && nameOwner.rowId != byId.rowId - ) { - return PcCatalogImportResult.Invalid( - "Conflit de nom catalogue PC pour $name." + if (mergedNameOwner) { + val retired = checkNotNull(nameOwner) + if ( + !exerciseProfilesAreReconcilable( + byId, + retired.recordingMode, + retired.trackingMode, + retired.dataFields, + ) || + !catalogEquipmentProfilesAreCompatible( + db, + retired.exerciseId, + byId.exerciseId, + ) + ) { + traceDecision( + "exercise_id:${byId.exerciseId};" + + "normalized_name:${retired.exerciseId}", + "conflict", + ) + return PcCatalogImportResult.Invalid( + "Conflit de nom/profil catalogue PC pour $name." + ) + } + + /* CONTRACT: the incoming PC identity is canonical on + * Android. Every row-ID owner is moved before the + * duplicate row disappears; occurrence/session IDs and + * all child values remain unchanged. */ + mergeExerciseRows( + db = db, + canonical = byId, + retired = retired, ) } + val richerFields = + byId.dataFields or dataFields or + (nameOwner?.dataFields ?: 0) + if ( + byId.name == name.trim() && + byId.normalizedName == normalized && + byId.dataFields == richerFields && + !mergedNameOwner + ) { + skipped += 1 + traceDecision( + "exercise_id:${byId.exerciseId}", + "existing-identical", + ) + continue + } + val values = ContentValues().apply { put("name", name.trim()) put("normalized_name", normalized) + put("data_fields", richerFields) } if ( db.update( @@ -1100,6 +1205,15 @@ class TrainlogRepository( } reconciled += 1 + traceDecision( + if (mergedNameOwner) { + "exercise_id:${byId.exerciseId};" + + "normalized_name:${nameOwner.exerciseId}" + } else { + "exercise_id:${byId.exerciseId}" + }, + "existing-reconciled", + ) continue } @@ -1114,42 +1228,57 @@ class TrainlogRepository( if (byName != null) { if ( - byName.recordingMode != - recording || - byName.trackingMode != - tracking || - byName.dataFields != - dataFields + !exerciseProfilesAreReconcilable( + byName, + recording, + tracking, + dataFields, + ) || + !catalogEquipmentProfilesAreCompatible( + db, + byName.exerciseId, + exerciseId, + ) ) { + traceDecision( + "normalized_name:${byName.exerciseId}", + "conflict", + ) return PcCatalogImportResult.Invalid( - "Conflit de profil pour $name." + "Conflit de nom/profil entre ${byName.exerciseId} et $exerciseId." ) } - val values = - ContentValues().apply { - put( - "exercise_id", - exerciseId - ) - - put( - "name", - name - ) - } - - db.update( - "exercises", - values, - "id = ?", - arrayOf( - byName.rowId - .toString() - ) + /* WHY: re-keying the existing Android row preserves every + * completed/draft FK directly. Only the stable catalogue + * identity and its string-keyed equipment metadata move. */ + mergeCatalogEquipmentIdentity( + db, + byName.exerciseId, + exerciseId, ) + val values = ContentValues().apply { + put("exercise_id", exerciseId) + put("name", name.trim()) + put("normalized_name", normalized) + put("data_fields", byName.dataFields or dataFields) + } + if ( + db.update( + "exercises", + values, + "id = ?", + arrayOf(byName.rowId.toString()), + ) != 1 + ) { + return PcCatalogImportResult.DatabaseError + } reconciled += 1 + traceDecision( + "normalized_name:${byName.exerciseId}", + "existing-reconciled", + ) continue } @@ -1193,6 +1322,7 @@ class TrainlogRepository( ) imported += 1 + traceDecision("none", "inserted") } db.setTransactionSuccessful() @@ -1216,11 +1346,99 @@ class TrainlogRepository( private data class ExerciseRow( val rowId: Long, + val exerciseId: String, + val name: String, + val normalizedName: String, val recordingMode: RecordingMode, val trackingMode: TrackingMode, val dataFields: Int, ) + private fun exerciseProfilesAreReconcilable( + row: ExerciseRow, + recordingMode: RecordingMode, + trackingMode: TrackingMode, + dataFields: Int, + ): Boolean = + row.recordingMode == recordingMode && + row.trackingMode == trackingMode && + ( + dataFields and row.dataFields.inv() == 0 || + row.dataFields and dataFields.inv() == 0 + ) + + private fun catalogEquipmentProfilesAreCompatible( + db: SQLiteDatabase, + retiredExerciseId: String, + canonicalExerciseId: String, + ): Boolean = + db.rawQuery( + "SELECT 1 FROM catalog_exercise_equipment retired " + + "JOIN catalog_exercise_equipment canonical " + + "ON canonical.equipment_row_id=retired.equipment_row_id " + + "WHERE retired.exercise_id=? AND canonical.exercise_id=? " + + "AND retired.load_semantics<>canonical.load_semantics LIMIT 1;", + arrayOf(retiredExerciseId, canonicalExerciseId), + ).use { !it.moveToFirst() } + + private fun mergeCatalogEquipmentIdentity( + db: SQLiteDatabase, + retiredExerciseId: String, + canonicalExerciseId: String, + ) { + /* INVARIANT: equal equipment relationships coalesce; a differing load + * semantic was rejected before this helper is called. */ + db.execSQL( + "INSERT OR IGNORE INTO catalog_exercise_equipment(" + + "exercise_id,equipment_row_id,load_semantics) " + + "SELECT ?,equipment_row_id,load_semantics " + + "FROM catalog_exercise_equipment WHERE exercise_id=?;", + arrayOf(canonicalExerciseId, retiredExerciseId), + ) + db.execSQL( + "DELETE FROM catalog_exercise_equipment WHERE exercise_id=?;", + arrayOf(retiredExerciseId), + ) + } + + private fun mergeExerciseRows( + db: SQLiteDatabase, + canonical: ExerciseRow, + retired: ExerciseRow, + ) { + db.execSQL( + "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, retired.rowId), + ) + db.execSQL( + "UPDATE draft_session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, retired.rowId), + ) + db.execSQL( + "UPDATE active_session_draft SET selected_exercise_row_id=? " + + "WHERE selected_exercise_row_id=?;", + arrayOf(canonical.rowId, retired.rowId), + ) + db.execSQL( + "INSERT OR IGNORE INTO exercise_equipment(exercise_row_id,equipment_row_id) " + + "SELECT ?,equipment_row_id FROM exercise_equipment WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, retired.rowId), + ) + db.execSQL( + "DELETE FROM exercise_equipment WHERE exercise_row_id=?;", + arrayOf(retired.rowId), + ) + mergeCatalogEquipmentIdentity( + db, + retired.exerciseId, + canonical.exerciseId, + ) + db.execSQL( + "DELETE FROM exercises WHERE id=?;", + arrayOf(retired.rowId), + ) + } + private fun findExerciseRow( db: SQLiteDatabase, selection: String, @@ -1230,6 +1448,9 @@ class TrainlogRepository( "exercises", arrayOf( "id", + "exercise_id", + "name", + "normalized_name", "recording_mode", "tracking_mode", "data_fields", @@ -1250,9 +1471,12 @@ class TrainlogRepository( return ExerciseRow( rowId = cursor.getLong(0), + exerciseId = cursor.getString(1), + name = cursor.getString(2), + normalizedName = cursor.getString(3), recordingMode = if ( - cursor.getString(1) == + cursor.getString(4) == "continuous" ) { RecordingMode.CONTINUOUS @@ -1261,7 +1485,7 @@ class TrainlogRepository( }, trackingMode = if ( - cursor.getString(2) == + cursor.getString(5) == "duration" ) { TrackingMode.DURATION @@ -1269,7 +1493,7 @@ class TrainlogRepository( TrackingMode.REPS }, dataFields = - cursor.getInt(3), + cursor.getInt(6), ) } } @@ -1511,11 +1735,13 @@ class TrainlogRepository( val root = try { JSONObject(json) } catch (_: Exception) { return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide.") } - if (root.optString("format") != "trainlog-mobile-export" || root.optInt("version", -1) != 2) { - return MobileSessionImportResult.Invalid("Snapshot séances V2 non supporté.") - } - val sessions = root.optJSONArray("sessions") ?: return MobileSessionImportResult.Invalid("Sessions manquantes.") + validatePcMobileExportV2(root)?.let { return MobileSessionImportResult.Invalid(it) } + val sessions = root.getJSONArray("sessions") val db = database.writableDatabase + var sessionsAdded = 0 + var sessionsSkipped = 0 + var bodyObservationsAdded = 0 + var bodyObservationsSkipped = 0 return try { db.beginTransaction() for (i in 0 until sessions.length()) { @@ -1527,15 +1753,34 @@ class TrainlogRepository( if (sessionId.isBlank() || startedAt.isBlank() || type !in setOf("training", "max_test") || entries == null) { return MobileSessionImportResult.Invalid("Session V2 invalide.") } - val rowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { + for (index in 0 until entries.length()) { + val entry = entries.getJSONObject(index) + val exerciseId = entry.optString("exercise_id") + val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(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") + } + } + val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { if (it.moveToFirst()) it.getLong(0) else null - } ?: run { + } + if (existingRowId != null) { + if (pcSessionV2Matches(db, existingRowId, session)) { + sessionsSkipped += 1 + continue + } + return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId") + } + val rowId = run { val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) } db.insertOrThrow("sessions", null, values) } - /* V2 replaces only a same stable session; every entry is - * checked before insertion and entry_id remains global unique. */ - db.delete("session_exercises", "session_row_id=?", arrayOf(rowId.toString())) val seen = mutableSetOf() for (index in 0 until entries.length()) { val entry = entries.getJSONObject(index) @@ -1552,10 +1797,16 @@ class TrainlogRepository( if (recording !in setOf("sets", "continuous") || tracking !in setOf("reps", "duration")) { return MobileSessionImportResult.Invalid("Profil V2 invalide.") } + val equipmentId = if (entry.isNull("equipment_id")) null else entry.optString("equipment_id") + val equipmentRowId = if (equipmentId == null) null else lookupEquipmentRowIdOrNull(db, equipmentId) + if (equipmentId != null && (equipmentId.isBlank() || equipmentRowId == null)) { + return MobileSessionImportResult.Invalid("Équipement V2 inconnu : $equipmentId") + } val values = ContentValues().apply { put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId) put("position", position); put("recording_mode", recording); put("tracking_mode", tracking) - put("data_fields", entry.optInt("data_fields", 0)); put("equipment_row_id", lookupEquipmentRowIdOrNull(db, entry.optString("equipment_id").ifBlank { null })) + put("data_fields", entry.optInt("data_fields", 0)) + if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId) } val occurrence = db.insertOrThrow("session_exercises", null, values) if (recording == "continuous") { @@ -1576,18 +1827,253 @@ class TrainlogRepository( } } } + sessionsAdded += 1 + } + val body = root.optJSONArray("body_observations") + ?: return MobileSessionImportResult.Invalid("Mesures corporelles manquantes.") + val metrics = arrayOf("body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", + "left_arm_cm", "right_arm_cm", "left_forearm_cm", "right_forearm_cm", "left_thigh_cm", "right_thigh_cm", + "left_calf_cm", "right_calf_cm") + for (index in 0 until body.length()) { + val item = body.getJSONObject(index) + val id = item.optString("observation_id") + val observedAt = item.optString("observed_at") + if (id.isBlank() || observedAt.isBlank() || metrics.none { item.has(it) }) { + return MobileSessionImportResult.Invalid("Observation corporelle V2 invalide.") + } + val existing = db.rawQuery( + "SELECT observed_at,${metrics.joinToString(",")} FROM body_observations WHERE observation_id=?", + arrayOf(id)).use { cursor -> + if (!cursor.moveToFirst()) null else List(1 + metrics.size) { column -> + if (cursor.isNull(column)) null else if (column == 0) cursor.getString(column) else cursor.getDouble(column) + } + } + val expected: List = listOf(observedAt) + metrics.map { metric -> + if (item.has(metric)) item.getDouble(metric) else null + } + if (existing != null) { + if (existing != expected) return MobileSessionImportResult.Invalid("Conflit observation corporelle : $id") + bodyObservationsSkipped += 1 + continue + } + db.insertOrThrow("body_observations", null, ContentValues().apply { + put("observation_id", id); put("observed_at", observedAt) + metrics.forEach { metric -> if (item.has(metric)) put(metric, item.getDouble(metric)) } + }) + bodyObservationsAdded += 1 } db.setTransactionSuccessful() - MobileSessionImportResult.Applied(sessions.length()) + MobileSessionImportResult.Applied( + sessionsAdded = sessionsAdded, + sessionsSkipped = sessionsSkipped, + bodyObservationsAdded = bodyObservationsAdded, + bodyObservationsSkipped = bodyObservationsSkipped, + ) } catch (_: Exception) { MobileSessionImportResult.DatabaseError } finally { db.endTransaction() } } + private fun validatePcMobileExportV2(root: JSONObject): String? { + val rootKeys = setOf("format", "version", "generated_at", "exercises", "sessions", "body_observations") + if (!root.hasExactKeys(rootKeys) || root.value("format") != "trainlog-mobile-export" || + !root.value("version").isJsonInt(2, 2) || !root.value("generated_at").isNonemptyJsonString() || + root.value("exercises") !is JSONArray || root.value("sessions") !is JSONArray || + root.value("body_observations") !is JSONArray) { + return "Snapshot séances V2 invalide." + } + 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()) { + val item = exercises.opt(index) as? JSONObject ?: return "Catalogue exercices V2 invalide." + val id = item.value("exercise_id") + if (!item.hasExactKeys(exerciseKeys) || !id.isNonemptyJsonString() || !exerciseIds.add(id as String) || + !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") + val entryBaseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields", + "load_mode", "rest_seconds", "entry_id", "position", "equipment_id") + val sessionIds = mutableSetOf() + val sessions = root.getJSONArray("sessions") + for (sessionIndex in 0 until sessions.length()) { + val session = sessions.opt(sessionIndex) as? JSONObject ?: return "Session V2 invalide." + val sessionId = session.value("session_id") + val entries = session.value("exercises") as? JSONArray + if (!session.hasExactKeys(sessionKeys) || !sessionId.isNonemptyJsonString() || + !sessionIds.add(sessionId as String) || !session.value("started_at").isNonemptyJsonString() || + session.value("session_type") !in setOf("training", "max_test") || entries == null || entries.length() == 0) { + return "Session V2 invalide." + } + val entryIds = mutableSetOf() + val positions = mutableSetOf() + for (entryIndex in 0 until entries.length()) { + val entry = entries.opt(entryIndex) as? JSONObject ?: return "Entrée de séance V2 invalide." + val recording = entry.value("recording_mode") + val tracking = entry.value("tracking_mode") + val expectedKeys = entryBaseKeys + if (recording == "continuous") setOf("continuous") else setOf("sets") + val entryId = entry.value("entry_id") + 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) || entry.value("load_mode") != "none" || !entry.value("rest_seconds").isJsonInt(0, 0) || + !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." + } + 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 (recording == "continuous") { + val continuous = entry.value("continuous") as? JSONObject ?: return "Activité continue V2 invalide." + val allowed = setOf("duration_seconds", "speed_kmh", "distance_km") + val fields = (entry.value("data_fields") as Number).toInt() + if (!continuous.hasOnlyKeys(allowed, setOf("duration_seconds")) || + !continuous.value("duration_seconds").isJsonInt(1, 86400) || + continuous.has("speed_kmh") != (fields and 1 != 0) || + continuous.has("distance_km") != (fields and 2 != 0) || + (continuous.has("speed_kmh") && !continuous.value("speed_kmh").isPositiveJsonNumber()) || + (continuous.has("distance_km") && !continuous.value("distance_km").isPositiveJsonNumber())) { + return "Activité continue V2 invalide." + } + } else { + val sets = entry.value("sets") as? JSONArray ?: return "Séries V2 invalides." + if (sets.length() == 0) return "Séries V2 invalides." + for (setIndex in 0 until sets.length()) { + val set = sets.opt(setIndex) as? JSONObject ?: return "Série V2 invalide." + val valueKey = if (tracking == "reps") "reps" else "duration_seconds" + val minimum = if (tracking == "reps") 0 else 1 + val maximum = if (tracking == "reps") 10000 else 86400 + if (!set.hasOnlyKeys(setOf(valueKey, "weight_kg"), setOf(valueKey)) || + !set.value(valueKey).isJsonInt(minimum, maximum) || + (set.has("weight_kg") && !set.value("weight_kg").isPositiveJsonNumber())) { + return "Série V2 invalide." + } + } + } + } + } + + val metricKeys = setOf("body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", + "left_arm_cm", "right_arm_cm", "left_forearm_cm", "right_forearm_cm", "left_thigh_cm", "right_thigh_cm", + "left_calf_cm", "right_calf_cm") + val bodyIds = mutableSetOf() + val body = root.getJSONArray("body_observations") + for (index in 0 until body.length()) { + val item = body.opt(index) as? JSONObject ?: return "Observation corporelle V2 invalide." + val id = item.value("observation_id") + val presentMetrics = item.keys().asSequence().toSet() intersect metricKeys + if (!item.hasOnlyKeys(metricKeys + setOf("observation_id", "observed_at"), setOf("observation_id", "observed_at")) || + !id.isNonemptyJsonString() || !bodyIds.add(id as String) || !item.value("observed_at").isNonemptyJsonString() || + presentMetrics.isEmpty() || presentMetrics.any { !item.value(it).isPositiveJsonNumber() }) { + return "Observation corporelle V2 invalide." + } + } + null + } catch (_: Exception) { + "Snapshot séances V2 invalide." + } + } + + private fun validJsonProfile(item: JSONObject): Boolean { + val recording = item.value("recording_mode") + val tracking = item.value("tracking_mode") + val dataFields = item.value("data_fields") + return recording in setOf("sets", "continuous") && + tracking in setOf("reps", "duration") && + !(recording == "continuous" && tracking != "duration") && + dataFields.isJsonInt(0, ExerciseDataFields.KNOWN_MASK) && + !(recording == "sets" && (dataFields as Number).toInt() != 0) + } + + private fun JSONObject.value(key: String): Any? = if (has(key)) get(key) else null + private fun JSONObject.hasExactKeys(expected: Set): Boolean = keys().asSequence().toSet() == expected + private fun JSONObject.hasOnlyKeys(allowed: Set, required: Set): Boolean { + val actual = keys().asSequence().toSet() + return actual.all { it in allowed } && required.all { it in actual } + } + private fun Any?.isNonemptyJsonString(): Boolean = this is String && isNotEmpty() + private fun Any?.isJsonInt(minimum: Int, maximum: Int): Boolean { + if (this !is Number) return false + val number = toDouble() + return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum + } + private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0 + + private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean { + val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use { + it.moveToFirst() && it.getString(0) == session.optString("started_at") && it.getString(1) == session.optString("session_type") + } + if (!headerMatches) return false + val incoming = session.optJSONArray("exercises") ?: return false + val rows = mutableListOf() + val metadata = mutableListOf>() + db.rawQuery( + "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,se.tracking_mode,se.data_fields,eq.equipment_id " + + "FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " + + "WHERE se.session_row_id=? ORDER BY se.position", arrayOf(rowId.toString())).use { cursor -> + while (cursor.moveToNext()) { + rows += cursor.getLong(0) + metadata += listOf(cursor.getString(1), cursor.getInt(2), cursor.getString(3), cursor.getString(4), + cursor.getString(5), cursor.getInt(6), if (cursor.isNull(7)) null else cursor.getString(7)) + } + } + if (rows.size != incoming.length()) return false + for (index in rows.indices) { + val item = incoming.getJSONObject(index) + val expected = listOf(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"), + item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1), + if (item.isNull("equipment_id")) null else item.optString("equipment_id")) + if (metadata[index] != expected) return false + if (item.optString("recording_mode") == "continuous") { + val value = item.optJSONObject("continuous") ?: return false + val current = db.rawQuery("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", + arrayOf(rows[index].toString())).use { cursor -> + if (!cursor.moveToFirst()) null else listOf(cursor.getInt(0), if (cursor.isNull(1)) null else cursor.getDouble(1), if (cursor.isNull(2)) null else cursor.getDouble(2)) + } + if (current != listOf(value.optInt("duration_seconds", -1), value.optDoubleOrNull("speed_kmh"), value.optDoubleOrNull("distance_km"))) return false + } else { + val sets = item.optJSONArray("sets") ?: return false + val current = mutableListOf>() + db.rawQuery("SELECT reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", + arrayOf(rows[index].toString())).use { cursor -> while (cursor.moveToNext()) current += listOf( + if (cursor.isNull(0)) null else cursor.getInt(0), if (cursor.isNull(1)) null else cursor.getInt(1), + if (cursor.isNull(2)) null else cursor.getDouble(2)) } + val wanted = (0 until sets.length()).map { setIndex -> sets.getJSONObject(setIndex).let { set -> + listOf(if (set.has("reps")) set.getInt("reps") else null, + if (set.has("duration_seconds")) set.getInt("duration_seconds") else null, + set.optDoubleOrNull("weight_kg")) } } + if (current != wanted) return false + } + } + return true + } + /** * CONTRACT: this companion artifact is deliberately outside frozen mobile - * export v1. `(session_id, exercise_id)` is stable and unambiguous under - * the one-entry-per-exercise-per-session model. `cleared` is intentional - * state, unlike an absent v1 artifact which conveys no equipment signal. + * export v1. Version 2 keys an occurrence by `(session_id, entry_id)`; + * `exercise_id` is corroborating catalogue identity, never an occurrence + * key. `cleared` is intentional state, unlike an absent artifact which + * conveys no equipment signal. */ fun buildEquipmentAssociationsJson(): String { val root = JSONObject() @@ -1618,19 +2104,161 @@ class TrainlogRepository( return root.put("associations", associations).toString() } + /** Snapshot only user-created definitions; bundled manifest rows are never + * exported as mutable data and absence never requests deletion. */ + fun buildEquipmentDefinitionsJson(): String { + val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet() + val items = JSONArray() + listEquipment().filter { it.equipmentId !in supplied }.forEach { entry -> + /* A companion is authoritative metadata, never a repair for an + * arbitrary session reference. Refuse corrupt persisted custom + * rows before publication so desktop never receives a partial + * definition snapshot. */ + check( + entry.equipmentId.isNotBlank() && + entry.displayName.isNotBlank() && + entry.displayName.length <= 120 && + entry.type.isNotBlank() + ) { + "Définition équipement personnalisée invalide : ${entry.equipmentId}" + } + items.put(JSONObject() + .put("equipment_id", entry.equipmentId) + .put("display_name", entry.displayName) + .put("label_name", entry.labelName) + .put("equipment_type", entry.type) + .put("load_semantics", entry.loadSemantics.name.lowercase(Locale.ROOT))) + } + return JSONObject() + .put("format", "trainlog-equipment-definitions") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("equipment", items).toString() + } + + fun applyPcEquipmentDefinitionsJson(json: String): EquipmentDefinitionImportResult { + val root = try { JSONObject(json) } catch (_: Exception) { + return EquipmentDefinitionImportResult.Invalid("Définitions équipement JSON invalides.") + } + if (root.value("format") != "trainlog-equipment-definitions" || !root.value("version").isJsonInt(1, 1) || + root.keys().asSequence().toSet() != setOf("format", "version", "generated_at", "equipment") || + !root.value("generated_at").isNonemptyJsonString()) { + return EquipmentDefinitionImportResult.Invalid("Définitions équipement non supportées.") + } + val items = root.optJSONArray("equipment") + ?: return EquipmentDefinitionImportResult.Invalid("Tableau equipment manquant.") + val supplied = EquipmentCatalog.load(applicationContext).map { it.equipmentId }.toSet() + val parsed = mutableListOf() + val seen = mutableSetOf() + try { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + if (item.keys().asSequence().toSet() != setOf("equipment_id", "display_name", "label_name", "equipment_type", "load_semantics")) { + return EquipmentDefinitionImportResult.Invalid("Définition équipement invalide.") + } + val fields = listOf( + "equipment_id", + "display_name", + "label_name", + "equipment_type", + "load_semantics", + ) + if (fields.any { item.value(it) !is String }) { + return EquipmentDefinitionImportResult.Invalid("Définition équipement invalide.") + } + val id = item.value("equipment_id") as String + val display = item.value("display_name") as String + val label = item.value("label_name") as String + val type = item.value("equipment_type") as String + val semanticsValue = item.value("load_semantics") as String + if (semanticsValue !in setOf("none", "external", "assistance")) { + return EquipmentDefinitionImportResult.Invalid("Sémantique équipement invalide.") + } + val semantics = try { EquipmentLoadSemantics.valueOf(semanticsValue.uppercase(Locale.ROOT)) } + catch (_: Exception) { return EquipmentDefinitionImportResult.Invalid("Sémantique équipement invalide.") } + if (id.isBlank() || !seen.add(id) || id in supplied || display.isBlank() || display.length > 120 || type.isBlank()) { + return EquipmentDefinitionImportResult.Invalid("Identité équipement invalide ou réservée : $id") + } + parsed += EquipmentCatalogEntry(id, label, display, emptyList(), type, semantics) + } + } catch (_: Exception) { return EquipmentDefinitionImportResult.Invalid("Définition équipement invalide.") } + + val db = database.writableDatabase + return try { + var imported = 0 + var skipped = 0 + db.beginTransaction() + for (entry in parsed) { + val existing = db.rawQuery( + "SELECT label_name,display_name,equipment_type,load_semantics FROM equipment WHERE equipment_id=?", + arrayOf(entry.equipmentId)).use { cursor -> + if (cursor.moveToFirst()) listOf(cursor.getString(0), cursor.getString(1), cursor.getString(2), cursor.getString(3)) else null + } + val expected = listOf(entry.labelName, entry.displayName, entry.type, entry.loadSemantics.name.lowercase(Locale.ROOT)) + if (existing != null && existing != expected) { + return EquipmentDefinitionImportResult.Invalid("Conflit définition équipement : ${entry.equipmentId}") + } + if (existing != null) { skipped++; continue } + db.insertOrThrow("equipment", null, ContentValues().apply { + put("equipment_id", entry.equipmentId); put("label_name", entry.labelName) + put("display_name", entry.displayName); put("equipment_type", entry.type) + put("load_semantics", entry.loadSemantics.name.lowercase(Locale.ROOT)) + }) + imported++ + } + db.setTransactionSuccessful() + EquipmentDefinitionImportResult.Applied(imported, skipped) + } catch (_: Exception) { EquipmentDefinitionImportResult.DatabaseError + } finally { db.endTransaction() } + } + fun applyPcEquipmentAssociationsJson(json: String): EquipmentAssociationImportResult { val root = try { JSONObject(json) } catch (_: Exception) { return EquipmentAssociationImportResult.Invalid("Extension équipement JSON invalide.") } - val version = root.optInt("version", -1) - if (root.optString("format") != "trainlog-equipment-associations" || version !in setOf(1, 2)) { + val versionValue = root.value("version") + if (!root.hasExactKeys(setOf("format", "version", "generated_at", "associations")) || + root.value("format") != "trainlog-equipment-associations" || + !versionValue.isJsonInt(1, 2) || !root.value("generated_at").isNonemptyJsonString()) { return EquipmentAssociationImportResult.Invalid("Extension équipement non supportée.") } + val version = (versionValue as Number).toInt() val items = root.optJSONArray("associations") ?: return EquipmentAssociationImportResult.Invalid("Associations équipement manquantes.") val known = listEquipment().map { it.equipmentId }.toSet() + val validatedIdentities = mutableSetOf() + try { + for (index in 0 until items.length()) { + val item = items.opt(index) as? JSONObject + ?: return EquipmentAssociationImportResult.Invalid("Association équipement invalide.") + val sessionId = item.value("session_id") + val exerciseId = item.value("exercise_id") + val entryId = if (version == 2) item.value("entry_id") else null + val state = item.value("state") + val required = mutableSetOf("session_id", "exercise_id", "state") + if (version == 2) required += "entry_id" + if (state == "set") required += "equipment_id" + if (!item.hasExactKeys(required) || !sessionId.isNonemptyJsonString() || + !exerciseId.isNonemptyJsonString() || (version == 2 && !entryId.isNonemptyJsonString()) || + state !in setOf("set", "cleared")) { + return EquipmentAssociationImportResult.Invalid("Association équipement invalide.") + } + val identity = "${sessionId as String}\u0000${if (version == 2) entryId as String else exerciseId as String}" + if (!validatedIdentities.add(identity)) { + return EquipmentAssociationImportResult.Invalid("Association équipement dupliquée.") + } + if (state == "set") { + val equipmentId = item.value("equipment_id") + if (!equipmentId.isNonemptyJsonString() || equipmentId !in known) { + return EquipmentAssociationImportResult.Invalid("Équipement inconnu : $equipmentId") + } + } + } + } catch (_: Exception) { + return EquipmentAssociationImportResult.Invalid("Association équipement invalide.") + } val db = database.writableDatabase - var updated = 0 + val seen = mutableSetOf() db.beginTransaction() try { for (index in 0 until items.length()) { @@ -1639,7 +2267,8 @@ class TrainlogRepository( val exerciseId = item.optString("exercise_id") val entryId = if (version == 2) item.optString("entry_id") else null val state = item.optString("state") - if (sessionId.isBlank() || exerciseId.isBlank() || state !in setOf("set", "cleared") || (version == 2 && entryId.isNullOrBlank())) { + val identity = "$sessionId\u0000${entryId ?: exerciseId}" + if (sessionId.isBlank() || exerciseId.isBlank() || !seen.add(identity) || state !in setOf("set", "cleared") || (version == 2 && entryId.isNullOrBlank())) { return EquipmentAssociationImportResult.Invalid("Association équipement invalide.") } val equipmentId = if (state == "set") item.optString("equipment_id") else null @@ -1648,25 +2277,34 @@ class TrainlogRepository( } val row = db.rawQuery( if (version == 2) { - "SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;" + "SELECT se.id,e.exercise_id,eq.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE s.session_id=? AND se.entry_id=?;" } else { - "SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND e.exercise_id=?;" + "SELECT se.id,eq.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE s.session_id=? AND e.exercise_id=?;" }, if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId), ).use { cursor -> - val first = if (cursor.moveToFirst()) cursor.getLong(0) else null + val first = if (cursor.moveToFirst()) { + if (version == 2) { + Triple(cursor.getLong(0), cursor.getString(1), if (cursor.isNull(2)) null else cursor.getString(2)) + } else { + Triple(cursor.getLong(0), exerciseId, if (cursor.isNull(1)) null else cursor.getString(1)) + } + } else null if (version == 1 && first != null && cursor.moveToNext()) null else first } ?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId") - val values = ContentValues() - if (equipmentId == null) values.putNull("equipment_row_id") else { - val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)!! - values.put("equipment_row_id", equipmentRowId) + if (row.second != exerciseId) { + return EquipmentAssociationImportResult.Invalid("Conflit exercice association : $sessionId/${entryId ?: exerciseId}") } - updated += db.update("session_exercises", values, "id = ?", arrayOf(row.toString())) + if (row.third != equipmentId) { + return EquipmentAssociationImportResult.Invalid("Conflit association équipement : $sessionId/${entryId ?: exerciseId}") + } + /* Equal state is an idempotent replay; association snapshots + * never overwrite divergent local edits. */ + continue } db.setTransactionSuccessful() - return EquipmentAssociationImportResult.Applied(updated) + return EquipmentAssociationImportResult.Applied(0) } catch (_: Exception) { return EquipmentAssociationImportResult.DatabaseError } finally { db.endTransaction() } @@ -2806,6 +3444,9 @@ private fun ContentValues.putOptionalString( if (value == null) putNull(key) else put(key, value) } +private fun JSONObject.optDoubleOrNull(key: String): Double? = + if (has(key) && !isNull(key)) getDouble(key) else null + private fun equipmentAliasNormalize(value: String): String = Normalizer.normalize(value, Normalizer.Form.NFD) .replace("\\p{M}+".toRegex(), "") @@ -2816,6 +3457,10 @@ private const val ANDROID_DATABASE_NAME = "trainlog-android.db" private const val ACTIVE_DRAFT_ID = 1 private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 +private const val ANDROID_LEG_PRESS_LEGACY_ID = + "ex_d68a1af1-7247-4fb3-a48b-da8516906a29" +private const val DESKTOP_LEG_PRESS_CANONICAL_ID = + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" private class TrainlogDatabaseHelper( private val appContext: Context, @@ -2824,7 +3469,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 7, + 8, ) { override fun onConfigure( db: SQLiteDatabase, @@ -2836,6 +3481,13 @@ private class TrainlogDatabaseHelper( ) } + override fun onOpen( + db: SQLiteDatabase, + ) { + super.onOpen(db) + migrateApprovedLegPressIdentity(db) + } + override fun onCreate( db: SQLiteDatabase, ) { @@ -2906,6 +3558,15 @@ private class TrainlogDatabaseHelper( version = 7 } + if (version < 8 && newVersion >= 8) { + /* CONTRACT: desktop custom definitions may explicitly carry + * load_semantics=none. Rebuild the complete equipment reference + * graph with stable row IDs so history and the active draft keep + * pointing at exactly the same equipment records. */ + migrateEquipmentLoadSemanticsToVersionEight(db) + version = 8 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -2914,6 +3575,140 @@ private class TrainlogDatabaseHelper( } } + private data class ExerciseIdentityRow( + val rowId: Long, + val exerciseId: String, + val normalizedName: String, + val recordingMode: String, + val trackingMode: String, + val dataFields: Int, + ) + + private fun exerciseIdentityRow( + db: SQLiteDatabase, + exerciseId: String, + ): ExerciseIdentityRow? = db.rawQuery( + "SELECT id,exercise_id,normalized_name,recording_mode,tracking_mode,data_fields " + + "FROM exercises WHERE exercise_id=?;", + arrayOf(exerciseId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else ExerciseIdentityRow( + rowId = cursor.getLong(0), + exerciseId = cursor.getString(1), + normalizedName = cursor.getString(2), + recordingMode = cursor.getString(3), + trackingMode = cursor.getString(4), + dataFields = cursor.getInt(5), + ) + } + + private fun isApprovedLegPressProfile( + row: ExerciseIdentityRow, + ): Boolean = row.normalizedName == "leg press" && + row.recordingMode == "sets" && + row.trackingMode == "reps" && + row.dataFields == 0 + + /** + * WHY: the user explicitly designated this one Android-created historic + * identity as the desktop Leg press identity. This is intentionally not + * a generic normalized-name reconciliation: every other different-ID name + * collision remains an import conflict. + * + * INVARIANT: row-ID references are retained whenever the canonical row is + * absent, preserving completed/draft entries, sets, loads and equipment. + * If both rows exist, every known reference is moved transactionally before + * the legacy row is deleted, and incompatible catalog equipment metadata + * aborts the transaction rather than being silently chosen. + */ + private fun migrateApprovedLegPressIdentity( + db: SQLiteDatabase, + ) { + val legacy = exerciseIdentityRow(db, ANDROID_LEG_PRESS_LEGACY_ID) + ?: return /* Already migrated or this database never held it. */ + val canonical = exerciseIdentityRow(db, DESKTOP_LEG_PRESS_CANONICAL_ID) + + check(isApprovedLegPressProfile(legacy)) { + "Profil Leg press Android inattendu; migration refusée." + } + if (canonical != null) { + check(isApprovedLegPressProfile(canonical) && + canonical.normalizedName == legacy.normalizedName && + canonical.recordingMode == legacy.recordingMode && + canonical.trackingMode == legacy.trackingMode && + canonical.dataFields == legacy.dataFields) { + "Profil Leg press desktop incompatible; migration refusée." + } + } + + db.beginTransaction() + try { + if (canonical == null) { + /* Keep the legacy exercise row itself: all foreign-key graph + * members therefore retain their row IDs and occurrence IDs. */ + db.execSQL( + "UPDATE catalog_exercise_equipment SET exercise_id=? WHERE exercise_id=?;", + arrayOf(DESKTOP_LEG_PRESS_CANONICAL_ID, ANDROID_LEG_PRESS_LEGACY_ID), + ) + db.execSQL( + "UPDATE exercises SET exercise_id=? WHERE id=?;", + arrayOf(DESKTOP_LEG_PRESS_CANONICAL_ID, legacy.rowId), + ) + } else { + val incompatibleCatalogEquipment = db.rawQuery( + "SELECT 1 FROM catalog_exercise_equipment legacy " + + "JOIN catalog_exercise_equipment canonical " + + "ON canonical.equipment_row_id=legacy.equipment_row_id " + + "WHERE legacy.exercise_id=? AND canonical.exercise_id=? " + + "AND legacy.load_semantics<>canonical.load_semantics LIMIT 1;", + arrayOf(ANDROID_LEG_PRESS_LEGACY_ID, DESKTOP_LEG_PRESS_CANONICAL_ID), + ).use { it.moveToFirst() } + check(!incompatibleCatalogEquipment) { + "Métadonnées équipement Leg press incompatibles; migration refusée." + } + + db.execSQL( + "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, legacy.rowId), + ) + db.execSQL( + "UPDATE draft_session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, legacy.rowId), + ) + db.execSQL( + "UPDATE active_session_draft SET selected_exercise_row_id=? WHERE selected_exercise_row_id=?;", + arrayOf(canonical.rowId, legacy.rowId), + ) + db.execSQL( + "INSERT OR IGNORE INTO exercise_equipment(exercise_row_id,equipment_row_id) " + + "SELECT ?,equipment_row_id FROM exercise_equipment WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, legacy.rowId), + ) + db.execSQL("DELETE FROM exercise_equipment WHERE exercise_row_id=?;", arrayOf(legacy.rowId)) + db.execSQL( + "INSERT OR IGNORE INTO catalog_exercise_equipment(exercise_id,equipment_row_id,load_semantics) " + + "SELECT ?,equipment_row_id,load_semantics FROM catalog_exercise_equipment WHERE exercise_id=?;", + arrayOf(DESKTOP_LEG_PRESS_CANONICAL_ID, ANDROID_LEG_PRESS_LEGACY_ID), + ) + db.execSQL( + "DELETE FROM catalog_exercise_equipment WHERE exercise_id=?;", + arrayOf(ANDROID_LEG_PRESS_LEGACY_ID), + ) + db.execSQL("DELETE FROM exercises WHERE id=?;", arrayOf(legacy.rowId)) + } + + check(exerciseIdentityRow(db, ANDROID_LEG_PRESS_LEGACY_ID) == null) { + "Référence Leg press Android résiduelle après migration." + } + check(exerciseIdentityRow(db, DESKTOP_LEG_PRESS_CANONICAL_ID) != null) { + "Identité Leg press desktop absente après migration." + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + private fun createExerciseTable( db: SQLiteDatabase, ) { @@ -3266,7 +4061,7 @@ private class TrainlogDatabaseHelper( display_name TEXT NOT NULL, equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL - CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio')) + CHECK(load_semantics IN ('none', 'external', 'assistance', 'bodyweight', 'cardio')) ); """.trimIndent(), ) @@ -3299,13 +4094,90 @@ private class TrainlogDatabaseHelper( REFERENCES equipment(id) ON DELETE RESTRICT, load_semantics TEXT NOT NULL - CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio')), + CHECK(load_semantics IN ('none', 'external', 'assistance', 'bodyweight', 'cardio')), PRIMARY KEY(exercise_id, equipment_row_id) ); """.trimIndent(), ) } + private fun migrateEquipmentLoadSemanticsToVersionEight( + db: SQLiteDatabase, + ) { + db.execSQL("PRAGMA defer_foreign_keys = ON;") + + db.execSQL("ALTER TABLE equipment_aliases RENAME TO equipment_aliases_v7;") + db.execSQL("ALTER TABLE exercise_equipment RENAME TO exercise_equipment_v7;") + db.execSQL("ALTER TABLE catalog_exercise_equipment RENAME TO catalog_exercise_equipment_v7;") + db.execSQL("ALTER TABLE performed_sets RENAME TO performed_sets_v7;") + db.execSQL("ALTER TABLE continuous_activity RENAME TO continuous_activity_v7;") + db.execSQL("ALTER TABLE session_exercises RENAME TO session_exercises_v7;") + db.execSQL("ALTER TABLE draft_performed_sets RENAME TO draft_performed_sets_v7;") + db.execSQL("ALTER TABLE draft_continuous_activity RENAME TO draft_continuous_activity_v7;") + db.execSQL("ALTER TABLE draft_session_exercises RENAME TO draft_session_exercises_v7;") + db.execSQL("ALTER TABLE equipment RENAME TO equipment_v7;") + + createEquipmentTables(db) + createSessionTables(db) + createActiveDraftTables(db) + + db.execSQL( + "INSERT INTO equipment(id,equipment_id,label_name,display_name,equipment_type,load_semantics) " + + "SELECT id,equipment_id,label_name,display_name,equipment_type,load_semantics FROM equipment_v7;", + ) + db.execSQL( + "INSERT INTO equipment_aliases(equipment_row_id,alias,normalized_alias) " + + "SELECT equipment_row_id,alias,normalized_alias FROM equipment_aliases_v7;", + ) + db.execSQL( + "INSERT INTO exercise_equipment(exercise_row_id,equipment_row_id) " + + "SELECT exercise_row_id,equipment_row_id FROM exercise_equipment_v7;", + ) + db.execSQL( + "INSERT INTO catalog_exercise_equipment(exercise_id,equipment_row_id,load_semantics) " + + "SELECT exercise_id,equipment_row_id,load_semantics FROM catalog_exercise_equipment_v7;", + ) + db.execSQL( + "INSERT INTO session_exercises(id,session_row_id,exercise_row_id,position,recording_mode," + + "tracking_mode,data_fields,equipment_row_id,entry_id) " + + "SELECT id,session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields," + + "equipment_row_id,entry_id FROM session_exercises_v7;", + ) + db.execSQL( + "INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,duration_seconds,weight_kg) " + + "SELECT id,session_exercise_row_id,position,reps,duration_seconds,weight_kg FROM performed_sets_v7;", + ) + db.execSQL( + "INSERT INTO continuous_activity(id,session_exercise_row_id,duration_seconds,speed_kmh,distance_km) " + + "SELECT id,session_exercise_row_id,duration_seconds,speed_kmh,distance_km FROM continuous_activity_v7;", + ) + db.execSQL( + "INSERT INTO draft_session_exercises(id,draft_id,exercise_row_id,position,recording_mode," + + "tracking_mode,data_fields,equipment_row_id,entry_id) " + + "SELECT id,draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields," + + "equipment_row_id,entry_id FROM draft_session_exercises_v7;", + ) + db.execSQL( + "INSERT INTO draft_performed_sets(id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg) " + + "SELECT id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg FROM draft_performed_sets_v7;", + ) + db.execSQL( + "INSERT INTO draft_continuous_activity(id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km) " + + "SELECT id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km FROM draft_continuous_activity_v7;", + ) + + db.execSQL("DROP TABLE performed_sets_v7;") + db.execSQL("DROP TABLE continuous_activity_v7;") + db.execSQL("DROP TABLE session_exercises_v7;") + db.execSQL("DROP TABLE draft_performed_sets_v7;") + db.execSQL("DROP TABLE draft_continuous_activity_v7;") + db.execSQL("DROP TABLE draft_session_exercises_v7;") + db.execSQL("DROP TABLE equipment_aliases_v7;") + db.execSQL("DROP TABLE exercise_equipment_v7;") + db.execSQL("DROP TABLE catalog_exercise_equipment_v7;") + db.execSQL("DROP TABLE equipment_v7;") + } + private fun addEquipmentReferenceColumns( db: SQLiteDatabase, ) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 5589d95..523acbc 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -11,7 +11,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,6 +22,7 @@ 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 androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.labfytools.trainlog.data.ActiveDraftLoadResult @@ -38,6 +42,8 @@ import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogTypography +import java.text.Normalizer +import java.util.Locale @Composable fun SessionScreen( @@ -230,6 +236,21 @@ fun SessionScreen( ) } + ExercisePicker( + exercises = exercises, + selectedExercise = currentDraft.form.selectedExercise, + onExerciseSelected = { exercise -> + /* CONTRACT: only a tapped catalogue result changes the durable + * form identity. The transient query is never an exercise. */ + persistDraft( + currentDraft.copy( + form = currentDraft.form.copy(selectedExercise = exercise), + ), + null, + ) + }, + ) + TrainlogFrame( title = "SEANCE EN COURS" ) { @@ -317,55 +338,6 @@ fun SessionScreen( } } - TrainlogFrame( - title = "CATALOGUE", - active = - exercises.isNotEmpty(), - ) { - if ( - exercises.isEmpty() - ) { - TrainlogInfo( - "Aucun exercice." - ) - } else { - exercises.forEach { - exercise -> - - val alreadyAdded = - currentDraft.exercises.any { - it.exercise.exerciseId == - exercise.exerciseId - } - - CatalogChoice( - exercise = - exercise, - selected = - currentDraft.form - .selectedExercise - ?.exerciseId == - exercise.exerciseId, - disabled = false, - onClick = { - if (true) { - persistDraft( - currentDraft.copy( - form = - currentDraft.form.copy( - selectedExercise = - exercise - ) - ), - null, - ) - } - }, - ) - } - } - } - val editingExercise = currentDraft.form.selectedExercise @@ -544,8 +516,8 @@ fun SessionScreen( private fun CatalogChoice( exercise: ExerciseProfile, selected: Boolean, - disabled: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val colors = LocalTrainlogColors.current @@ -558,6 +530,7 @@ private fun CatalogChoice( Row( modifier = Modifier + .then(modifier) .fillMaxWidth() .padding(vertical = 2.dp) .background( @@ -568,7 +541,6 @@ private fun CatalogChoice( } ) .clickable( - enabled = !disabled, onClick = onClick, ) .padding( @@ -579,9 +551,7 @@ private fun CatalogChoice( BasicText( text = ( - if (disabled) { - "✓ " - } else if (selected) { + if (selected) { "▌ " } else { " " @@ -593,9 +563,7 @@ private fun CatalogChoice( style = TrainlogTypography.normal.copy( color = - if (disabled) { - colors.muted - } else if (selected) { + if (selected) { colors.warning } else { colors.text @@ -611,6 +579,110 @@ private fun CatalogChoice( } } +/** + * WHY: the session picker keeps query state outside [SessionDraftForm] so + * typing and cancelling are harmless to the selected exercise and raw values. + * CONTRACT: matching is presentation-only prefix matching; it never changes + * stored names or treats query text as a catalogue identity. + * INVARIANT: [exercises] is the complete, stable repository catalogue, so a + * movement already used in this session remains selectable for another entry. + */ +@Composable +private fun ExercisePicker( + exercises: List, + selectedExercise: ExerciseProfile?, + onExerciseSelected: (ExerciseProfile) -> Unit, +) { + val colors = LocalTrainlogColors.current + var searchOpen by remember(selectedExercise?.exerciseId) { + mutableStateOf(selectedExercise == null) + } + var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") } + val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) } + + TrainlogFrame(title = "EXERCICE", active = exercises.isNotEmpty()) { + if (exercises.isEmpty()) { + TrainlogInfo("Aucun exercice.") + return@TrainlogFrame + } + + TrainlogAction( + label = selectedExercise?.name ?: "Choisir un exercice", + description = "Exercice sélectionné. Appuyer pour afficher les suggestions.", + accent = if (selectedExercise == null) colors.muted else colors.success, + modifier = Modifier.testTag("exercise-picker-open"), + onClick = { searchOpen = true }, + ) + + TrainlogInputField( + label = "Rechercher un exercice…", + value = query, + onValueChange = { + query = it + searchOpen = true + }, + testTag = "exercise-search-input", + ) + + if (!searchOpen) { + return@TrainlogFrame + } + + if (results.isEmpty()) { + TrainlogInfo("Aucun exercice trouvé", color = colors.muted) + } else { + /* The viewport stays keyboard-friendly while LazyColumn preserves + * access to every prefix result instead of truncating it. */ + LazyColumn( + modifier = Modifier.heightIn(max = 280.dp).testTag("exercise-search-results"), + ) { + items(results, key = { it.exerciseId }) { exercise -> + CatalogChoice( + exercise = exercise, + selected = selectedExercise?.exerciseId == exercise.exerciseId, + modifier = Modifier.testTag("exercise-search-result-${exercise.exerciseId}"), + onClick = { + onExerciseSelected(exercise) + query = "" + searchOpen = false + }, + ) + } + } + } + + if (selectedExercise != null) { + TrainlogAction( + label = "Annuler la recherche", + description = "Conserver ${selectedExercise.name} et les valeurs saisies.", + accent = colors.muted, + onClick = { + query = "" + searchOpen = false + }, + ) + } + } +} + +internal fun exercisePrefixMatches( + exercises: List, + query: String, +): List { + val normalizedQuery = normalizeExerciseSearchText(query) + return exercises.filter { exercise -> + normalizedQuery.isEmpty() || + normalizeExerciseSearchText(exercise.name).startsWith(normalizedQuery) + } +} + +private fun normalizeExerciseSearchText(value: String): String = + Normalizer.normalize(value, Normalizer.Form.NFD) + .replace("\\p{M}+".toRegex(), "") + .lowercase(Locale.ROOT) + .trim() + .replace("\\s+".toRegex(), " ") + @Composable private fun SessionExerciseForm( key: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index f6101bc..c98aa58 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -225,6 +226,7 @@ fun TrainlogInputField( onValueChange: (String) -> Unit, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + testTag: String? = null, ) { val colors = LocalTrainlogColors.current @@ -276,6 +278,13 @@ fun TrainlogInputField( .onFocusChanged { focused = it.isFocused } + .then( + if (testTag == null) { + Modifier + } else { + Modifier.testTag(testTag) + } + ) .background(colors.surface) .padding( horizontal = 12.dp, 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 451f72f..f6df933 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 @@ -4,6 +4,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.ExerciseProfile @@ -21,11 +22,14 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.io.File +import java.nio.file.Files import java.util.UUID @RunWith(RobolectricTestRunner::class) @@ -225,6 +229,57 @@ class TrainlogRepositoryDraftTest { assertEquals(listOf(12.5, 15.0), detail.exercises.single().sets.map { it.weightKg }) } + @Test + fun androidCustomEquipmentDefinitionsExportWithTheirV2SessionReferences() { + val repo = openRepository() + val first = repo.createCustomEquipment("Presse Android") as CreateEquipmentResult.Created + val second = repo.createCustomEquipment("Tirage Android") as CreateEquipmentResult.Created + val exercise = createExercise(repo, "Développé Android", RecordingMode.SETS, TrackingMode.REPS) + assertTrue(repo.saveSession(SessionDraft(exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + equipmentId = first.equipment.equipmentId, + sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)), + ), + ))) is SaveSessionResult.Saved) + + val definitions = JSONObject(repo.buildEquipmentDefinitionsJson()) + assertEquals("trainlog-equipment-definitions", definitions.getString("format")) + assertEquals(1, definitions.getInt("version")) + val exported = (0 until definitions.getJSONArray("equipment").length()).map { + definitions.getJSONArray("equipment").getJSONObject(it) + } + assertEquals(setOf(first.equipment.equipmentId, second.equipment.equipmentId), + exported.map { it.getString("equipment_id") }.toSet()) + assertTrue(exported.none { it.getString("equipment_id") == "leg_press" }) + assertTrue(exported.all { item -> + item.keys().asSequence().toSet() == setOf( + "equipment_id", "display_name", "label_name", "equipment_type", "load_semantics", + ) && item.getString("display_name").isNotBlank() && + item.getString("equipment_type") == "custom_machine" && + item.getString("load_semantics") == "external" + }) + + val mobile = JSONObject(repo.buildMobileExportV2Json()) + val entry = mobile.getJSONArray("sessions").getJSONObject(0) + .getJSONArray("exercises").getJSONObject(0) + assertEquals(first.equipment.equipmentId, entry.getString("equipment_id")) + + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("UPDATE equipment SET display_name='' WHERE equipment_id=?", arrayOf(second.equipment.equipmentId)) + } + try { + repo.buildEquipmentDefinitionsJson() + fail("Une définition persistée invalide doit empêcher l'export.") + } catch (_: IllegalStateException) { + /* Expected: exporting a partial custom-definition snapshot is forbidden. */ + } + } + @Test fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() { val repo = openRepository() @@ -260,7 +315,7 @@ class TrainlogRepositoryDraftTest { } @Test - fun catalogIdentityReconciliationKeepsDraftAndRawForm() { + fun catalogCompatibleDifferentIdentityRekeysDraftAndKeepsRawForm() { val repo = openRepository() val local = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION) val raw = SessionDraftForm( @@ -297,12 +352,181 @@ class TrainlogRepositoryDraftTest { .put("data_fields", 0) ) ) - assertTrue(repo.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals( + PcCatalogImportResult.Applied(imported = 0, reconciled = 1, skipped = 0), + repo.applyPcCatalogJson(catalog.toString()), + ) val restored = loadDraft(repo) assertEquals(canonicalId, restored.exercises.single().exercise.exerciseId) assertEquals(canonicalId, restored.form.selectedExercise?.exerciseId) assertEquals("12,", restored.form.durationText) assertEquals("5,", restored.form.speedText) + assertFalse(repo.listExercises().any { it.exerciseId == local.exerciseId }) + assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId }) + } + + @Test + fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() { + val repo = openRepository() + val androidId = "ex_23212d79-52ce-4195-914d-dd983f133936" + val desktopId = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d" + val androidCatalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", androidId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put( + "data_fields", + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + ), + ), + ) + assertTrue(repo.applyPcCatalogJson(androidCatalog.toString()) is PcCatalogImportResult.Applied) + val local = repo.listExercises().single { it.exerciseId == androidId } + val customEquipment = + (repo.createCustomEquipment("Tapis Marche personnalisé") as + CreateEquipmentResult.Created).equipment + val saved = repo.saveSession( + SessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = local, + equipmentId = customEquipment.equipmentId, + continuousDurationSeconds = 900, + speedKmh = 5.5, + distanceKm = 1.2, + ), + SessionExerciseDraft( + exercise = local, + equipmentId = customEquipment.equipmentId, + continuousDurationSeconds = 300, + speedKmh = 4.0, + distanceKm = 0.3, + ), + ), + ), + ) as SaveSessionResult.Saved + assertTrue( + repo.saveBodyObservation( + BodyObservationDraft(bodyWeightKg = 83.7, waistCm = 84.4), + ) is SaveBodyObservationResult.Saved, + ) + val before = repo.getSessionDetail(saved.sessionId)!!.exercises + val entryIds = before.map { it.entryId } + + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", desktopId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", ExerciseDataFields.SPEED_KMH), + ), + ) + + assertEquals( + PcCatalogImportResult.Applied(imported = 0, reconciled = 1, skipped = 0), + repo.applyPcCatalogJson(catalog.toString()), + ) + val canonical = repo.listExercises().single { it.exerciseId == desktopId } + assertEquals( + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + canonical.dataFields, + ) + assertFalse(repo.listExercises().any { it.exerciseId == androidId }) + val after = repo.getSessionDetail(saved.sessionId)!!.exercises + assertEquals(entryIds, after.map { it.entryId }) + assertEquals(listOf(900, 300), after.map { it.continuousDurationSeconds }) + assertEquals(listOf(5.5, 4.0), after.map { it.speedKmh }) + assertEquals(listOf(1.2, 0.3), after.map { it.distanceKm }) + assertTrue(after.all { it.equipmentDisplayName == customEquipment.displayName }) + assertTrue(after.all { it.exerciseId == desktopId }) + + /* F: exercise the real Android consumers in outbound protocol order, + * then prove that a second complete state is semantically stable. */ + val definitions = JSONObject(repo.buildEquipmentDefinitionsJson()) + val mobile = JSONObject(repo.buildMobileExportV2Json()) + val associations = JSONObject(repo.buildEquipmentAssociationsJson()) + val beforeSemantic = listOf( + definitions.getJSONArray("equipment").toString(), + mobile.getJSONArray("exercises").toString(), + mobile.getJSONArray("sessions").toString(), + mobile.getJSONArray("body_observations").toString(), + associations.getJSONArray("associations").toString(), + ) + assertEquals( + EquipmentDefinitionImportResult.Applied(imported = 0, skipped = 1), + repo.applyPcEquipmentDefinitionsJson(definitions.toString()), + ) + assertEquals( + PcCatalogImportResult.Applied(imported = 0, reconciled = 0, skipped = 1), + repo.applyPcCatalogJson(catalog.toString()), + ) + assertEquals( + MobileSessionImportResult.Applied(0, 1, 0, 1), + repo.applyPcMobileExportV2Json(mobile.toString()), + ) + assertEquals( + EquipmentAssociationImportResult.Applied(0), + repo.applyPcEquipmentAssociationsJson(associations.toString()), + ) + val replayedDefinitions = JSONObject(repo.buildEquipmentDefinitionsJson()) + val replayedMobile = JSONObject(repo.buildMobileExportV2Json()) + val replayedAssociations = JSONObject(repo.buildEquipmentAssociationsJson()) + assertEquals( + beforeSemantic, + listOf( + replayedDefinitions.getJSONArray("equipment").toString(), + replayedMobile.getJSONArray("exercises").toString(), + replayedMobile.getJSONArray("sessions").toString(), + replayedMobile.getJSONArray("body_observations").toString(), + replayedAssociations.getJSONArray("associations").toString(), + ), + ) + assertEquals(1, repo.listExercises().count { it.exerciseId == desktopId }) + assertEquals(entryIds, repo.getSessionDetail(saved.sessionId)!!.exercises.map { it.entryId }) + } + + @Test + fun catalogNameEqualityDoesNotMergeIncomparableDataFields() { + val repo = openRepository() + val local = createExercise( + repo, + "Marche", + RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + ExerciseDataFields.SPEED_KMH, + ) + val foreignId = "ex_${UUID.randomUUID()}" + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", foreignId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", ExerciseDataFields.DISTANCE_KM), + ), + ) + + assertTrue(repo.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Invalid) + assertEquals(local.exerciseId, repo.listExercises().single().exerciseId) + assertEquals(ExerciseDataFields.SPEED_KMH, repo.listExercises().single().dataFields) } @Test @@ -458,6 +682,313 @@ class TrainlogRepositoryDraftTest { assertEquals("Marche", repo.listExercises().single().name) } + @Test + fun pcMobileV2AcceptsOlderSubsetSnapshotWithoutInventingDistance() { + val repo = openRepository() + val walk = createExercise( + repo, + "Marche riche", + RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + ) + val entryId = "sxe_subset_walk" + val artifact = JSONObject() + .put("format", "trainlog-mobile-export") + .put("version", 2) + .put("generated_at", "2026-09-08T16:00:00+02:00") + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("exercise_id", walk.exerciseId) + .put("name", walk.name) + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", walk.dataFields), + ), + ) + .put( + "sessions", + org.json.JSONArray().put( + JSONObject() + .put("session_id", "se_subset_walk") + .put("started_at", "2026-09-08T15:45:00+02:00") + .put("session_type", "training") + .put( + "exercises", + org.json.JSONArray().put( + JSONObject() + .put("entry_id", entryId) + .put("position", 0) + .put("exercise_id", walk.exerciseId) + .put("name", walk.name) + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", ExerciseDataFields.SPEED_KMH) + .put("load_mode", "none") + .put("rest_seconds", 0) + .put("equipment_id", JSONObject.NULL) + .put( + "continuous", + JSONObject() + .put("duration_seconds", 600) + .put("speed_kmh", 5.8), + ), + ), + ), + ), + ) + .put("body_observations", org.json.JSONArray()) + + assertEquals( + MobileSessionImportResult.Applied(1, 0, 0, 0), + repo.applyPcMobileExportV2Json(artifact.toString()), + ) + val detail = repo.getSessionDetail("se_subset_walk")!!.exercises.single() + assertEquals(entryId, detail.entryId) + assertEquals(ExerciseDataFields.SPEED_KMH, detail.dataFields) + assertEquals(600, detail.continuousDurationSeconds) + assertEquals(5.8, detail.speedKmh!!, 0.0) + assertEquals(null, detail.distanceKm) + assertEquals( + MobileSessionImportResult.Applied(0, 1, 0, 0), + repo.applyPcMobileExportV2Json(artifact.toString()), + ) + } + + @Test + fun realPcExportersAndAndroidImportersAreStrictlyIdempotentForThreePasses() { + val repo = openRepository() + val androidWalkId = "ex_23212d79-52ce-4195-914d-dd983f133936" + val desktopWalkId = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d" + val legPressId = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" + val pcOnlyId = "ex_7e7cf906-2214-4066-bcb7-c16382d83b3b" + val customEquipmentId = "eq_44444444-4444-4444-8444-444444444444" + val pcSessionId = "se_11111111-1111-4111-8111-111111111111" + val pcRichWalkEntryId = "sxe_22222222-2222-4222-8222-222222222221" + val pcLegacyWalkEntryId = "sxe_22222222-2222-4222-8222-222222222222" + val pcLegPressEntryId = "sxe_22222222-2222-4222-8222-222222222223" + val fixtureDirectory = Files.createTempDirectory("trainlog-pc-android-idempotence-").toFile() + + try { + val fixtureScript = findRepositoryFile( + "tests/create_pc_android_idempotence_fixture.py", + ) + val process = ProcessBuilder( + "python3", + fixtureScript.path, + fixtureDirectory.path, + ).redirectErrorStream(true).start() + val fixtureOutput = process.inputStream.bufferedReader().use { it.readText() } + assertEquals(fixtureOutput, 0, process.waitFor()) + assertTrue(fixtureOutput, fixtureOutput.contains("PASS pc_android_idempotence_fixture")) + + val initialCatalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray() + .put( + JSONObject() + .put("exercise_id", androidWalkId) + .put("name", "Marche") + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put( + "data_fields", + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + ), + ) + .put( + JSONObject() + .put("exercise_id", legPressId) + .put("name", "Leg press") + .put("recording_mode", "sets") + .put("tracking_mode", "reps") + .put("data_fields", ExerciseDataFields.NONE), + ), + ) + assertEquals( + PcCatalogImportResult.Applied(imported = 2, reconciled = 0, skipped = 0), + repo.applyPcCatalogJson(initialCatalog.toString()), + ) + val initialWalk = repo.listExercises().single { it.exerciseId == androidWalkId } + val initialLegPress = repo.listExercises().single { it.exerciseId == legPressId } + val localEquipment = + (repo.createCustomEquipment("Équipement Android conservé") as + CreateEquipmentResult.Created).equipment + val localSession = repo.saveSession( + SessionDraft( + exercises = listOf( + SessionExerciseDraft( + entryId = "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + exercise = initialWalk, + equipmentId = localEquipment.equipmentId, + continuousDurationSeconds = 720, + speedKmh = 5.1, + distanceKm = 1.02, + ), + SessionExerciseDraft( + entryId = "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + exercise = initialWalk, + equipmentId = localEquipment.equipmentId, + continuousDurationSeconds = 240, + speedKmh = 4.2, + distanceKm = 0.28, + ), + SessionExerciseDraft( + entryId = "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", + exercise = initialLegPress, + equipmentId = localEquipment.equipmentId, + sets = listOf( + SessionSetDraft(reps = 10, weightKg = 75.0), + SessionSetDraft(reps = 8, weightKg = 82.5), + ), + ), + ), + ), + ) as SaveSessionResult.Saved + assertEquals( + ActiveDraftMutationResult.Saved, + repo.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + entryId = "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4", + exercise = initialWalk, + equipmentId = localEquipment.equipmentId, + continuousDurationSeconds = 180, + speedKmh = 4.8, + distanceKm = 0.24, + ), + ), + form = SessionDraftForm( + selectedExercise = initialWalk, + selectedEquipmentId = localEquipment.equipmentId, + durationText = "180", + speedText = "4.8", + distanceText = "0.24", + ), + ), + ), + ) + + val definitions = File( + fixtureDirectory, + "trainlog-pc-equipment-definitions-v1.json", + ).readText() + val catalog = File( + fixtureDirectory, + "trainlog-pc-catalog-v1.json", + ).readText() + val mobile = File( + fixtureDirectory, + "trainlog-pc-mobile-export-v2.json", + ).readText() + val associations = File( + fixtureDirectory, + "trainlog-equipment-associations-v2.json", + ).readText() + + val firstDecisions = mutableListOf() + assertEquals( + EquipmentDefinitionImportResult.Applied(imported = 1, skipped = 0), + repo.applyPcEquipmentDefinitionsJson(definitions), + ) + assertEquals( + PcCatalogImportResult.Applied(imported = 1, reconciled = 1, skipped = 1), + repo.applyPcCatalogJson(catalog, firstDecisions::add), + ) + assertEquals( + MobileSessionImportResult.Applied(1, 0, 1, 0), + repo.applyPcMobileExportV2Json(mobile), + ) + assertEquals( + EquipmentAssociationImportResult.Applied(updated = 0), + repo.applyPcEquipmentAssociationsJson(associations), + ) + assertEquals( + mapOf( + desktopWalkId to "existing-reconciled", + legPressId to "existing-identical", + pcOnlyId to "inserted", + ), + firstDecisions.associate { it.exerciseId to it.decision }, + ) + assertEquals( + "normalized_name:$androidWalkId", + firstDecisions.single { it.exerciseId == desktopWalkId }.lookup, + ) + + val pcSession = repo.getSessionDetail(pcSessionId)!! + assertEquals( + listOf(pcRichWalkEntryId, pcLegacyWalkEntryId, pcLegPressEntryId), + pcSession.exercises.map { it.entryId }, + ) + assertEquals(listOf(900, 300), pcSession.exercises.take(2).map { it.continuousDurationSeconds }) + assertEquals(listOf(5.5, 4.0), pcSession.exercises.take(2).map { it.speedKmh }) + assertEquals(listOf(1.2, null), pcSession.exercises.take(2).map { it.distanceKm }) + assertEquals( + listOf(80.0, 85.0), + pcSession.exercises.last().sets.map { it.weightKg }, + ) + assertTrue(pcSession.exercises.all { it.equipmentDisplayName == "Machine PC custom" }) + val localAfterReconciliation = repo.getSessionDetail(localSession.sessionId)!! + assertEquals( + listOf( + "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + "sxe_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", + ), + localAfterReconciliation.exercises.map { it.entryId }, + ) + assertEquals( + listOf(desktopWalkId, desktopWalkId, legPressId), + localAfterReconciliation.exercises.map { it.exerciseId }, + ) + assertEquals(desktopWalkId, loadDraft(repo).exercises.single().exercise.exerciseId) + assertFalse(repo.listExercises().any { it.exerciseId == androidWalkId }) + assertTrue(repo.listEquipment().any { it.equipmentId == customEquipmentId }) + assertTrue(repo.listEquipment().any { it.equipmentId == localEquipment.equipmentId }) + + val afterFirst = snapshotBusinessTables(context.getDatabasePath(databaseName).path) + + repeat(2) { replayIndex -> + val replayDecisions = mutableListOf() + assertEquals( + EquipmentDefinitionImportResult.Applied(imported = 0, skipped = 1), + repo.applyPcEquipmentDefinitionsJson(definitions), + ) + assertEquals( + PcCatalogImportResult.Applied(imported = 0, reconciled = 0, skipped = 3), + repo.applyPcCatalogJson(catalog, replayDecisions::add), + ) + assertEquals( + MobileSessionImportResult.Applied(0, 1, 0, 1), + repo.applyPcMobileExportV2Json(mobile), + ) + assertEquals( + EquipmentAssociationImportResult.Applied(updated = 0), + repo.applyPcEquipmentAssociationsJson(associations), + ) + assertEquals( + setOf(desktopWalkId, legPressId, pcOnlyId), + replayDecisions.map { it.exerciseId }.toSet(), + ) + assertTrue(replayDecisions.all { it.decision == "existing-identical" }) + assertEquals( + "business tables changed on replay ${replayIndex + 2}", + afterFirst, + snapshotBusinessTables(context.getDatabasePath(databaseName).path), + ) + } + } finally { + fixtureDirectory.walkBottomUp().forEach { it.delete() } + } + } + @Test fun missingSelectedExerciseClearsOnlyFormAndReturnsDiagnostic() { val repo = openRepository() @@ -549,9 +1080,416 @@ class TrainlogRepositoryDraftTest { .put("session_id", sessionId) .put("exercise_id", exercise.exerciseId) .put("state", "cleared"))) - assertTrue(reopened.applyPcEquipmentAssociationsJson(cleared.toString()) is EquipmentAssociationImportResult.Applied) + assertTrue(reopened.applyPcEquipmentAssociationsJson(cleared.toString()) is EquipmentAssociationImportResult.Invalid) val afterClear = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations") - assertEquals("cleared", afterClear.getJSONObject(0).getString("state")) + assertEquals("set", afterClear.getJSONObject(0).getString("state")) + assertEquals("leg_press", afterClear.getJSONObject(0).getString("equipment_id")) + + val differentExercise = JSONObject(reopened.buildEquipmentAssociationsJson()) + val association = differentExercise.getJSONArray("associations").getJSONObject(0) + association.put("exercise_id", "ex_different") + assertTrue(reopened.applyPcEquipmentAssociationsJson(differentExercise.toString()) is EquipmentAssociationImportResult.Invalid) + val afterExerciseConflict = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations") + assertEquals("leg_press", afterExerciseConflict.getJSONObject(0).getString("equipment_id")) + } + + @Test + fun customEquipmentDefinitionsAreAdditiveIdempotentAndConflictConservatively() { + val repo = openRepository() + val root = JSONObject().put("format", "trainlog-equipment-definitions").put("version", 1) + .put("generated_at", "2026-03-01T10:00:00+01:00") + val definition = JSONObject().put("equipment_id", "eq_remote") + .put("display_name", "Presse voyage").put("label_name", "Presse") + .put("equipment_type", "custom_machine").put("load_semantics", "external") + root.put("equipment", org.json.JSONArray().put(definition)) + assertTrue(repo.applyPcEquipmentDefinitionsJson(root.toString()) is EquipmentDefinitionImportResult.Applied) + val replay = repo.applyPcEquipmentDefinitionsJson(root.toString()) + assertTrue(replay is EquipmentDefinitionImportResult.Applied) + assertEquals(1, (replay as EquipmentDefinitionImportResult.Applied).skipped) + + root.put("equipment", org.json.JSONArray()) + assertTrue(repo.applyPcEquipmentDefinitionsJson(root.toString()) is EquipmentDefinitionImportResult.Applied) + assertTrue(repo.listEquipment().any { it.equipmentId == "eq_remote" }) + + definition.put("display_name", "Nom divergent") + root.put("equipment", org.json.JSONArray().put(definition)) + assertTrue(repo.applyPcEquipmentDefinitionsJson(root.toString()) is EquipmentDefinitionImportResult.Invalid) + assertEquals("Presse voyage", repo.listEquipment().single { it.equipmentId == "eq_remote" }.displayName) + + definition.put("equipment_id", "leg_press") + assertTrue(repo.applyPcEquipmentDefinitionsJson(root.toString()) is EquipmentDefinitionImportResult.Invalid) + } + + @Test + fun customEquipmentDefinitionWithNoneSemanticsRoundTripsAndReplaysIdempotently() { + val repo = openRepository() + val definition = JSONObject() + .put("equipment_id", "eq_unloaded_remote") + .put("display_name", "Agrès sans charge") + .put("label_name", "Agrès") + .put("equipment_type", "custom_station") + .put("load_semantics", "none") + val root = JSONObject() + .put("format", "trainlog-equipment-definitions") + .put("version", 1) + .put("generated_at", "2026-03-01T10:00:00+01:00") + .put("equipment", org.json.JSONArray().put(definition)) + + val first = repo.applyPcEquipmentDefinitionsJson(root.toString()) + assertEquals(EquipmentDefinitionImportResult.Applied(1, 0), first) + assertEquals( + EquipmentLoadSemantics.NONE, + repo.listEquipment().single { it.equipmentId == "eq_unloaded_remote" }.loadSemantics, + ) + + val exported = JSONObject(repo.buildEquipmentDefinitionsJson()).getJSONArray("equipment") + val roundTripped = (0 until exported.length()) + .map { exported.getJSONObject(it) } + .single { it.getString("equipment_id") == "eq_unloaded_remote" } + assertEquals("none", roundTripped.getString("load_semantics")) + assertEquals( + EquipmentDefinitionImportResult.Applied(0, 1), + repo.applyPcEquipmentDefinitionsJson(root.toString()), + ) + } + + @Test + fun equipmentDefinitionsV1RejectMalformedMetadataAndUnsupportedSemanticsAtomically() { + val repo = openRepository() + fun definition(id: String, semantics: String) = JSONObject() + .put("equipment_id", id) + .put("display_name", "Équipement $id") + .put("label_name", "Équipement") + .put("equipment_type", "custom_station") + .put("load_semantics", semantics) + fun artifact(items: org.json.JSONArray, generatedAt: Any = "2026-03-01T10:00:00+01:00") = JSONObject() + .put("format", "trainlog-equipment-definitions") + .put("version", 1) + .put("generated_at", generatedAt) + .put("equipment", items) + + for (semantics in listOf("bodyweight", "cardio")) { + val result = repo.applyPcEquipmentDefinitionsJson( + artifact(org.json.JSONArray().put(definition("eq_$semantics", semantics))).toString(), + ) + assertTrue(result is EquipmentDefinitionImportResult.Invalid) + assertFalse(repo.listEquipment().any { it.equipmentId == "eq_$semantics" }) + } + + val malformedMetadata = artifact(org.json.JSONArray().put(definition("eq_bad_time", "external")), 42) + assertTrue(repo.applyPcEquipmentDefinitionsJson(malformedMetadata.toString()) is EquipmentDefinitionImportResult.Invalid) + + val stringVersion = artifact(org.json.JSONArray().put(definition("eq_string_version", "external"))).put("version", "1") + assertTrue(repo.applyPcEquipmentDefinitionsJson(stringVersion.toString()) is EquipmentDefinitionImportResult.Invalid) + + val unknownRootKey = artifact(org.json.JSONArray().put(definition("eq_unknown_root", "external"))).put("extra", true) + assertTrue(repo.applyPcEquipmentDefinitionsJson(unknownRootKey.toString()) is EquipmentDefinitionImportResult.Invalid) + + val unknownItemKey = definition("eq_unknown_item", "external").put("extra", true) + assertTrue(repo.applyPcEquipmentDefinitionsJson( + artifact(org.json.JSONArray().put(unknownItemKey)).toString(), + ) is EquipmentDefinitionImportResult.Invalid) + + val atomic = artifact(org.json.JSONArray() + .put(definition("eq_atomic_valid", "external")) + .put(definition("eq_atomic_invalid", "cardio"))) + assertTrue(repo.applyPcEquipmentDefinitionsJson(atomic.toString()) is EquipmentDefinitionImportResult.Invalid) + assertFalse(repo.listEquipment().any { it.equipmentId in setOf("eq_atomic_valid", "eq_atomic_invalid") }) + + for (field in listOf("equipment_id", "display_name", "label_name", "equipment_type", "load_semantics")) { + for (invalidValue in listOf(42, true)) { + val invalid = definition("eq_bad_${field}_${invalidValue}", "external") + .put(field, invalidValue) + val strictAtomic = artifact(org.json.JSONArray() + .put(definition("eq_strict_valid_${field}_${invalidValue}", "external")) + .put(invalid)) + assertTrue(repo.applyPcEquipmentDefinitionsJson(strictAtomic.toString()) is EquipmentDefinitionImportResult.Invalid) + assertFalse(repo.listEquipment().any { + it.equipmentId == "eq_strict_valid_${field}_${invalidValue}" + }) + } + } + } + + @Test + fun pcMobileExportV2RejectsMalformedShapesAsInvalidAndDoesNotPartiallyImport() { + val repo = openRepository() + val exercise = createExercise(repo, "Import V2", RecordingMode.SETS, TrackingMode.REPS) + fun entry(id: String) = JSONObject() + .put("exercise_id", exercise.exerciseId).put("name", exercise.name) + .put("recording_mode", "sets").put("tracking_mode", "reps").put("data_fields", 0) + .put("load_mode", "none").put("rest_seconds", 0) + .put("entry_id", id).put("position", 0).put("equipment_id", JSONObject.NULL) + .put("sets", org.json.JSONArray().put(JSONObject().put("reps", 8))) + fun session(id: String, sessionEntry: JSONObject) = JSONObject() + .put("session_id", id).put("started_at", "2026-03-02T10:00:00+01:00") + .put("session_type", "training").put("exercises", org.json.JSONArray().put(sessionEntry)) + fun artifact(sessions: org.json.JSONArray) = JSONObject() + .put("format", "trainlog-mobile-export").put("version", 2) + .put("generated_at", "2026-03-02T11:00:00+01:00") + .put("exercises", org.json.JSONArray().put(JSONObject() + .put("exercise_id", exercise.exerciseId).put("name", exercise.name) + .put("recording_mode", "sets").put("tracking_mode", "reps").put("data_fields", 0))) + .put("sessions", sessions).put("body_observations", org.json.JSONArray()) + + val valid = artifact(org.json.JSONArray().put(session("se_pc_valid", entry("en_pc_valid")))) + assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), repo.applyPcMobileExportV2Json(valid.toString())) + + /* Regression: a body observation emitted by the desktop V2 artifact + * must survive Android's real importer/exporter unchanged. A repeat + * is an idempotent replay; changing one business metric remains an + * explicit conflict rather than a last-writer-wins update. */ + valid.getJSONArray("body_observations").put( + JSONObject() + .put("observation_id", "bo_pc_round_trip") + .put("observed_at", "2026-03-02T11:30:00.123456+01:00") + .put("body_weight_kg", 83.7) + .put("left_calf_cm", 40.0), + ) + assertEquals(MobileSessionImportResult.Applied(0, 1, 1, 0), repo.applyPcMobileExportV2Json(valid.toString())) + val androidRoundTrip = JSONObject(repo.buildMobileExportV2Json()) + val exportedBody = androidRoundTrip.getJSONArray("body_observations").getJSONObject(0) + assertEquals("bo_pc_round_trip", exportedBody.getString("observation_id")) + assertEquals("2026-03-02T11:30:00.123456+01:00", exportedBody.getString("observed_at")) + assertEquals(83.7, exportedBody.getDouble("body_weight_kg"), 0.0) + assertEquals(40.0, exportedBody.getDouble("left_calf_cm"), 0.0) + assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 1), repo.applyPcMobileExportV2Json(androidRoundTrip.toString())) + + val changedBody = JSONObject(androidRoundTrip.toString()) + changedBody.getJSONArray("body_observations").getJSONObject(0).put("body_weight_kg", 84.7) + 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(), + ) is MobileSessionImportResult.Invalid) + assertEquals(1, JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions").length()) + + val unknownNestedKey = entry("en_unknown").put("unexpected", true) + assertTrue(repo.applyPcMobileExportV2Json( + artifact(org.json.JSONArray().put(session("se_unknown", unknownNestedKey))).toString(), + ) is MobileSessionImportResult.Invalid) + + val malformedSet = entry("en_bad_set") + malformedSet.put("sets", org.json.JSONArray().put(JSONObject().put("reps", "eight"))) + assertTrue(repo.applyPcMobileExportV2Json( + artifact(org.json.JSONArray().put(session("se_bad_set", malformedSet))).toString(), + ) is MobileSessionImportResult.Invalid) + + val missingNestedData = entry("en_missing_sets").also { it.remove("sets") } + val atomicArtifact = artifact(org.json.JSONArray() + .put(session("se_atomic_first", entry("en_atomic_first"))) + .put(session("se_atomic_bad", missingNestedData))) + assertTrue(repo.applyPcMobileExportV2Json(atomicArtifact.toString()) is MobileSessionImportResult.Invalid) + val sessionIds = JSONObject(repo.buildMobileExportV2Json()).getJSONArray("sessions") + assertEquals(1, sessionIds.length()) + assertEquals("se_pc_valid", sessionIds.getJSONObject(0).getString("session_id")) + } + + @Test + fun pcEquipmentAssociationsEnforceExactShapesAndRetainV1AndV2Compatibility() { + val repo = openRepository() + val exercise = createExercise(repo, "Association stricte", RecordingMode.SETS, TrackingMode.REPS) + val saved = repo.saveSession(SessionDraft(exercises = listOf(SessionExerciseDraft( + exercise = exercise, equipmentId = "leg_press", sets = listOf(SessionSetDraft(reps = 6)), + )))) as SaveSessionResult.Saved + val exported = JSONObject(repo.buildEquipmentAssociationsJson()) + assertEquals(EquipmentAssociationImportResult.Applied(0), repo.applyPcEquipmentAssociationsJson(exported.toString())) + + val association = exported.getJSONArray("associations").getJSONObject(0) + val v1 = JSONObject(exported.toString()).put("version", 1) + v1.getJSONArray("associations").getJSONObject(0).remove("entry_id") + assertEquals(EquipmentAssociationImportResult.Applied(0), repo.applyPcEquipmentAssociationsJson(v1.toString())) + + val unknownRoot = JSONObject(exported.toString()).put("unexpected", true) + assertTrue(repo.applyPcEquipmentAssociationsJson(unknownRoot.toString()) is EquipmentAssociationImportResult.Invalid) + + val unknownItem = JSONObject(exported.toString()) + unknownItem.getJSONArray("associations").getJSONObject(0).put("unexpected", true) + assertTrue(repo.applyPcEquipmentAssociationsJson(unknownItem.toString()) is EquipmentAssociationImportResult.Invalid) + + val missingSetEquipment = JSONObject(exported.toString()) + missingSetEquipment.getJSONArray("associations").getJSONObject(0).remove("equipment_id") + assertTrue(repo.applyPcEquipmentAssociationsJson(missingSetEquipment.toString()) is EquipmentAssociationImportResult.Invalid) + + val clearedWithEquipment = JSONObject(exported.toString()) + clearedWithEquipment.getJSONArray("associations").getJSONObject(0).put("state", "cleared") + assertTrue(repo.applyPcEquipmentAssociationsJson(clearedWithEquipment.toString()) is EquipmentAssociationImportResult.Invalid) + assertEquals(saved.sessionId, association.getString("session_id")) + assertEquals("leg_press", JSONObject(repo.buildEquipmentAssociationsJson()) + .getJSONArray("associations").getJSONObject(0).getString("equipment_id")) + } + + @Test + fun versionSevenMigrationPreservesEquipmentHistoryAndActiveDraftReferences() { + val first = openRepository() + val custom = first.createCustomEquipment("Machine v7") as CreateEquipmentResult.Created + val exercise = createExercise(first, "Exercice v7", RecordingMode.SETS, TrackingMode.REPS) + assertTrue( + first.saveSession( + SessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + equipmentId = custom.equipment.equipmentId, + sets = listOf(SessionSetDraft(reps = 7, weightKg = 42.5)), + ), + ), + ), + ) is SaveSessionResult.Saved, + ) + assertEquals( + ActiveDraftMutationResult.Saved, + first.saveActiveSessionDraft( + ActiveSessionDraft( + exercises = listOf( + SessionExerciseDraft( + exercise = exercise, + equipmentId = custom.equipment.equipmentId, + sets = listOf(SessionSetDraft(reps = 5, weightKg = 30.0)), + ), + ), + form = SessionDraftForm(selectedEquipmentId = custom.equipment.equipmentId), + ), + ), + ) + first.close() + repository = null + + /* Recreate the v7 constraint text on an otherwise exact v7-shaped + * fixture; v8 must widen it without losing any graph member. */ + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("PRAGMA writable_schema = ON;") + db.execSQL( + "UPDATE sqlite_master SET sql = replace(sql, \"'none', \" , '') " + + "WHERE type = 'table' AND name IN ('equipment', 'catalog_exercise_equipment');", + ) + db.execSQL("PRAGMA writable_schema = OFF;") + db.execSQL("PRAGMA user_version = 7;") + } + + val migrated = openRepository() + assertEquals(custom.equipment.equipmentId, loadDraft(migrated).exercises.single().equipmentId) + SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).path, + null, + SQLiteDatabase.OPEN_READONLY, + ).use { db -> + db.rawQuery("PRAGMA user_version;", null).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(8, cursor.getInt(0)) + } + db.rawQuery( + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + + "JOIN equipment eq ON eq.id = se.equipment_row_id " + + "JOIN performed_sets ps ON ps.session_exercise_row_id = se.id;", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(custom.equipment.equipmentId, cursor.getString(0)) + assertEquals(7, cursor.getInt(1)) + assertEquals(42.5, cursor.getDouble(2), 0.0) + } + db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + } + + @Test + fun approvedAndroidLegPressIdentityMigrationPreservesHistoryDraftAndEquipment() { + val initial = openRepository() + initial.listExercises() /* Materialize the test database before raw fixture inserts. */ + initial.close() + repository = null + val path = context.getDatabasePath(databaseName).path + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL( + "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) " + + "VALUES(?,?,?,?,?,?);", + arrayOf( + "ex_d68a1af1-7247-4fb3-a48b-da8516906a29", + "Leg press", + "leg press", + "sets", + "reps", + 0, + ), + ) + val exerciseRow = db.rawQuery( + "SELECT id FROM exercises WHERE exercise_id=?;", + arrayOf("ex_d68a1af1-7247-4fb3-a48b-da8516906a29"), + ).use { cursor -> cursor.moveToFirst(); cursor.getLong(0) } + val equipmentRow = db.rawQuery( + "SELECT id FROM equipment WHERE equipment_id='leg_press';", + null, + ).use { cursor -> cursor.moveToFirst(); cursor.getLong(0) } + db.execSQL( + "INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);", + arrayOf("se_leg_press_migration", "2026-09-08T15:00:00+02:00", "training"), + ) + val sessionRow = db.rawQuery( + "SELECT id FROM sessions WHERE session_id='se_leg_press_migration';", + null, + ).use { cursor -> cursor.moveToFirst(); cursor.getLong(0) } + db.execSQL( + "INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) " + + "VALUES(?,?,?,?,?,?,?,?);", + arrayOf(sessionRow, exerciseRow, 0, "sets", "reps", 0, equipmentRow, "sxe_leg_press_migration"), + ) + val occurrence = db.rawQuery( + "SELECT id FROM session_exercises WHERE entry_id='sxe_leg_press_migration';", + null, + ).use { cursor -> cursor.moveToFirst(); cursor.getLong(0) } + db.execSQL( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);", + arrayOf(occurrence, 0, 9, 80.0), + ) + db.execSQL( + "INSERT INTO active_session_draft(id,session_type,selected_exercise_row_id,selected_exercise_label,selected_equipment_id,set_count_text,reps_text,duration_text,speed_text,distance_text,updated_at) " + + "VALUES(1,?,?,?,?,?,?,?,?,?,?);", + arrayOf("training", exerciseRow, "Leg press", "leg_press", "1", "7", "", "", "", "2026-09-08T15:00:00+02:00"), + ) + db.execSQL( + "INSERT INTO draft_session_exercises(draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) " + + "VALUES(?,?,?,?,?,?,?,?);", + arrayOf(1, exerciseRow, 0, "sets", "reps", 0, equipmentRow, "sxe_draft_leg_press_migration"), + ) + val draftRow = db.rawQuery( + "SELECT id FROM draft_session_exercises WHERE entry_id='sxe_draft_leg_press_migration';", + null, + ).use { cursor -> cursor.moveToFirst(); cursor.getLong(0) } + db.execSQL( + "INSERT INTO draft_performed_sets(draft_exercise_row_id,position,reps,weight_kg) VALUES(?,?,?,?);", + arrayOf(draftRow, 0, 7, 60.0), + ) + } + + val migrated = openRepository() + val detail = migrated.getSessionDetail("se_leg_press_migration")!! + assertEquals("sxe_leg_press_migration", detail.exercises.single().entryId) + assertEquals("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", detail.exercises.single().exerciseId) + assertEquals(80.0, detail.exercises.single().sets.single().weightKg!!, 0.0) + assertNotNull(detail.exercises.single().equipmentDisplayName) + val draft = loadDraft(migrated) + assertEquals("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", draft.exercises.single().exercise.exerciseId) + assertEquals("sxe_draft_leg_press_migration", draft.exercises.single().entryId) + assertEquals(60.0, draft.exercises.single().sets.single().weightKg!!, 0.0) + migrated.close(); repository = null + + val reopened = openRepository() + assertEquals(1, reopened.listExercises().count { + it.exerciseId == "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" + }) + assertFalse(reopened.listExercises().any { + it.exerciseId == "ex_d68a1af1-7247-4fb3-a48b-da8516906a29" + }) } @Test @@ -572,7 +1510,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(7, cursor.getInt(0)) + assertEquals(8, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) @@ -608,6 +1546,63 @@ class TrainlogRepositoryDraftTest { return (result as CreateExerciseResult.Created).exercise } + private fun findRepositoryFile(relativePath: String): File { + var directory = File(requireNotNull(System.getProperty("user.dir"))).canonicalFile + repeat(5) { + val candidate = File(directory, relativePath) + if (candidate.isFile) return candidate + directory = directory.parentFile ?: directory + } + fail("Repository file not found: $relativePath") + throw AssertionError("unreachable") + } + + /** Exact raw snapshots make idempotence cover every persisted business + * table, including stable IDs and FKs that higher-level projections omit. */ + private fun snapshotBusinessTables(path: String): Map>> { + val snapshot = linkedMapOf>>() + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + val tables = mutableListOf() + db.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' " + + "AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' ORDER BY name;", + null, + ).use { cursor -> + while (cursor.moveToNext()) tables += cursor.getString(0) + } + for (table in tables) { + val quotedTable = quoteSqlIdentifier(table) + val columns = mutableListOf() + db.rawQuery("PRAGMA table_info($quotedTable);", null).use { cursor -> + while (cursor.moveToNext()) columns += cursor.getString(1) + } + val orderBy = columns.joinToString(",") { quoteSqlIdentifier(it) } + val rows = mutableListOf>() + db.rawQuery("SELECT * FROM $quotedTable ORDER BY $orderBy;", null).use { cursor -> + while (cursor.moveToNext()) { + rows += (0 until cursor.columnCount).map { index -> + when (cursor.getType(index)) { + android.database.Cursor.FIELD_TYPE_NULL -> "null" + android.database.Cursor.FIELD_TYPE_INTEGER -> "integer:${cursor.getLong(index)}" + android.database.Cursor.FIELD_TYPE_FLOAT -> + "float:${java.lang.Double.toHexString(cursor.getDouble(index))}" + android.database.Cursor.FIELD_TYPE_STRING -> "string:${cursor.getString(index)}" + android.database.Cursor.FIELD_TYPE_BLOB -> + "blob:${cursor.getBlob(index).contentToString()}" + else -> throw AssertionError("Unknown SQLite field type") + } + } + } + } + snapshot[table] = rows + } + } + return snapshot + } + + private fun quoteSqlIdentifier(value: String): String = + "\"${value.replace("\"", "\"\"")}\"" + /** A real v4 shape: completed history plus the v4 durable draft tables. */ private fun createVersionFourFixture(path: String) { SQLiteDatabase.openOrCreateDatabase(path, null).use { db -> diff --git a/docs/android.md b/docs/android.md index ab448f3..3d21b49 100644 --- a/docs/android.md +++ b/docs/android.md @@ -33,7 +33,7 @@ Accueil Android local database version: ```text -7 +8 ``` Domain tables cover: @@ -51,7 +51,7 @@ This database is Android-local. It is not copied to the PC. Schema v4 introduced `active_session_draft`, `draft_session_exercises`, `draft_performed_sets` and `draft_continuous_activity`. The implemented -additive v4 -> v7 chain preserves catalog, completed sessions/actuals, body +additive v4 -> v8 chain preserves catalog, completed sessions/actuals, body observations and the draft while adding the shared equipment catalogue, occurrence-level equipment links and stable completed/draft `entry_id` values. Exactly one active draft is supported; it is separate from completed history. @@ -172,9 +172,16 @@ The existing completed-session save-time timestamp behavior is unchanged. PC catalog reconciliation preserves draft references through catalog row ownership. If an editing selection no longer resolves, only the selection is cleared; added exercises and raw text remain, with a specific diagnostic. -When a received or exported catalog entry has the same `exercise_id`, a changed -display name is reconciled in that same row. A different-ID normalized-name -collision is rejected, so a rename cannot become a duplicate exercise. +When a received catalog entry has the same `exercise_id`, a changed display +name is reconciled in that same row. When the incoming PC identity differs but +the normalized name matches, Android rekeys or merges only if recording and +tracking modes match, the bounded `data_fields` masks are comparable by +inclusion, and overlapping equipment metadata has the same load semantics. +The incoming PC identity is canonical and the bit-mask union retains the richer +profile. Completed and draft occurrence row IDs, `entry_id`, positions, values, +equipment references and the active selection are moved transactionally. Any +incompatible condition rejects the catalog transaction; name equality alone +never authorizes a merge. ## 7. Session history @@ -231,6 +238,14 @@ per-set weights. Android also publishes the V2 companion `trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are targeted by `(session_id, entry_id)`. +Before either V2 artifact, Android publishes its user-created equipment +definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict +`trainlog-equipment-definitions` v1 format uses stable IDs and the fields +`equipment_id`, `display_name`, `label_name`, `equipment_type`, and +`load_semantics`. Absence never deletes a definition; equal same-ID definitions +are idempotent and divergent same-ID definitions conflict. Supplied-manifest +IDs are reserved. + The user does not need a separate manual export step before synchronization. An active draft is never included in completed history, session detail or this @@ -277,6 +292,11 @@ The receipt is matched by `request_id`. On success Android then applies the latest PC catalog and displays the final result. +Before applying the PC catalog or its V2 artifacts, Android applies +`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known +before a received V2 association references them. Android schema v8 provides +the non-destructive v7 -> v8 migration required for `load_semantics = none`. + A receipt belonging to another request is ignored as pending rather than misreported as the current result. @@ -314,13 +334,16 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk `local.properties` is local machine configuration and must not be committed. -Host regression suite: 8 tests. Device instrumentation: 5 tests (2 repository, -3 production-screen UI tests using an isolated database and no shared export). -The real device matrix additionally exercised production `MainActivity`, +The prior host regression suite had 8 tests and the prior device +instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests +using an isolated database and no shared export). That device matrix exercised +production `MainActivity`, including verified process exit with `am kill`, force-stop, configuration relaunch, raw-form recovery, removal, discard and unchanged user data. Final-save -UI checks use isolated data so fictitious workouts do not enter user history. -See [tests](tests.md) for commands and the precise validation boundary. +UI checks used isolated data so fictitious workouts did not enter user history. +The schema-v8 definition change is recorded as targeted JVM validation, not a +blanket device-validation claim. See [tests](tests.md) for commands and the +precise validation boundary. ## 14. Non-goals @@ -333,9 +356,7 @@ Android is not intended to own: - exercise-name heuristics; - a mounted-filesystem dependency. -## 15. Test-max sessions - -## 16. Equipment and multi-occurrence exchange V2 +## 15. Equipment and multi-occurrence exchange V2 During exercise entry, `Machine / équipement (optionnel)` searches the shared catalogue by display name, physical-machine label and aliases. The selected @@ -354,12 +375,14 @@ be separated with `;`. Assisted equipment is explicitly labelled `Nouvelle machine` in that same selector creates a persistent local custom equipment entry with a generated stable `eq_…` ID and selects it immediately. -The shared bundled catalogue is synchronized by canonical IDs; a custom ID is -not silently converted to null on the PC and is rejected until its definition -is available to the receiving catalogue. +The shared bundled catalogue uses reserved canonical IDs. User-created IDs are +exchanged first by definitions V1, so a V2 association can resolve them on the +receiving side; conflicts remain explicit and are never converted to null. The active-session list exposes `Modifier `; saving replaces that entry in place, while cancelling only discards the form and preserves it. +## 16. Test-max sessions + Android session entry exposes: ```text @@ -383,3 +406,33 @@ from large repetition or duration values. Android's current session form still records the exercise data fields it supports. Measured-max classification on the desktop uses only actual values that were truly captured and synchronized. + +## 17. Audited limitations + +Android's stored `normalized_name` currently removes diacritics, while the +frozen desktop/Python normalization contract uses NFC, Unicode whitespace +collapse and case folding without accent removal. Existing Marche/Leg press +data is unaffected, but changing this safely requires an explicit Android +schema migration that recomputes every normalized key and handles newly exposed +collisions. It is not silently changed inside schema v8. + +The bundled exercise/equipment relationship metadata is seeded and preserved, +including during exercise-identity reconciliation, but the current equipment +picker searches all known supplied and custom definitions. Filtering or ranking +that picker by exercise relation remains future gym-catalog policy. + +Custom equipment remains identified only by `equipment_id`: Android never +coalesces two different custom IDs merely because their labels match. This can +leave conceptually duplicated definitions created independently on two devices, +but avoids guessing across potentially different load semantics and persisted +occurrence references. + +The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field +masks, but unlike the newer mobile V2 and equipment-companion parsers it does +not reject every unknown root or item key. Tightening this published V1 reader +requires a compatibility decision rather than an incidental schema-v8 change. + +Android requires `data_fields = 0` for `SETS`, while the desktop model/API +currently accepts known supplemental bits on either recording mode. Supplied +profiles do not exercise this difference. Supporting a future set-based +supplemental field requires an explicit shared-model decision. diff --git a/docs/architecture.md b/docs/architecture.md index d3a55d6..7908dad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ Android capture client shared desktop sync engine / \ / \ - desktop SQLite PC catalog artifact + desktop SQLite directional PC artifacts | | v v desktop TUI Android @@ -120,7 +120,7 @@ Continuous work is persisted separately from performed sets. ### Desktop -Desktop SQLite schema v7 is canonical long-term history. `session_exercises` +Desktop SQLite schema v8 is canonical long-term history. `session_exercises` stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore occur more than once in one session without identity fusion. @@ -133,11 +133,12 @@ session_exercises performed_sets continuous_activity body_observations +custom_equipment ``` ### Android -Android has an independent local SQLite schema, currently v7. +Android has an independent local SQLite schema, currently v8. It mirrors domain concepts needed for capture, but its schema version is not coupled to the desktop schema. @@ -160,9 +161,12 @@ recovery preserves the raw fields and added exercises with a specific warning. the display name and normalized form in the existing catalog row identified by `exercise_id`; foreign-key ownership consequently preserves completed history and active drafts. A profile edit is admitted only before that row is referenced -by either completed or active-draft data. Android and desktop same-ID catalog -reconciliation apply name metadata in place and reject a collision with a -different stable ID. +by either completed or active-draft data. Same-ID reconciliation applies name +metadata in place. A different-ID normalized-name collision may merge only for +equal recording/tracking modes, compatible represented invariants, and +`data_fields` masks comparable by inclusion. Desktop ownership wins on both +sides; the richer mask is retained and every occurrence/draft reference moves +transactionally. Otherwise synchronization reports a conflict. Compose presentation has one `TrainlogScreen` header component for every page. It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line; @@ -181,7 +185,10 @@ Synchronization uses separate formats: ```text trainlog-mobile-export v1 +trainlog-mobile-export v2 trainlog-pc-catalog v1 +trainlog-equipment-associations v2 +trainlog-equipment-definitions v1 trainlog-sync-request v1 trainlog-sync-receipt v1 ``` @@ -189,6 +196,15 @@ trainlog-sync-receipt v1 A new domain requirement must not be forced into frozen v1 by using notes, synthetic sets, or data loss. +The active occurrence-aware session exchange remains +`trainlog-mobile-export` v2. The separate directional +`trainlog-equipment-definitions` v1 artifacts carry user-created equipment +definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from +Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC +to Android. The filename identifies direction; the JSON format and version do +not change. V1 historical artifacts remain readable under their frozen +contracts. + ## 6. Direct MTP transport Linux transport: @@ -238,19 +254,45 @@ Android request -> receipt ``` -The engine performs: +The same engine owns all supported directions. Its explicit modes are: + +```text +a = Android -> PC only +p = PC -> Android only +b = Android -> PC, then PC -> Android +``` + +The Android-to-PC direction receives the mobile equipment-definitions v1 +artifact, mobile-export v2, and equipment-associations v2. The PC-to-Android +direction publishes PC equipment-definitions v1 before dependent artifacts, +then publishes the PC catalog v1, PC mobile-export v2 (including completed +sessions and body observations), and equipment-associations v2. + +The engine performs the applicable direction steps: ```text 1. direct-MTP device/storage discovery 2. exchange-folder resolution -3. mobile snapshot download -4. strict transactional Android -> PC import -5. PC catalog export -6. direct-MTP PC catalog publication +3. definition artifact transfer and reconciliation before dependent V2 data +4. strict transactional Android -> PC import when selected +5. PC catalog, mobile/body, and association export when selected +6. direct-MTP publication of the selected PC -> Android artifacts 7. optional request receipt publication 8. structured run-history recording ``` +Synchronization is additive and reconciliatory: artifact absence never implies +deletion of local content. Equal same-ID definitions are idempotent; divergent +same-ID definitions, unknown references, and association conflicts are reported +explicitly. The affected persisted content is preserved on conflict; the engine +does not silently overwrite it. + +There is no exercise, session, body-observation, or equipment-definition +tombstone in the current formats. Omitting one of those objects from a later +snapshot is not a deletion request. The only explicit removal signal is +equipment-association V2 `state: cleared`, scoped to one +`(session_id, entry_id)` occurrence. + ## 8. Synchronization concurrency The shared engine serializes synchronization with: @@ -298,7 +340,7 @@ known errors with generic placeholders. ## 11. Layering rule ```text -ncurses / Compose rendering +Notcurses / Compose rendering | v application workflow @@ -313,7 +355,7 @@ persistence / MTP transport Rendering does not own persistence rules. -Persistence and MTP code do not depend on ncurses rendering. +Persistence and MTP code do not depend on Notcurses rendering. ## 12. Body analytics boundary @@ -345,3 +387,37 @@ with `~/.config` fallback. This profile is not synchronized to Android and does not require a SQLite schema change. + +## 13. Audited evolution constraints + +Each mutating importer has strict validation and a SQLite transaction. The V2 +association companion is a strict corroboration of the equipment already +carried by the mobile snapshot and does not rewrite divergent state. However, +one definitions/mobile/associations publication has no common generation +manifest and is not one cross-artifact database transaction. A late association +conflict can therefore follow a successfully committed definitions or mobile +import; replay remains idempotent and existing conflicting content is not +overwritten. A future batch protocol must be separately versioned rather than +retrofitted into frozen formats. + +Android and desktop also have different stored name-normalization behavior: +Android removes diacritics, while the frozen desktop/Python rule preserves them +through NFC plus Unicode case folding. Correcting existing Android keys requires +an explicit migration and collision policy. Finally, Android retains supplied +exercise/equipment relationship tables, but its current picker searches the +complete definition catalog; applying those relations as recommendations is +future gym-catalog behavior. + +Custom-equipment reconciliation is deliberately ID-based. Different custom +IDs are not merged by equal or similar display names because their load +semantics and existing occurrence references may differ. The current PC +catalog V1 Android reader also validates its required semantic fields without +the exact-key rejection used by newer V2/companion readers; stricter V1 parsing +needs an explicit compatibility review. + +The desktop model/API currently permits bounded supplemental field masks on a +`SETS` profile, while Android creation and V2 inbox validation require those +masks to be zero for `SETS`. The shipped catalog uses supplemental speed and +distance only with `CONTINUOUS`; defining cross-platform behavior for a future +set-based supplemental field is a model-contract decision, not part of this +reconciliation. diff --git a/docs/coding_style.md b/docs/coding_style.md index f91af4c..42303c4 100644 --- a/docs/coding_style.md +++ b/docs/coding_style.md @@ -10,7 +10,7 @@ Avoid clever code when a straightforward implementation is easier to verify. The TUI uses C17. -The project will initially target a strict warning profile similar to: +Meson enforces C17, warning level 3 and `werror=true`. Desktop targets also use: ```text -Wall @@ -63,6 +63,12 @@ Comments are required for: - error-handling decisions; - algorithms whose intent is not immediately obvious. +For synchronization, persistence, stable identity, serialization, ownership, +ABI boundaries, concurrency, and scientific interpretation, comments identify +the relevant intent explicitly as `WHY`, `CONTRACT`, or `INVARIANT`. They belong +in the same change as the behavior and explain the non-obvious rule rather than +restating syntax. + Bad: ```c @@ -81,9 +87,9 @@ Good: ## 6. Formatting -The project will use `clang-format`. - -A canonical `.clang-format` file will be added before substantial C implementation. +The repository currently has no canonical `.clang-format` file. Do not apply a +bulk formatter with tool-default behavior; introducing a format definition and +any broad normalization requires a dedicated, reviewable change. Formatting changes should not be mixed with unrelated semantic changes when avoidable. diff --git a/docs/current_state.md b/docs/current_state.md index b61b072..35e07dd 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,6 +1,6 @@ # Current implementation state -Canonical snapshot: 2026-09-07. +Canonical snapshot: 2026-09-08. This document is the compact source of truth for the implemented Trainlog baseline. Detailed behavior belongs in the topic-specific documents. @@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V7=PASS -ANDROID_LOCAL_DATABASE_V7=PASS +DESKTOP_SCHEMA_V8=PASS +ANDROID_LOCAL_DATABASE_V8=PASS ANDROID_SESSION_DRAFT_V1=PASS ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS @@ -48,10 +48,12 @@ TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS MULTI_OCCURRENCE_SESSION_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS +EQUIPMENT_DEFINITIONS_V1=PASS +EXERCISE_RECONCILIATION_V2=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS ANDROID_BUILD=PASS -HARDWARE_SYNC_VALIDATION=PASS +HARDWARE_SYNC_VALIDATION=HISTORICAL_BASELINE_ONLY ``` ## Desktop @@ -59,12 +61,15 @@ HARDWARE_SYNC_VALIDATION=PASS Implemented: - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); -- SQLite schema v7, with stable ordered `session_exercises.entry_id` and - occurrence-level equipment identity; +- SQLite schema v8, with stable ordered `session_exercises.entry_id`, + occurrence-level equipment identity, and desktop-local custom-equipment + definitions; - direct session entry; - persisted session detail and editing; - exercise removal from a session through transactional child replacement; - exercise catalog; +- supplied-equipment browsing/search/detail and desktop-local custom-equipment + creation/selection; - profile-aware set and continuous activities; - heterogeneous repetition sets; - multiple occurrences of one catalogue exercise in a session; @@ -73,7 +78,7 @@ Implemented: - body graphs and normalized overlays; - exercise performance history; - direct USB/MTP device access; -- manual bidirectional synchronization; +- manual Android -> PC, PC -> Android, and bidirectional synchronization; - structured synchronization history and detail. Primary navigation: @@ -83,8 +88,9 @@ Primary navigation: 1 Séance 2 Historique 3 Exercices -4 Corps -5 Sync +4 Équipements +5 Corps +6 Sync ``` ## Android @@ -92,7 +98,7 @@ Primary navigation: Implemented: - native Kotlin/Compose application; -- local SQLite database v7, with non-destructive v3 -> v7 migration; +- local SQLite database v8, with non-destructive v3 -> v8 migration; - one durable active-session draft, Home resume and raw-form restoration; - explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; @@ -115,8 +121,12 @@ The Android catalog exposes **Modifier** for every existing exercise. A rename updates `name` and `normalized_name` in the original row, never creates an ID, and remains valid for completed session and active-draft references. A profile change is only accepted while the row has neither completed-session nor draft -references. Same-ID catalog reconciliation updates display metadata in place in -both Android and desktop import directions. +references. Same-ID catalog reconciliation updates display metadata in place. +A different-ID normalized-name collision is reconciled only when recording and +tracking modes match, other known invariants are compatible, and one +`data_fields` mask contains the other. PC identity is canonical on Android; +the existing desktop identity is canonical on desktop. The richer bit-mask +union is retained without rewriting historical occurrence snapshots. All Android screens use the shared compact `◆ TRAINLOG ◆` header: the Notcurses accent, muted context line, and flat touch layout reproduce the TUI @@ -134,10 +144,12 @@ Artifacts: ```text Android -> PC + trainlog-mobile-equipment-definitions-v1.json trainlog-mobile-export-v2.json trainlog-equipment-associations-v2.json PC -> Android + trainlog-pc-equipment-definitions-v1.json trainlog-pc-catalog-v1.json trainlog-pc-mobile-export-v2.json trainlog-equipment-associations-v2.json @@ -149,12 +161,51 @@ PC agent -> Android trainlog-sync-receipt-v1.json ``` -The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. +The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. Its explicit +`a`, `p`, and `b` modes perform Android -> PC only, PC -> Android only, and +inbound-then-outbound respectively. Each direction reconciles equipment +definitions V1 before V2 artifacts that reference them. + +Each structured local sync-history run persists its selected `a`, `p`, or `b` +direction. `direction inconnue` is reserved for legacy history rows whose +direction was not recorded; it is never used for a current run. + +On the TUI Sync page, `a`, `p`, and `b` directly open one confirmation for the +selected direction; `Enter` runs that confirmed action once, while `Esc` +cancels it without an operation (or returns when idle). `r` refreshes device +status only, and the former `s` shortcut is inert. Full and compact footers use +the same three directional actions, with compact labels only abbreviating the +endpoints. V2 resolves equipment by `(session_id, entry_id)`, never by display name or catalogue identity alone. V1 files remain legacy-compatible and do not gain multi-occurrence semantics retroactively. +Supplied equipment remains generated from `catalog/equipment-v1.json`; its IDs +are reserved. User-created definitions synchronize additively through the +directional definitions V1 artifacts. Equal same-ID definitions are idempotent; +divergent definitions, unknown references, and association conflicts are +reported explicitly without silent overwrite. + +Conflict summaries identify the stable session, body-observation, association, +or definition identity and the source artifact/direction. Existing persisted +content is preserved when a conflict is reported. + +Synchronization addition counters describe durable inserts only. Exercise +rekeys or profile enrichments remain available through the separate +reconciliation counter; a compatible different-ID lookup that requires no +database write is an idempotent skip. This prevents a repeated Android +snapshot carrying a retired creator ID from being displayed as `+1 exercice` +when the canonical desktop catalog row and all business tables are unchanged. + +The current Marche collision is safely reconciled to desktop identity +`ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d`. Its catalog profile is the compatible +union `continuous/duration/data_fields=3`; prior `data_fields=1` occurrences +retain their own snapshots and absent distance values. The Android occurrence +`sxe_f25142c8-455e-4346-9bfc-31d0989e275d` retains its duration, speed, distance +and equipment. No reference to the retired Android exercise identity remains +in the validated merged database copy. + No SQLite file is copied. No mounted Android filesystem is required. @@ -164,35 +215,50 @@ No mounted Android filesystem is required. Desktop: ```text -25/25 Meson tests PASS +32/32 Meson tests PASS for the current desktop schema v8 baseline frozen JSON validator PASS import-contract validator PASS +ASan/UBSan 32/32 Meson tests PASS git diff --check PASS ``` Android: ```text -assembleDebug and Android unit tests are run for every Android delivery. -The prior device baseline below is hardware evidence, not a claim that every -new implementation detail was re-exercised on the device in this document. -device instrumentation 5/5 PASS -real Samsung background/process-death/force-stop/resume matrix PASS -real migration and original user-data preservation PASS -real Samsung request -> daemon -> bidirectional sync -> receipt PASS -multiple distinct request IDs consumed once each PASS +testDebugUnitTest PASS +assembleDebug PASS ``` +Current real-data/environment boundary: + +```text +REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS +PC_EXPORT_ANDROID_IMPORT_THREE_PASS_COPY=PASS +ANDROID_INSTALL_R_DATABASE_HASH_PRESERVED=PASS +CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED +REAL_PC_TO_ANDROID_TWO_RUN=BLOCKED_BY_SANDBOX_LIBMTP_OPEN +``` + +The current sandbox discovers the connected Samsung MTP interface but +`libusb_open()` cannot acquire it. It also exposes the canonical desktop DB as +read-only. The failed first write left that DB byte-for-byte logically +unchanged and integrity-clean; Android remained force-stopped and was not +modified through ADB. Therefore this checkpoint makes no new direct-device MTP +claim and does not claim that the real stores have consumed the reconciled +artifacts. + ## Current implementation cursor -The Android draft correction is implemented, device-validated and reviewed. -No product-roadmap ordering changes were made. +Exercise reconciliation, definition-first V2 synchronization and the current +real-data importer/exporter copy validation are complete. No product-roadmap +ordering change was made by this corrective tranche. ```text MEASURED_MAX_V1=PASS WORKING_LOAD_PERCENTAGES=PASS ANDROID_MAX_TEST_SESSION=PASS -NEXT_FEATURE=UNFROZEN +CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1 +NEXT_FEATURE=GYM_CATALOG_V1 ``` Future work must start from this validated baseline rather than from obsolete @@ -206,7 +272,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS WORKING_LOAD_PERCENTAGES=PASS ASSISTANCE_DIRECTION_AWARE=PASS ANDROID_MAX_TEST_SESSION=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS ``` A measured maximum is derived only from explicit `max_test` sessions. Ordinary @@ -227,7 +293,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS ``` Android remains capture-only for this feature. diff --git a/docs/database.md b/docs/database.md index 7faf9ab..a9e1659 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,8 +3,8 @@ ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=7 -DATABASE_SCHEMA_V7=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=8 +DATABASE_SCHEMA_V8=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -23,7 +23,7 @@ PRAGMA user_version; Current value: ```text -7 +8 ``` Supported historical databases are migrated explicitly through the implemented @@ -36,6 +36,11 @@ in a session. The v6 → v7 migration rebuilds the obsolete uniqueness constraint while retaining rows, sets, continuous activities, weights and equipment associations. +Version 8 adds local `custom_equipment` definitions. The v7 → v8 migration is +additive: it retains all historic occurrences and their `equipment_id` values. +Supplied definitions continue to be generated from `catalog/equipment-v1.json`; +custom definitions exist only in the desktop database. + A schema fixture must represent the real historical structure. Rewriting only `user_version` is not an acceptable migration test. @@ -99,7 +104,7 @@ target_sets target_reps target_duration_seconds target_weight_kg -equipment_id nullable canonical equipment identity +equipment_id nullable equipment identity notes ``` @@ -107,6 +112,21 @@ Current desktop history snapshots `recording_mode` and `data_fields` in the session row. `tracking_mode` remains associated with the referenced exercise catalog identity. +An occurrence `equipment_id` resolves to either a supplied manifest definition +or a desktop-local `custom_equipment` definition. An ID that cannot be +resolved is retained as historic data and is explicitly visible to the user; +it is never silently converted to `NULL`. + +### `custom_equipment` + +Desktop-local custom equipment definitions. They supplement, but do not alter +or replace, the supplied catalogue generated from `catalog/equipment-v1.json`. +They synchronize through the separate `trainlog-equipment-definitions` v1 +artifact; session/mobile and association V2 artifacts retain their existing +shapes. Definition reconciliation is additive: omission does not delete, an +equal same-ID row is idempotent, a divergent same-ID row conflicts, and IDs in +the supplied manifest are reserved. + `SETS` rows support two target shapes in schema v5: ```text @@ -202,6 +222,7 @@ ex_ exercise se_ session bo_ body observation sy_ synchronization run +sxe_ session-exercise occurrence ``` All use random UUIDv4 values. @@ -241,7 +262,7 @@ committing database changes. Properties: ```text -schema-v5 aware +schema-v5-through-v8 aware transactional idempotent by stable IDs profile-aware catalog reconciliation @@ -250,6 +271,15 @@ no fake target generated continuous activity kept separate ``` +For active V2 reconciliation, an identical normalized name never suffices by +itself. Different exercise identities may coalesce only when recording and +tracking modes match, other represented invariants remain compatible, and +their bounded `data_fields` masks are comparable by inclusion. The existing +desktop identity is deterministic canonical ownership. The mask union retains +the richer capability, while `session_exercises.data_fields` and all child +values remain unchanged. A missing historic optional value stays `NULL`. +Incomparable masks or modes reject the entire mobile-import transaction. + ## 8. Units Canonical desktop persistence: @@ -266,8 +296,10 @@ distance km The Android SQLite database is independent. -Current Android-local version: **4**. The explicit v3 -> v4 migration only adds -structured draft tables; it does not rebuild or delete existing domain tables. +Current Android-local version: **8**. The explicit migration chain adds the +durable draft in v4, equipment references in v5, per-set load in v6, occurrence +identity/multi-occurrence support in v7, and the widened custom-equipment +definition graph in v8. | Table | Ownership | | --- | --- | @@ -275,6 +307,8 @@ structured draft tables; it does not rebuild or delete existing domain tables. | `draft_session_exercises` | Ordered draft exercises and profile snapshots | | `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals | | `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets | +| `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs | +| `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation | Foreign keys remain enabled. Draft deletion cascades only through draft child tables; it cannot delete catalog entries or completed history. The repository @@ -282,10 +316,28 @@ commits completed-session insertion and draft removal together, rolling back both on failure. Repeating finalization after success cannot create another completed session. Completed `started_at` semantics are unchanged by this repair. -Migration tests use a real v3-shaped fixture. The physical Samsung upgrade also -preserved every existing domain row, with successful integrity and foreign-key -checks. Device backup files are outside the repository; no SQLite files are -used as synchronization artifacts. +Migration tests cover historical v4 and v7 shapes rather than changing only +`user_version`. The prior physical Samsung migration preserved every existing +domain row, with successful integrity and foreign-key checks. Current +reconciliation validation additionally uses coherent Android and desktop v8 +copies whose integrity and foreign keys are checked before and after import. +SQLite files are never synchronization artifacts. + +The schemas deliberately differ where ownership differs. Desktop sessions own +`ended_at`, notes, planned targets and session-specific load semantics; the +Android capture schema does not. Desktop body observations may link to a +session and carry notes; Android body observations contain capture metrics only. +Android occurrences snapshot `tracking_mode` as well as recording mode and +fields, whereas the current desktop schema resolves tracking mode through the +catalog exercise. Desktop stores occurrence `equipment_id` as stable text so +unknown historic IDs remain visible; Android stores a foreign key to its local +equipment definition. + +Audit limitation: Android persists supplied exercise/equipment relationship +tables, but the current session selector searches the complete equipment list +instead of filtering or ranking it through those relations. The tables are +retained because migration and identity reconciliation already preserve them; +making them authoritative UI policy is future catalog work, not a v8 cleanup. Desktop and Android schema versions are not required to match. @@ -302,13 +354,15 @@ Migration-specific regression coverage includes: ```text schema_v5_migration +schema_v7_migration +exercise_reconciliation ``` -The current normal desktop suite contains 25 tests. +The current normal desktop suite contains 32 tests. ## 11. Measured-max derivation -Measured maxima require no schema change beyond the current desktop schema v7. +Measured maxima require no schema change beyond the current desktop schema v8. The existing `sessions.session_type = max_test` classification plus actual `performed_sets` are sufficient. @@ -341,23 +395,31 @@ is the best max-test point using the same load mode. No extra maximum row is persisted; results are derived from canonical history. -## 12. Body analytics persistence rule +## 12. Equipment and occurrence migration -## 13. Equipment and occurrence migration - -Desktop schema v6 added nullable `session_exercises.equipment_id`, which stores -a canonical manifest ID rather than a local SQLite row ID. Schema v7 adds the -non-null stable `entry_id` and removes the obsolete +Desktop schema v6 added nullable `session_exercises.equipment_id`. Schema v7 +adds the non-null stable `entry_id` and removes the obsolete `UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild preserves primary keys, completed sessions, ordered sets, continuous activities, per-set weights and equipment values. +Schema v8 adds local `custom_equipment` definitions through an additive v7 -> +v8 migration. Existing occurrence links remain unchanged. They resolve through +the supplied manifest or the local custom table; unknown historic references +remain explicit rather than being discarded. + Android schema v6 added nullable `weight_kg` to completed and durable draft set rows. Schema v7 assigns stable `entry_id` values to completed and draft occurrences. Actual per-set weights remain independent values, so heterogeneous sets and weights survive edit, finalization, reopen and V2 exchange. -Body analytics require no schema change beyond schema v7. +Android schema v8 non-destructively migrates the v7 equipment reference graph +so custom definitions may use `load_semantics = none`. Historic completed +occurrences, draft occurrences, and their equipment references remain intact. + +## 13. Body analytics persistence rule + +Body analytics require no schema change beyond schema v8. Canonical persistence continues to contain only measurements actually entered by the user. diff --git a/docs/exchange_format.md b/docs/exchange_format.md index aea86f1..981f43c 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -10,9 +10,9 @@ GATE_1=PASS TRAINLOG_FORMAT_V1=FROZEN ``` -This document defines the proposed final Trainlog v1 exchange contract. - -The format remains `DRAFT` until Gate 1 validation and mirror review complete. +This document defines the frozen Trainlog v1 exchange contract. Gate 1 +validation and review are complete; incompatible new semantics require a new, +separately versioned format. ## 2. Design goal diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index d97e4f1..82ea1da 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -24,10 +24,14 @@ data_fields = supplemental field bit mask Known supplemental fields: ```text -SPEED_KMH -DISTANCE_KM +SPEED_KMH bit 0, mask value 1 +DISTANCE_KM bit 1, mask value 2 ``` +Consequently `data_fields = 1` means speed only and `data_fields = 3` +means speed plus distance. These meanings come from the shared C/Kotlin model +constants and serializers; the masks are not ordinal profile numbers. + Valid model-v1 combinations: ```text @@ -172,6 +176,11 @@ Current desktop tracking mode remains tied to the referenced exercise identity. Any future change to that historical contract requires an explicit schema decision rather than an implicit name-based migration. +Catalog enrichment does not rewrite an occurrence snapshot. For example, a +historic `data_fields = 1` walk remains a speed-only occurrence after its +catalog definition becomes `data_fields = 3`. Its absent distance stays +absent/`NULL`; synchronization does not infer it from duration and speed. + ## 8. Android/TUI parity Both interfaces use the same metadata axes. @@ -188,6 +197,29 @@ data_fields Creating an exercise inline on Android or desktop follows the same model rules. +### Safe V2 identity reconciliation + +A normalized-name collision between distinct `exercise_id` values is eligible +for automatic V2/catalog reconciliation only when: + +- the normalized name is identical under the receiving store's current rule; +- `recording_mode` and `tracking_mode` are identical; +- all other represented business invariants, including overlapping equipment + load semantics on Android, are compatible; +- the `data_fields` masks are equal, or one is a bitwise subset of the other; +- moving references loses no occurrence, actual value or metadata. + +The existing desktop identity is canonical. Android therefore adopts the PC +identity when applying a PC catalog. The catalog stores the bitwise union (the +compatible richer profile), while sessions retain their own `entry_id`, order, +profile snapshot, sets, loads, continuous values and equipment. The operation +is transactional and replay-safe. Different modes, incomparable masks, or any +other incompatible invariant produce an explicit conflict. Equal names alone +never establish identity. + +This is a synchronization-V2 policy. It does not relax or redefine the frozen +Trainlog JSON v1 document rules. + ## 9. Exchange boundaries Frozen Trainlog session JSON v1 remains unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2c4076c..1ab2a8b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,8 +14,8 @@ GATE_1=PASS GATE_2=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V7=PASS -ANDROID_LOCAL_DATABASE_V7=PASS +DESKTOP_SCHEMA_V8=PASS +ANDROID_LOCAL_DATABASE_V8=PASS DIRECT_MTP_TRANSPORT=PASS BIDIRECTIONAL_SYNC_V1=PASS @@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS -DESKTOP_TESTS=25/25 PASS +DESKTOP_TESTS=32/32 PASS TUI_NOTCURSES_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS @@ -46,6 +46,8 @@ The current product baseline includes: - desktop body analytics; - direct USB/MTP transport; - bidirectional Android/PC synchronization; +- safe V2 exercise-identity reconciliation with richer compatible profiles; +- supplied and custom equipment definitions with occurrence-level links; - shared synchronization engine and `trainlog-syncd`. `EXERCISE_EDIT_V1` is a completed capture correction: Android permits diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index f69296f..fa89917 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -14,6 +14,8 @@ TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS MULTI_OCCURRENCE_SESSION_V2=PASS EQUIPMENT_ASSOCIATIONS_V2=PASS +EQUIPMENT_DEFINITIONS_V1=PASS +EXERCISE_RECONCILIATION_V2=PASS TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED ``` @@ -40,8 +42,10 @@ Framework folder grant. | --- | --- | --- | | Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 | | Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) | +| Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | | Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 | +| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | | PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 | | PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | @@ -49,6 +53,20 @@ Framework folder grant. No SQLite file is transferred. +Android scoped storage can preserve a prior MTP-created object and create a +new artifact with the provider collision suffix, for example +`trainlog-mobile-export-v2 (N).json` or +`trainlog-mobile-equipment-definitions-v1 (N).json`, or +`trainlog-equipment-associations-v2 (N).json`. For Android -> PC, the engine +accepts only the canonical name and this exact suffix form, selects the newest +MTP modification time (then the greatest suffix and a deterministic object-ID +tie break), and validates that selected artifact normally. It never silently +falls back to an older candidate when the newest one is malformed. Definitions +reconcile before the V2 snapshot, so a valid custom `equipment_id` is known +before a session may reference it. This prevents an older canonical object from +being mistaken for Android's current data while preserving every file in +`Download/Trainlog`. + V2 is a separate format: every session entry has an `entry_id`, `position`, metrics, actual loads and optional equipment identity. This permits two occurrences of the same exercise without fusion. V1 remains readable with its @@ -56,7 +74,33 @@ frozen contract. A V1 historical session is reconciled with V2 only when the exercise/order correspondence is unambiguous; otherwise the importer reports a conflict rather than silently overwriting data. -## 4. Android -> PC mobile snapshot +## 4. Equipment definitions V1 + +User-created equipment definitions use the separate, directional +`trainlog-equipment-definitions` v1 artifact. The Android-to-PC filename is +`trainlog-mobile-equipment-definitions-v1.json`; the PC-to-Android filename is +`trainlog-pc-equipment-definitions-v1.json`. The filenames identify direction; +the JSON format and version are the same. + +The strict root has exactly `format`, `version`, `generated_at`, and +`equipment`. Each `equipment` item has exactly: + +```text +equipment_id +display_name +label_name +equipment_type +load_semantics none | external | assistance +``` + +Definitions are additive snapshots, not deletion instructions: absence never +deletes a local definition. A same-ID, field-for-field equal definition is an +idempotent skip. A same-ID divergent definition is a conflict. IDs reserved by +the supplied equipment manifest cannot appear in this artifact. These rules +preserve the definition identity that V2 equipment associations reference; +they do not change either V2 shape. + +## 5. Android -> PC mobile snapshot Header: @@ -75,6 +119,12 @@ sessions body_observations ``` +Android captures its custom-definition V1 companion, this V2 snapshot, and +the equipment-association companion before it publishes any of them. It then +publishes in that order: definitions, V2 snapshot, associations. A malformed +persisted custom definition aborts publication before a V2 file can advertise +its reference; bundled manifest equipment is never copied into definitions V1. + V2 session entries additionally carry: ```text @@ -84,10 +134,11 @@ equipment_id optional canonical equipment identity weight_kg optional actual value on each set ``` -The desktop imports sessions first, preserving `entry_id`, then applies the -equipment companion only after all referenced entries exist. Reimporting either -artifact reconciles stable identities; it neither duplicates sessions nor -regenerates occurrence IDs. +The desktop imports sessions first, preserving `entry_id` and their equipment, +then validates the equipment companion only after all referenced entries exist. +The companion corroborates explicit `set`/`cleared` state; it does not overwrite +a divergent occurrence. Reimporting either artifact reconciles stable +identities; it neither duplicates sessions nor regenerates occurrence IDs. Exercise profile fields: @@ -137,7 +188,7 @@ catalog reconciliation preserves active draft references. Same-ID catalog entries may update display-name metadata in their existing catalog row; a rename never creates a second exercise identity. -## 5. Desktop mobile importer +## 6. Desktop mobile importer Reference importer: @@ -160,7 +211,38 @@ continuous activity kept separate The importer never invents a uniform target merely to fit desktop persistence. -## 6. PC -> Android catalog +### Exercise identity reconciliation + +The active V2/catalog path may reconcile distinct `exercise_id` values sharing +one normalized name only when recording mode and tracking mode are equal, all +other represented invariants are compatible, and one bounded `data_fields` +mask contains the other. The existing desktop identity is deterministic +canonical ownership; Android adopts that PC identity when applying the outbound +catalog. The bitwise union keeps the richer compatible profile. + +All references move inside the relevant SQLite transaction. Session and +occurrence IDs, position, sets, loads, continuous duration/speed/distance, +equipment and draft data remain unchanged. Historical occurrence masks remain +snapshots, so an optional field newly present in the catalog is not invented in +old work. Replaying either direction is idempotent. A different mode, +incomparable masks, conflicting overlapping equipment semantics, or any unsafe +reference condition produces an explicit conflict. A normalized-name match by +itself never authorizes a merge. + +The current Marche case is the compatible `1` (speed) versus `3` (speed and +distance) subset/superset case. The canonical desktop definition is enriched +to `3`; speed-only history stays speed-only and the Android occurrence keeps +its real distance. + +Exercise import reporting separates persistent insertions, persistent +reconciliation changes, and idempotent skips. Resolving a different incoming +ID to an already-compatible canonical desktop row without changing that row is +an idempotent lookup, not a new insertion or a repeated mutation. Accordingly, +the TUI/history summary's `+N exercice(s)` value is exactly the number of new +catalog rows inserted; detailed structured records retain the separate +`exercises_reconciled` and `exercises_skipped` values. + +## 7. PC -> Android catalog The desktop publishes: @@ -171,10 +253,13 @@ trainlog-pc-catalog-v1.json It is a canonical exercise catalog snapshot containing stable profile metadata. Android reconciles the received catalog into its local exercise catalog. +Applying the same catalog again leaves a previously re-keyed exercise on the +canonical PC identity and reports it as an identical skip. Session occurrences, +active-draft references, and equipment links keep their existing row targets. This direction does not overload frozen Trainlog session JSON v1. -## 7. Android sync request +## 8. Android sync request Android writes: @@ -201,7 +286,7 @@ The artifact also carries the request timestamp. A new `request_id` represents a new synchronization request. -## 8. PC sync receipt +## 9. PC sync receipt After processing an Android request, the PC publishes: @@ -232,7 +317,7 @@ PC -> Android catalog count Android accepts a receipt only when its `request_id` matches the pending request. -## 9. Shared desktop engine +## 10. Shared desktop engine Canonical implementation: @@ -256,21 +341,29 @@ Android request -> receipt ``` -One synchronization transaction performs: +The engine has three explicit modes: ```text -mobile snapshot download --> mobile import --> equipment companion import by (session_id, entry_id) --> PC catalog export --> PC mobile V2 export --> PC equipment companion V2 export --> PC catalog MTP publication --> optional receipt publication --> structured run history +a Android -> PC: definition V1 -> mobile V2 -> association V2; no publish +p PC -> Android: definition V1 -> catalog V1 -> mobile V2 (including bodies) + -> association V2; no receive +b bidirectional: complete inbound sequence, then complete outbound sequence ``` -## 10. Concurrency and request consumption +In both directions, definitions are reconciled before V2 artifacts that may +reference their IDs. A mode retains normal validation, transactions, conflict +reporting, and structured history for the work it performs. + +## 11. Conflict reporting and preservation + +Synchronization does not silently overwrite a session, body observation, +equipment association, or equipment definition when stable-identity content +conflicts. The diagnostic identifies the affected stable identity and its +source artifact/direction, then records a concise source summary in the run +history. The conflicting persisted value remains preserved; resolution is an +explicit correction or reconciliation, not a side effect of synchronization. + +## 12. Concurrency and request consumption Synchronization owns: @@ -285,7 +378,7 @@ The TUI manual action waits for the active synchronization lock. After a request is completed and its receipt is published, the request ID is recorded locally so the same request is not processed as a new request again. -## 11. Structured sync history +## 13. Structured sync history Every real run has: @@ -293,6 +386,12 @@ Every real run has: sy_ ``` +Each structured run records the selected synchronization direction as `a`, +`p`, or `b` together with its result summary. Thus every current local-history +entry has a known direction. `direction inconnue` applies only to a legacy row +whose historical representation did not record one; the direction is not +inferred. + Artifacts: ```text @@ -305,9 +404,10 @@ The TUI presents newest runs in a selectable list and opens the detail file with `Enter`. Legacy history rows without a `sync_id` remain readable as list entries but -cannot have structured detail. +cannot have structured detail; a legacy row also has `direction inconnue` only +when its historical representation lacks a direction. -## 12. PC user service +## 14. PC user service Install or refresh: @@ -330,7 +430,7 @@ tail -f ~/.local/state/trainlog/syncd.log No root privilege is required. -## 13. Transport invariants +## 15. Transport invariants Do not regress to: @@ -343,25 +443,34 @@ fake uniform targets for heterogeneous actual sets overloading frozen Trainlog JSON v1 ``` -## 14. Hardware validation - -## 15. Equipment associations V2 and legacy V1 +## 16. Equipment associations V2 and legacy V1 `TRAINLOG_FORMAT_V1` remains frozen. The active companion is `trainlog-equipment-associations-v2.json`, format `trainlog-equipment-associations`, version `2`. Each row is identified by `(session_id, entry_id)` and contains `exercise_id` as consistency metadata, then either `state: set` with a canonical `equipment_id`, or `state: cleared` -for an intentional removal. A missing companion conveys no equipment -information and cannot clear a previously known choice. Unknown canonical IDs, -unknown entries and ambiguous identities reject the companion transaction -explicitly; an unknown equipment reference is never silently changed to null. +for an intentional removal. In the current V2 flow, the mobile snapshot already +carries the occurrence equipment value and the companion validates it. A +missing companion conveys no equipment information and cannot clear a +previously known choice. Unknown canonical IDs, unknown entries, ambiguous +identities and divergent values reject processing explicitly; an unknown +equipment reference is never silently changed to null. + +V2 association semantics are unchanged. A custom equipment ID is accepted only +after its definition V1 artifact has reconciled it on the receiving side. +Unknown IDs, unknown entries, ambiguous identities, and definition conflicts +reject processing explicitly; they are never converted to null or silently +overwritten. The historical V1 companion remains readable only where its `(session_id, exercise_id)` targeting is unambiguous. It cannot represent two occurrences of the same exercise in one session and is not redefined to do so. -Validated on the physical Android device: +## 17. Hardware validation + +The following is a prior hardware baseline; it does not claim device validation +of the definitions V1 or three-mode synchronization change: ```text MTP device discovery PASS @@ -375,3 +484,52 @@ trainlog-syncd processing PASS receipt publication/readback PASS multiple distinct Android request IDs PASS ``` + +The current reconciliation checkpoint pulled the real Android v8 database and +shared-storage artifacts through ADB for read-only inspection, then ran the +production definition, V2 mobile, association, body and outbound exporters on a +coherent desktop v8 copy. The second inbound/outbound replay was stable and both +SQLite integrity checks passed. This is real-data importer/exporter evidence, +not a claim that the current libmtp transport ran inside the sandbox. + +The PC-to-Android idempotence regression additionally feeds artifacts from all +four production PC exporters into the production Android repository importers. +Its first pass imports the missing fixture data; its second and third passes +report zero session, exercise, body-observation, and equipment additions and +leave exact snapshots of every Android business table unchanged. On the real +device, `install -r` of the validated APK preserved the backed-up database hash, +but the requested two live MTP runs remain unexecuted because the sandbox still +fails `libusb_open()` before opening device storage. + +## 18. Audited protocol limitations + +Each mutating importer validates strictly and owns a SQLite transaction. The +V2 association companion only corroborates equipment already imported in the +mobile snapshot and refuses divergent state. A complete +definitions/mobile/associations batch nevertheless has no common generation ID +or cross-file transaction. Independent “newest artifact” selection can +therefore observe a partially published generation; validation stops on a +mismatch, but an earlier artifact may already have committed. Replay is +idempotent and no conflicting local value is overwritten. A future atomic-batch +design requires a new versioned manifest rather than a semantic change to any +published format. + +Snapshots carry no exercise, session, body-observation, or equipment-definition +tombstones. Omission therefore never deletes one of those objects. The only +explicit removal operation is association V2 `state: cleared`, targeted to one +`(session_id, entry_id)`. + +Custom equipment is reconciled by stable `equipment_id`, never by display +name. Different custom IDs may coexist even when their names are conceptually +similar; a name-only merge could corrupt load semantics or occurrence +references and is intentionally forbidden. + +The Android PC-catalog V1 reader validates the required catalog identity and +exercise-profile fields, but does not enforce an exact root/item key set as +strictly as the newer mobile V2 and equipment companions. Tightening that +published V1 reader requires a compatibility review. + +The historical V1 fallback never consumes a V2 equipment companion: V1 carries +no occurrence-level equipment signal, so a neighboring V2 companion belongs to +a different generation and is ignored. This preserves rather than clears +existing equipment. diff --git a/docs/tests.md b/docs/tests.md index cbc0f1f..59e3fb1 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -56,42 +56,54 @@ different ID + equivalent normalized name -> reject new unique identity -> create ``` +That command validates the frozen Trainlog JSON V1 importer contract. Active +mobile V2 synchronization has a separate, stricter safe-reconciliation policy +covered below; it does not modify the frozen V1 expectation. + ## 4. Desktop Meson suite -Current normal suite: +Current registered set (Meson executes tests in parallel, so displayed order is +not contractual): ```text - 1 database - 2 catalog - 3 equipment_catalog - 4 session_detail - 5 duration - 6 body_metrics - 7 bodyviz - 8 exercise_performance - 9 session_type_schema -10 session_edit -11 body_observation_edit -12 mtp -13 continuous_session -14 continuous_detail -15 exercise_profile_schema -16 usb -17 reps -18 variable_sets -19 schema_v5_migration -20 measured_max -21 body_analytics -22 terminal_input_event_type_policy -23 mobile_import_multi_occurrence -24 equipment_associations_exchange -25 mobile_import_variable_sets +database +catalog +equipment_catalog +custom_equipment +session_detail +duration +body_metrics +bodyviz +exercise_performance +session_type_schema +session_edit +body_observation_edit +usb +mtp +exercise_profile_schema +continuous_session +continuous_detail +reps +variable_sets +schema_v5_migration +schema_v7_migration +mobile_import_variable_sets +mobile_import_multi_occurrence +equipment_associations_exchange +equipment_definitions_exchange +exercise_reconciliation +sync_direction +sync_history +sync_screen_action +measured_max +body_analytics +terminal_input_event_type_policy ``` -Validated checkpoint: +Validated current suite: ```text -25/25 PASS +32/32 Meson tests PASS ``` The desktop executable is additionally smoke-checked in isolated tmux PTYs at @@ -107,7 +119,7 @@ Notable regression coverage: - profile-aware exercise constraints; - continuous activity without fake sets; - repetition shorthand/list/pyramid parsing; -- direct v4 -> v7 database migration; +- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration; - heterogeneous mobile-set import; - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a RELEASE event is consumed without creating a second navigation action. @@ -120,6 +132,34 @@ Notable regression coverage: `--database` target, followed by the corrected complete V2 export chain. - stable-ID mobile-to-desktop rename reconciliation without duplicate catalog rows or historical-reference replacement. +- supplied catalogue search/detail, local custom-equipment creation and + selection, resolution of supplied/local occurrence links, and explicit + visibility of an unknown historic equipment reference; +- strict custom-equipment definitions V1 import/export: reserved supplied IDs, + additive omission, equal reimport, and divergent same-ID conflict; +- three-mode shared-engine plans and their directional receive/publish bounds; +- Sync-page action dispatch: direct `a`/`p`/`b` confirmation, one `Enter` run, + `Esc` cancellation without a run, refresh-only `r`, inert retired `s`, and + equivalent full/compact footer direction labels; +- definition-first ordering before V2 artifacts, so associations can resolve + custom IDs without changing V2 shapes. +- exercise reconciliation with identical profiles, compatible subset/superset + masks, explicit incomparable-profile rejection, current Marche identities, + preservation of two historic occurrences and their `entry_id`/continuous + values, ordered custom-equipment definitions/catalog/mobile/association + replay, complete definitions/mobile/associations/body import, outbound + publication, and stable second replay; +- a production PC-exporter to Android-importer regression over definitions, + catalog, mobile V2, and association V2 artifacts: the fixture includes + Marche, Leg press, two Marche occurrences, per-set loads, body data, a + durable draft, and custom equipment; after the first import, both the second + and third imports report zero additions and an exact snapshot of every + Android business table remains unchanged; +- synchronization summary reporting where a reconciliation-only result keeps + `+0 exercice(s)` and only an actual inserted row produces `+1`; +- all fourteen body metrics through the same complete reconciliation import; +- desktop-created occurrence identities use `sxe_`, not the `sy_` + synchronization-run prefix. ## 5. Build @@ -141,7 +181,7 @@ cd android printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties JAVA_HOME=/usr/lib/jvm/java-17-openjdk \ -./gradlew assembleDebug +./gradlew testDebugUnitTest assembleDebug ``` Install to the connected device when hardware behavior changes: @@ -184,6 +224,15 @@ MTP_READ=PASS MTP_ROUNDTRIP=PASS ``` +The automated `sync_direction` regression also covers selection of the newest +Android V2 export, equipment-definitions V1 artifact, and equipment-association +V2 artifact when scoped storage has retained collision-suffixed sibling files. +It rejects non-versioned/ +non-numeric names and uses deterministic ties; JSON validation remains part of +the normal import path. `mobile_import_multi_occurrence` exercises the real +definition importer before the V2 session importer, including idempotent +reimport and rejection of a missing custom definition. + ## 8. Bidirectional synchronization validation Validated workflow: @@ -263,10 +312,10 @@ Coverage proves: - external working loads round to the configured increment; - working-load percentages reject assistance. -Current normal baseline: +Validated current normal suite: ```text -25/25 PASS +32/32 Meson tests PASS ``` ## 12. Body analytics regression @@ -288,17 +337,18 @@ Coverage includes: - missing required circumference handling; - invalid estimation-profile rejection. -Current normal baseline: +Validated current normal suite: ```text -25/25 PASS +32/32 Meson tests PASS ``` ## 13. Android session draft v1 Android schema v4 introduced one durable active draft; the current additive -chain reaches schema v7 without clearing completed history or the draft. The -current host suite covers exercise +chain reaches schema v8 without clearing completed history or the draft. The +current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage +includes exercise shapes and raw partial text, fresh repository restore, remove/discard, atomic finalization and repeated-finalize rejection, rollback, catalog reconciliation, missing-selection recovery, explicit DB-open failure, historical migration, @@ -314,7 +364,9 @@ adb shell am instrument -w -e package com.labfytools.trainlog \ com.labfytools.trainlog.test/androidx.test.runner.AndroidJUnitRunner ``` -The device instrumentation suite has **5 tests**: two real-SQLite repository +The following device instrumentation evidence is a prior baseline, not a +blanket device-validation claim for schema-v8 definitions or three-mode sync. +That suite has **5 tests**: two real-SQLite repository checks and three production-screen Compose UI checks. Coverage includes exact raw form restoration through Activity recreation, cancellation and confirmation of discard, final save with no stale Resume after recreation, and refusal of a @@ -342,4 +394,35 @@ and final state passed SQLite integrity/foreign-key checks. The pre-upgrade DB and preferences backup is outside the repository. No uninstall, package-data clear, desktop schema change or frozen artifact change is part of this repair. +## 14. Current real-data reconciliation validation + +Before mutation, coherent desktop and Android v8 copies were created and passed +`PRAGMA integrity_check` plus `PRAGMA foreign_key_check`. The newest real +Android V2 artifacts were selected, including scoped-storage `(N)` siblings. +The production tools then ran in protocol order on the desktop copy: + +```text +equipment definitions -> exercise/session/body V2 -> equipment associations +-> PC definitions/catalog/mobile/associations export -> second import +``` + +The result retained the desktop Marche identity with catalog +`data_fields = 3`, both older speed-only Marche occurrences, the Android +speed-and-distance occurrence, all original `entry_id`/positions, custom +equipment, set weights and body observations. The retired Android Marche ID had +no remaining desktop catalog/reference owner. The second replay changed no row +counts and both integrity checks remained clean. + +This exercises real artifacts and production import/export code on copies. It +does not substitute for a final direct-MTP run when USB ownership or sandbox +permissions prevent libmtp access. + +For this checkpoint, the USB probe discovered the connected Samsung interface +but libmtp failed at `libusb_open()`. The canonical desktop DB was also +read-only to the sandbox; the first attempted definitions import failed before +mutation, and a repeated dump hash plus integrity/FK checks proved it unchanged. +The Android package remained force-stopped and ADB was used only for read-only +inspection/pull. Applying the validated state to both real stores and running +the two transport directions remains an out-of-sandbox hardware validation. + Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md). diff --git a/docs/tui.md b/docs/tui.md index 1e67a8a..d4423ce 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -17,8 +17,9 @@ Large-layout primary navigation: 1 Séance 2 Historique 3 Exercices -4 Corps -5 Sync +4 Équipements +5 Corps +6 Sync ``` Direct shortcuts include the matching function keys where implemented. @@ -144,9 +145,24 @@ A failed replacement rolls back completely. Removing an exercise from one session does not remove the exercise from the catalog. -## 7. Body tracking +## 7. Equipment -`4 Corps / F4` provides: +`4 Équipements / F4` provides supplied-equipment browsing, search, detail, and +custom-equipment creation and selection. Supplied definitions are generated +from `catalog/equipment-v1.json`; user-created definitions persist in desktop +SQLite and synchronize separately through definitions V1. + +Equipment can be selected while editing an exercise occurrence. Session detail, +exercise history, and historical occurrence views resolve an `equipment_id` +against the supplied manifest or a local custom definition. If an historic ID +is no longer resolvable, the view shows that unknown reference explicitly. +Session detail displays the selected occurrence's stable `entry_id` together +with its resolved equipment, so repeated appearances of one exercise remain +visibly distinct; `i` opens the resolved equipment detail. + +## 8. Body tracking + +`5 Corps / F5` provides: - newest-first body observations; - detail and correction; @@ -157,7 +173,7 @@ catalog. Editing preserves observation identity, timestamp, and optional session link. -## 8. Dashboard +## 9. Dashboard The dashboard includes a rolling 12-month normalized body graph. @@ -172,7 +188,7 @@ Rules include: Detailed raw observations remain in `Corps`. -## 9. Exercise performance +## 10. Exercise performance Exercise detail exposes recorded performance history. @@ -193,9 +209,10 @@ assistance A best recorded set is not automatically a measured maximum. -## 10. Sync page +## 11. Sync page -`5 Sync / F5` uses the shared synchronization engine. +`6 Sync` uses the shared synchronization engine. The supported shortcut is the +numeric `6` key. The page shows: @@ -206,10 +223,37 @@ The page shows: Manual action: ```text -s run bidirectional synchronization +a run Android -> PC synchronization +p run PC -> Android synchronization +b run bidirectional synchronization r refresh device status ``` +`a` imports definitions V1, mobile V2, and associations V2 only. `p` publishes +definitions V1, catalog V1, mobile V2 (including body observations), and +associations V2 only. `b` completes that inbound sequence before beginning the +outbound sequence. + +The direction keys are direct actions: pressing `a`, `p`, or `b` opens one +confirmation for that exact direction; there is no separate mode-selection +step. `Enter` accepts the pending action and can invoke the shared engine only +once. `Esc` cancels a pending confirmation without an operation; when no +confirmation is pending, `Esc` returns from Sync. `r` only refreshes connected +device status. The former `s` synchronization shortcut is retired and inert. + +The footer states the same contract in both layouts. At 100 columns or wider it +reads `a Android→PC p PC→Android b PC↔Android r actualiser Échap retour`; +the compact footer uses `a A→PC p PC→A b A↔PC r act. Échap retour` without +changing any action or direction. + +Before a confirmed run, Sync identifies the selected direction in its progress +feedback. It then shows the completed run summary on success, or a +direction-prefixed diagnostic on failure. Each structured local history run +persists its selected `a`, `p`, or `b` direction, and the history list displays +that direction label alongside its summary. `direction inconnue` is used only +for a legacy entry whose direction was never recorded, rather than being +guessed. + History behaves like a compact Git log: ```text @@ -231,7 +275,7 @@ summary error when applicable ``` -## 11. Shared sync engine +## 12. Shared sync engine The TUI does not own a separate synchronization implementation. @@ -246,7 +290,7 @@ The Android-triggered daemon calls the same engine. This keeps import/export, MTP publication, locking, history, and diagnostics in one implementation. -## 12. Direct MTP +## 13. Direct MTP Transport uses: @@ -257,9 +301,9 @@ libmtp -> storage/object operations No filesystem mount is required. -Raw libmtp output is suppressed while ncurses owns the terminal. +Raw libmtp output is suppressed while Notcurses owns the terminal. -## 13. Error behavior +## 14. Error behavior Input is validated before persistent mutation. @@ -268,20 +312,25 @@ Escape cancels prompts without committing partial edits. Synchronization failure displays a useful final diagnostic and records the structured run when a transaction actually begins. -## 14. Build and test +Conflict diagnostics identify the stable affected identity, the source +artifact/direction, and a concise source summary. They preserve existing +session, body-observation, association, and definition data rather than silently +overwriting it. + +## 15. Build and test ```bash meson compile -C build meson test -C build --print-errorlogs ``` -Current normal suite: +Validated current normal suite: ```text -19/19 PASS +32/32 Meson tests PASS ``` -## 15. Measured max view +## 16. Measured max view From `3 Exercices`, the selected exercise exposes: @@ -321,9 +370,9 @@ less assistance = better No percentage-of-max working load is produced for assistance or no-load performance. -## 16. Body analytics +## 17. Body analytics -`4 Corps` adds: +`5 Corps` adds: ```text v analyse corporelle diff --git a/tests/create_pc_android_idempotence_fixture.py b/tests/create_pc_android_idempotence_fixture.py new file mode 100644 index 0000000..fda906c --- /dev/null +++ b/tests/create_pc_android_idempotence_fixture.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Build a deterministic desktop-v8 fixture through the real PC exporters.""" + +import sqlite3 +import subprocess +import sys +from pathlib import Path + +from test_exercise_reconciliation import SCHEMA + + +ROOT = Path(__file__).resolve().parents[1] +DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d" +LEG_PRESS_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" +PC_ONLY_ID = "ex_7e7cf906-2214-4066-bcb7-c16382d83b3b" +CUSTOM_EQUIPMENT_ID = "eq_44444444-4444-4444-8444-444444444444" +PC_SESSION_ID = "se_11111111-1111-4111-8111-111111111111" +RICH_WALK_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222221" +LEGACY_WALK_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222222" +LEG_PRESS_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222223" +BODY_OBSERVATION_ID = "bo_33333333-3333-4333-8333-333333333333" + + +def run_export(tool, output, database): + result = subprocess.run( + [sys.executable, str(ROOT / "tools" / tool), str(output), + "--database", str(database)], + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("usage: create_pc_android_idempotence_fixture.py OUTPUT_DIR") + output_directory = Path(sys.argv[1]) + output_directory.mkdir(parents=True, exist_ok=True) + database = output_directory / "pc-v8.db" + + connection = sqlite3.connect(database) + connection.executescript(SCHEMA) + connection.executemany( + "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode," + "recording_mode,data_fields) VALUES(?,?,?,?,?,?);", + ( + (DESKTOP_WALK_ID, "Marche", "marche", "duration", "continuous", 3), + (LEG_PRESS_ID, "Leg press", "leg press", "reps", "sets", 0), + (PC_ONLY_ID, "Hip abduction", "hip abduction", "reps", "sets", 0), + ), + ) + connection.execute( + "INSERT INTO custom_equipment(equipment_id,display_name,label_name," + "equipment_type,load_semantics) VALUES(?,?,?,?,?);", + (CUSTOM_EQUIPMENT_ID, "Machine PC custom", "Machine custom", + "custom_machine", "external"), + ) + connection.execute( + "INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);", + (PC_SESSION_ID, "2026-09-08T16:30:00+02:00", "training"), + ) + session_row_id = connection.execute( + "SELECT id FROM sessions WHERE session_id=?;", (PC_SESSION_ID,) + ).fetchone()[0] + + def exercise_row(exercise_id): + return connection.execute( + "SELECT id FROM exercises WHERE exercise_id=?;", (exercise_id,) + ).fetchone()[0] + + first_walk = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id," + "recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'continuous',3,0,'none',0,?);", + (RICH_WALK_ENTRY_ID, session_row_id, exercise_row(DESKTOP_WALK_ID), + CUSTOM_EQUIPMENT_ID), + ).lastrowid + connection.execute( + "INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds," + "speed_kmh,distance_km) VALUES(?,?,?,?);", + (first_walk, 900, 5.5, 1.2), + ) + second_walk = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id," + "recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'continuous',1,1,'none',0,?);", + (LEGACY_WALK_ENTRY_ID, session_row_id, exercise_row(DESKTOP_WALK_ID), + CUSTOM_EQUIPMENT_ID), + ).lastrowid + connection.execute( + "INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds," + "speed_kmh) VALUES(?,?,?);", + (second_walk, 300, 4.0), + ) + leg_press = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id," + "recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'sets',0,2,'none',0,?);", + (LEG_PRESS_ENTRY_ID, session_row_id, exercise_row(LEG_PRESS_ID), + CUSTOM_EQUIPMENT_ID), + ).lastrowid + connection.executemany( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " + "VALUES(?,?,?,?);", + ((leg_press, 0, 9, 80.0), (leg_press, 1, 8, 85.0)), + ) + connection.execute( + "INSERT INTO body_observations(observation_id,observed_at,body_weight_kg," + "waist_cm) VALUES(?,?,?,?);", + (BODY_OBSERVATION_ID, "2026-09-08T08:00:00+02:00", 83.7, 84.4), + ) + connection.commit() + connection.close() + + for tool, filename in ( + ("export_equipment_definitions.py", "trainlog-pc-equipment-definitions-v1.json"), + ("export_pc_catalog.py", "trainlog-pc-catalog-v1.json"), + ("export_pc_mobile.py", "trainlog-pc-mobile-export-v2.json"), + ("export_equipment_associations.py", "trainlog-equipment-associations-v2.json"), + ): + run_export(tool, output_directory / filename, database) + + print("PASS pc_android_idempotence_fixture") + + +if __name__ == "__main__": + main() diff --git a/tests/test_equipment_associations_exchange.py b/tests/test_equipment_associations_exchange.py index 1c7169e..f87e988 100644 --- a/tests/test_equipment_associations_exchange.py +++ b/tests/test_equipment_associations_exchange.py @@ -15,11 +15,12 @@ def make_db(path, equipment): CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE); CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT); CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE, session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT); + CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL, equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL); """) db.execute("INSERT INTO exercises VALUES(1,'ex_fixture')") db.execute("INSERT INTO sessions VALUES(1,'se_fixture','2026-01-01T00:00:00+00:00')") db.execute("INSERT INTO session_exercises VALUES(1,'sxe_fixture',1,1,0,?)", (equipment,)) - db.execute("PRAGMA user_version=7") + db.execute("PRAGMA user_version=8") db.commit(); db.close() @@ -27,14 +28,65 @@ def main(): with tempfile.TemporaryDirectory() as directory: directory = Path(directory) source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json' - make_db(source, 'leg_press'); make_db(target, None) + make_db(source, 'leg_press'); make_db(target, 'leg_press') subprocess.run([sys.executable, ROOT / 'tools/export_equipment_associations.py', artifact, '--database', source], check=True) subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True) assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press' payload = json.loads(artifact.read_text()); payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_fixture','state':'cleared'} artifact.write_text(json.dumps(payload)) - subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True) - assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None + conflict = subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], text=True, capture_output=True) + assert conflict.returncode != 0 and 'conflit association' in conflict.stdout + assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press' + + # A V2 entry ID is not sufficient identity: its exercise ID must also + # agree before an otherwise equal association is treated as a replay. + payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_other','state':'set','equipment_id':'leg_press'} + artifact.write_text(json.dumps(payload)) + exercise_conflict = subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], text=True, capture_output=True) + assert exercise_conflict.returncode != 0 and 'conflit exercice association' in exercise_conflict.stdout + assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press' + + # The importer must validate the entire artifact before applying its + # first otherwise-valid association. v2 cannot transport custom IDs. + prevalidation_artifact = directory / 'prevalidation-rejected.json' + prevalidation_payload = { + 'format': 'trainlog-equipment-associations', + 'version': 2, + 'generated_at': '2026-01-01T00:00:00+00:00', + 'associations': [ + {'session_id': 'se_fixture', 'entry_id': 'sxe_fixture', + 'exercise_id': 'ex_fixture', 'state': 'set', 'equipment_id': 'leg_press'}, + {'session_id': 'se_later', 'entry_id': 'sxe_later', + 'exercise_id': 'ex_later', 'state': 'set', 'equipment_id': 'custom_rack'}, + ], + } + prevalidation_artifact.write_text(json.dumps(prevalidation_payload)) + rejected = subprocess.run( + [sys.executable, ROOT / 'tools/import_equipment_associations.py', prevalidation_artifact, '--database', target], + text=True, + capture_output=True, + ) + assert rejected.returncode != 0 + assert 'session_id=se_later' in rejected.stdout + assert 'entry_id=sxe_later' in rejected.stdout + assert 'equipment_id=custom_rack' in rejected.stdout + assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press' + + # Definition-v1 is ordered before unchanged association-v2, so the + # exporter admits a locally-defined custom stable ID. + custom_source, custom_target = directory / 'custom-source.db', directory / 'custom-target.db' + make_db(custom_source, 'custom_rack'); make_db(custom_target, None) + custom_db = sqlite3.connect(custom_source) + custom_db.execute("INSERT INTO custom_equipment VALUES('custom_rack','Rack','Rack','rack','none')") + custom_db.commit(); custom_db.close() + custom_artifact = directory / 'custom-equipment.json' + exported = subprocess.run( + [sys.executable, ROOT / 'tools/export_equipment_associations.py', custom_artifact, '--database', custom_source], + text=True, + capture_output=True, + ) + assert exported.returncode == 0, exported.stdout + exported.stderr + assert json.loads(custom_artifact.read_text())['associations'][0]['equipment_id'] == 'custom_rack' print('PASS equipment association exchange') diff --git a/tests/test_equipment_definitions_exchange.py b/tests/test_equipment_definitions_exchange.py new file mode 100644 index 0000000..8b35860 --- /dev/null +++ b/tests/test_equipment_definitions_exchange.py @@ -0,0 +1,68 @@ +"""Strict additive custom-equipment definitions-v1 integration test.""" +import json +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXPORTER = ROOT / "tools/export_equipment_definitions.py" +IMPORTER = ROOT / "tools/import_equipment_definitions.py" + + +def database(path): + connection = sqlite3.connect(path) + connection.execute("CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL)") + connection.execute("PRAGMA user_version=8") + connection.commit() + return connection + + +def run(tool, artifact, db): + return subprocess.run([sys.executable, tool, artifact, "--database", db], text=True, capture_output=True) + + +def main(): + with tempfile.TemporaryDirectory(prefix="trainlog-definitions-") as temporary: + root = Path(temporary) + source, target, artifact = root / "source.db", root / "target.db", root / "definitions.json" + connection = database(source) + connection.execute("INSERT INTO custom_equipment VALUES(?,?,?,?,?)", + ("eq_custom", "Presse voyage", "Presse", "custom_machine", "external")) + connection.commit(); connection.close() + database(target).close() + + result = run(EXPORTER, artifact, source) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(artifact.read_text()) + assert payload["format"] == "trainlog-equipment-definitions" and payload["version"] == 1 + assert len(payload["equipment"]) == 1 + first = run(IMPORTER, artifact, target) + assert first.returncode == 0 and "definitions_imported=1" in first.stdout + second = run(IMPORTER, artifact, target) + assert second.returncode == 0 and "definitions_skipped=1" in second.stdout + + # Missing from the next snapshot is not a deletion request. + payload["equipment"] = [] + artifact.write_text(json.dumps(payload)) + assert run(IMPORTER, artifact, target).returncode == 0 + assert sqlite3.connect(target).execute("SELECT count(*) FROM custom_equipment").fetchone()[0] == 1 + + # Divergent same-ID content conflicts without changing the local row. + payload["equipment"] = [{"equipment_id": "eq_custom", "display_name": "Autre", + "label_name": "Presse", "equipment_type": "custom_machine", "load_semantics": "external"}] + artifact.write_text(json.dumps(payload)) + conflict = run(IMPORTER, artifact, target) + assert conflict.returncode != 0 and "conflit définition" in conflict.stdout + assert sqlite3.connect(target).execute("SELECT display_name FROM custom_equipment").fetchone()[0] == "Presse voyage" + + # Supplied-manifest identities cannot be overridden by the companion. + payload["equipment"][0]["equipment_id"] = "leg_press" + artifact.write_text(json.dumps(payload)) + reserved = run(IMPORTER, artifact, target) + assert reserved.returncode != 0 and "réservé" in reserved.stdout + print("PASS equipment definitions exchange") + + +if __name__ == "__main__": main() diff --git a/tests/test_exercise_reconciliation.py b/tests/test_exercise_reconciliation.py new file mode 100644 index 0000000..6b2f682 --- /dev/null +++ b/tests/test_exercise_reconciliation.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +"""Exercise-identity reconciliation and complete V2 round-trip regressions.""" + +import copy +import json +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +IMPORT_MOBILE = ROOT / "tools/import_mobile_export.py" +IMPORT_DEFINITIONS = ROOT / "tools/import_equipment_definitions.py" +IMPORT_ASSOCIATIONS = ROOT / "tools/import_equipment_associations.py" +EXPORT_DEFINITIONS = ROOT / "tools/export_equipment_definitions.py" +EXPORT_CATALOG = ROOT / "tools/export_pc_catalog.py" +EXPORT_MOBILE = ROOT / "tools/export_pc_mobile.py" +EXPORT_ASSOCIATIONS = ROOT / "tools/export_equipment_associations.py" + +DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d" +ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936" +ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a" +ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d" + +SCHEMA = """ +PRAGMA foreign_keys=ON; +CREATE TABLE exercises( + id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, + tracking_mode TEXT NOT NULL, recording_mode TEXT NOT NULL, + data_fields INTEGER NOT NULL +); +CREATE TABLE sessions( + id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, + started_at TEXT NOT NULL, ended_at TEXT, session_type TEXT NOT NULL, notes TEXT +); +CREATE TABLE session_exercises( + id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE, + session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT, + recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, position INTEGER NOT NULL, + load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL, target_sets INTEGER, + target_reps INTEGER, target_duration_seconds INTEGER, target_weight_kg REAL, + equipment_id TEXT, notes TEXT, UNIQUE(session_row_id,position) +); +CREATE TABLE performed_sets( + id INTEGER PRIMARY KEY, + session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE, + position INTEGER NOT NULL, reps INTEGER, duration_seconds INTEGER, weight_kg REAL +); +CREATE TABLE continuous_activity( + id INTEGER PRIMARY KEY, + session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE, + duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL +); +CREATE TABLE body_observations( + id INTEGER PRIMARY KEY, observation_id TEXT NOT NULL UNIQUE, observed_at TEXT NOT NULL, + session_row_id INTEGER, body_weight_kg REAL, neck_cm REAL, shoulders_cm REAL, + chest_cm REAL, waist_cm REAL, hips_cm REAL, left_arm_cm REAL, right_arm_cm REAL, + left_forearm_cm REAL, right_forearm_cm REAL, left_thigh_cm REAL, + right_thigh_cm REAL, left_calf_cm REAL, right_calf_cm REAL, notes TEXT +); +CREATE TABLE custom_equipment( + equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, label_name TEXT NOT NULL, + equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL +); +PRAGMA user_version=8; +""" + + +def run(tool, *arguments, succeeds=True): + result = subprocess.run( + [sys.executable, str(tool), *(str(value) for value in arguments)], + text=True, + capture_output=True, + ) + if succeeds: + assert result.returncode == 0, result.stdout + result.stderr + else: + assert result.returncode != 0, result.stdout + result.stderr + return result.stdout + result.stderr + + +def create_database(path, fields=1, with_history=False): + connection = sqlite3.connect(path) + connection.executescript(SCHEMA) + connection.execute( + "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) " + "VALUES(?,?,?,?,?,?);", + (DESKTOP_WALK_ID, "Marche", "marche", "duration", "continuous", fields), + ) + if with_history: + connection.execute( + "INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);", + ("se_c0d07454-b58b-403e-8b79-744619815fc5", "2026-09-07T19:27:35+02:00", "training"), + ) + session_row = connection.execute("SELECT id FROM sessions").fetchone()[0] + exercise_row = connection.execute("SELECT id FROM exercises").fetchone()[0] + for position, entry_id, duration, speed, equipment in ( + (0, "sxe_draft_legacy_1", 900, 6.6, None), + (8, "sxe_093c1331-beaa-4b69-91b3-240292709be6", 300, 4.0, "treadmill"), + ): + cursor = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id," + "recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'continuous',1,?,'none',0,?);", + (entry_id, session_row, exercise_row, position, equipment), + ) + connection.execute( + "INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,speed_kmh) " + "VALUES(?,?,?);", + (cursor.lastrowid, duration, speed), + ) + connection.commit() + connection.close() + + +def mobile_payload(fields=3): + return { + "format": "trainlog-mobile-export", + "version": 2, + "generated_at": "2026-09-08T15:21:26+02:00", + "exercises": [ + { + "exercise_id": ANDROID_WALK_ID, + "name": "Marche", + "recording_mode": "continuous", + "tracking_mode": "duration", + "data_fields": fields, + }, + { + "exercise_id": "ex_android_strength", + "name": "Machine Android", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0, + }, + ], + "sessions": [ + { + "session_id": ANDROID_SESSION_ID, + "started_at": "2026-09-08T11:25:30+02:00", + "session_type": "max_test", + "exercises": [ + { + "entry_id": ANDROID_WALK_ENTRY_ID, + "position": 0, + "exercise_id": ANDROID_WALK_ID, + "name": "Marche", + "recording_mode": "continuous", + "tracking_mode": "duration", + "data_fields": fields, + "load_mode": "none", + "rest_seconds": 0, + "equipment_id": "treadmill", + "continuous": { + "duration_seconds": 900, + **({"speed_kmh": 5.5} if fields & 1 else {}), + **({"distance_km": 1.2} if fields & 2 else {}), + }, + }, + { + "entry_id": "sxe_android_strength", + "position": 1, + "exercise_id": "ex_android_strength", + "name": "Machine Android", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0, + "load_mode": "none", + "rest_seconds": 0, + "equipment_id": "eq_android_custom", + "sets": [{"reps": 9, "weight_kg": 42.5}], + }, + ], + }, + ], + "body_observations": [ + { + "observation_id": "bo_android_reconciliation", + "observed_at": "2026-09-08T08:00:00+02:00", + "body_weight_kg": 83.7, + "neck_cm": 37.1, + "shoulders_cm": 111.2, + "chest_cm": 99.3, + "waist_cm": 84.4, + "hips_cm": 96.5, + "left_arm_cm": 31.6, + "right_arm_cm": 31.7, + "left_forearm_cm": 27.8, + "right_forearm_cm": 27.9, + "left_thigh_cm": 56.1, + "right_thigh_cm": 56.2, + "left_calf_cm": 37.3, + "right_calf_cm": 37.4, + }, + ], + } + + +def definitions_payload(): + return { + "format": "trainlog-equipment-definitions", + "version": 1, + "generated_at": "2026-09-08T15:21:26+02:00", + "equipment": [ + { + "equipment_id": "eq_android_custom", + "display_name": "Machine Android custom", + "label_name": "Machine custom", + "equipment_type": "custom_machine", + "load_semantics": "external", + }, + ], + } + + +def associations_payload(): + return { + "format": "trainlog-equipment-associations", + "version": 2, + "generated_at": "2026-09-08T15:21:26+02:00", + "associations": [ + { + "session_id": ANDROID_SESSION_ID, + "entry_id": ANDROID_WALK_ENTRY_ID, + "exercise_id": ANDROID_WALK_ID, + "state": "set", + "equipment_id": "treadmill", + }, + { + "session_id": ANDROID_SESSION_ID, + "entry_id": "sxe_android_strength", + "exercise_id": "ex_android_strength", + "state": "set", + "equipment_id": "eq_android_custom", + }, + ], + } + + +def assert_no_integrity_error(connection): + assert connection.execute("PRAGMA integrity_check;").fetchone()[0] == "ok" + assert connection.execute("PRAGMA foreign_key_check;").fetchall() == [] + + +def semantic_state(connection): + """Capture exchanged business state without depending on local rowids.""" + return ( + connection.execute( + "SELECT exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields " + "FROM exercises ORDER BY exercise_id;" + ).fetchall(), + connection.execute( + "SELECT session_id,started_at,ended_at,session_type,notes " + "FROM sessions ORDER BY session_id;" + ).fetchall(), + connection.execute( + "SELECT s.session_id,se.entry_id,e.exercise_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 " + "FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "ORDER BY s.session_id,se.position;" + ).fetchall(), + connection.execute( + "SELECT se.entry_id,ps.position,ps.reps,ps.duration_seconds,ps.weight_kg " + "FROM performed_sets ps JOIN session_exercises se " + "ON se.id=ps.session_exercise_row_id ORDER BY se.entry_id,ps.position;" + ).fetchall(), + connection.execute( + "SELECT se.entry_id,ca.duration_seconds,ca.speed_kmh,ca.distance_km " + "FROM continuous_activity ca JOIN session_exercises se " + "ON se.id=ca.session_exercise_row_id ORDER BY se.entry_id;" + ).fetchall(), + connection.execute( + "SELECT observation_id,observed_at,body_weight_kg,neck_cm,shoulders_cm," + "chest_cm,waist_cm,hips_cm,left_arm_cm,right_arm_cm,left_forearm_cm," + "right_forearm_cm,left_thigh_cm,right_thigh_cm,left_calf_cm,right_calf_cm " + "FROM body_observations ORDER BY observation_id;" + ).fetchall(), + connection.execute( + "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " + "FROM custom_equipment ORDER BY equipment_id;" + ).fetchall(), + ) + + +def main(): + with tempfile.TemporaryDirectory(prefix="trainlog-reconcile-") as directory: + root = Path(directory) + + # A: identical profiles with different IDs use the existing desktop ID. + identical_db = root / "identical.db" + create_database(identical_db, fields=1) + identical = mobile_payload(fields=1) + identical["sessions"] = [] + identical["body_observations"] = [] + identical["exercises"] = identical["exercises"][:1] + identical_path = root / "identical.json" + identical_path.write_text(json.dumps(identical), encoding="utf-8") + first_identical = run( + IMPORT_MOBILE, identical_path, "--database", identical_db, + "--trace-exercises", + ) + assert "exercises_reconciled=0" in first_identical + assert "exercises_skipped=1" in first_identical + assert f'"exercise_id": "{ANDROID_WALK_ID}"' in first_identical + assert f'"lookup": "normalized_name:{DESKTOP_WALK_ID}"' in first_identical + assert '"decision": "existing-identical"' in first_identical + with sqlite3.connect(identical_db) as connection: + assert connection.execute( + "SELECT exercise_id,data_fields FROM exercises" + ).fetchall() == [(DESKTOP_WALK_ID, 1)] + identical_before_replay = semantic_state(connection) + second_identical = run( + IMPORT_MOBILE, identical_path, "--database", identical_db, + "--trace-exercises", + ) + assert "exercises_reconciled=0" in second_identical + assert "exercises_skipped=1" in second_identical + assert '"decision": "existing-identical"' in second_identical + with sqlite3.connect(identical_db) as connection: + assert semantic_state(connection) == identical_before_replay + + # C/G: equal text cannot merge incomparable masks or different modes. + for label, mutate in ( + ("fields", lambda value: value["exercises"][0].update(data_fields=2)), + ("mode", lambda value: value["exercises"][0].update(recording_mode="sets", tracking_mode="duration", data_fields=0)), + ): + conflict_db = root / f"conflict-{label}.db" + create_database(conflict_db, fields=1) + conflict = copy.deepcopy(identical) + mutate(conflict) + conflict_path = root / f"conflict-{label}.json" + conflict_path.write_text(json.dumps(conflict), encoding="utf-8") + output = run( + IMPORT_MOBILE, + conflict_path, + "--database", + conflict_db, + succeeds=False, + ) + assert "profil incompatible entre identités" in output + with sqlite3.connect(conflict_db) as connection: + assert connection.execute( + "SELECT exercise_id,data_fields FROM exercises" + ).fetchall() == [(DESKTOP_WALK_ID, 1)] + + # B/D/E: run the real ordered Android -> PC tools on a v8 copy. + complete_db = root / "complete.db" + create_database(complete_db, fields=1, with_history=True) + mobile_path = root / "trainlog-mobile-export-v2.json" + definitions_path = root / "trainlog-mobile-equipment-definitions-v1.json" + associations_path = root / "trainlog-equipment-associations-v2.json" + mobile_path.write_text(json.dumps(mobile_payload()), encoding="utf-8") + definitions_path.write_text(json.dumps(definitions_payload()), encoding="utf-8") + associations_path.write_text(json.dumps(associations_payload()), encoding="utf-8") + + assert "definitions_imported=1" in run( + IMPORT_DEFINITIONS, definitions_path, "--database", complete_db + ) + assert "sessions_imported=1" in run( + IMPORT_MOBILE, mobile_path, "--database", complete_db + ) + assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in run( + IMPORT_ASSOCIATIONS, + associations_path, + "--database", + complete_db, + "--mobile-export", + mobile_path, + ) + + with sqlite3.connect(complete_db) as connection: + assert connection.execute( + "SELECT exercise_id,data_fields FROM exercises WHERE normalized_name='marche';" + ).fetchall() == [(DESKTOP_WALK_ID, 3)] + assert connection.execute( + "SELECT COUNT(*) FROM exercises WHERE exercise_id=?;", + (ANDROID_WALK_ID,), + ).fetchone()[0] == 0 + rows = connection.execute( + "SELECT s.session_id,se.entry_id,se.position,e.exercise_id,se.data_fields," + "ca.duration_seconds,ca.speed_kmh,ca.distance_km,se.equipment_id " + "FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id " + "WHERE e.normalized_name='marche' ORDER BY s.started_at,se.position;" + ).fetchall() + assert rows == [ + ( + "se_c0d07454-b58b-403e-8b79-744619815fc5", + "sxe_draft_legacy_1", 0, DESKTOP_WALK_ID, 1, + 900, 6.6, None, None, + ), + ( + "se_c0d07454-b58b-403e-8b79-744619815fc5", + "sxe_093c1331-beaa-4b69-91b3-240292709be6", 8, + DESKTOP_WALK_ID, 1, 300, 4.0, None, "treadmill", + ), + ( + ANDROID_SESSION_ID, ANDROID_WALK_ENTRY_ID, 0, + DESKTOP_WALK_ID, 3, 900, 5.5, 1.2, "treadmill", + ), + ] + assert connection.execute( + "SELECT reps,weight_kg FROM performed_sets" + ).fetchall() == [(9, 42.5)] + assert connection.execute( + "SELECT body_weight_kg,neck_cm,shoulders_cm,chest_cm,waist_cm,hips_cm," + "left_arm_cm,right_arm_cm,left_forearm_cm,right_forearm_cm," + "left_thigh_cm,right_thigh_cm,left_calf_cm,right_calf_cm " + "FROM body_observations " + "WHERE observation_id='bo_android_reconciliation';" + ).fetchone() == ( + 83.7, 37.1, 111.2, 99.3, 84.4, 96.5, 31.6, 31.7, + 27.8, 27.9, 56.1, 56.2, 37.3, 37.4, + ) + assert_no_integrity_error(connection) + + # F: replay Android input, publish every PC companion, then re-import + # the merged PC snapshot. The semantic database state must stay fixed. + assert "definitions_skipped=1" in run( + IMPORT_DEFINITIONS, definitions_path, "--database", complete_db + ) + replay_output = run( + IMPORT_MOBILE, mobile_path, "--database", complete_db + ) + assert "sessions_skipped=1" in replay_output + assert "exercises_reconciled=0" in replay_output + assert "exercises_skipped=2" in replay_output + run( + IMPORT_ASSOCIATIONS, + associations_path, + "--database", + complete_db, + "--mobile-export", + mobile_path, + ) + + pc_definitions = root / "trainlog-pc-equipment-definitions-v1.json" + pc_catalog = root / "trainlog-pc-catalog-v1.json" + pc_mobile = root / "trainlog-pc-mobile-export-v2.json" + pc_associations = root / "trainlog-pc-equipment-associations-v2.json" + for tool, output in ( + (EXPORT_DEFINITIONS, pc_definitions), + (EXPORT_CATALOG, pc_catalog), + (EXPORT_MOBILE, pc_mobile), + (EXPORT_ASSOCIATIONS, pc_associations), + ): + run(tool, output, "--database", complete_db) + + exported = json.loads(pc_mobile.read_text(encoding="utf-8")) + walk = next( + item for item in exported["exercises"] + if item["exercise_id"] == DESKTOP_WALK_ID + ) + assert walk["data_fields"] == 3 + old_walks = [ + item + for session in exported["sessions"] + for item in session["exercises"] + if item["entry_id"] in { + "sxe_draft_legacy_1", + "sxe_093c1331-beaa-4b69-91b3-240292709be6", + } + ] + assert len(old_walks) == 2 + assert all(item["data_fields"] == 1 for item in old_walks) + assert all("distance_km" not in item["continuous"] for item in old_walks) + + with sqlite3.connect(complete_db) as connection: + before = semantic_state(connection) + assert "sessions_skipped=2" in run( + IMPORT_MOBILE, pc_mobile, "--database", complete_db + ) + run(IMPORT_ASSOCIATIONS, pc_associations, "--database", complete_db) + with sqlite3.connect(complete_db) as connection: + after = semantic_state(connection) + assert before == after + assert_no_integrity_error(connection) + + print("PASS exercise_reconciliation") + + +if __name__ == "__main__": + main() diff --git a/tests/test_mobile_import_multi_occurrence.py b/tests/test_mobile_import_multi_occurrence.py index a1d3ef9..a5c9150 100644 --- a/tests/test_mobile_import_multi_occurrence.py +++ b/tests/test_mobile_import_multi_occurrence.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """End-to-end V2 import regression: Walk, other movement, Walk again.""" import json +import copy import sqlite3 import subprocess import sys @@ -10,7 +11,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] IMPORTER = ROOT / "tools/import_mobile_export.py" EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.py" -CATALOG_EXPORTER = ROOT / "tools/export_pc_catalog.py" +DEFINITIONS_IMPORTER = ROOT / "tools/import_equipment_definitions.py" MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py" EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.py" @@ -21,7 +22,8 @@ CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNI CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL); CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL); CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT); -PRAGMA user_version=7; +CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL); +PRAGMA user_version=8; """ def payload(): @@ -34,7 +36,7 @@ def payload(): {"entry_id":"sxe_push","position":1,"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":"leg_press","sets":[{"reps":12,"weight_kg":20.0}]}, {"entry_id":"sxe_walk_15","position":2,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":900}}, ] - return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[]} + return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[{"observation_id":"bo_fixture","observed_at":"2026-03-01T08:00:00+01:00","body_weight_kg":72.5}]} def invoke(path, db): result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True) @@ -52,25 +54,110 @@ def invoke_equipment(path, db): raise AssertionError(result.stdout + result.stderr) return result.stdout + +def invoke_definitions(path, db): + result = subprocess.run( + [sys.executable, str(DEFINITIONS_IMPORTER), str(path), "--database", str(db)], + text=True, + capture_output=True, + ) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + return result.stdout + def main(): with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp: root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json" equipment = root / "equipment-v2.json" con = sqlite3.connect(db); con.executescript(SCHEMA); con.close() + + # Mobile v2 cannot transport custom definitions. Reject the complete + # v8 input before catalog/session mutation, with occurrence context. + unknown_db = root / "unknown.db" + con = sqlite3.connect(unknown_db); con.executescript(SCHEMA); con.close() + unknown_artifact = root / "unknown-mobile-v2.json" + unknown_payload = copy.deepcopy(payload()) + unknown_payload["sessions"][0]["exercises"][1]["equipment_id"] = "custom_rack" + unknown_artifact.write_text(json.dumps(unknown_payload), encoding="utf-8") + rejected = subprocess.run( + [sys.executable, str(IMPORTER), str(unknown_artifact), "--database", str(unknown_db)], + text=True, + capture_output=True, + ) + assert rejected.returncode != 0 + assert "session_id=se_multi" in rejected.stderr + assert "entry_id=sxe_push" in rejected.stderr + assert "equipment_id=custom_rack" in rejected.stderr + con = sqlite3.connect(unknown_db) + assert con.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0 + assert con.execute("SELECT count(*) FROM exercises").fetchone()[0] == 0 + con.close() + + # Android -> PC ordering regression: the companion definition is + # imported through its real importer before the V2 snapshot validates + # the entry reference. A replay retains both stable identities and + # never creates a duplicate custom machine or session occurrence. + defined_db = root / "defined.db" + con = sqlite3.connect(defined_db); con.executescript(SCHEMA); con.close() + definitions = root / "mobile-definitions-v1.json" + definitions.write_text(json.dumps({ + "format": "trainlog-equipment-definitions", + "version": 1, + "generated_at": "2026-09-07T10:00:00+02:00", + "equipment": [{ + "equipment_id": "custom_rack", + "display_name": "Rack Android", + "label_name": "Rack", + "equipment_type": "custom_machine", + "load_semantics": "external", + }], + }), encoding="utf-8") + assert "definitions_imported=1" in invoke_definitions(definitions, defined_db) + defined_payload = copy.deepcopy(payload()) + defined_payload["sessions"][0]["exercises"][1]["equipment_id"] = "custom_rack" + defined_artifact = root / "defined-mobile-v2.json" + defined_artifact.write_text(json.dumps(defined_payload), encoding="utf-8") + assert "sessions_imported=1" in invoke(defined_artifact, defined_db) + con = sqlite3.connect(defined_db) + assert con.execute( + "SELECT display_name,label_name,equipment_type,load_semantics " + "FROM custom_equipment WHERE equipment_id='custom_rack'" + ).fetchone() == ("Rack Android", "Rack", "custom_machine", "external") + assert con.execute( + "SELECT equipment_id FROM session_exercises WHERE entry_id='sxe_push'" + ).fetchone() == ("custom_rack",) + con.close() + assert "definitions_skipped=1" in invoke_definitions(definitions, defined_db) + assert "sessions_skipped=1" in invoke(defined_artifact, defined_db) + artifact.write_text(json.dumps(payload()), encoding="utf-8") assert "sessions_imported=1" in invoke(artifact, db) con = sqlite3.connect(db) rows = con.execute("SELECT e.exercise_id,se.entry_id,se.position,ca.duration_seconds FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id ORDER BY se.position").fetchall() assert rows == [("ex_walk","sxe_walk_10",0,600),("ex_push","sxe_push",1,None),("ex_walk","sxe_walk_15",2,900)], rows assert con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0 + assert "sessions_skipped=1" in invoke(artifact, db) con.close() + divergent = copy.deepcopy(payload()) + divergent["sessions"][0]["exercises"][1]["sets"][0]["reps"] = 13 + artifact.write_text(json.dumps(divergent), encoding="utf-8") + rejected = subprocess.run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)], text=True, capture_output=True) + assert rejected.returncode != 0 and "conflit de contenu" in rejected.stderr + assert sqlite3.connect(db).execute("SELECT reps FROM performed_sets").fetchone()[0] == 12 + divergent = copy.deepcopy(payload()) + divergent["body_observations"][0]["body_weight_kg"] = 73.0 + artifact.write_text(json.dumps(divergent), encoding="utf-8") + rejected = subprocess.run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)], text=True, capture_output=True) + assert rejected.returncode != 0 and "conflit observation corporelle" in rejected.stderr + artifact.write_text(json.dumps(payload()), encoding="utf-8") + equipment.write_text(json.dumps({ "format": "trainlog-equipment-associations", "version": 2, "generated_at": "2026-09-07T10:00:00+02:00", "associations": [ - {"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "set", "equipment_id": "treadmill"}, + {"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "cleared"}, {"session_id": "se_multi", "entry_id": "sxe_push", "exercise_id": "ex_push", "state": "set", "equipment_id": "leg_press"}, {"session_id": "se_multi", "entry_id": "sxe_walk_15", "exercise_id": "ex_walk", "state": "cleared"}, ], @@ -92,13 +179,12 @@ def main(): "SELECT entry_id,equipment_id FROM session_exercises ORDER BY position" ).fetchall() assert rows == [ - ("sxe_walk_10", "treadmill"), + ("sxe_walk_10", None), ("sxe_push", "leg_press"), ("sxe_walk_15", None), ], rows con.close() - assert "sessions_reconciled=1" in invoke(artifact, db) assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db) con = sqlite3.connect(db) assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3 @@ -107,11 +193,9 @@ def main(): ).fetchall() == rows con.close() - catalog_output = root / "pc-catalog.json" mobile_output = root / "pc-mobile-v2.json" equipment_output = root / "pc-equipment-v2.json" for tool, output, marker in ( - (CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"), (MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"), (EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"), ): @@ -122,6 +206,26 @@ def main(): ) assert result.returncode == 0, result.stdout + result.stderr assert marker in result.stdout + assert json.loads(mobile_output.read_text())["body_observations"][0]["body_weight_kg"] == 72.5 + # The desktop serializer is also accepted as an idempotent V2 replay, + # which is the PC endpoint of the Android importer/exporter round trip. + assert "body_skipped=1" in invoke(mobile_output, db) + + # Definitions travel in their companion; unchanged v2 references may + # therefore contain a locally-known custom stable ID. + con = sqlite3.connect(db) + con.execute("INSERT INTO custom_equipment VALUES('custom_rack','Rack','Rack','rack','none')") + con.execute("UPDATE session_exercises SET equipment_id='custom_rack' WHERE entry_id='sxe_push'") + con.commit() + con.close() + custom_output = root / "custom-mobile-v2.json" + exported = subprocess.run( + [sys.executable, str(MOBILE_EXPORTER), str(custom_output), "--database", str(db)], + text=True, + capture_output=True, + ) + assert exported.returncode == 0, exported.stdout + exported.stderr + assert json.loads(custom_output.read_text())["sessions"][0]["exercises"][1]["equipment_id"] == "custom_rack" print("PASS mobile_import_multi_occurrence") if __name__ == "__main__": main() diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index a0021c5..4988ab3 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -8,16 +8,30 @@ from datetime import datetime from pathlib import Path +DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" + + +def load_supplied_equipment_ids(path): + catalog = json.loads(path.read_text(encoding="utf-8")) + if catalog.get("format") != "trainlog-equipment-catalog" or catalog.get("version") != 1: + raise ValueError("catalogue équipement v1 invalide") + return {item["id"] for item in catalog["equipment"]} + + def main(): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, 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) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] != 7: - raise ValueError("schema desktop v7 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] != 8: + raise ValueError("schema desktop v8 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")) rows = connection.execute( "SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se " "JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id " @@ -25,6 +39,14 @@ def main(): payload = {"format": "trainlog-equipment-associations", "version": 2, "generated_at": datetime.now().astimezone().isoformat(), "associations": []} for session_id, entry_id, exercise_id, equipment_id in rows: + # CONTRACT: definitions-v1 is published before this unchanged v2 + # reference artifact, so every referenced custom ID must exist. + if equipment_id is not None and equipment_id not in known_equipment: + raise ValueError( + "équipement non transportable " + f"session_id={session_id} entry_id={entry_id} " + f"equipment_id={equipment_id}" + ) item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id, "state": "set" if equipment_id is not None else "cleared"} if equipment_id is not None: diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py new file mode 100644 index 0000000..1306c67 --- /dev/null +++ b/tools/export_equipment_definitions.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Export user-created equipment definitions without changing association v2.""" +import argparse +import json +import os +import sqlite3 +from datetime import datetime +from pathlib import Path + +FORMAT = "trainlog-equipment-definitions" +VERSION = 1 + + +def default_database(): + return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db" + + +def main(): + parser = argparse.ArgumentParser() + 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.row_factory = sqlite3.Row + try: + if connection.execute("PRAGMA user_version").fetchone()[0] != 8: + raise ValueError("schema desktop v8 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")] + payload = {"format": FORMAT, "version": VERSION, + "generated_at": datetime.now().astimezone().isoformat(), + "equipment": equipment} + args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + print("EQUIPMENT_DEFINITIONS_EXPORT=PASS") + print(f"definitions={len(equipment)}") + finally: + connection.close() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"EQUIPMENT_DEFINITIONS_EXPORT=FAIL {error}") + raise SystemExit(1) diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 9ebefee..3b84f71 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -61,7 +61,10 @@ def main(): "PRAGMA user_version;" ).fetchone()[0] - if version != 7: + # 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 != 8: raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 1e29ea1..8f798fa 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -8,10 +8,20 @@ from datetime import datetime from pathlib import Path +CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" + + def default_database(): return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db" +def supplied_equipment_ids(): + catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + if catalog.get("format") != "trainlog-equipment-catalog" or catalog.get("version") != 1: + raise ValueError("catalog/equipment-v1.json invalide") + return {item["id"] for item in catalog["equipment"]} + + def main(): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) @@ -20,8 +30,11 @@ def main(): con = sqlite3.connect(args.database) con.row_factory = sqlite3.Row try: - if con.execute("PRAGMA user_version").fetchone()[0] != 7: - raise ValueError("schema desktop v7 requis") + if con.execute("PRAGMA user_version").fetchone()[0] != 8: + raise ValueError("schema desktop v8 requis") + known_equipment = supplied_equipment_ids() + known_equipment.update(row[0] for row in con.execute( + "SELECT equipment_id FROM custom_equipment")) root = {"format": "trainlog-mobile-export", "version": 2, "generated_at": datetime.now().astimezone().isoformat(), "exercises": [], "sessions": [], "body_observations": []} @@ -34,6 +47,14 @@ def main(): # 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 FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position" for entry in con.execute(sql, (session["id"],)): + # CONTRACT: references remain in mobile-export v2 unchanged; + # definitions-v1 travels first and makes custom IDs resolvable. + if entry["equipment_id"] is not None and entry["equipment_id"] not in known_equipment: + raise ValueError( + "équipement non transportable " + f"session_id={session['session_id']} " + f"entry_id={entry['entry_id']}: {entry['equipment_id']}" + ) item = {"entry_id": entry["entry_id"], "position": entry["position"], "exercise_id": entry["exercise_id"], "name": entry["name"], "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], @@ -52,6 +73,15 @@ def main(): item["sets"].append(set_value) payload["exercises"].append(item) root["sessions"].append(payload) + metric_names = ("body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", + "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", + "left_forearm_cm", "right_forearm_cm", "left_thigh_cm", + "right_thigh_cm", "left_calf_cm", "right_calf_cm") + columns = ",".join(("observation_id", "observed_at") + metric_names) + for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"): + item = {"observation_id": row["observation_id"], "observed_at": row["observed_at"]} + item.update({name: row[name] for name in metric_names if row[name] is not None}) + root["body_observations"].append(item) args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") print("PC_MOBILE_EXPORT=PASS") print("sessions=" + str(len(root["sessions"]))) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index ab80339..781f8a9 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -20,54 +20,139 @@ def load_catalog(path): return {item["id"] for item in root["equipment"]} +def validate_associations(associations, known): + """Validate the complete v2 payload before any database mutation.""" + validated = [] + seen = set() + for item in associations: + if not isinstance(item, dict): + fail("association invalide") + session_id, entry_id, exercise_id, state = ( + item.get(key) for key in ("session_id", "entry_id", "exercise_id", "state")) + key = (session_id, entry_id) + if (not isinstance(session_id, str) or not session_id or + not isinstance(entry_id, str) or not entry_id or + not isinstance(exercise_id, str) or not exercise_id or key in seen): + fail("identité association invalide ou dupliquée") + seen.add(key) + if state == "set": + if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}: + fail("association set invalide") + equipment_id = item["equipment_id"] + # CONTRACT: the definitions-v1 companion is imported first, so a + # custom ID is as valid here as a supplied catalog ID. + if not isinstance(equipment_id, str) or equipment_id not in known: + fail("équipement inconnu: " + f"session_id={session_id} entry_id={entry_id} equipment_id={equipment_id}") + elif state == "cleared": + if set(item) != {"session_id", "entry_id", "exercise_id", "state"}: + fail("association cleared invalide") + equipment_id = None + else: + fail("état association invalide") + validated.append((session_id, entry_id, exercise_id, equipment_id)) + return validated + + +def load_mobile_occurrences(path): + """Return the source V2 identity claimed for every stable occurrence.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if ( + payload.get("format") != "trainlog-mobile-export" + or payload.get("version") != 2 + or not isinstance(payload.get("sessions"), list) + ): + fail("snapshot mobile V2 de preuve invalide") + + occurrences = {} + for session in payload["sessions"]: + if not isinstance(session, dict) or not isinstance( + session.get("exercises"), list + ): + fail("snapshot mobile V2 de preuve invalide") + session_id = session.get("session_id") + for item in session["exercises"]: + if not isinstance(item, dict): + fail("snapshot mobile V2 de preuve invalide") + entry_id = item.get("entry_id") + exercise_id = item.get("exercise_id") + key = (session_id, entry_id) + if ( + not isinstance(session_id, str) + or not session_id + or not isinstance(entry_id, str) + or not entry_id + or not isinstance(exercise_id, str) + or not exercise_id + or key in occurrences + ): + fail("identité du snapshot mobile V2 de preuve invalide") + occurrences[key] = exercise_id + return occurrences + + def main(): parser = argparse.ArgumentParser() parser.add_argument("artifact", type=Path) parser.add_argument("--database", type=Path, required=True) parser.add_argument("--catalog", type=Path, default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json") + parser.add_argument( + "--mobile-export", + type=Path, + help=( + "snapshot mobile V2 importé juste avant ce companion; requis " + "pour prouver un exercise_id réconcilié" + ), + ) args = parser.parse_args() payload = json.loads(args.artifact.read_text(encoding="utf-8")) if payload.get("format") != FORMAT or payload.get("version") != VERSION: fail("extension équipement non supportée") if set(payload) != {"format", "version", "generated_at", "associations"}: fail("clés extension équipement invalides") - known = load_catalog(args.catalog) connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] != 7: - fail("schema desktop v7 requis") - seen = set() - with connection: - for item in payload["associations"]: - if not isinstance(item, dict): - fail("association invalide") - session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state")) - key = (session_id, entry_id) - if not isinstance(session_id, str) or not session_id or not isinstance(entry_id, str) or not entry_id or not isinstance(exercise_id, str) or not exercise_id or key in seen: - fail("identité association invalide ou dupliquée") - seen.add(key) - if state == "set": - if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}: - fail("association set invalide") - equipment_id = item["equipment_id"] - if not isinstance(equipment_id, str) or equipment_id not in known: - fail(f"équipement inconnu: {equipment_id}") - elif state == "cleared": - if set(item) != {"session_id", "entry_id", "exercise_id", "state"}: - fail("association cleared invalide") - equipment_id = None - else: - fail("état association invalide") - cursor = connection.execute( - "UPDATE session_exercises SET equipment_id=? WHERE id=(" - "SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id " - "WHERE s.session_id=? AND se.entry_id=?)", - (equipment_id, session_id, entry_id)) - if cursor.rowcount != 1: - fail(f"entrée séance inconnue: {session_id}/{entry_id}") + if connection.execute("PRAGMA user_version;").fetchone()[0] != 8: + fail("schema desktop v8 requis") + known = load_catalog(args.catalog) + known.update(row[0] for row in connection.execute( + "SELECT equipment_id FROM custom_equipment")) + associations = validate_associations(payload["associations"], known) + proof_path = args.mobile_export + if proof_path is None: + candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") + if candidate.exists(): + proof_path = candidate + mobile_occurrences = ( + load_mobile_occurrences(proof_path) + if proof_path is not None + else {} + ) + for session_id, entry_id, exercise_id, equipment_id in associations: + exists = connection.execute( + "SELECT e.exercise_id,se.equipment_id FROM session_exercises se " + "JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id " + "WHERE s.session_id=? AND se.entry_id=?", + (session_id, entry_id)).fetchone() + if exists is None: + fail(f"entrée séance inconnue: {session_id}/{entry_id}") + if exists[0] != exercise_id: + proof = mobile_occurrences.get((session_id, entry_id)) + incoming_still_exists = connection.execute( + "SELECT 1 FROM exercises WHERE exercise_id=?;", + (exercise_id,), + ).fetchone() is not None + if proof != exercise_id or incoming_still_exists: + fail(f"conflit exercice association: {session_id}/{entry_id}") + # WHY: import_mobile_export may have replaced a safe duplicate + # creator ID with the canonical desktop ID. entry_id is the V2 + # occurrence identity; the just-validated source snapshot proves + # that this stale exercise_id belongs to that same occurrence. + if exists[1] != equipment_id: + fail(f"conflit association équipement: {session_id}/{entry_id}") print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") - print(f"associations={len(seen)}") + print(f"associations={len(associations)}") finally: connection.close() diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py new file mode 100644 index 0000000..9d6b713 --- /dev/null +++ b/tools/import_equipment_definitions.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Add/reconcile a strict custom-equipment definitions-v1 snapshot.""" +import argparse +import json +import sqlite3 +from pathlib import Path + +FORMAT = "trainlog-equipment-definitions" +VERSION = 1 +ROOT_KEYS = {"format", "version", "generated_at", "equipment"} +ITEM_KEYS = {"equipment_id", "display_name", "label_name", "equipment_type", "load_semantics"} +SEMANTICS = {"none", "external", "assistance"} + + +def fail(message): + raise ValueError(message) + + +def supplied_ids(path): + root = json.loads(path.read_text(encoding="utf-8")) + if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1: + fail("catalogue équipement fourni non supporté") + return {item["id"] for item in root["equipment"]} + + +def validate(payload, reserved): + if not isinstance(payload, dict) or set(payload) != ROOT_KEYS: + fail("clés racine définitions équipement invalides") + if payload["format"] != FORMAT or payload["version"] != VERSION: + fail("définitions équipement non supportées") + if not isinstance(payload["generated_at"], str) or not payload["generated_at"]: + fail("generated_at invalide") + if not isinstance(payload["equipment"], list): + fail("equipment doit être un tableau") + output, seen = [], set() + for index, item in enumerate(payload["equipment"]): + if not isinstance(item, dict) or set(item) != ITEM_KEYS: + fail(f"equipment[{index}]: clés invalides") + values = tuple(item[key] for key in + ("equipment_id", "display_name", "label_name", "equipment_type", "load_semantics")) + if any(not isinstance(value, str) for value in values): + fail(f"equipment[{index}]: chaînes attendues") + equipment_id, display_name, label_name, equipment_type, load_semantics = values + if (not equipment_id or equipment_id in seen or equipment_id in reserved or + not display_name.strip() or len(display_name) > 120 or + not equipment_type or load_semantics not in SEMANTICS): + fail(f"equipment[{index}]: définition invalide ou ID fourni réservé: {equipment_id}") + seen.add(equipment_id) + output.append(values) + return output + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("artifact", type=Path) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--catalog", type=Path, + 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) + try: + if connection.execute("PRAGMA user_version").fetchone()[0] != 8: + fail("schema desktop v8 requis") + imported = skipped = 0 + # Validate every same-ID row before inserting any definition. + for definition in definitions: + row = connection.execute( + "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " + "FROM custom_equipment WHERE equipment_id=?", (definition[0],)).fetchone() + if row is not None and tuple(row) != definition: + fail(f"conflit définition équipement: {definition[0]}") + with connection: + for definition in definitions: + cursor = connection.execute( + "INSERT OR IGNORE INTO custom_equipment" + "(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?,?,?,?,?)", + definition) + if cursor.rowcount == 1: + imported += 1 + else: + skipped += 1 + print("EQUIPMENT_DEFINITIONS_IMPORT=PASS") + print(f"definitions_imported={imported}") + print(f"definitions_skipped={skipped}") + finally: + connection.close() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"EQUIPMENT_DEFINITIONS_IMPORT=FAIL {error}") + raise SystemExit(1) diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index e3d8853..b0486fa 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -11,6 +11,11 @@ from pathlib import Path FORMAT = "trainlog-mobile-export" VERSION = 1 KNOWN_DATA_FIELDS = 3 +DEFAULT_EQUIPMENT_CATALOG = ( + Path(__file__).resolve().parents[1] + / "catalog" + / "equipment-v1.json" +) TOP_LEVEL_KEYS = { "format", @@ -236,6 +241,38 @@ def load_payload(path): return payload +def load_supplied_equipment_ids(path): + try: + with path.open("r", encoding="utf-8") as handle: + catalog = json.load(handle) + except (OSError, json.JSONDecodeError) as error: + raise ImportFailure( + f"lecture catalogue équipement impossible: {error}" + ) from error + + if ( + not isinstance(catalog, dict) + or catalog.get("format") != "trainlog-equipment-catalog" + or catalog.get("version") != 1 + or not isinstance(catalog.get("equipment"), list) + ): + raise ImportFailure("catalogue équipement v1 invalide") + + known = set() + for index, item in enumerate(catalog["equipment"]): + if not isinstance(item, dict): + raise ImportFailure( + f"catalogue équipement v1 invalide: equipment[{index}]" + ) + known.add( + require_nonempty_string( + item.get("id"), + f"catalogue.equipment[{index}].id", + ) + ) + return known + + def validate_profile( recording_mode, tracking_mode, @@ -281,6 +318,7 @@ def validate_profile( def validate_exercises(payload): seen_ids = set() + profiles = {} for index, item in enumerate( payload["exercises"] @@ -318,7 +356,9 @@ def validate_exercises(payload): label, ) - return seen_ids + profiles[exercise_id] = item + + return profiles def validate_set_item( @@ -406,6 +446,19 @@ 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" + ) + if item["load_mode"] != "none": raise ImportFailure( f"{label}: mobile export v1 exige load_mode=none" @@ -516,6 +569,7 @@ def validate_session_exercise( def validate_sessions( payload, known_exercise_ids, + known_equipment_ids, ): seen_ids = set() @@ -582,6 +636,22 @@ def validate_sessions( known_exercise_ids, ) + equipment_id = exercise.get("equipment_id") + # CONTRACT: mobile export v2 carries only references whose supplied + # or custom definition is already known. Validate before run_import + # opens a transaction so an unknown ID cannot create partial history. + if ( + payload["version"] == 2 + and equipment_id is not None + and equipment_id not in known_equipment_ids + ): + raise ImportFailure( + "équipement inconnu " + f"session_id={session_id} " + f"entry_id={exercise['entry_id']} " + f"equipment_id={equipment_id}" + ) + exercise_id = exercise["exercise_id"] identity = exercise.get("entry_id", exercise_id) @@ -649,7 +719,7 @@ def validate_body(payload): ) -def validate_payload(payload): +def validate_payload(payload, known_equipment_ids): exercise_ids = validate_exercises( payload ) @@ -657,19 +727,22 @@ def validate_payload(payload): validate_sessions( payload, exercise_ids, + known_equipment_ids, ) validate_body(payload) -def require_schema_v5(connection): +def require_supported_schema(connection): version = connection.execute( "PRAGMA user_version;" ).fetchone()[0] - if version not in (5, 6, 7): + # CONTRACT: desktop startup migrates canonical databases through v8; the + # mobile-v2 tables used below retain their v7 shape in v8. + if version not in (5, 6, 7, 8): raise ImportFailure( - f"base desktop schema v5, v6 ou v7 attendue, version trouvée: {version}" + f"base desktop schema v5, v6, v7 ou v8 attendue, version trouvée: {version}" ) @@ -715,17 +788,94 @@ def lookup_exercise_by_normalized( ).fetchone() -def profile_matches( - row, - exercise, -): +def data_fields_are_comparable(left, right): + """Return true only when either bounded field mask contains the other.""" + return (left & ~right) == 0 or (right & ~left) == 0 + + +def trace_exercise_decision(enabled, exercise, lookup, decision): + """Emit an opt-in structured diagnostic without changing import state.""" + if not enabled: + return + print( + "EXERCISE_DECISION=" + json.dumps( + { + "exercise_id": exercise["exercise_id"], + "name": exercise["name"], + "recording_mode": exercise["recording_mode"], + "tracking_mode": exercise["tracking_mode"], + "data_fields": exercise["data_fields"], + "lookup": lookup, + "decision": decision, + }, + ensure_ascii=False, + sort_keys=True, + ), + file=sys.stderr, + ) + + +def profiles_are_reconcilable(row, exercise): + """Check the complete exercise-profile boundary carried by mobile V2. + + Name equality is deliberately checked by the caller: it is a collision + precondition, never sufficient identity evidence by itself. + """ return ( - row["tracking_mode"] - == exercise["tracking_mode"] - and row["recording_mode"] - == exercise["recording_mode"] - and row["data_fields"] - == exercise["data_fields"] + row["tracking_mode"] == exercise["tracking_mode"] + and row["recording_mode"] == exercise["recording_mode"] + and data_fields_are_comparable( + row["data_fields"], exercise["data_fields"] + ) + ) + + +def profile_conflict(row, exercise): + return ( + "profil incompatible entre identités " + f"{row['exercise_id']} et {exercise['exercise_id']}: " + f"desktop={row['recording_mode']}/{row['tracking_mode']}/" + f"data_fields={row['data_fields']}, " + f"entrant={exercise['recording_mode']}/{exercise['tracking_mode']}/" + f"data_fields={exercise['data_fields']}" + ) + + +def enrich_desktop_profile(connection, row, exercise): + """Retain the richer compatible capability without rewriting history.""" + # CONTRACT: field values are a bit mask, not an ordered enumeration. The + # prior comparability check proves that this union is one existing superset. + richer_fields = row["data_fields"] | exercise["data_fields"] + if richer_fields != row["data_fields"]: + # INVARIANT: session_exercises.data_fields remains untouched. Missing + # optional values therefore remain NULL instead of being invented. + connection.execute( + "UPDATE exercises SET data_fields=? WHERE id=?;", + (richer_fields, row["id"]), + ) + return richer_fields + + +def merge_desktop_exercise_rows(connection, canonical, retired): + """Move the complete current desktop FK graph before deleting a duplicate.""" + # WHY: the current v8 desktop schema has exactly one exercise-row FK owner. + # Keeping this operation explicit makes a future schema addition fail its + # reconciliation tests instead of silently leaving a dangling identity. + connection.execute( + "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", + (canonical["id"], retired["id"]), + ) + remaining = connection.execute( + "SELECT COUNT(*) FROM session_exercises WHERE exercise_row_id=?;", + (retired["id"],), + ).fetchone()[0] + if remaining != 0: + raise ImportFailure( + f"références résiduelles vers {retired['exercise_id']}" + ) + connection.execute( + "DELETE FROM exercises WHERE id=?;", + (retired["id"],), ) @@ -733,6 +883,7 @@ def import_exercises( connection, payload, report, + trace_exercises=False, ): mapping = {} @@ -748,12 +899,18 @@ def import_exercises( ) if by_id is not None: - if not profile_matches( + if not profiles_are_reconcilable( by_id, exercise, ): + trace_exercise_decision( + trace_exercises, + exercise, + f"exercise_id:{by_id['exercise_id']}", + "conflict", + ) raise ImportFailure( - f"profil incompatible pour {exercise_id}" + profile_conflict(by_id, exercise) ) by_name = lookup_exercise_by_normalized( @@ -764,10 +921,46 @@ def import_exercises( by_name is not None and by_name["id"] != by_id["id"] ): - raise ImportFailure( - "conflit de nom pour l'identité " - + exercise_id + if not profiles_are_reconcilable(by_name, by_id): + trace_exercise_decision( + trace_exercises, + exercise, + "exercise_id:" + f"{by_id['exercise_id']};normalized_name:" + f"{by_name['exercise_id']}", + "conflict", + ) + raise ImportFailure(profile_conflict(by_name, by_id)) + + # CONTRACT: the row already owning the normalized desktop name + # is the deterministic canonical identity. All row references + # move transactionally; entry_id/session_id and child values do + # not change. + richer_fields = ( + by_name["data_fields"] + | by_id["data_fields"] + | exercise["data_fields"] ) + connection.execute( + "UPDATE exercises SET data_fields=? WHERE id=?;", + (richer_fields, by_name["id"]), + ) + merge_desktop_exercise_rows(connection, by_name, by_id) + mapping[exercise_id] = by_name["exercise_id"] + report["exercises_reconciled"] += 1 + trace_exercise_decision( + trace_exercises, + exercise, + "exercise_id:" + f"{by_id['exercise_id']};normalized_name:" + f"{by_name['exercise_id']}", + "existing-reconciled", + ) + continue + + richer_fields = enrich_desktop_profile( + connection, by_id, exercise + ) # CONTRACT: exercise_id is the synchronization identity. A rename # updates metadata in place, retaining every historical and draft @@ -779,22 +972,34 @@ def import_exercises( connection.execute( """ UPDATE exercises - SET name = ?, normalized_name = ? + SET name = ?, normalized_name = ?, data_fields = ? WHERE id = ?; """, ( exercise["name"], normalized, + richer_fields, by_id["id"], ), ) report["exercises_reconciled"] += 1 + decision = "existing-reconciled" + elif richer_fields != by_id["data_fields"]: + report["exercises_reconciled"] += 1 + decision = "existing-reconciled" else: report["exercises_skipped"] += 1 + decision = "existing-identical" mapping[exercise_id] = ( by_id["exercise_id"] ) + trace_exercise_decision( + trace_exercises, + exercise, + f"exercise_id:{by_id['exercise_id']}", + decision, + ) continue by_name = lookup_exercise_by_normalized( @@ -803,20 +1008,34 @@ def import_exercises( ) if by_name is not None: - if not profile_matches( - by_name, - exercise, - ): - raise ImportFailure( - "conflit de profil pour le nom " - + exercise["name"] + if not profiles_are_reconcilable(by_name, exercise): + # Name equality alone remains insufficient: incompatible modes + # or incomparable optional-field masks are an explicit conflict. + trace_exercise_decision( + trace_exercises, + exercise, + f"normalized_name:{by_name['exercise_id']}", + "conflict", ) + raise ImportFailure(profile_conflict(by_name, exercise)) - mapping[exercise_id] = ( - by_name["exercise_id"] + richer_fields = enrich_desktop_profile(connection, by_name, exercise) + mapping[exercise_id] = by_name["exercise_id"] + if richer_fields != by_name["data_fields"]: + report["exercises_reconciled"] += 1 + decision = "existing-reconciled" + else: + # INVARIANT: resolving an already-compatible creator ID to the + # persisted canonical row is idempotent lookup work, not a new + # persistent reconciliation on every snapshot replay. + report["exercises_skipped"] += 1 + decision = "existing-identical" + trace_exercise_decision( + trace_exercises, + exercise, + f"normalized_name:{by_name['exercise_id']}", + decision, ) - - report["exercises_reconciled"] += 1 continue connection.execute( @@ -842,6 +1061,12 @@ def import_exercises( mapping[exercise_id] = exercise_id report["exercises_imported"] += 1 + trace_exercise_decision( + trace_exercises, + exercise, + "none", + "inserted", + ) return mapping @@ -1037,7 +1262,17 @@ def import_sessions( "SELECT s.id FROM sessions s WHERE s.session_id=?;", (session["session_id"],)).fetchone() session_row_id = existing[0] - incoming = [(x["entry_id"], x["exercise_id"]) for x in session["exercises"]] + incoming = [ + ( + item["entry_id"], + exercise_mapping.get(item["exercise_id"]), + ) + for item in session["exercises"] + ] + if any(exercise_id is None for _, exercise_id in incoming): + raise ImportFailure( + "mapping exercice absent pour " + session["session_id"] + ) rows = connection.execute( "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=? ORDER BY se.position;", (session_row_id,)).fetchall() @@ -1047,6 +1282,16 @@ def import_sessions( # 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]): raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"]) + if not legacy and session_semantically_matches( + connection, + session_row_id, + session, + exercise_mapping, + ): + report["sessions_skipped"] += 1 + continue + if not legacy: + raise ImportFailure("conflit de contenu pour " + session["session_id"]) # 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,)) @@ -1103,8 +1348,7 @@ def import_sessions( != item["tracking_mode"] or row["recording_mode"] != item["recording_mode"] - or row["data_fields"] - != item["data_fields"] + or item["data_fields"] & ~row["data_fields"] ): raise ImportFailure( "snapshot de séance incompatible avec le catalogue desktop" @@ -1135,6 +1379,47 @@ def import_sessions( report["sessions_imported"] += 1 +def session_semantically_matches( + connection, + session_row_id, + incoming, + exercise_mapping, +): + header = connection.execute( + "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 + rows = connection.execute( + "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode," + "se.data_fields,se.equipment_id 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() + items = sorted(incoming["exercises"], key=lambda item: item["position"]) + if len(rows) != len(items): + return False + for row, item in zip(rows, items): + canonical_exercise_id = exercise_mapping.get(item["exercise_id"]) + if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id, + item["recording_mode"], item["tracking_mode"], + item["data_fields"], item.get("equipment_id")): + return False + if item["recording_mode"] == "continuous": + current = connection.execute( + "SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity " + "WHERE session_exercise_row_id=?", (row[0],)).fetchone() + expected = item["continuous"] + if current is None or tuple(current) != (expected["duration_seconds"], expected.get("speed_kmh"), expected.get("distance_km")): + return False + else: + current = connection.execute( + "SELECT reps,duration_seconds,weight_kg FROM performed_sets " + "WHERE session_exercise_row_id=? ORDER BY position", (row[0],)).fetchall() + expected = [(value.get("reps"), value.get("duration_seconds"), value.get("weight_kg")) + for value in item["sets"]] + if [tuple(value) for value in current] != expected: + return False + return True + + def body_exists( connection, observation_id, @@ -1194,10 +1479,13 @@ def import_body( for observation in payload[ "body_observations" ]: - if body_exists( - connection, - observation["observation_id"], - ): + existing = connection.execute( + f"SELECT observed_at,{','.join(metric_order)} FROM body_observations WHERE observation_id=?", + (observation["observation_id"],)).fetchone() + if existing is not None: + expected = [observation["observed_at"]] + [observation.get(metric) for metric in metric_order] + if list(existing) != expected: + raise ImportFailure("conflit observation corporelle: " + observation["observation_id"]) report["body_skipped"] += 1 continue @@ -1223,6 +1511,7 @@ def run_import( payload, database_path, dry_run, + trace_exercises=False, ): if not database_path.exists(): raise ImportFailure( @@ -1251,7 +1540,7 @@ def run_import( "PRAGMA foreign_keys = ON;" ) - require_schema_v5( + require_supported_schema( connection ) @@ -1263,6 +1552,7 @@ def run_import( connection, payload, report, + trace_exercises, ) import_sessions( @@ -1342,6 +1632,18 @@ def main(): action="store_true", ) + parser.add_argument( + "--catalog", + type=Path, + default=DEFAULT_EQUIPMENT_CATALOG, + ) + + parser.add_argument( + "--trace-exercises", + action="store_true", + help="journaliser les lookups et décisions catalogue sur stderr", + ) + args = parser.parse_args() try: @@ -1349,14 +1651,20 @@ def main(): args.json_path ) - validate_payload( - payload - ) + known_equipment_ids = load_supplied_equipment_ids(args.catalog) + if args.database.exists(): + with sqlite3.connect(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")) + + validate_payload(payload, known_equipment_ids) report = run_import( payload, args.database, args.dry_run, + args.trace_exercises, ) print_report( diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 1d7eef0..26540cd 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,10 +11,60 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 7 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 8 typedef struct TrainlogDatabase TrainlogDatabase; +/* WHY: session occurrences retain an equipment ID, while custom definitions + * need durable presentation metadata. A reference alone is never a definition. */ +typedef struct TrainlogCustomEquipment { + char equipment_id[TRAINLOG_ID_MAX + 1U]; + char display_name[TRAINLOG_NAME_MAX + 1U]; + char label_name[TRAINLOG_NAME_MAX + 1U]; + char equipment_type[TRAINLOG_NAME_MAX + 1U]; + char load_semantics[32]; +} TrainlogCustomEquipment; + +typedef enum TrainlogEquipmentOrigin { + TRAINLOG_EQUIPMENT_SUPPLIED = 0, + TRAINLOG_EQUIPMENT_CUSTOM, + TRAINLOG_EQUIPMENT_UNKNOWN +} TrainlogEquipmentOrigin; + +typedef struct TrainlogResolvedEquipment { + char equipment_id[TRAINLOG_ID_MAX + 1U]; + char display_name[TRAINLOG_NAME_MAX + 1U]; + char label_name[TRAINLOG_NAME_MAX + 1U]; + char equipment_type[TRAINLOG_NAME_MAX + 1U]; + char load_semantics[32]; + TrainlogEquipmentOrigin origin; +} TrainlogResolvedEquipment; + +TrainlogStatus trainlog_database_create_custom_equipment( + TrainlogDatabase *database, + const TrainlogCustomEquipment *equipment +); +TrainlogStatus trainlog_database_list_custom_equipment( + TrainlogDatabase *database, + TrainlogCustomEquipment *output, + size_t capacity, + size_t *output_count +); +/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs + * are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */ +TrainlogStatus trainlog_database_resolve_equipment( + TrainlogDatabase *database, + const char *equipment_id, + TrainlogResolvedEquipment *output +); +TrainlogStatus trainlog_database_list_exercise_equipment( + TrainlogDatabase *database, + const char *exercise_id, + TrainlogResolvedEquipment *output, + size_t capacity, + size_t *output_count +); + TrainlogStatus trainlog_database_open( const char *path, TrainlogDatabase **output_database diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h index ce0d0ed..2ef6b85 100644 --- a/tui/include/trainlog/id.h +++ b/tui/include/trainlog/id.h @@ -14,33 +14,40 @@ #define TRAINLOG_UUID_TEXT_LENGTH 36U /** - * Longest official v1 generated identifier: - * two-character type prefix + '_' + UUID text + terminating NUL. + * Longest official generated identifier: + * three-character type prefix + '_' + UUID text + terminating NUL. + * + * CONTRACT: occurrence identities use the longer `sxe` prefix while the + * frozen v1 object identities retain their existing two-character prefixes. */ #define TRAINLOG_GENERATED_ID_CAPACITY \ - (2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) + (3U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) /** - * @brief Generate an official Trainlog v1 identifier using UUID version 4. + * @brief Generate an official Trainlog identifier using UUID version 4. * - * The frozen v1 contract requires official creators to generate identifiers - * in the form `_`. The function accepts only the currently - * reserved two-character prefixes: + * Official creators generate identifiers in the form + * `_`. The function accepts only the currently reserved + * prefixes: * * - `ex` for exercises; * - `se` for sessions; * - `bo` for body observations. + * - `sy` for synchronization runs; + * - `sxe` for persisted session-exercise occurrences. * * Imported v1 documents may contain other schema-valid opaque identifiers; * this API defines creation policy, not import validation. * - * @param prefix Two-character Trainlog object prefix. + * @param prefix Reserved Trainlog object prefix. * @param output Caller-owned output buffer. * @param output_size Size of @p output in bytes. * * @return TRAINLOG_STATUS_OK on success. * @return TRAINLOG_STATUS_INVALID_ARGUMENT for invalid pointers, prefix, or a - * buffer smaller than TRAINLOG_GENERATED_ID_CAPACITY. + * buffer smaller than that prefix's exact identifier representation. + * Existing two-character prefixes still require 40 bytes; `sxe` + * requires TRAINLOG_GENERATED_ID_CAPACITY bytes. */ TrainlogStatus trainlog_id_generate( const char *prefix, diff --git a/tui/include/trainlog/mtp.h b/tui/include/trainlog/mtp.h index 1abef98..6e6cc67 100644 --- a/tui/include/trainlog/mtp.h +++ b/tui/include/trainlog/mtp.h @@ -74,6 +74,7 @@ typedef struct TrainlogMtpEntry { uint32_t parent_id; uint32_t storage_id; uint64_t size_bytes; + uint64_t modification_unix_seconds; bool folder; char name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U]; } TrainlogMtpEntry; diff --git a/tui/include/trainlog/sync.h b/tui/include/trainlog/sync.h index 11940ea..f4ccf8a 100644 --- a/tui/include/trainlog/sync.h +++ b/tui/include/trainlog/sync.h @@ -23,6 +23,17 @@ typedef enum TrainlogSyncTrigger { TRAINLOG_SYNC_TRIGGER_DAEMON } TrainlogSyncTrigger; +typedef enum TrainlogSyncDirection { + TRAINLOG_SYNC_ANDROID_TO_PC = 0, + TRAINLOG_SYNC_PC_TO_ANDROID, + TRAINLOG_SYNC_BIDIRECTIONAL +} TrainlogSyncDirection; + +typedef struct TrainlogSyncDirectionPlan { + bool receive_android; + bool publish_android; +} TrainlogSyncDirectionPlan; + typedef struct TrainlogSyncDeviceInfo { bool connected; bool storage_ready; @@ -33,6 +44,7 @@ typedef struct TrainlogSyncDeviceInfo { typedef struct TrainlogSyncReport { bool success; bool request_present; + TrainlogSyncDirection direction; char sync_id[ TRAINLOG_ID_MAX + 1U @@ -56,6 +68,9 @@ typedef struct TrainlogSyncReport { size_t body_imported; size_t body_skipped; + size_t equipment_definitions_imported; + size_t equipment_definitions_skipped; + size_t catalog_published; char summary[ @@ -67,6 +82,59 @@ typedef struct TrainlogSyncReport { ]; } TrainlogSyncReport; +TrainlogSyncDirectionPlan trainlog_sync_direction_plan( + TrainlogSyncDirection direction +); + +/** + * @brief Build the user-visible mutation summary for an initialized report. + * + * CONTRACT: the `+N exercice(s)` value counts only newly inserted persistent + * exercise rows. Reconciliation and idempotent lookup remain available in + * their dedicated report fields and must never be presented as additions. + * The function borrows `report`, mutates only its `summary` array, performs no + * allocation or persistence, and is deterministic for equal report fields. + * A NULL report is accepted as a no-op; output is always bounded and NUL + * terminated by the fixed Trainlog report storage. + */ +void trainlog_sync_build_summary( + TrainlogSyncReport *report +); + +/** + * @brief Select the current Android mobile-export V2 entry from one folder. + * + * Android's scoped-storage provider can retain an existing immutable object + * and create a collision-suffixed sibling such as + * `trainlog-mobile-export-v2 (2).json`. Both that form and the canonical + * filename are valid V2 publications. The newest usable MTP modification + * time wins; deterministic suffix and item-ID tie breaks prevent arbitrary + * selection when a device reports equal or unavailable times. This routine + * only selects a candidate. The caller must still validate its JSON before + * import and must never fall back to an older candidate on validation error. + */ +bool trainlog_sync_select_mobile_export( + const TrainlogMtpEntry *entries, + size_t count, + size_t *output_index +); + +/** + * @brief Select the latest Android artifact for one canonical JSON filename. + * + * The canonical name and Android's scoped-storage collision form + * ` (N).json` are accepted. @p canonical_name is borrowed and must be + * a nonempty `.json` filename. False reports invalid arguments or no valid + * candidate. This selection is deterministic, but callers must validate the + * selected artifact before making any persistent import decision. + */ +bool trainlog_sync_select_android_artifact( + const TrainlogMtpEntry *entries, + size_t count, + const char *canonical_name, + size_t *output_index +); + /** * @brief Probe one currently connected MTP device without mutating it. */ @@ -75,16 +143,17 @@ TrainlogStatus trainlog_sync_probe( ); /** - * @brief Run one complete bidirectional synchronization transaction. + * @brief Run one synchronization transaction in the selected direction. * * When require_request is true, the transaction runs only if Android has * published a new trainlog-sync-request-v1 request ID. * - * The engine is shared by the ncurses TUI and trainlog-syncd. + * The engine is shared by the Notcurses TUI and trainlog-syncd. */ TrainlogStatus trainlog_sync_run( TrainlogSyncTrigger trigger, bool require_request, + TrainlogSyncDirection direction, TrainlogSyncReport *output ); diff --git a/tui/include/trainlog/sync_history.h b/tui/include/trainlog/sync_history.h new file mode 100644 index 0000000..e5f337b --- /dev/null +++ b/tui/include/trainlog/sync_history.h @@ -0,0 +1,51 @@ +#ifndef TRAINLOG_SYNC_HISTORY_H +#define TRAINLOG_SYNC_HISTORY_H + +/** + * @file sync_history.h + * @brief Local, backward-compatible synchronization history records. + * + * This format is local diagnostic state. It is not a synchronization + * exchange artifact and has no effect on TRAINLOG_FORMAT_V1. + */ + +#include + +#include "trainlog/sync.h" + +typedef struct TrainlogSyncHistoryEntry { + char sync_id[TRAINLOG_ID_MAX + 1U]; + char timestamp[17]; + bool success; + bool direction_known; + TrainlogSyncDirection direction; + char summary[TRAINLOG_SYNC_SUMMARY_MAX + 1U]; +} TrainlogSyncHistoryEntry; + +/** Parse one complete history line. New records require an a/p/b field. */ +bool trainlog_sync_history_parse_line( + const char *line, + TrainlogSyncHistoryEntry *output +); + +/** Return the stable one-character local-history encoding for a direction. */ +const char *trainlog_sync_history_direction_code( + TrainlogSyncDirection direction +); + +/** Return the user-facing label, including unknown for legacy records. */ +const char *trainlog_sync_history_direction_label( + const TrainlogSyncHistoryEntry *entry +); + +/** + * Persist a completed local run record, structured JSON, and text detail. + * Exposed for deterministic local-history regression coverage; no MTP work is + * performed by this function. + */ +bool trainlog_sync_record_local_run( + TrainlogSyncTrigger trigger, + const TrainlogSyncReport *report +); + +#endif diff --git a/tui/include/trainlog/sync_screen_action.h b/tui/include/trainlog/sync_screen_action.h new file mode 100644 index 0000000..59eda7e --- /dev/null +++ b/tui/include/trainlog/sync_screen_action.h @@ -0,0 +1,68 @@ +#ifndef TRAINLOG_SYNC_SCREEN_ACTION_H +#define TRAINLOG_SYNC_SCREEN_ACTION_H + +/** + * @file sync_screen_action.h + * @brief TUI-owned sync-screen keyboard actions and confirmation state. + */ + +#include +#include + +#include "trainlog/status.h" +#include "trainlog/sync.h" + +typedef enum TrainlogSyncScreenEffect { + TRAINLOG_SYNC_SCREEN_NONE = 0, + TRAINLOG_SYNC_SCREEN_CONFIRM, + TRAINLOG_SYNC_SCREEN_RUN, + TRAINLOG_SYNC_SCREEN_CANCEL, + TRAINLOG_SYNC_SCREEN_REFRESH, + TRAINLOG_SYNC_SCREEN_EXIT +} TrainlogSyncScreenEffect; + +typedef struct TrainlogSyncScreenState { + bool confirming; + TrainlogSyncDirection direction; +} TrainlogSyncScreenState; + +typedef struct TrainlogSyncScreenAction { + TrainlogSyncScreenEffect effect; + TrainlogSyncDirection direction; +} TrainlogSyncScreenAction; + +typedef TrainlogStatus (*TrainlogSyncScreenRunCallback)( + TrainlogSyncDirection direction, + TrainlogSyncReport *report, + void *context +); + +void trainlog_sync_screen_state_init( + TrainlogSyncScreenState *state +); + +TrainlogSyncScreenAction trainlog_sync_screen_dispatch( + TrainlogSyncScreenState *state, + int key +); + +TrainlogStatus trainlog_sync_screen_execute( + const TrainlogSyncScreenAction *action, + TrainlogSyncScreenRunCallback callback, + void *context, + TrainlogSyncReport *report +); + +const char *trainlog_sync_screen_direction_label( + TrainlogSyncDirection direction +); + +const char *trainlog_sync_screen_confirmation( + TrainlogSyncDirection direction +); + +const char *trainlog_sync_screen_footer( + int terminal_columns +); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 140613a..73272a6 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -43,6 +43,7 @@ trainlog_core_sources = files( 'src/reps.c', 'src/measured_max.c', 'src/sync.c', + 'src/sync_history.c', ) trainlog_core_sources += equipment_catalog_generated @@ -76,6 +77,7 @@ trainlog_core_dep = declare_dependency( trainlog_tui_sources = files( 'src/main.c', + 'src/sync_screen_action.c', 'src/terminal.c', 'src/theme.c', 'src/tui.c', @@ -124,6 +126,14 @@ test_equipment_catalog = executable( ) test('equipment_catalog', test_equipment_catalog) +test_custom_equipment = executable( + 'test_custom_equipment', + 'tests/test_custom_equipment.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('custom_equipment', test_custom_equipment) + test_session_detail = executable( 'test_session_detail', 'tests/test_session_detail.c', @@ -341,6 +351,18 @@ test( test_schema_v5_migration, ) +test_schema_v7_migration = executable( + 'test_schema_v7_migration', + 'tests/test_schema_v7_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test( + 'schema_v7_migration', + test_schema_v7_migration, +) + python3_trainlog_tests = find_program('python3') test( @@ -363,6 +385,46 @@ test( args: [meson.project_source_root() / 'tests/test_equipment_associations_exchange.py'], ) +test( + 'equipment_definitions_exchange', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_equipment_definitions_exchange.py'], +) + +test( + 'exercise_reconciliation', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_exercise_reconciliation.py'], +) + +test_sync_direction = executable( + 'test_sync_direction', + 'tests/test_sync_direction.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('sync_direction', test_sync_direction) + +test_sync_history = executable( + 'test_sync_history', + 'tests/test_sync_history.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('sync_history', test_sync_history) + +test_sync_screen_action = executable( + 'test_sync_screen_action', + 'tests/test_sync_screen_action.c', + 'src/sync_screen_action.c', + dependencies: [trainlog_core_dep, notcurses_dep], + c_args: strict_c_args, +) + +test('sync_screen_action', test_sync_screen_action) + trainlog_sync_once = executable( 'trainlog-sync-once', 'tools/sync_once.c', diff --git a/tui/src/database.c b/tui/src/database.c index e35003b..7000299 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -19,6 +19,21 @@ struct TrainlogDatabase { sqlite3 *connection; }; +static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id) +{ + sqlite3_stmt *statement = NULL; + int rc; + bool found = false; + if (database == NULL || equipment_id == NULL) return false; + rc = sqlite3_prepare_v2(database->connection, + "SELECT 1 FROM custom_equipment WHERE equipment_id=?1;", -1, &statement, NULL); + if (rc != SQLITE_OK) return false; + (void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT); + found = sqlite3_step(statement) == SQLITE_ROW; + (void)sqlite3_finalize(statement); + return found; +} + static void set_open_diagnostic( char *output, size_t capacity, @@ -28,15 +43,42 @@ static void set_open_diagnostic( ) { const char *message; + int extended_status = sqlite_status; if (output == NULL || capacity == 0U) { return; } - message = connection != NULL - ? sqlite3_errmsg(connection) - : sqlite3_errstr(sqlite_status); - (void)snprintf(output, capacity, "%s: %s", operation, message); + if (connection != NULL) { + message = sqlite3_errmsg(connection); + sqlite_status = sqlite3_errcode(connection); + extended_status = sqlite3_extended_errcode(connection); + } else { + message = sqlite3_errstr(sqlite_status); + } + /* WHY: preserve the actionable SQLite result while the owning connection + * is still open; rollback/close may replace its diagnostic state. */ + (void)snprintf( + output, + capacity, + "%s: SQLite rc=%d extended_rc=%d: %s", + operation, + sqlite_status, + extended_status, + message + ); +} + +static void set_application_diagnostic( + char *output, + size_t capacity, + const char *message +) +{ + if (output == NULL || capacity == 0U) { + return; + } + (void)snprintf(output, capacity, "%s", message); } static const char *const SCHEMA_V7_SQL_A = @@ -210,6 +252,17 @@ static const char *const MIGRATE_V6_TO_V7_SQL = "DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE session_exercises_v6;" "PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;"; +/* CONTRACT: v8 stores only user-created equipment definitions. Canonical + * equipment remains generated from the frozen manifest; historic IDs are not + * backfilled because an occurrence reference has no trustworthy metadata. */ +static const char *const MIGRATE_V7_TO_V8_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE custom_equipment (" + "equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL," + "label_name TEXT NOT NULL,equipment_type TEXT NOT NULL," + "load_semantics TEXT NOT NULL CHECK(load_semantics IN ('none','external','assistance'))" + ");PRAGMA user_version = 8;COMMIT;"; + static const char *const MIGRATE_V1_TO_V3_SQL = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -576,13 +629,13 @@ static TrainlogStatus initialize_or_validate_schema( version > TRAINLOG_DATABASE_SCHEMA_VERSION ) { + char message[128]; (void)snprintf( - output_diagnostic, - output_diagnostic_capacity, + message, sizeof(message), "schema version %d is newer than supported version %d", - version, - TRAINLOG_DATABASE_SCHEMA_VERSION - ); + version, TRAINLOG_DATABASE_SCHEMA_VERSION); + set_application_diagnostic( + output_diagnostic, output_diagnostic_capacity, message); return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } @@ -611,6 +664,13 @@ static TrainlogStatus initialize_or_validate_schema( SCHEMA_V7_SQL_B ); } + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); + } + } else if (version == 7) { + /* CONTRACT: v7 is the immediate historic schema and must open through + * its lossless custom-equipment-table migration. */ + status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); } else { if (version == 1) { status = @@ -693,6 +753,13 @@ static TrainlogStatus initialize_or_validate_schema( } else if (version == 6) { status = TRAINLOG_STATUS_OK; } else { + char message[128]; + (void)snprintf( + message, sizeof(message), + "schema version %d is unsupported (supported through version %d)", + version, TRAINLOG_DATABASE_SCHEMA_VERSION); + set_application_diagnostic( + output_diagnostic, output_diagnostic_capacity, message); return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } @@ -725,6 +792,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK) { status = execute_sql(database, MIGRATE_V6_TO_V7_SQL); } + if (status == TRAINLOG_STATUS_OK) { + status = execute_sql(database, MIGRATE_V7_TO_V8_SQL); + } } if ( @@ -734,7 +804,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v7" : "migrate database to schema v7", + version == 0 ? "create schema v8" : "migrate database to schema v8", database->connection, SQLITE_ERROR ); @@ -896,6 +966,141 @@ TrainlogStatus trainlog_database_foreign_keys_enabled( ); } +TrainlogStatus trainlog_database_create_custom_equipment( + TrainlogDatabase *database, + const TrainlogCustomEquipment *equipment +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + /* CONTRACT: custom IDs must not shadow frozen manifest identities. */ + if (database == NULL || equipment == NULL || equipment->equipment_id[0] == '\0' || + equipment->display_name[0] == '\0' || equipment->equipment_type[0] == '\0' || + trainlog_equipment_catalog_lookup(equipment->equipment_id) != NULL || + (strcmp(equipment->load_semantics, "none") != 0 && + strcmp(equipment->load_semantics, "external") != 0 && + strcmp(equipment->load_semantics, "assistance") != 0)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + rc = sqlite3_prepare_v2(database->connection, + "INSERT INTO custom_equipment(equipment_id,display_name,label_name,equipment_type,load_semantics) VALUES(?1,?2,?3,?4,?5);", + -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_bind_text(statement, 1, equipment->equipment_id, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, equipment->display_name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, equipment->label_name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 4, equipment->equipment_type, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 5, equipment->load_semantics, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(statement); + (void)sqlite3_finalize(statement); + return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_list_custom_equipment( + TrainlogDatabase *database, TrainlogCustomEquipment *output, + size_t capacity, size_t *output_count +) +{ + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + if (database == NULL || output_count == NULL || (capacity > 0U && output == NULL)) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; + rc = sqlite3_prepare_v2(database->connection, + "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics FROM custom_equipment ORDER BY display_name COLLATE NOCASE,equipment_id;", + -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + if (count < capacity) { + TrainlogCustomEquipment *item = &output[count]; + (void)memset(item, 0, sizeof(*item)); + (void)snprintf(item->equipment_id, sizeof(item->equipment_id), "%s", sqlite3_column_text(statement, 0)); + (void)snprintf(item->display_name, sizeof(item->display_name), "%s", sqlite3_column_text(statement, 1)); + (void)snprintf(item->label_name, sizeof(item->label_name), "%s", sqlite3_column_text(statement, 2)); + (void)snprintf(item->equipment_type, sizeof(item->equipment_type), "%s", sqlite3_column_text(statement, 3)); + (void)snprintf(item->load_semantics, sizeof(item->load_semantics), "%s", sqlite3_column_text(statement, 4)); + } + ++count; + } + (void)sqlite3_finalize(statement); + *output_count = count; + return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_resolve_equipment( + TrainlogDatabase *database, const char *equipment_id, + TrainlogResolvedEquipment *output +) +{ + const TrainlogEquipment *supplied; + sqlite3_stmt *statement = NULL; + int rc; + if (database == NULL || equipment_id == NULL || equipment_id[0] == '\0' || + output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + (void)memset(output, 0, sizeof(*output)); + (void)snprintf(output->equipment_id, sizeof(output->equipment_id), "%s", equipment_id); + supplied = trainlog_equipment_catalog_lookup(equipment_id); + if (supplied != NULL) { + (void)snprintf(output->display_name, sizeof(output->display_name), "%s", supplied->display_name); + (void)snprintf(output->label_name, sizeof(output->label_name), "%s", supplied->label_name); + (void)snprintf(output->equipment_type, sizeof(output->equipment_type), "%s", supplied->equipment_type); + (void)snprintf(output->load_semantics, sizeof(output->load_semantics), "%s", supplied->load_semantics); + output->origin = TRAINLOG_EQUIPMENT_SUPPLIED; + return TRAINLOG_STATUS_OK; + } + rc = sqlite3_prepare_v2(database->connection, + "SELECT display_name,label_name,equipment_type,load_semantics FROM custom_equipment WHERE equipment_id=?1;", + -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(statement); + if (rc == SQLITE_ROW) { + (void)snprintf(output->display_name, sizeof(output->display_name), "%s", sqlite3_column_text(statement, 0)); + (void)snprintf(output->label_name, sizeof(output->label_name), "%s", sqlite3_column_text(statement, 1)); + (void)snprintf(output->equipment_type, sizeof(output->equipment_type), "%s", sqlite3_column_text(statement, 2)); + (void)snprintf(output->load_semantics, sizeof(output->load_semantics), "%s", sqlite3_column_text(statement, 3)); + output->origin = TRAINLOG_EQUIPMENT_CUSTOM; + } else if (rc == SQLITE_DONE) { + /* INVARIANT: historic references survive catalogue changes visibly. */ + (void)snprintf(output->display_name, sizeof(output->display_name), "Inconnu (%s)", equipment_id); + output->origin = TRAINLOG_EQUIPMENT_UNKNOWN; + rc = SQLITE_ROW; + } + (void)sqlite3_finalize(statement); + return rc == SQLITE_ROW ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_list_exercise_equipment( + TrainlogDatabase *database, const char *exercise_id, + TrainlogResolvedEquipment *output, size_t capacity, size_t *output_count +) +{ + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + if (database == NULL || exercise_id == NULL || output_count == NULL || + (capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; + rc = sqlite3_prepare_v2(database->connection, + "SELECT DISTINCT se.equipment_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND se.equipment_id IS NOT NULL AND se.equipment_id<>'' ORDER BY se.equipment_id;", + -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT); + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + const char *id = (const char *)sqlite3_column_text(statement, 0); + if (count < capacity && trainlog_database_resolve_equipment(database, id, &output[count]) != TRAINLOG_STATUS_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + ++count; + } + (void)sqlite3_finalize(statement); + *output_count = count; + return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + TrainlogStatus trainlog_database_begin(TrainlogDatabase *database) { return execute_sql(database, "BEGIN IMMEDIATE;"); @@ -1574,15 +1779,17 @@ static TrainlogStatus insert_session_exercise( } if (input->equipment_id[0] != '\0' && - trainlog_equipment_catalog_lookup(input->equipment_id) == NULL) { - /* INVARIANT: a persisted occurrence only names a manifest identity. */ + trainlog_equipment_catalog_lookup(input->equipment_id) == NULL && + !custom_equipment_exists(database, input->equipment_id)) { + /* INVARIANT: a persisted occurrence names a known canonical or custom definition. */ return TRAINLOG_STATUS_INVALID_ARGUMENT; } - /* Local TUI creation has no transport-supplied identity; allocate it once + /* WHY: entry_id, not position or exercise_id, is the durable identity of + * one occurrence. Local TUI creation therefore allocates it exactly once * at persistence, while imported V2 identities pass through unchanged. */ if (input->entry_id[0] == '\0') { - status = trainlog_id_generate("sy", generated_entry_id, + status = trainlog_id_generate("sxe", generated_entry_id, sizeof(generated_entry_id)); if (status != TRAINLOG_STATUS_OK) { return status; diff --git a/tui/src/id.c b/tui/src/id.c index c1a96f8..7eb2c9a 100644 --- a/tui/src/id.c +++ b/tui/src/id.c @@ -21,7 +21,8 @@ static bool prefix_is_supported(const char *prefix) return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 || - strcmp(prefix, "sy") == 0; + strcmp(prefix, "sy") == 0 || + strcmp(prefix, "sxe") == 0; } TrainlogStatus trainlog_id_generate( @@ -39,7 +40,7 @@ TrainlogStatus trainlog_id_generate( } if (!prefix_is_supported(prefix) || - output_size < TRAINLOG_GENERATED_ID_CAPACITY) { + output_size < strlen(prefix) + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } diff --git a/tui/src/mtp.c b/tui/src/mtp.c index ebfda39..a6c897d 100644 --- a/tui/src/mtp.c +++ b/tui/src/mtp.c @@ -682,6 +682,16 @@ TrainlogStatus trainlog_mtp_list_folder( entry->size_bytes = item->filesize; + /* MTP exposes this value with file metadata. Keep the transport + * timestamp separate from the filename: callers use it only to + * choose the newest immutable exchange artifact, never as a + * domain timestamp. A zero value means the device did not supply + * a usable modification time. */ + if (item->modificationdate > 0) { + entry->modification_unix_seconds = + (uint64_t)item->modificationdate; + } + entry->folder = item->filetype == LIBMTP_FILETYPE_FOLDER; diff --git a/tui/src/sync.c b/tui/src/sync.c index 07665d4..7159c1c 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -19,6 +19,7 @@ #include #include "trainlog/id.h" +#include "trainlog/sync_history.h" #include "trainlog/timeutil.h" #define SYNC_DEVICE_CAPACITY 8U @@ -42,6 +43,12 @@ static const char *const PC_CATALOG_NAME = static const char *const EQUIPMENT_ASSOCIATIONS_NAME = "trainlog-equipment-associations-v2.json"; +static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME = + "trainlog-mobile-equipment-definitions-v1.json"; + +static const char *const PC_EQUIPMENT_DEFINITIONS_NAME = + "trainlog-pc-equipment-definitions-v1.json"; + static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; @@ -60,6 +67,15 @@ static const char *const PC_CATALOG_LOCAL = static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL = "/tmp/trainlog-equipment-associations-v2.json"; +static const char *const MOBILE_EQUIPMENT_DEFINITIONS_LOCAL = + "/tmp/trainlog-mobile-equipment-definitions-v1.json"; + +static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL = + "/tmp/trainlog-pc-equipment-definitions-v1.json"; + +static const char *const EQUIPMENT_DEFINITIONS_RESULT = + "/tmp/trainlog-equipment-definitions-result.txt"; + static const char *const SYNC_REQUEST_LOCAL = "/tmp/trainlog-sync-request-v1.json"; @@ -81,6 +97,22 @@ typedef struct SyncSilence { int null_fd; } SyncSilence; +TrainlogSyncDirectionPlan trainlog_sync_direction_plan( + TrainlogSyncDirection direction +) +{ + TrainlogSyncDirectionPlan plan = {false, false}; + if (direction == TRAINLOG_SYNC_ANDROID_TO_PC || + direction == TRAINLOG_SYNC_BIDIRECTIONAL) { + plan.receive_android = true; + } + if (direction == TRAINLOG_SYNC_PC_TO_ANDROID || + direction == TRAINLOG_SYNC_BIDIRECTIONAL) { + plan.publish_android = true; + } + return plan; +} + static bool ensure_directory( const char *path ) @@ -673,6 +705,137 @@ static TrainlogStatus sync_find_child( return TRAINLOG_STATUS_NOT_FOUND; } +static bool sync_android_artifact_ordinal( + const char *name, + const char *canonical_name, + uint32_t *output_ordinal +) +{ + static const char suffix[] = ").json"; + static const char extension[] = ".json"; + size_t suffix_length; + size_t extension_length; + size_t canonical_length; + size_t stem_length; + size_t name_length; + size_t index; + uint32_t ordinal = 0U; + + if (name == NULL || canonical_name == NULL || output_ordinal == NULL) { + return false; + } + + if (strcmp(name, canonical_name) == 0) { + *output_ordinal = 0U; + return true; + } + + suffix_length = sizeof(suffix) - 1U; + extension_length = sizeof(extension) - 1U; + canonical_length = strlen(canonical_name); + if (canonical_length <= extension_length || + strcmp(canonical_name + canonical_length - extension_length, + extension) != 0) { + return false; + } + + stem_length = canonical_length - extension_length; + name_length = strlen(name); + if (name_length < stem_length + 3U + suffix_length || + strncmp(name, canonical_name, stem_length) != 0 || + name[stem_length] != ' ' || + name[stem_length + 1U] != '(' || + strcmp(name + name_length - suffix_length, suffix) != 0) { + return false; + } + + for (index = stem_length + 2U; + index < name_length - suffix_length; + ++index) { + unsigned int digit; + + if (name[index] < '0' || name[index] > '9') { + return false; + } + + digit = (unsigned int)(name[index] - '0'); + if (ordinal > (UINT32_MAX - digit) / 10U) { + return false; + } + + ordinal = ordinal * 10U + digit; + } + + if (ordinal == 0U) { + return false; + } + + *output_ordinal = ordinal; + return true; +} + +bool trainlog_sync_select_android_artifact( + const TrainlogMtpEntry *entries, + size_t count, + const char *canonical_name, + size_t *output_index +) +{ + bool found = false; + uint64_t best_modified = 0U; + uint32_t best_ordinal = 0U; + uint32_t best_item_id = 0U; + size_t best_index = 0U; + size_t index; + + if (entries == NULL || count == 0U || canonical_name == NULL || + canonical_name[0] == '\0' || output_index == NULL) { + return false; + } + + for (index = 0U; index < count; ++index) { + uint32_t ordinal; + const TrainlogMtpEntry *entry = &entries[index]; + + if (entry->folder || + !sync_android_artifact_ordinal(entry->name, canonical_name, + &ordinal)) { + continue; + } + + if (!found || + entry->modification_unix_seconds > best_modified || + (entry->modification_unix_seconds == best_modified && + ordinal > best_ordinal) || + (entry->modification_unix_seconds == best_modified && + ordinal == best_ordinal && entry->item_id < best_item_id)) { + found = true; + best_modified = entry->modification_unix_seconds; + best_ordinal = ordinal; + best_item_id = entry->item_id; + best_index = index; + } + } + + if (!found) { + return false; + } + + *output_index = best_index; + return true; +} + +bool trainlog_sync_select_mobile_export( + const TrainlogMtpEntry *entries, + size_t count, + size_t *output_index +) +{ + return trainlog_sync_select_android_artifact(entries, count, + MOBILE_EXPORT_NAME, + output_index); +} + static TrainlogStatus sync_find_exchange_folder( const TrainlogSyncDeviceInfo *device, uint32_t *output_folder_id @@ -774,6 +937,54 @@ static TrainlogStatus sync_receive_named( return status; } +static TrainlogStatus sync_receive_current_android_artifact( + const TrainlogSyncDeviceInfo *device, + uint32_t folder_id, + const char *canonical_name, + const char *local_path, + uint64_t *output_size +) +{ + TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; + size_t count = 0U; + size_t selected_index; + TrainlogStatus status; + + if (device == NULL || canonical_name == NULL || local_path == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = trainlog_mtp_list_folder( + device->device.bus_number, + device->device.device_number, + device->storage.storage_id, + folder_id, + entries, + SYNC_ENTRY_CAPACITY, + &count + ); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + if (!trainlog_sync_select_android_artifact(entries, count, canonical_name, + &selected_index)) { + return TRAINLOG_STATUS_NOT_FOUND; + } + + status = trainlog_mtp_receive_file( + device->device.bus_number, + device->device.device_number, + entries[selected_index].item_id, + local_path + ); + if (status == TRAINLOG_STATUS_OK && output_size != NULL) { + *output_size = entries[selected_index].size_bytes; + } + + return status; +} + static TrainlogStatus sync_publish_named( const TrainlogSyncDeviceInfo *device, uint32_t folder_id, @@ -1521,6 +1732,25 @@ static const char *sync_trigger_text( } } +static const char *sync_direction_text( + TrainlogSyncDirection direction +) +{ + switch (direction) { + case TRAINLOG_SYNC_ANDROID_TO_PC: + return "android_to_pc"; + + case TRAINLOG_SYNC_PC_TO_ANDROID: + return "pc_to_android"; + + case TRAINLOG_SYNC_BIDIRECTIONAL: + return "bidirectional"; + + default: + return NULL; + } +} + static bool sync_local_timestamp( char output[17] ) @@ -1654,6 +1884,37 @@ static bool sync_json_write_escaped( ) != EOF; } +static bool sync_history_write_summary( + FILE *file, + const char *summary +) +{ + size_t index; + + if (file == NULL || summary == NULL) { + return false; + } + + for ( + index = 0U; + index < TRAINLOG_SYNC_SUMMARY_MAX && summary[index] != '\0'; + ++index + ) { + const char value = + summary[index] == '\t' || + summary[index] == '\r' || + summary[index] == '\n' + ? ' ' + : summary[index]; + + if (fputc((unsigned char)value, file) == EOF) { + return false; + } + } + + return true; +} + static bool sync_write_receipt( const TrainlogSyncReport *report ) @@ -1769,7 +2030,7 @@ static bool sync_write_receipt( fclose(file) == 0; } -static bool sync_record_run( +bool trainlog_sync_record_local_run( TrainlogSyncTrigger trigger, const TrainlogSyncReport *report ) @@ -1796,9 +2057,45 @@ static bool sync_record_run( FILE *detail_file; FILE *history_file; int written; + const char *direction_code; + const char *direction_text; + const char *direction_label; + TrainlogSyncHistoryEntry direction_entry; + + (void)memset( + &direction_entry, + 0, + sizeof(direction_entry) + ); + if (report != NULL) { + direction_entry.direction_known = true; + direction_entry.direction = report->direction; + } + + direction_code = + report == NULL + ? NULL + : trainlog_sync_history_direction_code( + report->direction + ); + direction_text = + report == NULL + ? NULL + : sync_direction_text( + report->direction + ); + direction_label = + report == NULL + ? NULL + : trainlog_sync_history_direction_label( + &direction_entry + ); if ( report == NULL || + direction_code == NULL || + direction_text == NULL || + direction_label == NULL || report->sync_id[0] == '\0' || !sync_runs_directory( directory, @@ -1910,6 +2207,21 @@ static bool sync_record_run( return false; } + (void)fputs( + ",\n \"direction\":", + json_file + ); + + if ( + !sync_json_write_escaped( + json_file, + direction_text + ) + ) { + (void)fclose(json_file); + return false; + } + (void)fputs( ",\n \"status\":", json_file @@ -2003,12 +2315,14 @@ static bool sync_record_run( detail_file, "SYNC %s\n\n" "Déclencheur : %s\n" + "Direction : %s\n" "Début : %s\n" "État : %s\n", report->sync_id, sync_trigger_text( trigger ), + direction_label, local_timestamp, report->success ? "succès" @@ -2078,25 +2392,38 @@ static bool sync_record_run( return false; } - (void)fprintf( + /* CONTRACT: direction is a dedicated field because failure summaries may + * be neutral or mention a different phase than the selected operation. */ + written = fprintf( history_file, - "%s\t%s\t%d\t%.*s\n", + "%s\t%s\t%d\t%s\t", report->sync_id, local_timestamp, report->success ? 1 : 0, - (int)TRAINLOG_SYNC_SUMMARY_MAX, - report->summary + direction_code ); + if ( + written < 0 || + !sync_history_write_summary( + history_file, + report->summary + ) || + fputc('\n', history_file) == EOF + ) { + (void)fclose(history_file); + return false; + } + return fclose( history_file ) == 0; } -static void sync_build_summary( +void trainlog_sync_build_summary( TrainlogSyncReport *report ) { @@ -2105,16 +2432,24 @@ static void sync_build_summary( } if (report->success) { - (void)snprintf( - report->summary, - sizeof(report->summary), - "Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s) · PC→Android catalogue %zu exercice(s)", - report->sessions_imported, - report->exercises_imported + - report->exercises_reconciled, - report->body_imported, - report->catalog_published - ); + if (report->direction == TRAINLOG_SYNC_ANDROID_TO_PC) { + (void)snprintf(report->summary, sizeof(report->summary), + "Android→PC +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s)", + report->sessions_imported, + report->exercises_imported, + report->body_imported, report->equipment_definitions_imported); + } else if (report->direction == TRAINLOG_SYNC_PC_TO_ANDROID) { + (void)snprintf(report->summary, sizeof(report->summary), + "PC→Android catalogue %zu exercice(s), définitions/séances/mesures publiées", + report->catalog_published); + } else { + (void)snprintf(report->summary, sizeof(report->summary), + "PC↔Android import +%zu séance(s), +%zu exercice(s), +%zu mesure(s), +%zu équipement(s) · catalogue %zu publié(s)", + report->sessions_imported, + report->exercises_imported, + report->body_imported, report->equipment_definitions_imported, + report->catalog_published); + } } else { (void)snprintf( report->summary, @@ -2209,6 +2544,7 @@ static void sync_parse_import_report( TrainlogStatus trainlog_sync_run( TrainlogSyncTrigger trigger, bool require_request, + TrainlogSyncDirection direction, TrainlogSyncReport *output ) { @@ -2236,8 +2572,11 @@ TrainlogStatus trainlog_sync_run( bool silence_active = false; bool run_started = false; bool receipt_published = false; + bool mobile_export_is_v2 = false; - if (output == NULL) { + if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC || + direction > TRAINLOG_SYNC_BIDIRECTIONAL || + (require_request && direction != TRAINLOG_SYNC_BIDIRECTIONAL)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -2247,6 +2586,7 @@ TrainlogStatus trainlog_sync_run( 0, sizeof(*output) ); + output->direction = direction; lock_fd = sync_lock_open( @@ -2448,14 +2788,54 @@ TrainlogStatus trainlog_sync_run( goto finalize; } - status = - sync_receive_named( - &device, - folder_id, - MOBILE_EXPORT_NAME, - MOBILE_EXPORT_LOCAL, - &ignored_size - ); + if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { + goto outbound; + } + + /* Definitions must reconcile before either v2 reference artifact. A + * missing file is accepted only for historic snapshots with no custom ID. */ + status = sync_receive_current_android_artifact( + &device, + folder_id, + MOBILE_EQUIPMENT_DEFINITIONS_NAME, + MOBILE_EQUIPMENT_DEFINITIONS_LOCAL, + &ignored_size + ); + if (status == TRAINLOG_STATUS_OK) { + status = sync_run_python_tool("import_equipment_definitions.py", + MOBILE_EQUIPMENT_DEFINITIONS_LOCAL, + database_path, + EQUIPMENT_DEFINITIONS_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EQUIPMENT_DEFINITIONS_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + (void)snprintf(output->error, sizeof(output->error), + "Android→PC : définitions équipement : %s", + useful[0] != '\0' ? useful : "import échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + output->equipment_definitions_imported = + sync_report_value(tool_output, "definitions_imported"); + output->equipment_definitions_skipped = + sync_report_value(tool_output, "definitions_skipped"); + } else if (status != TRAINLOG_STATUS_NOT_FOUND) { + (void)snprintf(output->error, sizeof(output->error), + "Android→PC : lecture définitions équipement échouée."); + final_status = status; + goto finalize; + } + + status = sync_receive_current_android_artifact( + &device, + folder_id, + MOBILE_EXPORT_NAME, + MOBILE_EXPORT_LOCAL, + &ignored_size + ); + mobile_export_is_v2 = status == TRAINLOG_STATUS_OK; if ( status != @@ -2525,10 +2905,20 @@ TrainlogStatus trainlog_sync_run( output ); - /* V1 exports carry no equipment signal. A missing companion therefore - * preserves existing desktop associations rather than clearing them. */ - status = sync_receive_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME, - EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size); + /* V1 exports carry no equipment signal. A V2 companion beside a historic + * V1 snapshot belongs to another generation and must not be applied. Its + * absence therefore preserves existing associations rather than clearing + * them. Scoped storage may suffix every V2 Android publication, so the V2 + * path selects its newest candidate instead of an older canonical object. */ + status = mobile_export_is_v2 + ? sync_receive_current_android_artifact( + &device, + folder_id, + EQUIPMENT_ASSOCIATIONS_NAME, + EQUIPMENT_ASSOCIATIONS_LOCAL, + &ignored_size + ) + : TRAINLOG_STATUS_NOT_FOUND; if (status == TRAINLOG_STATUS_OK) { char useful[ TRAINLOG_SYNC_ERROR_MAX + 1U @@ -2557,6 +2947,35 @@ TrainlogStatus trainlog_sync_run( goto finalize; } + if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { + output->success = true; + final_status = TRAINLOG_STATUS_OK; + goto finalize; + } + +outbound: + status = sync_run_python_tool("export_equipment_definitions.py", + PC_EQUIPMENT_DEFINITIONS_LOCAL, + database_path, + EQUIPMENT_DEFINITIONS_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EQUIPMENT_DEFINITIONS_EXPORT=PASS") == NULL) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : export définitions équipement échoué."); + final_status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finalize; + } + status = sync_publish_named(&device, folder_id, + PC_EQUIPMENT_DEFINITIONS_LOCAL, + PC_EQUIPMENT_DEFINITIONS_NAME); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : publication définitions équipement échouée."); + final_status = status; + goto finalize; + } + status = sync_run_python_tool( "export_pc_catalog.py", @@ -2605,6 +3024,14 @@ TrainlogStatus trainlog_sync_run( "exercises" ); + status = sync_publish_named(&device, folder_id, PC_CATALOG_LOCAL, PC_CATALOG_NAME); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : publication MTP du catalogue échouée."); + final_status = status; + goto finalize; + } + status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL, database_path, PC_CATALOG_RESULT, tool_output, sizeof(tool_output)); @@ -2620,29 +3047,6 @@ TrainlogStatus trainlog_sync_run( goto finalize; } - status = - sync_publish_named( - &device, - folder_id, - PC_CATALOG_LOCAL, - PC_CATALOG_NAME - ); - - if ( - status != - TRAINLOG_STATUS_OK - ) { - (void)snprintf( - output->error, - sizeof(output->error), - "%s", - "PC→Android : publication MTP du catalogue échouée." - ); - - final_status = status; - goto finalize; - } - status = sync_run_python_tool("export_equipment_associations.py", EQUIPMENT_ASSOCIATIONS_LOCAL, database_path, @@ -2670,7 +3074,7 @@ TrainlogStatus trainlog_sync_run( TRAINLOG_STATUS_OK; finalize: - sync_build_summary( + trainlog_sync_build_summary( output ); @@ -2711,13 +3115,13 @@ finalize: final_status = TRAINLOG_STATUS_SYSTEM_ERROR; - sync_build_summary( + trainlog_sync_build_summary( output ); } } - (void)sync_record_run( + (void)trainlog_sync_record_local_run( trigger, output ); diff --git a/tui/src/sync_history.c b/tui/src/sync_history.c new file mode 100644 index 0000000..5d68ba5 --- /dev/null +++ b/tui/src/sync_history.c @@ -0,0 +1,181 @@ +/** + * @file sync_history.c + * @brief Strict codec for local synchronization history lines. + */ + +#include "trainlog/sync_history.h" + +#include +#include +#include + +#define SYNC_HISTORY_LINE_MAX 767U + +static bool sync_history_timestamp_valid(const char *timestamp) +{ + size_t index; + + if (timestamp == NULL || strlen(timestamp) != 16U) { + return false; + } + + for (index = 0U; index < 16U; ++index) { + char expected = '\0'; + + if (index == 2U || index == 5U) { + expected = '/'; + } else if (index == 10U) { + expected = ' '; + } else if (index == 13U) { + expected = ':'; + } + + if ( + (expected != '\0' && timestamp[index] != expected) || + (expected == '\0' && !isdigit((unsigned char)timestamp[index])) + ) { + return false; + } + } + + return true; +} + +const char *trainlog_sync_history_direction_code( + TrainlogSyncDirection direction +) +{ + switch (direction) { + case TRAINLOG_SYNC_ANDROID_TO_PC: + return "a"; + case TRAINLOG_SYNC_PC_TO_ANDROID: + return "p"; + case TRAINLOG_SYNC_BIDIRECTIONAL: + return "b"; + default: + return NULL; + } +} + +static bool sync_history_direction_parse( + const char *text, + TrainlogSyncDirection *output +) +{ + if (text == NULL || output == NULL || text[1] != '\0') { + return false; + } + + if (text[0] == 'a') { + *output = TRAINLOG_SYNC_ANDROID_TO_PC; + } else if (text[0] == 'p') { + *output = TRAINLOG_SYNC_PC_TO_ANDROID; + } else if (text[0] == 'b') { + *output = TRAINLOG_SYNC_BIDIRECTIONAL; + } else { + return false; + } + + return true; +} + +const char *trainlog_sync_history_direction_label( + const TrainlogSyncHistoryEntry *entry +) +{ + if (entry == NULL || !entry->direction_known) { + /* Legacy records predate the direction field; summary text is not a + * trustworthy substitute, especially for failure diagnostics. */ + return "direction inconnue"; + } + + switch (entry->direction) { + case TRAINLOG_SYNC_ANDROID_TO_PC: + return "Android→PC"; + case TRAINLOG_SYNC_PC_TO_ANDROID: + return "PC→Android"; + case TRAINLOG_SYNC_BIDIRECTIONAL: + return "PC↔Android"; + default: + return "direction inconnue"; + } +} + +bool trainlog_sync_history_parse_line( + const char *line, + TrainlogSyncHistoryEntry *output +) +{ + char copy[SYNC_HISTORY_LINE_MAX + 1U]; + char *fields[5]; + char *cursor; + size_t field_count = 1U; + size_t length; + bool has_sync_id; + bool has_direction; + const char *sync_id; + const char *timestamp; + const char *status; + const char *summary; + + if (line == NULL || output == NULL) { + return false; + } + + length = strlen(line); + while (length > 0U && (line[length - 1U] == '\n' || line[length - 1U] == '\r')) { + --length; + } + if (length == 0U || length > SYNC_HISTORY_LINE_MAX) { + return false; + } + + (void)memcpy(copy, line, length); + copy[length] = '\0'; + fields[0] = copy; + for (cursor = copy; *cursor != '\0'; ++cursor) { + if (*cursor == '\t') { + if (field_count == sizeof(fields) / sizeof(fields[0])) { + return false; + } + *cursor = '\0'; + fields[field_count] = cursor + 1; + ++field_count; + } + } + + has_sync_id = field_count >= 4U; + has_direction = field_count == 5U; + if (field_count != 3U && field_count != 4U && field_count != 5U) { + return false; + } + + sync_id = has_sync_id ? fields[0] : ""; + timestamp = has_sync_id ? fields[1] : fields[0]; + status = has_sync_id ? fields[2] : fields[1]; + summary = fields[field_count - 1U]; + + if ( + (has_sync_id && (sync_id[0] == '\0' || strlen(sync_id) > TRAINLOG_ID_MAX)) || + !sync_history_timestamp_valid(timestamp) || + (strcmp(status, "0") != 0 && strcmp(status, "1") != 0) || + summary[0] == '\0' || strlen(summary) > TRAINLOG_SYNC_SUMMARY_MAX + ) { + return false; + } + + (void)memset(output, 0, sizeof(*output)); + (void)snprintf(output->sync_id, sizeof(output->sync_id), "%s", sync_id); + (void)snprintf(output->timestamp, sizeof(output->timestamp), "%s", timestamp); + output->success = strcmp(status, "1") == 0; + (void)snprintf(output->summary, sizeof(output->summary), "%s", summary); + + if (has_direction) { + if (!sync_history_direction_parse(fields[3], &output->direction)) { + return false; + } + output->direction_known = true; + } + + return true; +} diff --git a/tui/src/sync_screen_action.c b/tui/src/sync_screen_action.c new file mode 100644 index 0000000..d6e5f17 --- /dev/null +++ b/tui/src/sync_screen_action.c @@ -0,0 +1,167 @@ +/** + * @file sync_screen_action.c + * @brief Backend-independent sync-screen keyboard action dispatcher. + */ + +#include "trainlog/sync_screen_action.h" + +#include "trainlog/terminal.h" + +static TrainlogSyncScreenAction sync_screen_action( + TrainlogSyncScreenEffect effect, + TrainlogSyncDirection direction +) +{ + TrainlogSyncScreenAction action; + + action.effect = effect; + action.direction = direction; + return action; +} + +void trainlog_sync_screen_state_init( + TrainlogSyncScreenState *state +) +{ + if (state == NULL) { + return; + } + + state->confirming = false; + state->direction = TRAINLOG_SYNC_BIDIRECTIONAL; +} + +TrainlogSyncScreenAction trainlog_sync_screen_dispatch( + TrainlogSyncScreenState *state, + int key +) +{ + TrainlogSyncDirection direction; + + if (state == NULL) { + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_NONE, + TRAINLOG_SYNC_BIDIRECTIONAL + ); + } + + direction = state->direction; + + if (key == 'a' || key == 'A') { + direction = TRAINLOG_SYNC_ANDROID_TO_PC; + } else if (key == 'p' || key == 'P') { + direction = TRAINLOG_SYNC_PC_TO_ANDROID; + } else if (key == 'b' || key == 'B') { + direction = TRAINLOG_SYNC_BIDIRECTIONAL; + } else if (state->confirming) { + if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + /* CONTRACT: consuming confirmation before execution guarantees + * that one Enter event can yield at most one sync-engine call. */ + state->confirming = false; + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_RUN, + state->direction + ); + } + + if (key == 27) { + state->confirming = false; + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_CANCEL, + state->direction + ); + } + + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_NONE, + state->direction + ); + } else if (key == 'r' || key == 'R') { + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_REFRESH, + state->direction + ); + } else if (key == 27) { + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_EXIT, + state->direction + ); + } else { + /* The former s shortcut is deliberately not an action. */ + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_NONE, + state->direction + ); + } + + state->direction = direction; + state->confirming = true; + return sync_screen_action( + TRAINLOG_SYNC_SCREEN_CONFIRM, + direction + ); +} + +TrainlogStatus trainlog_sync_screen_execute( + const TrainlogSyncScreenAction *action, + TrainlogSyncScreenRunCallback callback, + void *context, + TrainlogSyncReport *report +) +{ + if ( + action == NULL || + action->effect != TRAINLOG_SYNC_SCREEN_RUN || + callback == NULL || + report == NULL + ) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + return callback( + action->direction, + report, + context + ); +} + +const char *trainlog_sync_screen_direction_label( + TrainlogSyncDirection direction +) +{ + if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { + return "Android→PC"; + } + + if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { + return "PC→Android"; + } + + return "PC↔Android"; +} + +const char *trainlog_sync_screen_confirmation( + TrainlogSyncDirection direction +) +{ + if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { + return "Importer Android vers le PC maintenant ?"; + } + + if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { + return "Publier le catalogue du PC vers Android maintenant ?"; + } + + return "Synchroniser Android et PC dans les deux sens maintenant ?"; +} + +const char *trainlog_sync_screen_footer( + int terminal_columns +) +{ + if (terminal_columns >= 100) { + return "a Android→PC p PC→Android b PC↔Android r actualiser Échap retour"; + } + + return "a A→PC p PC→A b A↔PC r act. Échap retour"; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index 560fa9c..5785a03 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -1,6 +1,6 @@ /** * @file tui.c - * @brief First usable Trainlog ncurses interface. + * @brief Trainlog Notcurses terminal interface. */ #include "trainlog/tui.h" @@ -19,16 +19,21 @@ #include #include +#include + #include "trainlog/terminal.h" #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" #include "trainlog/catalog.h" #include "trainlog/duration.h" +#include "trainlog/equipment_catalog.h" #include "trainlog/id.h" #include "trainlog/measured_max.h" #include "trainlog/mtp.h" #include "trainlog/sync.h" +#include "trainlog/sync_history.h" +#include "trainlog/sync_screen_action.h" #include "trainlog/reps.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" @@ -64,11 +69,22 @@ typedef enum DashboardAction { DASHBOARD_NEW_SESSION = 0, DASHBOARD_HISTORY, DASHBOARD_EXERCISES, + DASHBOARD_EQUIPMENT, DASHBOARD_BODY, DASHBOARD_SYNC, DASHBOARD_QUIT } DashboardAction; +/* CONTRACT: every primary navigation renderer and selector uses this single + * list/count pair so adding a workflow cannot desynchronize array bounds. */ +static const char *const primary_nav_labels[] = { + "0 Accueil", "1 Séance", "2 Historique", "3 Exercices", + "4 Équipements", "5 Corps", "6 Sync" +}; + +#define PRIMARY_NAV_COUNT \ + ((int)(sizeof(primary_nav_labels) / sizeof(primary_nav_labels[0]))) + /* TRAINLOG_DASHBOARD_FORWARD_DECLARATIONS */ static DashboardAction screen_dashboard( TrainlogDatabase *database @@ -81,6 +97,13 @@ static void screen_exercises( static void screen_sync( TrainlogDatabase *database ); +static void screen_equipment(TrainlogDatabase *database); +static void screen_equipment_detail(const TrainlogResolvedEquipment *equipment); +static void wait_key(void); +static void status_line(const char *text, TrainlogColorRole role); +static bool prompt_text(int row, const char *label, char *output, size_t output_size, bool allow_empty); +static bool prompt_int_value(int row, const char *label, int minimum, + int maximum, int default_value, int *output); static void dashboard_panel( int top, @@ -137,6 +160,191 @@ static void draw_shell(const char *heading, const char *footer) trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); } +/* WHY: one read-only catalogue view combines frozen supplied definitions with + * durable local ones; neither source is silently substituted for the other. */ +static bool equipment_text_matches(const char *text, const char *query) +{ + size_t index; + size_t query_index; + if (query == NULL || query[0] == '\0') return true; + if (text == NULL) return false; + for (index = 0U; text[index] != '\0'; ++index) { + for (query_index = 0U; query[query_index] != '\0'; ++query_index) { + unsigned char left = (unsigned char)text[index + query_index]; + unsigned char right = (unsigned char)query[query_index]; + if (left == '\0' || tolower(left) != tolower(right)) break; + } + if (query[query_index] == '\0') return true; + } + return false; +} + +static size_t equipment_collect(TrainlogDatabase *database, const char *query, + TrainlogResolvedEquipment *output, size_t capacity) +{ + TrainlogCustomEquipment customs[128]; + const TrainlogEquipment *supplied_matches[128]; + size_t custom_count = 0U; + size_t supplied_count; + size_t count = 0U; + size_t index; + supplied_count = query != NULL && query[0] != '\0' + ? trainlog_equipment_catalog_search(query, supplied_matches, 128U) + : trainlog_equipment_catalog_count(); + if (supplied_count > 128U) supplied_count = 128U; + for (index = 0U; index < supplied_count && count < capacity; ++index) { + const TrainlogEquipment *item = query != NULL && query[0] != '\0' + ? supplied_matches[index] : trainlog_equipment_catalog_at(index); + if (item != NULL && trainlog_database_resolve_equipment(database, + item->equipment_id, &output[count]) == TRAINLOG_STATUS_OK) ++count; + } + if (trainlog_database_list_custom_equipment(database, customs, 128U, + &custom_count) != TRAINLOG_STATUS_OK) return count; + if (custom_count > 128U) custom_count = 128U; + for (index = 0U; index < custom_count && count < capacity; ++index) { + if (equipment_text_matches(customs[index].display_name, query) || + equipment_text_matches(customs[index].label_name, query) || + equipment_text_matches(customs[index].equipment_type, query)) { + if (trainlog_database_resolve_equipment(database, + customs[index].equipment_id, &output[count]) == TRAINLOG_STATUS_OK) ++count; + } + } + return count; +} + +static const char *equipment_origin_label(TrainlogEquipmentOrigin origin) +{ + if (origin == TRAINLOG_EQUIPMENT_SUPPLIED) return "fourni"; + if (origin == TRAINLOG_EQUIPMENT_CUSTOM) return "personnalisé"; + return "inconnu"; +} + +static void generate_custom_equipment_uuid(char output[TRAINLOG_UUID_TEXT_LENGTH + 1U]) +{ + uuid_t value; + /* CONTRACT: equipment IDs are opaque alongside manifest slugs. A raw + * UUIDv4 avoids extending the frozen creator-prefix namespace. */ + uuid_generate_random(value); + uuid_unparse_lower(value, output); +} + +static void screen_equipment_detail(const TrainlogResolvedEquipment *equipment) +{ + if (equipment == NULL) return; + draw_shell("Fiche équipement", "Une touche pour revenir"); + trainlog_terminal_printf(tui_terminal, 4, 4, "%s", equipment->display_name); + trainlog_terminal_printf(tui_terminal, 6, 4, "Identifiant : %s", equipment->equipment_id); + trainlog_terminal_printf(tui_terminal, 7, 4, "Étiquette : %s", + equipment->label_name[0] != '\0' ? equipment->label_name : "—"); + trainlog_terminal_printf(tui_terminal, 8, 4, "Type : %s", + equipment->equipment_type[0] != '\0' ? equipment->equipment_type : "—"); + trainlog_terminal_printf(tui_terminal, 9, 4, "Charge : %s · Origine : %s", + equipment->load_semantics[0] != '\0' ? equipment->load_semantics : "—", + equipment_origin_label(equipment->origin)); + wait_key(); +} + +static bool choose_equipment(TrainlogDatabase *database, char *output, size_t output_size) +{ + TrainlogResolvedEquipment items[256]; + char query[TRAINLOG_NAME_MAX + 1U] = ""; + size_t selected = 0U; + for (;;) { + size_t count = equipment_collect(database, query, items, 256U); + size_t visible = trainlog_terminal_rows(tui_terminal) > 7 + ? (size_t)(trainlog_terminal_rows(tui_terminal) - 7) : 1U; + size_t top = selected > 0U && selected - 1U >= visible + ? selected - visible : 0U; + size_t i; + int key; + if (selected > count) selected = count; + draw_shell("Choisir un équipement", "↑↓ naviguer Entrée choisir / rechercher x aucun Échap annuler"); + trainlog_terminal_printf(tui_terminal, 3, 4, "Recherche : %s", query[0] ? query : "—"); + if (selected == 0U) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, 4, 4, " %-48s ", "Aucun équipement"); + if (selected == 0U) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + for (i = 0U; i < visible && top + i < count; ++i) { + size_t absolute = top + i; + if (absolute + 1U == selected) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, 5 + (int)i, 4, "%-46.46s %s", + items[absolute].display_name, equipment_origin_label(items[absolute].origin)); + if (absolute + 1U == selected) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 27) return false; + if (key == '/') { + draw_shell("Rechercher un équipement", "Entrée applique · Échap annule"); + if (prompt_text(4, "Recherche", query, sizeof(query), true)) selected = 0U; + } else if (key == 'x' || key == 'X') { + output[0] = '\0'; + return true; + } else if (key == TRAINLOG_KEY_UP && selected > 0U) --selected; + else if (key == TRAINLOG_KEY_DOWN && selected < count) ++selected; + else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + if (selected == 0U) output[0] = '\0'; + else (void)snprintf(output, output_size, "%s", items[selected - 1U].equipment_id); + return true; + } + } +} + +static void screen_equipment(TrainlogDatabase *database) +{ + char query[TRAINLOG_NAME_MAX + 1U] = ""; + size_t selected = 0U; + for (;;) { + TrainlogResolvedEquipment items[256]; + size_t total = equipment_collect(database, query, items, 256U); + size_t visible = trainlog_terminal_rows(tui_terminal) > 7 + ? (size_t)(trainlog_terminal_rows(tui_terminal) - 7) : 1U; + size_t top; + size_t i; + int key; + if (selected >= total && total > 0U) selected = total - 1U; + top = selected >= visible ? selected - visible + 1U : 0U; + draw_shell("TRAINLOG — Équipements", "↑↓ naviguer Entrée fiche / rechercher n créer b/Échap retour"); + trainlog_terminal_printf(tui_terminal, 3, 4, "Recherche : %s", query[0] ? query : "—"); + for (i = 0U; i < visible && top + i < total; ++i) { + size_t absolute = top + i; + if (absolute == selected) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, 4 + (int)i, 4, "%-46.46s %s", + items[absolute].display_name, equipment_origin_label(items[absolute].origin)); + if (absolute == selected) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 27 || key == 'b' || key == 'B') return; + if (key == TRAINLOG_KEY_UP && selected > 0U) --selected; + else if (key == TRAINLOG_KEY_DOWN && selected + 1U < total) ++selected; + else if (key == '/') { + draw_shell("Rechercher un équipement", "Entrée applique · Échap annule"); + if (prompt_text(4, "Recherche", query, sizeof(query), true)) selected = 0U; + } + else if (key == 'n' || key == 'N') { + TrainlogCustomEquipment equipment; + char generated[TRAINLOG_UUID_TEXT_LENGTH + 1U]; + int load_semantics = 2; + (void)memset(&equipment, 0, sizeof(equipment)); + draw_shell("Nouvel équipement", "Échap annule ; Entrée valide chaque champ"); + if (!prompt_text(4, "Nom convivial", equipment.display_name, sizeof(equipment.display_name), false) || + !prompt_text(6, "Nom étiquette (optionnel)", equipment.label_name, sizeof(equipment.label_name), true) || + !prompt_text(8, "Type", equipment.equipment_type, sizeof(equipment.equipment_type), false) || + !prompt_int_value(10, "Charge 1=aucune 2=externe 3=assistance", + 1, 3, 2, &load_semantics)) continue; + (void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics), "%s", + load_semantics == 1 ? "none" : + (load_semantics == 2 ? "external" : "assistance")); + generate_custom_equipment_uuid(generated); + (void)snprintf(equipment.equipment_id, sizeof(equipment.equipment_id), "%s", generated); + if (trainlog_database_create_custom_equipment(database, &equipment) != TRAINLOG_STATUS_OK) { + status_line("Équipement non créé : nom ou identifiant invalide/conflit.", TRAINLOG_COLOR_ERROR); wait_key(); + } + } else if ((key == '\n' || key == TRAINLOG_KEY_ENTER) && total > 0U) + screen_equipment_detail(&items[selected]); + } +} + static void wait_key(void) { trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); @@ -1923,6 +2131,82 @@ static void section_scrollbar( ); } +static void screen_exercise_detail( + TrainlogDatabase *database, + const TrainlogExercise *exercise +) +{ + TrainlogResolvedEquipment explicit_items[64]; + TrainlogResolvedEquipment historic_items[128]; + size_t explicit_count = 0U; + size_t historic_count = 0U; + size_t selected = 0U; + size_t index; + + if (database == NULL || exercise == NULL) return; + for (index = 0U; index < trainlog_equipment_catalog_relation_count() && + explicit_count < 64U; ++index) { + const TrainlogExerciseEquipmentRelation *relation = + trainlog_equipment_catalog_relation_at(index); + if (relation != NULL && strcmp(relation->exercise_id, + exercise->exercise_id) == 0 && + trainlog_database_resolve_equipment(database, relation->equipment_id, + &explicit_items[explicit_count]) == TRAINLOG_STATUS_OK) { + ++explicit_count; + } + } + if (trainlog_database_list_exercise_equipment(database, + exercise->exercise_id, historic_items, 128U, + &historic_count) != TRAINLOG_STATUS_OK) historic_count = 0U; + if (historic_count > 128U) historic_count = 128U; + + for (;;) { + size_t total = explicit_count + historic_count; + int row = 7; + int key; + if (total > 0U && selected >= total) selected = total - 1U; + draw_shell("TRAINLOG — Fiche exercice", + "↑↓ équipement Entrée fiche p performance m max mesuré b/Échap retour"); + trainlog_terminal_printf(tui_terminal, 3, 4, "%s", exercise->name); + trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s", + exercise->exercise_id, + exercise->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée"); + trainlog_terminal_printf(tui_terminal, 6, 4, + "Relations explicites du manifeste (%zu)", explicit_count); + if (explicit_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucune"); + for (index = 0U; index < explicit_count; ++index, ++row) { + if (selected == index) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, row, 6, " %-58.58s ", explicit_items[index].display_name); + if (selected == index) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + } + ++row; + trainlog_terminal_printf(tui_terminal, row++, 4, + "Équipements utilisés historiquement (%zu)", historic_count); + if (historic_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucun"); + for (index = 0U; index < historic_count && row < trainlog_terminal_rows(tui_terminal) - 3; + ++index, ++row) { + size_t absolute = explicit_count + index; + if (selected == absolute) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, row, 6, " %-58.58s [%s] ", + historic_items[index].display_name, + equipment_origin_label(historic_items[index].origin)); + if (selected == absolute) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 27 || key == 'b' || key == 'B') return; + if (total > 0U && key == TRAINLOG_KEY_UP) + selected = selected > 0U ? selected - 1U : total - 1U; + else if (total > 0U && key == TRAINLOG_KEY_DOWN) + selected = selected + 1U < total ? selected + 1U : 0U; + else if (total > 0U && (key == '\n' || key == TRAINLOG_KEY_ENTER)) + screen_equipment_detail(selected < explicit_count + ? &explicit_items[selected] : &historic_items[selected - explicit_count]); + else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise); + else if (key == 'm' || key == 'M') screen_exercise_measured_max(database, exercise); + } +} + static void screen_exercises( TrainlogDatabase *database ) @@ -2017,7 +2301,7 @@ static void screen_exercises( 2, "%.*s", trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F4 direct b/Échap retour" + "Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F5 direct b/Échap retour" ); trainlog_terminal_style_off(tui_terminal, @@ -2148,10 +2432,10 @@ primary_top_nav_forward(key)) { nav_selected = nav_selected > 0 ? nav_selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = - nav_selected < 5 + nav_selected < PRIMARY_NAV_COUNT - 1 ? nav_selected + 1 : 0; } else if ( @@ -2228,7 +2512,7 @@ if (primary_top_nav_activate( if (count > 0U && (key == '\n' || key == TRAINLOG_KEY_ENTER)) { - screen_exercise_performance( + screen_exercise_detail( database, &exercises[selected] ); @@ -2236,6 +2520,11 @@ if (primary_top_nav_activate( continue; } + if (count > 0U && (key == 'p' || key == 'P')) { + screen_exercise_performance(database, &exercises[selected]); + continue; + } + if (key == 'a' || key == 'A') { char name[TRAINLOG_NAME_MAX + 1U]; @@ -3939,15 +4228,6 @@ static void primary_top_navbar( bool focused ) { - static const char *const labels[] = { - "0 Accueil", - "1 Séance", - "2 Historique", - "3 Exercices", - "4 Corps", - "5 Sync" - }; - int column = 5; int index; @@ -3961,10 +4241,10 @@ static void primary_top_navbar( ); for (index = 0; - index < 6; + index < PRIMARY_NAV_COUNT; ++index) { int width = - (int)strlen(labels[index]) + 4; + (int)strlen(primary_nav_labels[index]) + 4; if (index == selected_page) { trainlog_terminal_style_on(tui_terminal, @@ -3986,7 +4266,7 @@ static void primary_top_navbar( 9, column, " %s ", - labels[index] + primary_nav_labels[index] ); if (index == selected_page) { @@ -4014,7 +4294,7 @@ static bool primary_top_nav_activate( ) { if (selected_page < 0 || - selected_page > 5) { + selected_page >= PRIMARY_NAV_COUNT) { return false; } @@ -4066,6 +4346,10 @@ static bool primary_top_nav_forward( forwarded = '5'; break; + case '6': + forwarded = '6'; + break; + default: return false; } @@ -4682,17 +4966,8 @@ static DashboardAction screen_dashboard( TrainlogDatabase *database ) { - static const char *const labels[] = { - "0 Accueil", - "1 Séance", - "2 Historique", - "3 Exercices", - "4 Corps", - "5 Sync" - }; - static const char *const footer = - "←→ naviguer Entrée ouvrir 0 accueil F1-F4 accès direct q quitter"; + "←→ naviguer Entrée ouvrir 0 accueil F1-F5 accès direct q quitter"; int selected = 0; @@ -4733,10 +5008,10 @@ static DashboardAction screen_dashboard( large_layout ? 5 : 3; for (index = 0; - index < 6; + index < PRIMARY_NAV_COUNT; ++index) { int width = - (int)strlen(labels[index]) + 4; + (int)strlen(primary_nav_labels[index]) + 4; if (index == selected) { trainlog_terminal_style_on(tui_terminal, @@ -4753,7 +5028,7 @@ static DashboardAction screen_dashboard( : trainlog_terminal_rows(tui_terminal) - 3, column, " %s ", - labels[index] + primary_nav_labels[index] ); if (index == selected) { @@ -4797,13 +5072,13 @@ static DashboardAction screen_dashboard( selected = selected > 0 ? selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; break; case TRAINLOG_KEY_DOWN: case TRAINLOG_KEY_RIGHT: selected = - selected < 5 + selected < PRIMARY_NAV_COUNT - 1 ? selected + 1 : 0; break; @@ -4820,8 +5095,10 @@ static DashboardAction screen_dashboard( case 3: return DASHBOARD_EXERCISES; case 4: - return DASHBOARD_BODY; + return DASHBOARD_EQUIPMENT; case 5: + return DASHBOARD_BODY; + case 6: return DASHBOARD_SYNC; default: break; @@ -4847,10 +5124,13 @@ static DashboardAction screen_dashboard( case TRAINLOG_KEY_F4: case '4': - return DASHBOARD_BODY; + return DASHBOARD_EQUIPMENT; case TRAINLOG_KEY_F5: case '5': + return DASHBOARD_BODY; + + case '6': return DASHBOARD_SYNC; case 'q': @@ -4909,6 +5189,11 @@ static bool build_session_exercise( output->data_fields = exercise.data_fields; + if (!choose_equipment(database, output->equipment_id, + sizeof(output->equipment_id))) { + return false; + } + if (exercise.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { int duration_minutes = 30; @@ -5346,7 +5631,7 @@ static bool choose_session_type( 2, "%.*s", trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ←→ menu ↑↓ type Entrée valider 0/Home accueil F2-F4 direct Échap annuler" + "Tab zone ←→ menu ↑↓ type Entrée valider 0/Home accueil F2-F5 direct Échap annuler" ); trainlog_terminal_style_off(tui_terminal, @@ -5594,7 +5879,8 @@ static bool choose_session_type( key == '4' || key == TRAINLOG_KEY_F4 || key == '5' || - key == TRAINLOG_KEY_F5)) { + key == TRAINLOG_KEY_F5 || + key == '6')) { if (primary_top_nav_forward(key)) { return false; } @@ -5608,10 +5894,10 @@ static bool choose_session_type( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = - nav_selected < 5 + nav_selected < PRIMARY_NAV_COUNT - 1 ? nav_selected + 1 : 0; } else if ( @@ -6770,7 +7056,7 @@ static void screen_session_detail( 2, "%.*s", trainlog_terminal_columns(tui_terminal) - 4, - "←→/↑↓ exercice précédent/suivant e modifier la séance b/Échap retour" + "←→/↑↓ exercice précédent/suivant i fiche équipement e modifier b/Échap retour" ); trainlog_terminal_style_off(tui_terminal, @@ -6806,7 +7092,7 @@ static void screen_session_detail( } else { draw_shell( "TRAINLOG — Détail séance", - "←→/↑↓ naviguer e modifier b/Échap retour" + "←→/↑↓ naviguer i fiche équipement e modifier b/Échap retour" ); trainlog_terminal_printf(tui_terminal, @@ -6844,6 +7130,24 @@ static void screen_session_detail( } else { TrainlogPersistedExerciseDetail *exercise = &exercises[selected]; + TrainlogResolvedEquipment resolved_equipment; + bool has_equipment = exercise->equipment_id[0] != '\0' && + trainlog_database_resolve_equipment(database, + exercise->equipment_id, &resolved_equipment) == TRAINLOG_STATUS_OK; + const char *equipment_label = has_equipment + ? resolved_equipment.display_name + : (exercise->equipment_id[0] != '\0' + ? exercise->equipment_id + : "—"); + + /* CONTRACT: history identifies the selected occurrence by its + * stable entry_id, so two appearances of Marche stay distinct. */ + trainlog_terminal_printf(tui_terminal, + decorated ? 19 : 9, + decorated ? 5 : 4, + "Occurrence : %s Équipement : %s", + exercise->entry_id, + equipment_label); if (exercise->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { @@ -7148,6 +7452,16 @@ static void screen_session_detail( return; } + if (count > 0U && (key == 'i' || key == 'I') && + exercises[selected].equipment_id[0] != '\0') { + TrainlogResolvedEquipment equipment; + if (trainlog_database_resolve_equipment(database, + exercises[selected].equipment_id, &equipment) == TRAINLOG_STATUS_OK) { + screen_equipment_detail(&equipment); + } + continue; + } + if (count > 0U && (key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_DOWN)) { @@ -7358,7 +7672,7 @@ static void screen_history( "%.*s", trainlog_terminal_columns(tui_terminal) - 4, large_layout - ? "Tab zone ↑↓/PgUp/PgDn liste ←→ menu Entrée ouvrir e modifier 0/Home accueil F1-F4 direct b/Échap retour" + ? "Tab zone ↑↓/PgUp/PgDn liste ←→ menu Entrée ouvrir e modifier 0/Home accueil F1-F5 direct b/Échap retour" : "↑↓ naviguer Entrée détail e modifier b/Échap retour" ); @@ -7487,10 +7801,10 @@ static void screen_history( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = - nav_selected < 5 + nav_selected < PRIMARY_NAV_COUNT - 1 ? nav_selected + 1 : 0; } else if ( @@ -9585,7 +9899,7 @@ static void screen_body( records[MAX_BODY_OBSERVATIONS]; size_t selected = 0U; - int nav_selected = 4; + int nav_selected = 5; int focus = 1; for (;;) { @@ -9667,7 +9981,7 @@ static void screen_body( ); primary_top_navbar( - 4, + 5, nav_selected, focus == 0 ); @@ -9683,7 +9997,7 @@ static void screen_body( 2, "%.*s", trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter v analyse g vue globale 0/Home accueil F1-F4 direct" + "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter v analyse g vue globale 0/Home accueil F1-F5 direct" ); trainlog_terminal_style_off(tui_terminal, @@ -9845,10 +10159,10 @@ static void screen_body( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; } else if (key == TRAINLOG_KEY_RIGHT) { nav_selected = - nav_selected < 5 + nav_selected < PRIMARY_NAV_COUNT - 1 ? nav_selected + 1 : 0; } else if ( @@ -9970,23 +10284,9 @@ static void screen_body( /* TRAINLOG_SHARED_SYNC_ENGINE_V1 */ #define SYNC_HISTORY_CAPACITY 64U -#define SYNC_HISTORY_TEXT_MAX TRAINLOG_SYNC_SUMMARY_MAX #define SYNC_DETAIL_LINE_CAPACITY 160U #define SYNC_DETAIL_TEXT_CAPACITY 16384U -typedef struct TrainlogSyncHistoryEntry { - char sync_id[ - TRAINLOG_ID_MAX + 1U - ]; - - char timestamp[17]; - bool success; - - char summary[ - SYNC_HISTORY_TEXT_MAX + 1U - ]; -} TrainlogSyncHistoryEntry; - static void session_history_datetime( const char *timestamp, char output[17] @@ -10205,101 +10505,32 @@ static void sync_history_load( file ) != NULL ) { - char *first; - char *second; - char *third; - char *newline; - - const char *sync_id; - const char *timestamp; - const char *status_text; - const char *summary; - TrainlogSyncHistoryEntry *entry; - newline = - strchr( - line, - '\n' - ); + if ( + strchr(line, '\n') == NULL && + !feof(file) + ) { + int discarded; - if (newline != NULL) { - *newline = '\0'; - } + do { + discarded = fgetc(file); + } while (discarded != '\n' && discarded != EOF); - first = - strchr( - line, - '\t' - ); - - if (first == NULL) { continue; } - *first = '\0'; - - second = - strchr( - first + 1, - '\t' - ); - - if (second == NULL) { - continue; - } - - *second = '\0'; - - third = - strchr( - second + 1, - '\t' - ); - - if (third != NULL) { - *third = '\0'; - - sync_id = line; - timestamp = first + 1; - status_text = second + 1; - summary = third + 1; - } else { - sync_id = ""; - timestamp = line; - status_text = first + 1; - summary = second + 1; - } - entry = &ring[next]; - (void)snprintf( - entry->sync_id, - sizeof(entry->sync_id), - "%s", - sync_id - ); - - (void)snprintf( - entry->timestamp, - sizeof(entry->timestamp), - "%s", - timestamp - ); - - entry->success = - strcmp( - status_text, - "1" - ) == 0; - - (void)snprintf( - entry->summary, - sizeof(entry->summary), - "%s", - summary - ); + if ( + !trainlog_sync_history_parse_line( + line, + entry + ) + ) { + continue; + } next = ( @@ -10606,13 +10837,30 @@ static double sync_bytes_to_gib( ); } +static TrainlogStatus sync_screen_run( + TrainlogSyncDirection direction, + TrainlogSyncReport *report, + void *context +) +{ + (void)context; + + return trainlog_sync_run( + TRAINLOG_SYNC_TRIGGER_TUI, + false, + direction, + report + ); +} + static void screen_sync( TrainlogDatabase *database ) { size_t selected = 0U; - int nav_selected = 5; + int nav_selected = 6; int focus = 1; + TrainlogSyncScreenState action_state; TrainlogSyncDeviceInfo device; TrainlogStatus probe_status = @@ -10628,6 +10876,10 @@ static void screen_sync( sizeof(device) ); + trainlog_sync_screen_state_init( + &action_state + ); + for (;;) { TrainlogSyncHistoryEntry history[SYNC_HISTORY_CAPACITY]; @@ -10687,7 +10939,7 @@ static void screen_sync( ); primary_top_navbar( - 5, + 6, nav_selected, focus == 0 ); @@ -10701,6 +10953,9 @@ static void screen_sync( false ); + trainlog_terminal_printf(tui_terminal, 15, 5, + "Actions : a Android→PC | p PC→Android | b PC↔Android"); + if ( probe_status == TRAINLOG_STATUS_OK && @@ -10814,15 +11069,18 @@ static void screen_sync( trainlog_terminal_printf(tui_terminal, row, 5, - " %-16s %c %-*.*s ", + " %-16s %c %-17.17s %-*.*s ", history[absolute] .timestamp, history[absolute] .success ? '+' : '!', - trainlog_terminal_columns(tui_terminal) - 28, - trainlog_terminal_columns(tui_terminal) - 28, + trainlog_sync_history_direction_label( + &history[absolute] + ), + trainlog_terminal_columns(tui_terminal) - 47, + trainlog_terminal_columns(tui_terminal) - 47, history[absolute] .summary ); @@ -10852,7 +11110,9 @@ static void screen_sync( 2, "%.*s", trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ←→ menu ↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour" + trainlog_sync_screen_footer( + trainlog_terminal_columns(tui_terminal) + ) ); trainlog_terminal_style_off(tui_terminal, @@ -10863,7 +11123,9 @@ static void screen_sync( } else { draw_shell( "TRAINLOG — Sync", - "↑↓ historique Entrée détail s synchroniser r actualiser b/Échap retour" + trainlog_sync_screen_footer( + trainlog_terminal_columns(tui_terminal) + ) ); if ( @@ -10886,6 +11148,9 @@ static void screen_sync( ); } + trainlog_terminal_printf(tui_terminal, 5, 4, + "a Android→PC | p PC→Android | b PC↔Android"); + if ( history_count == 0U ) { @@ -10921,14 +11186,17 @@ static void screen_sync( trainlog_terminal_printf(tui_terminal, 7 + (int)index, 4, - "%-16s %c %.*s", + "%-16s %c %-17.17s %.*s", history[index] .timestamp, history[index] .success ? '+' : '!', - trainlog_terminal_columns(tui_terminal) - 25, + trainlog_sync_history_direction_label( + &history[index] + ), + trainlog_terminal_columns(tui_terminal) - 43, history[index] .summary ); @@ -10947,69 +11215,136 @@ static void screen_sync( } } + if (action_state.confirming) { + int middle = + trainlog_terminal_rows(tui_terminal) / 2; + + focused_panel( + middle - 2, + 4, + middle + 2, + trainlog_terminal_columns(tui_terminal) - 5, + "CONFIRMATION", + true + ); + trainlog_terminal_printf(tui_terminal, + middle, + 7, + "%.*s", + trainlog_terminal_columns(tui_terminal) - 14, + trainlog_sync_screen_confirmation( + action_state.direction + ) + ); + trainlog_terminal_printf(tui_terminal, + middle + 1, + 7, + "Entrée confirmer · Échap annuler" + ); + } + trainlog_terminal_render(tui_terminal); key = trainlog_terminal_get_key(tui_terminal); - if ( - key == 's' || - key == 'S' - ) { - TrainlogSyncReport report; - TrainlogStatus status; + { + bool was_confirming = + action_state.confirming; - status_line( - "Synchronisation bidirectionnelle en cours...", - TRAINLOG_COLOR_WARNING - ); - - trainlog_terminal_render(tui_terminal); - - status = - trainlog_sync_run( - TRAINLOG_SYNC_TRIGGER_TUI, - false, - &report + TrainlogSyncScreenAction action = + trainlog_sync_screen_dispatch( + &action_state, + key ); - if ( - status == - TRAINLOG_STATUS_OK && - report.success - ) { - status_line( - report.summary, - TRAINLOG_COLOR_SUCCESS - ); - } else { - status_line( - report.error[0] != '\0' - ? report.error - : "Synchronisation échouée.", - TRAINLOG_COLOR_ERROR - ); + if (action.effect == TRAINLOG_SYNC_SCREEN_EXIT) { + return; } - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); + if (action.effect == TRAINLOG_SYNC_SCREEN_REFRESH) { + refresh_device = true; + continue; + } - refresh_device = true; - continue; - } + if ( + action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM || + action.effect == TRAINLOG_SYNC_SCREEN_CANCEL + ) { + continue; + } - if ( - key == 'r' || - key == 'R' - ) { - refresh_device = true; - continue; - } + if (action.effect == TRAINLOG_SYNC_SCREEN_RUN) { + TrainlogSyncReport report; + TrainlogStatus status; - if ( - key == 'b' || - key == 'B' || - key == 27 - ) { - return; + (void)memset( + &report, + 0, + sizeof(report) + ); + + status_line( + action.direction == TRAINLOG_SYNC_ANDROID_TO_PC + ? "Étape Android→PC : import en cours..." + : action.direction == TRAINLOG_SYNC_PC_TO_ANDROID + ? "Étape PC→Android : publication en cours..." + : "Étape PC↔Android : import puis publication...", + TRAINLOG_COLOR_WARNING + ); + + trainlog_terminal_render(tui_terminal); + + status = + trainlog_sync_screen_execute( + &action, + sync_screen_run, + NULL, + &report + ); + + if ( + status == + TRAINLOG_STATUS_OK && + report.success + ) { + status_line( + report.summary, + TRAINLOG_COLOR_SUCCESS + ); + } else { + char error_summary[ + TRAINLOG_SYNC_ERROR_MAX + 32U + ]; + + (void)snprintf( + error_summary, + sizeof(error_summary), + "%s — %s", + trainlog_sync_screen_direction_label( + action.direction + ), + report.error[0] != '\0' + ? report.error + : "Synchronisation échouée." + ); + + status_line( + error_summary, + TRAINLOG_COLOR_ERROR + ); + } + + trainlog_terminal_render(tui_terminal); + (void)trainlog_terminal_get_key(tui_terminal); + + refresh_device = true; + continue; + } + + /* While confirmation is visible, unrelated keys (including the + * retired s shortcut) cannot leak into history/navigation. */ + if (was_confirming) { + continue; + } } if ( @@ -11037,19 +11372,19 @@ static void screen_sync( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 5; + : PRIMARY_NAV_COUNT - 1; } else if ( key == TRAINLOG_KEY_RIGHT ) { nav_selected = - nav_selected < 5 + nav_selected < PRIMARY_NAV_COUNT - 1 ? nav_selected + 1 : 0; } else if ( key == '\n' || key == TRAINLOG_KEY_ENTER ) { - if (nav_selected == 5) { + if (nav_selected == 6) { focus = 1; } else if ( primary_top_nav_activate( @@ -11113,7 +11448,10 @@ static void screen_sync( key == '3' || key == TRAINLOG_KEY_F3 || key == '4' || - key == TRAINLOG_KEY_F4 + key == TRAINLOG_KEY_F4 || + key == '5' || + key == TRAINLOG_KEY_F5 || + key == '6' ) { if ( primary_top_nav_forward( @@ -11175,6 +11513,9 @@ int trainlog_tui_run(TrainlogDatabase *database) case DASHBOARD_EXERCISES: screen_exercises(database); break; + case DASHBOARD_EQUIPMENT: + screen_equipment(database); + break; case DASHBOARD_BODY: screen_body(database); break; diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c new file mode 100644 index 0000000..a7ba85a --- /dev/null +++ b/tui/tests/test_custom_equipment.c @@ -0,0 +1,121 @@ +/** + * @file test_custom_equipment.c + * @brief Schema-v8 custom equipment persistence and occurrence tests. + */ + +#include +#include +#include +#include +#include + +#include + +#include "trainlog/database.h" + +#define CHECK(condition) do { if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return false; } } while (0) + +static const char *const V7_SQL = + "CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL DEFAULT 'training',notes TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));" + "CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);" + "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);" + "CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);" + "PRAGMA user_version=7;"; + +static bool test_custom_equipment_round_trip(void) +{ + char path[] = "/tmp/trainlog-custom-equipment-XXXXXX"; + TrainlogDatabase *database = NULL; + TrainlogCustomEquipment custom; + TrainlogCustomEquipment listed[2]; + TrainlogResolvedEquipment resolved; + TrainlogResolvedEquipment used[2]; + TrainlogSessionExerciseInput exercise; + TrainlogSessionInput session; + TrainlogSetInput set; + TrainlogSessionSummary summary; + TrainlogPersistedExerciseDetail detail[2]; + sqlite3 *raw = NULL; + size_t count = 0U; + int version = 0; + int fd = mkstemp(path); + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, V7_SQL, NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 8); + (void)memset(&custom, 0, sizeof(custom)); + (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), + "%s", "eq_123e4567-e89b-42d3-a456-426614174000"); + (void)snprintf(custom.display_name, sizeof(custom.display_name), "%s", "Poulie maison"); + (void)snprintf(custom.label_name, sizeof(custom.label_name), "%s", "POULIE A"); + (void)snprintf(custom.equipment_type, sizeof(custom.equipment_type), "%s", "cable_machine"); + (void)snprintf(custom.load_semantics, sizeof(custom.load_semantics), "%s", "external"); + CHECK(trainlog_database_create_custom_equipment(database, &custom) == TRAINLOG_STATUS_OK); + trainlog_database_close(database); + database = NULL; + + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment(database, listed, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U); + CHECK(strcmp(listed[0].equipment_id, custom.equipment_id) == 0); + CHECK(trainlog_database_insert_exercise(database, "ex_custom_equipment", + "Tirage custom", "tirage custom", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + (void)memset(&set, 0, sizeof(set)); + set.reps = 8; + set.has_weight = true; + set.weight_kg = 20.0; + (void)memset(&exercise, 0, sizeof(exercise)); + (void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_custom_equipment"); + (void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s", "sxe_custom_equipment"); + (void)snprintf(exercise.equipment_id, sizeof(exercise.equipment_id), "%s", custom.equipment_id); + exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; + exercise.rest_seconds = 60; + exercise.target_sets = 1; + exercise.target_reps = 8; + exercise.target_has_weight = true; + exercise.target_weight_kg = 20.0; + exercise.sets = &set; + exercise.set_count = 1U; + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_custom_equipment"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", "2026-09-08T10:00:00+02:00"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-08T10:30:00+02:00"); + session.exercises = &exercise; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + trainlog_database_close(database); + database = NULL; + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_get_session_details(database, session.session_id, + &summary, detail, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U); + CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0); + CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0); + CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id, + used, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U); + CHECK(used[0].origin == TRAINLOG_EQUIPMENT_CUSTOM); + CHECK(strcmp(used[0].display_name, custom.display_name) == 0); + CHECK(trainlog_database_resolve_equipment(database, "retired_machine", &resolved) == TRAINLOG_STATUS_OK); + CHECK(resolved.origin == TRAINLOG_EQUIPMENT_UNKNOWN); + CHECK(strstr(resolved.display_name, "retired_machine") != NULL); + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + +int main(void) +{ + CHECK(test_custom_equipment_round_trip()); + (void)printf("PASS custom_equipment\n"); + return 0; +} diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index c143730..cc83688 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -75,6 +75,8 @@ static bool test_generated_ids(void) char first[TRAINLOG_GENERATED_ID_CAPACITY]; char second[TRAINLOG_GENERATED_ID_CAPACITY]; char sync_id[TRAINLOG_GENERATED_ID_CAPACITY]; + char entry_id[TRAINLOG_GENERATED_ID_CAPACITY]; + char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U]; CHECK( trainlog_id_generate("ex", first, sizeof(first)) == @@ -85,9 +87,18 @@ static bool test_generated_ids(void) TRAINLOG_STATUS_OK ); CHECK(strncmp(first, "ex_", 3U) == 0); - CHECK(strlen(first) == TRAINLOG_GENERATED_ID_CAPACITY - 1U); + CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH); CHECK(strcmp(first, second) != 0); CHECK(first[3U + 14U] == '4'); + /* ABI behavior: the new longer occurrence prefix must not invalidate the + * exact buffer size historically sufficient for two-character prefixes. */ + CHECK( + trainlog_id_generate( + "se", + v1_exact_capacity, + sizeof(v1_exact_capacity) + ) == TRAINLOG_STATUS_OK + ); /* TRAINLOG_SYNC_ID_PREFIX_TEST */ CHECK( @@ -111,6 +122,17 @@ static bool test_generated_ids(void) '4' ); + CHECK( + trainlog_id_generate( + "sxe", + entry_id, + sizeof(entry_id) + ) == TRAINLOG_STATUS_OK + ); + CHECK(strncmp(entry_id, "sxe_", 4U) == 0); + CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U); + CHECK(entry_id[4U + 14U] == '4'); + return true; } @@ -131,6 +153,8 @@ static bool test_session_insert(void) TrainlogSetInput sets[2]; TrainlogSessionExerciseInput exercise; TrainlogSessionInput session; + TrainlogSessionSummary persisted_session; + TrainlogPersistedExerciseDetail persisted_exercises[1]; size_t count = 0U; CHECK( @@ -196,6 +220,19 @@ static bool test_session_insert(void) TRAINLOG_STATUS_OK ); CHECK(count == 1U); + CHECK( + trainlog_database_get_session_details( + database, + "se_test", + &persisted_session, + persisted_exercises, + 1U, + &count + ) == TRAINLOG_STATUS_OK + ); + CHECK(count == 1U); + /* Regression: local occurrences are `sxe`, never synchronization runs. */ + CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0); trainlog_database_close(database); return true; diff --git a/tui/tests/test_schema_v5_migration.c b/tui/tests/test_schema_v5_migration.c index a0444e8..beed04e 100644 --- a/tui/tests/test_schema_v5_migration.c +++ b/tui/tests/test_schema_v5_migration.c @@ -1,6 +1,6 @@ /** * @file test_schema_v5_migration.c - * @brief Direct v4 -> v7 migration regression test. + * @brief Direct v4 -> current migration regression test. */ #include @@ -27,10 +27,10 @@ } \ } while (0) -static bool test_v4_to_v7_preserves_session(void) +static bool test_v4_to_current_preserves_session(void) { char path[] = - "/tmp/trainlog-schema-v4-v5-XXXXXX"; + "/tmp/trainlog-schema-v4-current-XXXXXX"; static const char *const V4_SQL = "CREATE TABLE exercises (" @@ -187,9 +187,7 @@ static bool test_v4_to_v7_preserves_session(void) TRAINLOG_DATABASE_SCHEMA_VERSION ); - CHECK( - version == 7 - ); + CHECK(version == 8); CHECK( trainlog_database_get_session_details( @@ -228,7 +226,7 @@ static bool test_v4_to_v7_preserves_session(void) int main(void) { CHECK( - test_v4_to_v7_preserves_session() + test_v4_to_current_preserves_session() ); (void)printf( diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c new file mode 100644 index 0000000..40eb286 --- /dev/null +++ b/tui/tests/test_schema_v7_migration.c @@ -0,0 +1,198 @@ +/** + * @file test_schema_v7_migration.c + * @brief Synthetic v7 -> v8 migration and reopen regression coverage. + */ + +#include +#include +#include +#include +#include + +#include + +#include "trainlog/database.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static const char *const V7_FIXTURE_SQL = + "PRAGMA foreign_keys=ON;" + "CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);" + "CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL,notes TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));" + "CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));" + "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);" + "CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER UNIQUE REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);" + "INSERT INTO exercises VALUES(11,'ex_fixture_sets','Squat','squat','reps','sets',0);" + "INSERT INTO exercises VALUES(12,'ex_fixture_run','Run','run','duration','continuous',3);" + "INSERT INTO sessions VALUES(21,'se_fixture','2026-09-08T06:00:00Z','2026-09-08T07:00:00Z','training','fixture session');" + "INSERT INTO session_exercises VALUES(31,'sxe_fixture_sets',21,11,'sets',0,0,'external',90,2,5,NULL,82.5,'eq_fixture_rack','sets note');" + "INSERT INTO session_exercises VALUES(32,'sxe_fixture_run',21,12,'continuous',3,1,'none',0,NULL,NULL,NULL,NULL,NULL,'run note');" + "INSERT INTO performed_sets VALUES(41,31,0,5,NULL,80.25);" + "INSERT INTO performed_sets VALUES(42,31,1,4,NULL,82.5);" + "INSERT INTO continuous_activity VALUES(51,32,1234,10.75,3.67);" + "INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,body_weight_kg,waist_cm,notes) VALUES(61,'bo_fixture','2026-09-08T07:05:00Z',21,74.25,81.5,'body note');" + "PRAGMA user_version=7;"; + +static bool verify_preserved_values(const char *path) +{ + sqlite3 *connection = NULL; + sqlite3_stmt *statement = NULL; + + CHECK(sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + CHECK(sqlite3_prepare_v2(connection, + "SELECT s.session_id,se.entry_id,se.position,se.equipment_id,ps.position,ps.reps,ps.weight_kg " + "FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " + "JOIN performed_sets ps ON ps.session_exercise_row_id=se.id ORDER BY ps.position;", + -1, &statement, NULL) == SQLITE_OK); + CHECK(sqlite3_step(statement) == SQLITE_ROW); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "se_fixture") == 0); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 1), "sxe_fixture_sets") == 0); + CHECK(sqlite3_column_int(statement, 2) == 0); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 3), "eq_fixture_rack") == 0); + CHECK(sqlite3_column_int(statement, 4) == 0); + CHECK(sqlite3_column_int(statement, 5) == 5); + CHECK(sqlite3_column_double(statement, 6) == 80.25); + CHECK(sqlite3_step(statement) == SQLITE_ROW); + CHECK(sqlite3_column_int(statement, 4) == 1); + CHECK(sqlite3_column_int(statement, 5) == 4); + CHECK(sqlite3_column_double(statement, 6) == 82.5); + CHECK(sqlite3_step(statement) == SQLITE_DONE); + CHECK(sqlite3_finalize(statement) == SQLITE_OK); + + CHECK(sqlite3_prepare_v2(connection, + "SELECT se.entry_id,se.position,ca.duration_seconds,ca.speed_kmh,ca.distance_km,bo.observation_id,bo.body_weight_kg,bo.waist_cm,bo.notes " + "FROM session_exercises se JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id " + "JOIN sessions s ON s.id=se.session_row_id JOIN body_observations bo ON bo.session_row_id=s.id;", + -1, &statement, NULL) == SQLITE_OK); + CHECK(sqlite3_step(statement) == SQLITE_ROW); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 0), "sxe_fixture_run") == 0); + CHECK(sqlite3_column_int(statement, 1) == 1); + CHECK(sqlite3_column_int(statement, 2) == 1234); + CHECK(sqlite3_column_double(statement, 3) == 10.75); + CHECK(sqlite3_column_double(statement, 4) == 3.67); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 5), "bo_fixture") == 0); + CHECK(sqlite3_column_double(statement, 6) == 74.25); + CHECK(sqlite3_column_double(statement, 7) == 81.5); + CHECK(strcmp((const char *)sqlite3_column_text(statement, 8), "body note") == 0); + CHECK(sqlite3_step(statement) == SQLITE_DONE); + CHECK(sqlite3_finalize(statement) == SQLITE_OK); + CHECK(sqlite3_close(connection) == SQLITE_OK); + return true; +} + +static bool test_v7_migrates_and_v8_reopens(void) +{ + char path[] = "/tmp/trainlog-schema-v7-XXXXXX"; + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + int version = 0; + int fd = mkstemp(path); + + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 8); + trainlog_database_close(database); + CHECK(verify_preserved_values(path)); + + database = NULL; + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + trainlog_database_close(database); + CHECK(verify_preserved_values(path)); + CHECK(unlink(path) == 0); + return true; +} + +static bool test_v7_migration_sqlite_failure_has_diagnostic(void) +{ + char path[] = "/tmp/trainlog-schema-v7-failure-XXXXXX"; + char diagnostic[256]; + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + int fd = mkstemp(path); + + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, V7_FIXTURE_SQL, NULL, NULL, NULL) == SQLITE_OK); + /* Synthetic collision: only the v8 migration target is pre-created. */ + CHECK(sqlite3_exec(raw, "CREATE TABLE custom_equipment (equipment_id TEXT PRIMARY KEY);", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, + sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(database == NULL); + CHECK(strncmp(diagnostic, "migrate database to schema v8: SQLite rc=", + strlen("migrate database to schema v8: SQLite rc=")) == 0); + CHECK(strstr(diagnostic, "extended_rc=") != NULL); + CHECK(strstr(diagnostic, "custom_equipment") != NULL); + CHECK(strstr(diagnostic, "already exists") != NULL); + CHECK(unlink(path) == 0); + return true; +} + +static bool test_newer_schema_has_application_diagnostic(void) +{ + char path[] = "/tmp/trainlog-schema-v9-XXXXXX"; + char diagnostic[256]; + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + int fd = mkstemp(path); + + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=9;", 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 9 is newer") != NULL); + CHECK(strstr(diagnostic, "SQLite") == NULL); + CHECK(unlink(path) == 0); + return true; +} + +static bool test_unrecognized_historic_schema_has_application_diagnostic(void) +{ + char path[] = "/tmp/trainlog-schema-invalid-XXXXXX"; + char diagnostic[256]; + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + int fd = mkstemp(path); + + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=-1;", 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 -1 is unsupported") != NULL); + CHECK(strstr(diagnostic, "SQLite") == NULL); + CHECK(unlink(path) == 0); + return true; +} + +int main(void) +{ + CHECK(test_v7_migrates_and_v8_reopens()); + CHECK(test_v7_migration_sqlite_failure_has_diagnostic()); + CHECK(test_newer_schema_has_application_diagnostic()); + CHECK(test_unrecognized_historic_schema_has_application_diagnostic()); + (void)printf("PASS schema_v7_migration\n"); + return 0; +} diff --git a/tui/tests/test_sync_direction.c b/tui/tests/test_sync_direction.c new file mode 100644 index 0000000..1ca685f --- /dev/null +++ b/tui/tests/test_sync_direction.c @@ -0,0 +1,97 @@ +#include +#include +#include "trainlog/sync.h" + +#define CHECK(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d\n", __LINE__); return 1; } } while (0) + +int main(void) +{ + TrainlogSyncDirectionPlan plan; + TrainlogSyncReport report = {0}; + TrainlogMtpEntry entries[5] = {0}; + size_t selected = 0U; + plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_ANDROID_TO_PC); + CHECK(plan.receive_android && !plan.publish_android); + plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_PC_TO_ANDROID); + CHECK(!plan.receive_android && plan.publish_android); + plan = trainlog_sync_direction_plan(TRAINLOG_SYNC_BIDIRECTIONAL); + CHECK(plan.receive_android && plan.publish_android); + + (void)snprintf(entries[0].name, sizeof(entries[0].name), "%s", + "trainlog-mobile-export-v2.json"); + entries[0].item_id = 20U; + entries[0].modification_unix_seconds = 100U; + (void)snprintf(entries[1].name, sizeof(entries[1].name), "%s", + "trainlog-mobile-export-v2 (25).json"); + entries[1].item_id = 30U; + entries[1].modification_unix_seconds = 200U; + (void)snprintf(entries[2].name, sizeof(entries[2].name), "%s", + "trainlog-mobile-export-v2 (26).json"); + entries[2].item_id = 10U; + entries[2].modification_unix_seconds = 200U; + (void)snprintf(entries[3].name, sizeof(entries[3].name), "%s", + "trainlog-mobile-export-v2 (oops).json"); + entries[3].item_id = 1U; + entries[3].modification_unix_seconds = 999U; + (void)snprintf(entries[4].name, sizeof(entries[4].name), "%s", + "trainlog-mobile-export-v1.json"); + entries[4].item_id = 2U; + entries[4].modification_unix_seconds = 999U; + CHECK(trainlog_sync_select_mobile_export(entries, 5U, &selected)); + CHECK(selected == 2U); + + entries[0].modification_unix_seconds = 0U; + entries[1].modification_unix_seconds = 0U; + entries[2].modification_unix_seconds = 0U; + CHECK(trainlog_sync_select_mobile_export(entries, 5U, &selected)); + CHECK(selected == 2U); + CHECK(!trainlog_sync_select_mobile_export(NULL, 0U, &selected)); + + (void)snprintf(entries[0].name, sizeof(entries[0].name), "%s", + "trainlog-mobile-equipment-definitions-v1.json"); + entries[0].item_id = 40U; + entries[0].modification_unix_seconds = 10U; + (void)snprintf(entries[1].name, sizeof(entries[1].name), "%s", + "trainlog-mobile-equipment-definitions-v1 (4).json"); + entries[1].item_id = 41U; + entries[1].modification_unix_seconds = 20U; + CHECK(trainlog_sync_select_android_artifact( + entries, + 2U, + "trainlog-mobile-equipment-definitions-v1.json", + &selected)); + CHECK(selected == 1U); + CHECK(!trainlog_sync_select_android_artifact(entries, 2U, + "not-json", &selected)); + + (void)snprintf(entries[0].name, sizeof(entries[0].name), "%s", + "trainlog-equipment-associations-v1.json"); + entries[0].item_id = 50U; + entries[0].modification_unix_seconds = 10U; + (void)snprintf(entries[1].name, sizeof(entries[1].name), "%s", + "trainlog-equipment-associations-v1 (31).json"); + entries[1].item_id = 51U; + entries[1].modification_unix_seconds = 30U; + CHECK(trainlog_sync_select_android_artifact( + entries, + 2U, + "trainlog-equipment-associations-v1.json", + &selected)); + CHECK(selected == 1U); + + report.success = true; + report.direction = TRAINLOG_SYNC_BIDIRECTIONAL; + report.exercises_reconciled = 1U; + report.exercises_skipped = 8U; + report.sessions_skipped = 1U; + report.catalog_published = 15U; + trainlog_sync_build_summary(&report); + CHECK(strstr(report.summary, "+0 exercice(s)") != NULL); + CHECK(strstr(report.summary, "+1 exercice(s)") == NULL); + + report.exercises_imported = 1U; + trainlog_sync_build_summary(&report); + CHECK(strstr(report.summary, "+1 exercice(s)") != NULL); + puts("PASS sync direction plan"); + return 0; +} diff --git a/tui/tests/test_sync_history.c b/tui/tests/test_sync_history.c new file mode 100644 index 0000000..b6721e0 --- /dev/null +++ b/tui/tests/test_sync_history.c @@ -0,0 +1,172 @@ +#include +#include +#include +#include +#include + +#include "trainlog/sync_history.h" + +#define CHECK(value) do { if (!(value)) { fprintf(stderr, "FAIL line %d\n", __LINE__); return 1; } } while (0) + +static bool read_text( + const char *path, + char *output, + size_t capacity +) +{ + FILE *file; + size_t used; + + file = fopen(path, "rb"); + if (file == NULL) { + return false; + } + used = fread(output, 1U, capacity - 1U, file); + output[used] = '\0'; + return fclose(file) == 0; +} + +static void initialize_failure( + TrainlogSyncReport *report, + const char *sync_id, + TrainlogSyncDirection direction, + const char *error +) +{ + (void)memset(report, 0, sizeof(*report)); + report->direction = direction; + (void)snprintf(report->sync_id, sizeof(report->sync_id), "%s", sync_id); + (void)snprintf( + report->started_at, + sizeof(report->started_at), + "%s", + "2026-01-02T03:04:05+00:00" + ); + (void)snprintf(report->error, sizeof(report->error), "%s", error); + (void)snprintf(report->summary, sizeof(report->summary), "%s", error); +} + +int main(void) +{ + char temporary[] = "/tmp/trainlog-sync-history-XXXXXX"; + char history_path[512]; + char run_path[512]; + char text[4096]; + char line[768]; + const TrainlogSyncDirection directions[] = { + TRAINLOG_SYNC_ANDROID_TO_PC, + TRAINLOG_SYNC_PC_TO_ANDROID, + TRAINLOG_SYNC_BIDIRECTIONAL + }; + const char *const ids[] = { + "sy_00000000-0000-4000-8000-000000000001", + "sy_00000000-0000-4000-8000-000000000002", + "sy_00000000-0000-4000-8000-000000000003" + }; + const char *const errors[] = { + "Échec neutre sans indication de sens.", + "Échec neutre sans indication de sens.", + "Android→PC indisponible; PC→Android non tenté." + }; + const char *const json_directions[] = { + "\"direction\":\"android_to_pc\"", + "\"direction\":\"pc_to_android\"", + "\"direction\":\"bidirectional\"" + }; + const char *const detail_directions[] = { + "Direction : Android→PC", + "Direction : PC→Android", + "Direction : PC↔Android" + }; + FILE *history; + TrainlogSyncHistoryEntry entry; + TrainlogSyncReport report; + size_t index; + + CHECK(trainlog_sync_history_parse_line( + "sy_legacy\t02/01/2026 03:04\t0\tPC→Android visible seulement dans le résumé\n", + &entry + )); + CHECK(!entry.direction_known); + CHECK(strcmp(trainlog_sync_history_direction_label(&entry), "direction inconnue") == 0); + CHECK(trainlog_sync_history_parse_line( + "02/01/2026 03:04\t1\tAndroid→PC ancien\n", + &entry + )); + CHECK(!entry.direction_known && entry.sync_id[0] == '\0'); + CHECK(!trainlog_sync_history_parse_line( + "sy_bad\t02/01/2026 03:04\t0\tx\téchec\n", + &entry + )); + CHECK(!trainlog_sync_history_parse_line( + "sy_bad\t02/01/2026 03:04\t0\ta\téchec\textra\n", + &entry + )); + + CHECK(mkdtemp(temporary) != NULL); + CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0); + + for (index = 0U; index < 3U; ++index) { + initialize_failure(&report, ids[index], directions[index], errors[index]); + CHECK(trainlog_sync_record_local_run( + TRAINLOG_SYNC_TRIGGER_TUI, + &report + )); + + CHECK(snprintf( + run_path, + sizeof(run_path), + "%s/trainlog/sync_runs/%s.json", + temporary, + ids[index] + ) > 0); + CHECK(read_text(run_path, text, sizeof(text))); + CHECK(strstr(text, json_directions[index]) != NULL); + CHECK(strstr(text, errors[index]) != NULL); + + CHECK(snprintf( + run_path, + sizeof(run_path), + "%s/trainlog/sync_runs/%s.txt", + temporary, + ids[index] + ) > 0); + CHECK(read_text(run_path, text, sizeof(text))); + CHECK(strstr(text, detail_directions[index]) != NULL); + CHECK(strstr(text, errors[index]) != NULL); + } + + CHECK(snprintf( + history_path, + sizeof(history_path), + "%s/trainlog/sync_history.log", + temporary + ) > 0); + history = fopen(history_path, "rb"); + CHECK(history != NULL); + for (index = 0U; index < 3U; ++index) { + CHECK(fgets(line, sizeof(line), history) != NULL); + CHECK(trainlog_sync_history_parse_line(line, &entry)); + CHECK(entry.direction_known); + CHECK(entry.direction == directions[index]); + CHECK(strcmp(entry.summary, errors[index]) == 0); + } + CHECK(fgets(line, sizeof(line), history) == NULL); + CHECK(fclose(history) == 0); + + for (index = 0U; index < 3U; ++index) { + (void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs/%s.json", temporary, ids[index]); + CHECK(unlink(run_path) == 0); + (void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs/%s.txt", temporary, ids[index]); + CHECK(unlink(run_path) == 0); + } + CHECK(unlink(history_path) == 0); + (void)snprintf(run_path, sizeof(run_path), "%s/trainlog/sync_runs", temporary); + CHECK(rmdir(run_path) == 0); + (void)snprintf(run_path, sizeof(run_path), "%s/trainlog", temporary); + CHECK(rmdir(run_path) == 0); + CHECK(rmdir(temporary) == 0); + + puts("PASS sync history directions"); + return 0; +} diff --git a/tui/tests/test_sync_screen_action.c b/tui/tests/test_sync_screen_action.c new file mode 100644 index 0000000..4c7765f --- /dev/null +++ b/tui/tests/test_sync_screen_action.c @@ -0,0 +1,113 @@ +#include +#include + +#include "trainlog/sync_screen_action.h" + +#define CHECK(value) do { \ + if (!(value)) { \ + fprintf(stderr, "FAIL line %d\n", __LINE__); \ + return 1; \ + } \ +} while (0) + +typedef struct RunRecorder { + size_t calls; + TrainlogSyncDirection direction; +} RunRecorder; + +static TrainlogStatus record_run( + TrainlogSyncDirection direction, + TrainlogSyncReport *report, + void *context +) +{ + RunRecorder *recorder = context; + + ++recorder->calls; + recorder->direction = direction; + report->success = true; + report->direction = direction; + return TRAINLOG_STATUS_OK; +} + +static int check_direction_key( + int key, + TrainlogSyncDirection expected +) +{ + TrainlogSyncScreenState state; + TrainlogSyncScreenAction action; + + trainlog_sync_screen_state_init(&state); + action = trainlog_sync_screen_dispatch(&state, key); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM); + CHECK(action.direction == expected); + CHECK(state.confirming); + CHECK(state.direction == expected); + return 0; +} + +int main(void) +{ + TrainlogSyncScreenState state; + TrainlogSyncScreenAction action; + TrainlogSyncReport report = {0}; + RunRecorder recorder = {0}; + + CHECK(check_direction_key('a', TRAINLOG_SYNC_ANDROID_TO_PC) == 0); + CHECK(check_direction_key('p', TRAINLOG_SYNC_PC_TO_ANDROID) == 0); + CHECK(check_direction_key('b', TRAINLOG_SYNC_BIDIRECTIONAL) == 0); + + trainlog_sync_screen_state_init(&state); + action = trainlog_sync_screen_dispatch(&state, 'a'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM); + action = trainlog_sync_screen_dispatch(&state, 27); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CANCEL); + CHECK(!state.confirming); + CHECK(recorder.calls == 0U); + + action = trainlog_sync_screen_dispatch(&state, 'p'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM); + action = trainlog_sync_screen_dispatch(&state, '\n'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_RUN); + CHECK(trainlog_sync_screen_execute( + &action, record_run, &recorder, &report + ) == TRAINLOG_STATUS_OK); + CHECK(recorder.calls == 1U); + CHECK(recorder.direction == TRAINLOG_SYNC_PC_TO_ANDROID); + action = trainlog_sync_screen_dispatch(&state, '\n'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE); + CHECK(recorder.calls == 1U); + + action = trainlog_sync_screen_dispatch(&state, 'r'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_REFRESH); + CHECK(recorder.calls == 1U); + action = trainlog_sync_screen_dispatch(&state, 's'); + CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE); + CHECK(recorder.calls == 1U); + + CHECK(strcmp( + trainlog_sync_screen_confirmation(TRAINLOG_SYNC_ANDROID_TO_PC), + "Importer Android vers le PC maintenant ?" + ) == 0); + CHECK(strcmp( + trainlog_sync_screen_confirmation(TRAINLOG_SYNC_PC_TO_ANDROID), + "Publier le catalogue du PC vers Android maintenant ?" + ) == 0); + CHECK(strcmp( + trainlog_sync_screen_confirmation(TRAINLOG_SYNC_BIDIRECTIONAL), + "Synchroniser Android et PC dans les deux sens maintenant ?" + ) == 0); + + CHECK(strstr(trainlog_sync_screen_footer(120), "a Android→PC") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(120), "p PC→Android") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(120), "b PC↔Android") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(80), "a A→PC") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(80), "p PC→A") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(80), "b A↔PC") != NULL); + CHECK(strstr(trainlog_sync_screen_footer(120), "s synchroniser") == NULL); + CHECK(strstr(trainlog_sync_screen_footer(80), "mode") == NULL); + + puts("PASS sync screen action dispatcher"); + return 0; +} diff --git a/tui/tools/sync_once.c b/tui/tools/sync_once.c index 77e2cd5..702369a 100644 --- a/tui/tools/sync_once.c +++ b/tui/tools/sync_once.c @@ -128,6 +128,7 @@ int main( trainlog_sync_run( trigger, request_only, + TRAINLOG_SYNC_BIDIRECTIONAL, &report );