From 1aad7b48b3143783ccfd326c0145f495fd568442 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 9 Sep 2026 14:37:16 +0200 Subject: [PATCH] feat(exercises): add hierarchical body zones --- CHANGELOG.md | 68 ++- README.md | 33 +- android/app/build.gradle.kts | 7 +- .../trainlog/data/BodyZoneCatalog.kt | 132 +++++ .../trainlog/data/SyncCatalogInbox.kt | 32 +- .../labfytools/trainlog/data/SyncExporter.kt | 51 +- .../trainlog/data/TrainlogRepository.kt | 510 ++++++++++++++++- .../trainlog/model/ExerciseProfile.kt | 11 +- .../labfytools/trainlog/ui/ExerciseScreen.kt | 135 ++++- .../labfytools/trainlog/data/BodyZonesTest.kt | 355 ++++++++++++ .../RealAndroidV9BodyZonesMigrationTest.kt | 122 ++++ .../data/TrainlogRepositoryDraftTest.kt | 4 +- catalog/body-zones-v1.json | 39 ++ docs/android.md | 59 +- docs/architecture.md | 61 +- docs/current_state.md | 108 +++- docs/database.md | 67 ++- docs/exchange_format.md | 3 +- docs/exercise_data_model.md | 58 +- docs/roadmap.md | 41 +- docs/sync_exchange.md | 77 ++- docs/tests.md | 73 ++- docs/tui.md | 24 +- tests/test_body_zone_catalog.py | 88 +++ tests/test_body_zone_sync.py | 215 ++++++++ tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_body_zones.py | 88 +++ tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 7 +- tools/generate_body_zone_catalog.py | 162 ++++++ tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_exercise_body_zones.py | 245 ++++++++ tools/import_mobile_export.py | 50 +- tools/validate_json.py | 90 +++ tui/include/trainlog/body_zone_catalog.h | 50 ++ tui/include/trainlog/catalog.h | 15 + tui/include/trainlog/database.h | 73 ++- tui/include/trainlog/model.h | 14 + tui/meson.build | 28 + tui/src/catalog.c | 49 ++ tui/src/database.c | 522 +++++++++++++++++- tui/src/sync.c | 83 ++- tui/src/tui.c | 297 +++++++++- tui/tests/test_body_metrics.c | 4 +- tui/tests/test_body_zones.c | 230 ++++++++ tui/tests/test_custom_equipment.c | 4 +- tui/tests/test_max_results.c | 5 + tui/tests/test_schema_v5_migration.c | 6 +- tui/tests/test_schema_v7_migration.c | 24 +- tui/tests/test_schema_v9_migration.c | 12 +- tui/tests/test_sync_direction.c | 15 + tui/tests/test_tui_workflows.c | 13 +- 54 files changed, 4264 insertions(+), 213 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneCatalog.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt create mode 100644 catalog/body-zones-v1.json create mode 100644 tests/test_body_zone_catalog.py create mode 100644 tests/test_body_zone_sync.py create mode 100644 tools/export_exercise_body_zones.py create mode 100644 tools/generate_body_zone_catalog.py create mode 100644 tools/import_exercise_body_zones.py create mode 100644 tui/include/trainlog/body_zone_catalog.h create mode 100644 tui/tests/test_body_zones.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d9f75..4b98318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ Detailed implementation chronology remains available in Git history and ### Added +- `BODY_ZONES_V1`: the canonical `catalog/body-zones-v1.json` taxonomy with + stable IDs, French display metadata, hierarchy, deterministic sort order and + exact stable-exercise-ID migration evidence; +- one primary plus multiple distinct secondary body-zone relations on desktop + and Android, with explicit unclassified history, transactional creation/edit, + exercise detail summaries, parent/descendant filters, prefix-search + composition, and custom-exercise support; +- one strict bidirectional `trainlog-exercise-body-zones` v1 companion carrying + `exercise_id`, nullable primary ID and ordered secondary IDs; replay is + idempotent, one-sided edits reconcile, simultaneous divergence conflicts, + secondary lists are never unioned automatically, and the publishing peer + records the exact published snapshot as its common baseline; +- generator-ready read composition for descendant and primary-only exercise + selection, direct zone relations, existing performance history and explicit + MAX history without duplicating historical or MAX data; +- taxonomy, schema migration, relation constraint, filtering, identity-merge, + reopen, companion replay/update/conflict and Android repository regressions; + - explicit MAX result mode for `max_test` sessions: exercise, optional equipment context and positive `max_weight_kg`, with no synthetic set or repetition; @@ -91,6 +109,15 @@ Detailed implementation chronology remains available in Git history and ### Changed +- desktop schema v11 additively introduces `exercise_body_zones` and its + internal sync-baseline table; Android schema v10 introduces the equivalent + relations. Both migrations seed only manifest mappings proven by stable + `exercise_id`, leave uncertain exercises unclassified, and preserve all + session, occurrence, set, MAX, equipment and body-observation identities; +- Android and TUI exercise catalog workflows now select translated manifest + values, derive group labels, exclude a primary from secondary selection and + combine hierarchy-aware zone filtering with normalized prefix search; + - desktop schema v10 losslessly rebuilds only `performed_sets` to accept an explicit zero actual `weight_kg`; historic NULL and positive actual loads remain unchanged, while planned targets and explicit MAX results stay @@ -135,6 +162,14 @@ Detailed implementation chronology remains available in Git history and ### Fixed +- a newly published custom-exercise zone state now establishes the publisher's + comparison baseline as well as the receiver's; a later peer-only edit no + longer produces a false simultaneous-conflict result; +- secondary-only body-zone states are rejected consistently by C, Kotlin and + Python boundaries instead of being persisted but hidden by exercise detail; +- touched migration/custom-equipment/body-metric/TUI C regressions now return + a failing process status from `main()`; the repaired workflow fixture uses a + real in-memory database instead of passing an invalid null handle; - 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 @@ -174,22 +209,22 @@ Current validated baseline: ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V10=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_SCHEMA_V11=PASS +DESKTOP_TESTS=39/39 PASS ANDROID_BUILD=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V9=PASS -ANDROID_TEST_DEBUG_UNIT=PASS +ANDROID_LOCAL_DATABASE_V10=PASS +ANDROID_TEST_DEBUG_UNIT=44/44 PASS (real v9 fixture enabled) ANDROID_SESSION_DRAFT_V1=PASS ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS ANDROID_MAX_V9_INSTALL_ADB=PASS USB_MTP_DETECTION=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 +REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=HISTORICAL_PASS +BODY_ZONES_DESKTOP_REAL_DATABASE=PASS +BODY_ZONES_ANDROID_DEVICE=PASS ANDROID_TO_PC_MTP=HISTORICAL_PASS PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS @@ -204,8 +239,27 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V1=PASS +BODY_ZONES_V1=PASS +BODY_ZONE_SYNC_V1=PASS +BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS +BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS ``` +For Body Zones V1, the real desktop database was backed up coherently and +migrated v10 -> v11 through the production binary. All eight pre-existing +tables compare equal row-for-row with the backup; integrity/FK checks pass and +the migration adds 32 direct relations for 20 of 23 exercises. On the Samsung +SM-G990B, the matching signed APK was installed with `adb install -r`; the real +Android database migrated v9 -> v10 with every pre-existing application row +unchanged, 32 relations for the same 20 exercises and the expected three +unclassified exercises. The Android zone/detail/filter/edit-cancel matrix and +two live PC <-> Android MTP passes completed successfully. The second pass +reported no additions or reconciliations and left every Android application +table and the semantic Body Zones companion unchanged. Scoped storage had +published the requests as exact `(N).json` collision siblings; the shared +engine now selects the newest request with the same deterministic helper used +for other Android-originated artifacts. + ### Measured max v1 Added: diff --git a/README.md b/README.md index ccf0b11..9daf3e8 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ desktop. ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V10=PASS +DESKTOP_SCHEMA_V11=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V9=PASS +ANDROID_LOCAL_DATABASE_V10=PASS ANDROID_SESSION_DRAFT_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS @@ -37,8 +37,12 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V1=PASS +BODY_ZONES_V1=PASS +BODY_ZONE_SYNC_V1=PASS +BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS +BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS ANDROID_BUILD=PASS ``` @@ -125,6 +129,16 @@ and historical occurrence snapshots remain unchanged; absent optional values stay absent. Incomparable profiles remain explicit conflicts. Name equality alone is never sufficient. +Exercise body zones are independent metadata sourced from the single canonical +[`catalog/body-zones-v1.json`](catalog/body-zones-v1.json) manifest. An exercise +may have one primary assignable zone and several distinct secondary zones. +Secondary relations require that primary; the explicit unclassified state has +no relations at all. +`upper_body` and `lower_body` are hierarchy groups used for display and +descendant-aware filtering; they are never duplicated as stored relations. +`full_body` and `core` remain autonomous. Historical exercises without an +objective stable-ID mapping remain visible as **Non renseignés**. + ## Repository layout ```text @@ -132,6 +146,7 @@ android/ native Kotlin/Compose Android client tui/ C17 Notcurses desktop application and core docs/ canonical project documentation format/ frozen Trainlog JSON v1 schema material +catalog/ canonical versioned equipment and body-zone manifests examples/ valid frozen-format examples tests/ fixtures and cross-component tests tools/ validators, import/export helpers, sync daemon tooling @@ -163,11 +178,11 @@ export or desktop synchronization as completed sessions. Schema migrations are additive and preserve existing capture data. See [Android behavior](docs/android.md) and [validation](docs/tests.md). -The current Android schema is v9. Its additive v4 -> v9 chain adds the shared +The current Android schema is v10. Its additive v4 -> v10 chain adds the shared equipment catalogue, per-occurrence equipment links, durable occurrence identities, custom-equipment definition support, explicit MAX results and -stable-source Test max resumption without recreating completed history or -discarding the active draft. +stable-source Test max resumption, then direct primary/secondary body-zone +relations, without recreating completed history or discarding the active draft. Exercises can be renamed in place from Android. The `ex_` identity is unchanged; completed history, an active draft, and synchronization therefore @@ -185,7 +200,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 ``` ## Android-triggered synchronization @@ -225,6 +240,8 @@ runs, a receipt is returned to Android, and the PC catalog is applied locally. The active completed-session exchange is V2 and preserves occurrence `entry_id`, per-set weights and equipment associations. Frozen V1 artifacts remain readable as legacy artifacts; they are not silently redefined as V2. +Exercise-zone metadata travels separately in the sole bidirectional +`trainlog-exercise-body-zones-v1.json` companion. ## Documentation @@ -296,5 +313,5 @@ BODY_ANALYTICS_V1=PASS BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 99b4651..9cea674 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,9 +29,10 @@ android { sourceSets { getByName("main") { - /* CONTRACT: this repository-level manifest is the one canonical - * equipment source. Android must not fork it into Kotlin constants. */ - assets.srcDir("../../catalog") + /* CONTRACT: repository-level equipment and body-zone manifests + * are the canonical shared sources. Android must not fork either + * taxonomy into Kotlin constants. */ + assets.directories.add("../../catalog") } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneCatalog.kt b/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneCatalog.kt new file mode 100644 index 0000000..14aa68c --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/BodyZoneCatalog.kt @@ -0,0 +1,132 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import org.json.JSONObject + +enum class BodyZoneKind { GROUP, LEAF, STANDALONE } + +data class BodyZone( + val zoneId: String, + val displayName: String, + val parentZoneId: String?, + val sortOrder: Int, + val kind: BodyZoneKind, +) + +data class InitialExerciseBodyZones( + val exerciseId: String, + val primaryZoneId: String, + val secondaryZoneIds: List, +) + +/** + * CONTRACT: Android reads the repository-level body-zone manifest directly. + * There is deliberately no Kotlin fallback list: IDs, hierarchy and initial + * identity mappings cannot silently diverge from the generated C catalogue. + */ +class BodyZoneCatalog private constructor( + val zones: List, + val initialMappings: List, +) { + private val byId = zones.associateBy { it.zoneId } + + fun lookup(zoneId: String): BodyZone? = byId[zoneId] + + fun children(zoneId: String): List = + zones.filter { it.parentZoneId == zoneId } + + fun ancestors(zoneId: String): List = buildList { + var current = lookup(zoneId) + while (current?.parentZoneId != null) { + current = lookup(current.parentZoneId) + checkNotNull(current) { "Parent de zone absent" } + add(current) + } + } + + fun descendantsAndSelf(zoneId: String): Set { + checkNotNull(lookup(zoneId)) { "zone_id inconnu: $zoneId" } + val output = linkedSetOf(zoneId) + var changed = true + while (changed) { + changed = false + zones.forEach { zone -> + if (zone.parentZoneId in output && output.add(zone.zoneId)) changed = true + } + } + return output + } + + companion object { + private const val ASSET_NAME = "body-zones-v1.json" + + fun load(context: Context): BodyZoneCatalog { + val root = context.assets.open(ASSET_NAME).bufferedReader().use { + JSONObject(it.readText()) + } + check(root.keys().asSequence().toSet() == setOf( + "format", "version", "zones", "exercise_mappings", + )) + check(root.getString("format") == "trainlog-body-zone-catalog") + check(root.getInt("version") == 1) + val array = root.getJSONArray("zones") + val seenIds = mutableSetOf() + val seenOrders = mutableSetOf() + val zones = List(array.length()) { index -> + val item = array.getJSONObject(index) + check(item.keys().asSequence().toSet() == setOf( + "zone_id", "display_name", "parent_zone_id", "sort_order", "kind", + )) + val zone = BodyZone( + zoneId = item.getString("zone_id"), + displayName = item.getString("display_name").trim(), + parentZoneId = if (item.isNull("parent_zone_id")) null else item.getString("parent_zone_id"), + sortOrder = item.getInt("sort_order"), + kind = BodyZoneKind.valueOf(item.getString("kind").uppercase()), + ) + check(zone.zoneId.matches(Regex("[a-z][a-z0-9_]*"))) + check(zone.displayName.isNotEmpty() && zone.sortOrder >= 0) + check(seenIds.add(zone.zoneId) && seenOrders.add(zone.sortOrder)) + zone + }.sortedBy { it.sortOrder } + val byId = zones.associateBy { it.zoneId } + zones.forEach { zone -> + check(zone.parentZoneId == null || zone.parentZoneId in byId) + val seen = mutableSetOf(zone.zoneId) + var parent = zone.parentZoneId + while (parent != null) { + check(seen.add(parent)) { "Boucle dans la hiérarchie corporelle" } + parent = byId.getValue(parent).parentZoneId + } + check((zone.kind == BodyZoneKind.GROUP) == zones.any { it.parentZoneId == zone.zoneId }) + } + val mappingsArray = root.getJSONArray("exercise_mappings") + val seenExercises = mutableSetOf() + val mappings = List(mappingsArray.length()) { index -> + val item = mappingsArray.getJSONObject(index) + check(item.keys().asSequence().toSet() == setOf( + "exercise_id", "exercise_name", "primary_zone_id", + "secondary_zone_ids", "decision_source", + )) + val secondary = item.getJSONArray("secondary_zone_ids") + val mapping = InitialExerciseBodyZones( + exerciseId = item.getString("exercise_id"), + primaryZoneId = item.getString("primary_zone_id"), + secondaryZoneIds = List(secondary.length()) { secondary.getString(it) }, + ) + check(seenExercises.add(mapping.exerciseId)) + check(item.getString("exercise_name").isNotBlank()) + check(item.getString("decision_source").isNotBlank()) + check(byId[mapping.primaryZoneId]?.kind != null && + byId.getValue(mapping.primaryZoneId).kind != BodyZoneKind.GROUP) + check(mapping.secondaryZoneIds.toSet().size == mapping.secondaryZoneIds.size) + check(mapping.primaryZoneId !in mapping.secondaryZoneIds) + check(mapping.secondaryZoneIds.all { + byId[it]?.kind != null && byId.getValue(it).kind != BodyZoneKind.GROUP + }) + mapping + } + return BodyZoneCatalog(zones, mappings) + } + } +} 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 5f62060..8ed34a1 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 @@ -142,11 +142,14 @@ class SyncCatalogInbox( is PcCatalogImportResult.Applied -> when (val sessions = importPcSessions(directory)) { null -> when (val equipment = importPcEquipmentAssociations(directory)) { - null -> CatalogInboxResult.Imported( - imported = result.imported, - reconciled = result.reconciled, - skipped = result.skipped, - ) + null -> when (val bodyZones = importPcBodyZones(directory)) { + null -> CatalogInboxResult.Imported( + imported = result.imported, + reconciled = result.reconciled, + skipped = result.skipped, + ) + else -> CatalogInboxResult.Error(bodyZones) + } else -> CatalogInboxResult.Error(equipment) } else -> CatalogInboxResult.Error(sessions) @@ -186,6 +189,25 @@ class SyncCatalogInbox( } catch (error: Exception) { error.message ?: "Import séances V2 impossible." } } + private fun importPcBodyZones(directory: DocumentFile): String? { + val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null + return try { + val json = appContext.contentResolver.openInputStream(file.uri) + ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } + ?: return "Lecture zones corporelles impossible." + when (val result = repository.applyExerciseBodyZonesJson(json)) { + is ExerciseBodyZoneImportResult.Applied -> null + is ExerciseBodyZoneImportResult.Invalid -> result.message + is ExerciseBodyZoneImportResult.Conflict -> + "Conflit de zones corporelles : ${result.exerciseId}" + ExerciseBodyZoneImportResult.DatabaseError -> + "Erreur base locale zones corporelles." + } + } catch (error: Exception) { + error.message ?: "Import zones corporelles impossible." + } + } + private fun importPcEquipmentDefinitions(directory: DocumentFile): String? { val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null return try { 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 d2115fc..ca44b1d 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,19 +39,21 @@ class SyncExporter( return SyncExportResult.Unsupported } - /* CONTRACT: capture all three artifacts before publishing any of + /* CONTRACT: capture all four 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 + val bodyZonesJson: 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() + bodyZonesJson = repository.buildExerciseBodyZonesJson() } catch (error: Exception) { return SyncExportResult.Error( error.message ?: "Préparation de l'export impossible.", @@ -188,6 +190,25 @@ class SyncExporter( ) } + val bodyZonesError = writeBodyZones(bodyZonesJson) + if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError) + /* CONTRACT: publication, not JSON construction, establishes the + * common sync ancestor. Applying the exact local snapshot can only + * record equal baselines; the strict reconciler never unions zones. */ + when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) { + is ExerciseBodyZoneImportResult.Applied -> Unit + is ExerciseBodyZoneImportResult.Conflict -> + return SyncExportResult.Error( + "Conflit pendant l'enregistrement de la baseline zones : " + + acknowledgement.exerciseId, + ) + is ExerciseBodyZoneImportResult.Invalid -> + return SyncExportResult.Error(acknowledgement.message) + ExerciseBodyZoneImportResult.DatabaseError -> + return SyncExportResult.Error( + "Enregistrement de la baseline zones impossible.", + ) + } val companionError = writeEquipmentAssociations(associationsJson) if (companionError != null) { return SyncExportResult.Error(companionError) @@ -273,6 +294,34 @@ class SyncExporter( } } + /** One directional-neutral companion is used by both peers. */ + private fun writeBodyZones(json: String): String? { + val resolver = appContext.contentResolver + val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/" + val name = "trainlog-exercise-body-zones-v1.json" + val existing = findExisting(collection, name, relativePath) + val created = existing == null + val uri = existing ?: resolver.insert(collection, ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, name) + put(MediaStore.MediaColumns.MIME_TYPE, "application/json") + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + }) ?: return "Création des zones corporelles impossible." + return try { + resolver.openOutputStream(uri, "wt")?.use { + it.write(json.toByteArray(Charsets.UTF_8)); it.flush() + } ?: return "Écriture des zones corporelles impossible." + if (created) resolver.update(uri, ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + }, null, null) + null + } catch (error: Exception) { + if (created) resolver.delete(uri, null, null) + error.message ?: "Export des zones corporelles impossible." + } + } + private fun findExisting( collection: Uri, displayName: String, 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 dd87784..7780a9e 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 @@ -40,6 +40,8 @@ sealed interface CreateExerciseResult { data object Invalid : CreateExerciseResult + + data class DatabaseError(val message: String) : CreateExerciseResult } sealed interface EditExerciseResult { @@ -92,6 +94,17 @@ sealed interface EquipmentDefinitionImportResult { data object DatabaseError : EquipmentDefinitionImportResult } +sealed interface ExerciseBodyZoneImportResult { + data class Applied( + val updated: Int, + val skipped: Int, + val keptLocal: Int, + ) : ExerciseBodyZoneImportResult + data class Invalid(val message: String) : ExerciseBodyZoneImportResult + data class Conflict(val exerciseId: String) : ExerciseBodyZoneImportResult + data object DatabaseError : ExerciseBodyZoneImportResult +} + sealed interface MobileSessionImportResult { /** CONTRACT: counters describe persistent mutations, not artifact size. */ data class Applied( @@ -177,6 +190,7 @@ class TrainlogRepository( ANDROID_DATABASE_NAME, ) { private val applicationContext = context.applicationContext + private val bodyZones = BodyZoneCatalog.load(applicationContext) private val database = TrainlogDatabaseHelper( applicationContext, @@ -242,26 +256,60 @@ class TrainlogRepository( } catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") } } - fun listExercises(): List { + /** Return manifest sort order; definitions are immutable application assets. */ + fun listBodyZones(): List = bodyZones.zones + + /** Stable-ID lookup; null means the ID is not part of taxonomy V1. */ + fun bodyZone(zoneId: String): BodyZone? = bodyZones.lookup(zoneId) + + /** Nearest parent first; the manifest loader has already rejected cycles. */ + fun bodyZoneAncestors(zoneId: String): List = bodyZones.ancestors(zoneId) + + /** + * SQL-backed normalized-prefix and body-zone query. + * + * CONTRACT: a parent includes manifest descendants only when requested; + * `primaryOnly` excludes secondary participation and `unclassifiedOnly` + * selects exercises with no direct relation. An unknown zone ID is a + * programmer error. Returned profiles own deterministic relation lists. + */ + fun listExercises( + query: String = "", + zoneId: String? = null, + includeDescendants: Boolean = true, + primaryOnly: Boolean = false, + unclassifiedOnly: Boolean = false, + ): List { + require(!(unclassifiedOnly && zoneId != null)) { + "zoneId et unclassifiedOnly sont mutuellement exclusifs" + } val output = mutableListOf() - - database.readableDatabase.query( - "exercises", - arrayOf( - "exercise_id", - "name", - "normalized_name", - "recording_mode", - "tracking_mode", - "data_fields", - ), - null, - null, - null, - null, - "name COLLATE NOCASE, exercise_id", - ).use { cursor -> + val normalizedPrefix = normalizeName(query) + val selection = mutableListOf() + val arguments = mutableListOf() + if (normalizedPrefix.isNotEmpty()) { + selection += "e.normalized_name LIKE ? ESCAPE '\\'" + arguments += normalizedPrefix + .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + } + if (unclassifiedOnly) { + selection += "NOT EXISTS(SELECT 1 FROM exercise_body_zones missing WHERE missing.exercise_row_id=e.id)" + } else if (zoneId != null) { + val accepted = if (includeDescendants) bodyZones.descendantsAndSelf(zoneId) else setOf(zoneId) + check(bodyZones.lookup(zoneId) != null) { "zone_id inconnu: $zoneId" } + val placeholders = accepted.joinToString(",") { "?" } + selection += "EXISTS(SELECT 1 FROM exercise_body_zones selected WHERE " + + "selected.exercise_row_id=e.id AND selected.zone_id IN($placeholders)" + + (if (primaryOnly) " AND selected.role='primary'" else "") + ")" + arguments += accepted + } + val sql = "SELECT e.exercise_id,e.name,e.normalized_name,e.recording_mode," + + "e.tracking_mode,e.data_fields FROM exercises e" + + (if (selection.isEmpty()) "" else " WHERE " + selection.joinToString(" AND ")) + + " ORDER BY e.name COLLATE NOCASE,e.exercise_id;" + val db = database.readableDatabase + db.rawQuery(sql, arguments.toTypedArray()).use { cursor -> val idIndex = cursor.getColumnIndexOrThrow( "exercise_id" @@ -293,12 +341,12 @@ class TrainlogRepository( ) while (cursor.moveToNext()) { + val exerciseId = cursor.getString(idIndex) + val selectionForExercise = readExerciseBodyZones(db, exerciseId) output += ExerciseProfile( exerciseId = - cursor.getString( - idIndex - ), + exerciseId, name = cursor.getString( nameIndex @@ -335,6 +383,8 @@ class TrainlogRepository( cursor.getInt( fieldsIndex ), + primaryZoneId = selectionForExercise.first, + secondaryZoneIds = selectionForExercise.second, ) } } @@ -345,7 +395,8 @@ class TrainlogRepository( fun createExercise( input: NewExerciseProfile, ): CreateExerciseResult { - if (!input.validate()) { + if (!input.validate() || !bodyZoneSelectionValid( + input.primaryZoneId, input.secondaryZoneIds)) { return CreateExerciseResult.Invalid } @@ -374,6 +425,10 @@ class TrainlogRepository( input.trackingMode, dataFields = input.dataFields, + primaryZoneId = input.primaryZoneId, + secondaryZoneIds = input.secondaryZoneIds.sortedBy { id -> + bodyZones.lookup(id)?.sortOrder ?: Int.MAX_VALUE + }, ) val sql = @@ -388,8 +443,10 @@ class TrainlogRepository( ) VALUES(?, ?, ?, ?, ?, ?); """.trimIndent() + val db = database.writableDatabase return try { - database.writableDatabase.execSQL( + db.beginTransaction() + db.execSQL( sql, arrayOf( exercise.exerciseId, @@ -403,6 +460,9 @@ class TrainlogRepository( ), ) + replaceExerciseBodyZones(db, exercise.exerciseId, + exercise.primaryZoneId, exercise.secondaryZoneIds) + db.setTransactionSuccessful() CreateExerciseResult.Created( exercise ) @@ -410,6 +470,12 @@ class TrainlogRepository( error: SQLiteConstraintException ) { CreateExerciseResult.Conflict + } catch (error: Exception) { + CreateExerciseResult.DatabaseError( + error.message ?: "Enregistrement de l'exercice impossible.", + ) + } finally { + if (db.inTransaction()) db.endTransaction() } } @@ -430,7 +496,8 @@ class TrainlogRepository( fun editExercise( input: ExerciseEditInput, ): EditExerciseResult { - if (!input.validateProfile()) { + if (!input.validateProfile() || !bodyZoneSelectionValid( + input.primaryZoneId, input.secondaryZoneIds)) { return EditExerciseResult.InvalidNameOrProfile } @@ -468,6 +535,8 @@ class TrainlogRepository( if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) { return EditExerciseResult.DatabaseError } + replaceExerciseBodyZones(db, input.exerciseId, + input.primaryZoneId, input.secondaryZoneIds) db.setTransactionSuccessful() EditExerciseResult.Saved( ExerciseProfile( @@ -477,6 +546,10 @@ class TrainlogRepository( recordingMode = input.recordingMode, trackingMode = input.trackingMode, dataFields = input.dataFields, + primaryZoneId = input.primaryZoneId, + secondaryZoneIds = input.secondaryZoneIds.sortedBy { id -> + bodyZones.lookup(id)?.sortOrder ?: Int.MAX_VALUE + }, ), ) } catch (error: SQLiteConstraintException) { @@ -490,6 +563,132 @@ class TrainlogRepository( } } + /** + * CONTRACT: this is the sole body-zone exchange source in both directions. + * Session V2 and frozen TRAINLOG_FORMAT_V1 retain their exact shapes. + */ + fun buildExerciseBodyZonesJson(): String { + val exercises = JSONArray() + listExercises().sortedBy { it.exerciseId }.forEach { exercise -> + exercises.put(JSONObject() + .put("exercise_id", exercise.exerciseId) + .put("primary_zone_id", exercise.primaryZoneId ?: JSONObject.NULL) + .put("secondary_zone_ids", JSONArray(exercise.secondaryZoneIds.sorted()))) + } + return JSONObject() + .put("format", "trainlog-exercise-body-zones") + .put("version", 1) + .put("generated_at", OffsetDateTime.now().toString()) + .put("exercises", exercises) + .toString() + } + + fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult { + val root = try { JSONObject(json) } catch (_: Exception) { + return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.") + } + val rootKeys = setOf("format", "version", "generated_at", "exercises") + if (!root.hasExactKeys(rootKeys) || + root.value("format") != "trainlog-exercise-body-zones" || + !root.value("version").isJsonInt(1, 1) || + !root.value("generated_at").isNonemptyJsonString() || + root.value("exercises") !is JSONArray) { + return ExerciseBodyZoneImportResult.Invalid("Relations de zones v1 non supportées.") + } + try { + OffsetDateTime.parse(root.getString("generated_at")) + } catch (_: Exception) { + return ExerciseBodyZoneImportResult.Invalid("Horodatage des zones invalide.") + } + data class Incoming(val exerciseId: String, val primary: String?, val secondary: List) + val incoming = mutableListOf() + val seenExercises = mutableSetOf() + try { + val array = root.getJSONArray("exercises") + for (index in 0 until array.length()) { + val item = array.opt(index) as? JSONObject + ?: return ExerciseBodyZoneImportResult.Invalid("Relation de zone invalide.") + if (!item.hasExactKeys(setOf("exercise_id", "primary_zone_id", "secondary_zone_ids"))) + return ExerciseBodyZoneImportResult.Invalid("Forme de relation de zone invalide.") + val exerciseId = item.value("exercise_id") + val primaryValue = item.value("primary_zone_id") + val secondaryValue = item.value("secondary_zone_ids") + if (!exerciseId.isNonemptyJsonString() || + !(exerciseId as String).matches(EXERCISE_ID_V4_PATTERN) || + !seenExercises.add(exerciseId) || + !(primaryValue === JSONObject.NULL || primaryValue.isNonemptyJsonString()) || + secondaryValue !is JSONArray) { + return ExerciseBodyZoneImportResult.Invalid("Identité de relation de zone invalide.") + } + val secondary = List(secondaryValue.length()) { position -> + secondaryValue.opt(position) as? String + ?: return ExerciseBodyZoneImportResult.Invalid("zone_id secondaire invalide.") + } + val primary = if (primaryValue === JSONObject.NULL) null else primaryValue as String + if (!bodyZoneSelectionValid(primary, secondary)) + return ExerciseBodyZoneImportResult.Invalid("Sélection de zones invalide : $exerciseId") + incoming += Incoming(exerciseId, primary, secondary.sorted()) + } + } catch (_: Exception) { + return ExerciseBodyZoneImportResult.Invalid("Relations de zones invalides.") + } + + val db = database.writableDatabase + var updated = 0 + var skipped = 0 + var keptLocal = 0 + return try { + db.beginTransaction() + incoming.forEach { item -> + val rowId = lookupExerciseRowIdOrNull(db, item.exerciseId) + ?: return ExerciseBodyZoneImportResult.Invalid( + "Exercice de relation inconnu : ${item.exerciseId}", + ) + val local = readExerciseBodyZones(db, item.exerciseId) + val localState = bodyZoneSyncState(local.first, local.second) + val incomingState = bodyZoneSyncState(item.primary, item.secondary) + val baseline = db.rawQuery( + "SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?;", + arrayOf(rowId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + when { + localState == incomingState -> { + writeBodyZoneSyncBaseline(db, rowId, incomingState) + skipped += 1 + } + baseline != null && localState == baseline -> { + replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary) + writeBodyZoneSyncBaseline(db, rowId, incomingState) + updated += 1 + } + baseline != null && incomingState == baseline -> keptLocal += 1 + baseline == null && localState == "|" -> { + replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary) + writeBodyZoneSyncBaseline(db, rowId, incomingState) + updated += 1 + } + else -> return ExerciseBodyZoneImportResult.Conflict(item.exerciseId) + } + } + db.setTransactionSuccessful() + ExerciseBodyZoneImportResult.Applied(updated, skipped, keptLocal) + } catch (_: Exception) { + ExerciseBodyZoneImportResult.DatabaseError + } finally { + if (db.inTransaction()) db.endTransaction() + } + } + + private fun bodyZoneSyncState(primary: String?, secondary: List): String = + (primary ?: "") + "|" + secondary.sorted().joinToString(",") + + private fun writeBodyZoneSyncBaseline(db: SQLiteDatabase, rowId: Long, state: String) { + db.execSQL( + "INSERT OR REPLACE INTO exercise_body_zone_sync(exercise_row_id,synced_state) VALUES(?,?);", + arrayOf(rowId, state), + ) + } + fun loadActiveSessionDraft(): ActiveDraftLoadResult = try { @@ -1229,6 +1428,11 @@ class TrainlogRepository( db, retired.exerciseId, byId.exerciseId, + ) || + !bodyZoneRowsAreCompatibleForMerge( + db, + byId.exerciseId, + retired.exerciseId, ) ) { traceDecision( @@ -1487,6 +1691,7 @@ class TrainlogRepository( canonical: ExerciseRow, retired: ExerciseRow, ) { + mergeExerciseBodyZoneRows(db, canonical, retired) db.execSQL( "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", arrayOf(canonical.rowId, retired.rowId), @@ -1520,6 +1725,64 @@ class TrainlogRepository( ) } + private fun sqliteTableExists(db: SQLiteDatabase, table: String): Boolean = + db.rawQuery( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;", + arrayOf(table), + ).use { it.moveToFirst() } + + private fun bodyZoneRowsAreCompatibleForMerge( + db: SQLiteDatabase, + canonicalExerciseId: String, + retiredExerciseId: String, + ): Boolean { + if (!sqliteTableExists(db, "exercise_body_zones")) return true + val canonical = readExerciseBodyZones(db, canonicalExerciseId) + val retired = readExerciseBodyZones(db, retiredExerciseId) + val canonicalEmpty = canonical.first == null && canonical.second.isEmpty() + val retiredEmpty = retired.first == null && retired.second.isEmpty() + return canonicalEmpty || retiredEmpty || canonical == retired + } + + private fun mergeExerciseBodyZoneRows( + db: SQLiteDatabase, + canonical: ExerciseRow, + retired: ExerciseRow, + ) { + if (!sqliteTableExists(db, "exercise_body_zones")) return + val canonicalState = readExerciseBodyZones(db, canonical.exerciseId) + val retiredState = readExerciseBodyZones(db, retired.exerciseId) + val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty() + val retiredEmpty = retiredState.first == null && retiredState.second.isEmpty() + check(canonicalEmpty || retiredEmpty || canonicalState == retiredState) { + "Relations de zones incompatibles pendant la réconciliation d'identité." + } + + /* WHY: row identity reconciliation must not discard the only body-zone + * decision. CONTRACT: equal states coalesce, one empty side adopts the + * non-empty state, and differing non-empty states were rejected above. + * INVARIANT: baselines are cleared because an identity merge is not a + * synchronization acknowledgement; the next companion must establish + * a fresh common ancestor before accepting a one-sided change. */ + if (canonicalEmpty && !retiredEmpty) { + db.execSQL( + "UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonical.rowId, retired.rowId), + ) + } else { + db.execSQL( + "DELETE FROM exercise_body_zones WHERE exercise_row_id=?;", + arrayOf(retired.rowId), + ) + } + if (sqliteTableExists(db, "exercise_body_zone_sync")) { + db.execSQL( + "DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);", + arrayOf(canonical.rowId, retired.rowId), + ) + } + } + private fun findExerciseRow( db: SQLiteDatabase, selection: String, @@ -3710,6 +3973,79 @@ class TrainlogRepository( } } + private fun bodyZoneSelectionValid( + primaryZoneId: String?, + secondaryZoneIds: List, + ): Boolean { + if (secondaryZoneIds.size != secondaryZoneIds.toSet().size || + primaryZoneId in secondaryZoneIds || + (primaryZoneId == null && secondaryZoneIds.isNotEmpty())) return false + val ids = listOfNotNull(primaryZoneId) + secondaryZoneIds + return ids.all { id -> + val zone = bodyZones.lookup(id) + zone != null && zone.kind != BodyZoneKind.GROUP + } + } + + /** INVARIANT: the first component is the sole primary relation; the + * secondary list follows manifest sort order for deterministic UI/export. */ + private fun readExerciseBodyZones( + db: SQLiteDatabase, + exerciseId: String, + ): Pair> { + var primary: String? = null + val secondary = mutableListOf() + db.rawQuery( + "SELECT ebz.zone_id,ebz.role FROM exercise_body_zones ebz " + + "JOIN exercises e ON e.id=ebz.exercise_row_id WHERE e.exercise_id=?;", + arrayOf(exerciseId), + ).use { cursor -> + while (cursor.moveToNext()) { + val zoneId = cursor.getString(0) + val zone = checkNotNull(bodyZones.lookup(zoneId)) { + "zone_id SQLite inconnu: $zoneId" + } + check(zone.kind != BodyZoneKind.GROUP) { + "Relation SQLite vers un groupe dérivable: $zoneId" + } + when (cursor.getString(1)) { + "primary" -> { + check(primary == null) { "Plusieurs zones principales" } + primary = zoneId + } + "secondary" -> secondary += zoneId + else -> error("Rôle de zone SQLite inconnu") + } + } + } + check(primary != null || secondary.isEmpty()) { + "Relations secondaires sans zone principale" + } + return primary to secondary.sortedBy { bodyZones.lookup(it)?.sortOrder ?: Int.MAX_VALUE } + } + + private fun replaceExerciseBodyZones( + db: SQLiteDatabase, + exerciseId: String, + primaryZoneId: String?, + secondaryZoneIds: List, + ) { + check(bodyZoneSelectionValid(primaryZoneId, secondaryZoneIds)) { + "Sélection de zones corporelles invalide" + } + val rowId = lookupExerciseRowId(db, exerciseId) + db.delete("exercise_body_zones", "exercise_row_id=?", arrayOf(rowId.toString())) + fun insert(zoneId: String, role: String) { + db.insertOrThrow("exercise_body_zones", null, ContentValues().apply { + put("exercise_row_id", rowId) + put("zone_id", zoneId) + put("role", role) + }) + } + primaryZoneId?.let { insert(it, "primary") } + secondaryZoneIds.forEach { insert(it, "secondary") } + } + private fun normalizeName( value: String, ): String { @@ -3771,6 +4107,9 @@ 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 val EXERCISE_ID_V4_PATTERN = Regex( + "^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", +) private class TrainlogDatabaseHelper( private val appContext: Context, @@ -3779,7 +4118,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 9, + 10, ) { override fun onConfigure( db: SQLiteDatabase, @@ -3806,6 +4145,7 @@ private class TrainlogDatabaseHelper( createBodyTable(db) createActiveDraftTables(db) createEquipmentTables(db) + createBodyZoneTables(db) seedEquipment(db) } @@ -3882,6 +4222,15 @@ private class TrainlogDatabaseHelper( version = 9 } + if (version < 10 && newVersion >= 10) { + /* WHY: the approved legacy Leg press identity must be canonical + * before identity-keyed manifest mappings are applied. */ + migrateApprovedLegPressIdentity(db) + createBodyZoneTables(db) + seedInitialBodyZones(db) + version = 10 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -3890,6 +4239,55 @@ private class TrainlogDatabaseHelper( } } + private fun createBodyZoneTables(db: SQLiteDatabase) { + /* CONTRACT: canonical zone definitions remain in the asset; SQLite + * stores only direct exercise relations and role cardinality. */ + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_body_zones(" + + "exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," + + "zone_id TEXT NOT NULL," + + "role TEXT NOT NULL CHECK(role IN('primary','secondary'))," + + "PRIMARY KEY(exercise_row_id,zone_id));", + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS exercise_body_zones_one_primary " + + "ON exercise_body_zones(exercise_row_id) WHERE role='primary';", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS exercise_body_zone_sync(" + + "exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," + + "synced_state TEXT NOT NULL);", + ) + } + + private fun seedInitialBodyZones(db: SQLiteDatabase) { + val catalog = BodyZoneCatalog.load(appContext) + catalog.initialMappings.forEach { mapping -> + val rowId = db.rawQuery( + "SELECT id FROM exercises WHERE exercise_id=?;", + arrayOf(mapping.exerciseId), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + ?: return@forEach + fun insert(zoneId: String, role: String) { + db.insertOrThrow("exercise_body_zones", null, ContentValues().apply { + put("exercise_row_id", rowId) + put("zone_id", zoneId) + put("role", role) + }) + } + insert(mapping.primaryZoneId, "primary") + mapping.secondaryZoneIds.forEach { insert(it, "secondary") } + } + db.execSQL( + "INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) " + + "SELECT e.id,COALESCE((SELECT p.zone_id FROM exercise_body_zones p " + + "WHERE p.exercise_row_id=e.id AND p.role='primary'),'')||'|'||" + + "COALESCE((SELECT group_concat(s.zone_id,',') FROM " + + "(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=e.id " + + "AND role='secondary' ORDER BY zone_id) s),'') FROM exercises e;", + ) + } + private data class ExerciseIdentityRow( val rowId: Long, val exerciseId: String, @@ -3956,7 +4354,8 @@ private class TrainlogDatabaseHelper( } } - db.beginTransaction() + val ownsTransaction = !db.inTransaction() + if (ownsTransaction) db.beginTransaction() try { if (canonical == null) { /* Keep the legacy exercise row itself: all foreign-key graph @@ -3982,6 +4381,8 @@ private class TrainlogDatabaseHelper( "Métadonnées équipement Leg press incompatibles; migration refusée." } + mergeApprovedIdentityBodyZones(db, canonical.rowId, legacy.rowId) + db.execSQL( "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", arrayOf(canonical.rowId, legacy.rowId), @@ -4018,9 +4419,62 @@ private class TrainlogDatabaseHelper( check(exerciseIdentityRow(db, DESKTOP_LEG_PRESS_CANONICAL_ID) != null) { "Identité Leg press desktop absente après migration." } - db.setTransactionSuccessful() + if (ownsTransaction) db.setTransactionSuccessful() } finally { - db.endTransaction() + if (ownsTransaction) db.endTransaction() + } + } + + private fun mergeApprovedIdentityBodyZones( + db: SQLiteDatabase, + canonicalRowId: Long, + legacyRowId: Long, + ) { + val hasRelations = db.rawQuery( + "SELECT 1 FROM sqlite_master WHERE type='table' " + + "AND name='exercise_body_zones';", + null, + ).use { it.moveToFirst() } + if (!hasRelations) return + + fun state(rowId: Long): List> = db.rawQuery( + "SELECT zone_id,role FROM exercise_body_zones " + + "WHERE exercise_row_id=? ORDER BY role,zone_id;", + arrayOf(rowId.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getString(0) to cursor.getString(1)) + } + } + val canonicalState = state(canonicalRowId) + val legacyState = state(legacyRowId) + check(canonicalState.isEmpty() || legacyState.isEmpty() || canonicalState == legacyState) { + "Relations de zones Leg press incompatibles; migration refusée." + } + if (canonicalState.isEmpty() && legacyState.isNotEmpty()) { + db.execSQL( + "UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;", + arrayOf(canonicalRowId, legacyRowId), + ) + } else { + db.execSQL( + "DELETE FROM exercise_body_zones WHERE exercise_row_id=?;", + arrayOf(legacyRowId), + ) + } + + /* INVARIANT: identity repair is not a sync acknowledgement. A fresh + * shared baseline must be established by an identical companion. */ + val hasBaseline = db.rawQuery( + "SELECT 1 FROM sqlite_master WHERE type='table' " + + "AND name='exercise_body_zone_sync';", + null, + ).use { it.moveToFirst() } + if (hasBaseline) { + db.execSQL( + "DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);", + arrayOf(canonicalRowId, legacyRowId), + ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt index 15d8ddd..974f3d1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/ExerciseProfile.kt @@ -29,6 +29,9 @@ data class ExerciseProfile( val recordingMode: RecordingMode, val trackingMode: TrackingMode, val dataFields: Int, + /** Stable manifest IDs; display names are presentation-only metadata. */ + val primaryZoneId: String? = null, + val secondaryZoneIds: List = emptyList(), ) data class NewExerciseProfile( @@ -36,6 +39,9 @@ data class NewExerciseProfile( val recordingMode: RecordingMode, val trackingMode: TrackingMode, val dataFields: Int, + /** Null is the explicit no-relations state; validation rejects groups and orphan secondaries. */ + val primaryZoneId: String? = null, + val secondaryZoneIds: List = emptyList(), ) { fun validate(): Boolean { if (name.isBlank()) { @@ -74,7 +80,8 @@ data class NewExerciseProfile( /** * CONTRACT: an edit addresses the existing stable identity. `name` is * presentation metadata, not a replacement identity, so callers must never - * create a second exercise merely to rename one. + * create a second exercise merely to rename one. Name/profile/zones are saved + * transactionally; cancelling before this call has no persistence effect. */ data class ExerciseEditInput( val exerciseId: String, @@ -82,6 +89,8 @@ data class ExerciseEditInput( val recordingMode: RecordingMode, val trackingMode: TrackingMode, val dataFields: Int, + val primaryZoneId: String? = null, + val secondaryZoneIds: List = emptyList(), ) { fun validateProfile(): Boolean = NewExerciseProfile( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 7f5c273..7c5cc16 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.labfytools.trainlog.data.CreateExerciseResult +import com.labfytools.trainlog.data.BodyZone +import com.labfytools.trainlog.data.BodyZoneKind import com.labfytools.trainlog.data.EditExerciseResult import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.model.ExerciseDataFields @@ -66,6 +68,13 @@ fun ExerciseScreen( mutableStateOf(false) } + var primaryZoneId by remember { mutableStateOf(null) } + var secondaryZoneIds by remember { mutableStateOf(emptySet()) } + var searchQuery by remember { mutableStateOf("") } + var filterZoneId by remember { mutableStateOf(null) } + var unclassifiedFilter by remember { mutableStateOf(false) } + val zones = repository.listBodyZones() + var message by remember { mutableStateOf( @@ -92,6 +101,8 @@ fun ExerciseScreen( trackingMode = exercise.trackingMode speed = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0 distance = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0 + primaryZoneId = exercise.primaryZoneId + secondaryZoneIds = exercise.secondaryZoneIds.toSet() message = null } @@ -245,6 +256,49 @@ fun ExerciseScreen( } } + TrainlogChoiceGroup(label = "Zone principale") { + TrainlogChoice( + label = "Non renseignée", + selected = primaryZoneId == null, + onClick = { + primaryZoneId = null + secondaryZoneIds = emptySet() + message = null + }, + ) + BodyZoneChoices(zones) { zone, indented -> + TrainlogChoice( + label = (if (indented) " ↳ " else "") + zone.displayName, + selected = primaryZoneId == zone.zoneId, + enabled = zone.kind != BodyZoneKind.GROUP, + onClick = { + primaryZoneId = zone.zoneId + secondaryZoneIds = secondaryZoneIds - zone.zoneId + message = null + }, + ) + } + } + + TrainlogChoiceGroup(label = "Zones secondaires") { + BodyZoneChoices(zones) { zone, indented -> + TrainlogChoice( + label = (if (indented) " ↳ " else "") + zone.displayName, + selected = zone.zoneId in secondaryZoneIds, + enabled = primaryZoneId != null && + zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId, + onClick = { + secondaryZoneIds = if (zone.zoneId in secondaryZoneIds) { + secondaryZoneIds - zone.zoneId + } else { + secondaryZoneIds + zone.zoneId + } + message = null + }, + ) + } + } + val fields = if ( recordingMode == @@ -309,7 +363,12 @@ fun ExerciseScreen( }, accent = colors.success, - onClick = { + onClick = save@{ + if (editedExercise == null && + recordingMode == RecordingMode.SETS && primaryZoneId == null) { + message = "Une zone principale est requise pour un nouvel exercice musculaire." + return@save + } val current = editedExercise val result = if (current == null) { @@ -322,6 +381,8 @@ fun ExerciseScreen( trackingMode, dataFields = fields, + primaryZoneId = primaryZoneId, + secondaryZoneIds = secondaryZoneIds.toList(), ), ) } else { @@ -332,6 +393,8 @@ fun ExerciseScreen( recordingMode = recordingMode, trackingMode = trackingMode, dataFields = fields, + primaryZoneId = primaryZoneId, + secondaryZoneIds = secondaryZoneIds.toList(), ), ) } @@ -351,6 +414,10 @@ fun ExerciseScreen( "Profil ou nom invalide." } + is CreateExerciseResult.DatabaseError -> { + message = result.message + } + is EditExerciseResult.Saved -> { message = null editedExercise = null @@ -389,6 +456,8 @@ fun ExerciseScreen( trackingMode = TrackingMode.REPS speed = false distance = false + primaryZoneId = null + secondaryZoneIds = emptySet() message = null }, ) @@ -404,14 +473,43 @@ fun ExerciseScreen( } TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) { - val exercises = repository.listExercises() + TrainlogInputField( + label = "Recherche par préfixe", + value = searchQuery, + onValueChange = { searchQuery = it }, + ) + TrainlogChoiceGroup(label = "Filtre par zone") { + TrainlogChoice( + label = "Toutes les zones", + selected = filterZoneId == null && !unclassifiedFilter, + onClick = { filterZoneId = null; unclassifiedFilter = false }, + ) + BodyZoneChoices(zones, includeGroups = true) { zone, indented -> + TrainlogChoice( + label = (if (indented) " ↳ " else "") + zone.displayName, + selected = filterZoneId == zone.zoneId && !unclassifiedFilter, + onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false }, + ) + } + TrainlogChoice( + label = "Non renseignés", + selected = unclassifiedFilter, + onClick = { filterZoneId = null; unclassifiedFilter = true }, + ) + } + val exercises = repository.listExercises( + query = searchQuery, + zoneId = filterZoneId, + includeDescendants = true, + unclassifiedOnly = unclassifiedFilter, + ) if (exercises.isEmpty()) { TrainlogInfo("Aucun exercice enregistré.") } else { exercises.forEach { exercise -> TrainlogAction( label = "Modifier · ${exercise.name}", - description = "Modifier le nom ou le profil si disponible.", + description = exerciseZoneSummary(repository, exercise), accent = colors.accent, onClick = { startEditing(exercise) }, ) @@ -434,6 +532,37 @@ fun ExerciseScreen( } } +@Composable +private fun BodyZoneChoices( + zones: List, + includeGroups: Boolean = false, + content: @Composable (BodyZone, Boolean) -> Unit, +) { + zones.forEach { zone -> + if (includeGroups || zone.kind != BodyZoneKind.GROUP) { + content(zone, zone.parentZoneId != null) + } else { + TrainlogInfo(zone.displayName) + } + } +} + +private fun exerciseZoneSummary( + repository: TrainlogRepository, + exercise: ExerciseProfile, +): String { + val primary = exercise.primaryZoneId?.let(repository::bodyZone) + ?: return "Zone : Non renseignée" + val secondary = exercise.secondaryZoneIds.mapNotNull(repository::bodyZone) + val group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull() + return buildString { + append("Zone principale : ${primary.displayName}") + append(" · Zones secondaires : ") + append(if (secondary.isEmpty()) "Aucune" else secondary.joinToString { it.displayName }) + group?.let { append(" · Groupe : ${it.displayName}") } + } +} + @Composable private fun TrainlogField( label: String, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt new file mode 100644 index 0000000..3d0cdcc --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -0,0 +1,355 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ExerciseEditInput +import com.labfytools.trainlog.model.NewExerciseProfile +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BodyZonesTest { + private lateinit var context: Context + private val databases = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + databases.forEach(context::deleteDatabase) + } + + private fun repository(name: String): TrainlogRepository { + databases += name + context.deleteDatabase(name) + return TrainlogRepository(context, name) + } + + @Test + fun canonicalTaxonomyIsUniqueAcyclicAndHasDeclaredGroups() { + val catalog = BodyZoneCatalog.load(context) + assertEquals(11, catalog.zones.size) + assertEquals(catalog.zones.size, catalog.zones.map { it.zoneId }.toSet().size) + assertEquals(catalog.zones.size, catalog.zones.map { it.sortOrder }.toSet().size) + assertEquals(null, catalog.lookup("full_body")!!.parentZoneId) + assertEquals(BodyZoneKind.GROUP, catalog.lookup("upper_body")!!.kind) + assertEquals(setOf("chest", "back", "shoulders", "arms"), + catalog.descendantsAndSelf("upper_body") - "upper_body") + assertEquals(setOf("glutes", "thighs", "calves"), + catalog.descendantsAndSelf("lower_body") - "lower_body") + catalog.zones.forEach { zone -> + assertTrue(catalog.ancestors(zone.zoneId).map { it.zoneId }.distinct().size <= 1) + } + } + + @Test + fun createEditFilterUnclassifiedAndReopenPreserveRelations() { + val name = "body-zones-model.db" + val first = repository(name) + val created = first.createExercise(NewExerciseProfile( + name = "Chest custom", recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, dataFields = 0, + primaryZoneId = "chest", secondaryZoneIds = listOf("shoulders", "arms"), + )) as CreateExerciseResult.Created + assertEquals("chest", created.exercise.primaryZoneId) + assertEquals(listOf(created.exercise.exerciseId), + first.listExercises(zoneId = "upper_body").map { it.exerciseId }) + assertEquals(listOf(created.exercise.exerciseId), + first.listExercises(query = "che", zoneId = "chest").map { it.exerciseId }) + assertTrue(first.listExercises(query = "lat", zoneId = "back").isEmpty()) + assertTrue(first.createExercise(NewExerciseProfile( + "Invalid", RecordingMode.SETS, TrackingMode.REPS, 0, + primaryZoneId = "chest", secondaryZoneIds = listOf("chest"), + )) is CreateExerciseResult.Invalid) + assertTrue(first.createExercise(NewExerciseProfile( + "Unknown", RecordingMode.SETS, TrackingMode.REPS, 0, + primaryZoneId = "unknown", secondaryZoneIds = emptyList(), + )) is CreateExerciseResult.Invalid) + assertTrue(first.createExercise(NewExerciseProfile( + "Derived group", RecordingMode.SETS, TrackingMode.REPS, 0, + primaryZoneId = "upper_body", secondaryZoneIds = emptyList(), + )) is CreateExerciseResult.Invalid) + assertTrue(first.createExercise(NewExerciseProfile( + "Orphan secondary", RecordingMode.SETS, TrackingMode.REPS, 0, + primaryZoneId = null, secondaryZoneIds = listOf("arms"), + )) is CreateExerciseResult.Invalid) + + listOf("glutes", "thighs", "calves").forEach { zoneId -> + assertTrue(first.createExercise(NewExerciseProfile( + "Lower $zoneId", RecordingMode.SETS, TrackingMode.REPS, 0, + primaryZoneId = zoneId, secondaryZoneIds = emptyList(), + )) is CreateExerciseResult.Created) + } + assertEquals(3, first.listExercises(zoneId = "lower_body").size) + + val edited = first.editExercise(ExerciseEditInput( + created.exercise.exerciseId, "Chest custom", RecordingMode.SETS, + TrackingMode.REPS, 0, "back", listOf("arms"), + )) + assertTrue(edited is EditExerciseResult.Saved) + assertTrue(first.listExercises(zoneId = "chest").isEmpty()) + assertEquals(1, first.listExercises(zoneId = "back").size) + val unclassified = first.createExercise(NewExerciseProfile( + "Marche custom", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0, + )) as CreateExerciseResult.Created + assertEquals(listOf(unclassified.exercise.exerciseId), + first.listExercises(unclassifiedOnly = true).map { it.exerciseId }) + var ambiguousFilterRejected = false + try { + first.listExercises(zoneId = "back", unclassifiedOnly = true) + } catch (_: IllegalArgumentException) { + ambiguousFilterRejected = true + } + assertTrue(ambiguousFilterRejected) + first.close() + + val reopened = TrainlogRepository(context, name) + val restored = reopened.listExercises().associateBy { it.exerciseId } + assertEquals("back", restored.getValue(created.exercise.exerciseId).primaryZoneId) + assertEquals(listOf("arms"), restored.getValue(created.exercise.exerciseId).secondaryZoneIds) + reopened.close() + } + + @Test + fun companionReplaysUpdatesOneSideAndRejectsSimultaneousDivergence() { + val first = repository("body-zones-sync-a.db") + val created = first.createExercise(NewExerciseProfile( + "Sync custom", RecordingMode.SETS, TrackingMode.REPS, 0, + "chest", listOf("arms"), + )) as CreateExerciseResult.Created + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog").put("version", 1) + .put("generated_at", "2032-01-01T00:00:00+00:00") + .put("exercises", JSONArray().put(JSONObject() + .put("exercise_id", created.exercise.exerciseId) + .put("name", created.exercise.name) + .put("recording_mode", "sets").put("tracking_mode", "reps") + .put("data_fields", 0))) + val second = repository("body-zones-sync-b.db") + assertTrue(second.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + val initial = first.buildExerciseBodyZonesJson() + // The creator acknowledges only the exact snapshot that publication + // made visible; this establishes the missing baseline for a custom row. + assertTrue(first.applyExerciseBodyZonesJson(initial) is ExerciseBodyZoneImportResult.Applied) + assertEquals(1, (second.applyExerciseBodyZonesJson(initial) as + ExerciseBodyZoneImportResult.Applied).updated) + assertTrue(second.applyExerciseBodyZonesJson(initial) is ExerciseBodyZoneImportResult.Applied) + + assertTrue(second.editExercise(ExerciseEditInput( + created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS, + TrackingMode.REPS, 0, "shoulders", listOf("arms"), + )) is EditExerciseResult.Saved) + val returned = second.buildExerciseBodyZonesJson() + assertTrue(second.applyExerciseBodyZonesJson(returned) is ExerciseBodyZoneImportResult.Applied) + assertEquals(1, (first.applyExerciseBodyZonesJson(returned) as + ExerciseBodyZoneImportResult.Applied).updated) + assertEquals("shoulders", first.listExercises().single().primaryZoneId) + + assertTrue(first.editExercise(ExerciseEditInput( + created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS, + TrackingMode.REPS, 0, "chest", listOf("arms"), + )) is EditExerciseResult.Saved) + assertTrue(second.editExercise(ExerciseEditInput( + created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS, + TrackingMode.REPS, 0, "back", listOf("arms"), + )) is EditExerciseResult.Saved) + val divergent = first.buildExerciseBodyZonesJson() + assertTrue(first.applyExerciseBodyZonesJson(divergent) is ExerciseBodyZoneImportResult.Applied) + assertTrue(second.applyExerciseBodyZonesJson(divergent) is + ExerciseBodyZoneImportResult.Conflict) + assertEquals("back", second.listExercises().single().primaryZoneId) + first.close() + second.close() + } + + @Test + fun companionRejectsInvalidStableIdentityAndTimestamp() { + val repository = repository("body-zones-invalid-companion.db") + fun artifact(exerciseId: String, generatedAt: String) = JSONObject() + .put("format", "trainlog-exercise-body-zones") + .put("version", 1) + .put("generated_at", generatedAt) + .put("exercises", JSONArray().put(JSONObject() + .put("exercise_id", exerciseId) + .put("primary_zone_id", JSONObject.NULL) + .put("secondary_zone_ids", JSONArray()))) + .toString() + assertTrue(repository.applyExerciseBodyZonesJson( + artifact("ex_not-a-uuid", "2032-01-01T00:00:00+00:00"), + ) is ExerciseBodyZoneImportResult.Invalid) + assertTrue(repository.applyExerciseBodyZonesJson( + artifact("ex_11111111-1111-4111-8111-111111111111", "sans-offset"), + ) is ExerciseBodyZoneImportResult.Invalid) + repository.close() + } + + @Test + fun catalogIdentityMergePreservesOneZoneStateAndRejectsTwoDifferentStates() { + fun pcCatalog(exerciseId: String, name: String) = JSONObject() + .put("format", "trainlog-pc-catalog").put("version", 1) + .put("generated_at", "2032-01-01T00:00:00+00:00") + .put("exercises", JSONArray().put(JSONObject() + .put("exercise_id", exerciseId).put("name", name) + .put("recording_mode", "sets").put("tracking_mode", "reps") + .put("data_fields", 0))) + .toString() + + val preserving = repository("body-zones-identity-merge.db") + val canonical = preserving.createExercise(NewExerciseProfile( + "Canonical old name", RecordingMode.SETS, TrackingMode.REPS, 0, + )) as CreateExerciseResult.Created + preserving.createExercise(NewExerciseProfile( + "Merged name", RecordingMode.SETS, TrackingMode.REPS, 0, + "chest", listOf("shoulders", "arms"), + )) + assertTrue(preserving.applyPcCatalogJson( + pcCatalog(canonical.exercise.exerciseId, "Merged name"), + ) is PcCatalogImportResult.Applied) + val merged = preserving.listExercises().single() + assertEquals(canonical.exercise.exerciseId, merged.exerciseId) + assertEquals("chest", merged.primaryZoneId) + assertEquals(listOf("shoulders", "arms"), merged.secondaryZoneIds) + preserving.close() + + val conflicting = repository("body-zones-identity-conflict.db") + val conflictingCanonical = conflicting.createExercise(NewExerciseProfile( + "Conflicting old name", RecordingMode.SETS, TrackingMode.REPS, 0, + "chest", emptyList(), + )) as CreateExerciseResult.Created + conflicting.createExercise(NewExerciseProfile( + "Conflicting merged name", RecordingMode.SETS, TrackingMode.REPS, 0, + "back", emptyList(), + )) + assertTrue(conflicting.applyPcCatalogJson( + pcCatalog(conflictingCanonical.exercise.exerciseId, "Conflicting merged name"), + ) is PcCatalogImportResult.Invalid) + assertEquals(2, conflicting.listExercises().size) + conflicting.close() + } + + @Test + fun versionNineFixtureMigratesKnownIdentityWithoutTouchingHistoryDraftMaxOrEquipment() { + val name = "body-zones-v9.db" + val initial = repository(name) + initial.listExercises() // Materialize the complete current schema and bundled equipment. + initial.close() + SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { db -> + db.execSQL("DROP TABLE exercise_body_zone_sync;") + db.execSQL("DROP TABLE exercise_body_zones;") + db.execSQL( + "INSERT INTO exercises VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," + + "'Abdominal crunch','abdominal crunch','sets','reps',0);", + ) + val equipmentRow = db.rawQuery( + "SELECT id FROM equipment WHERE equipment_id='abdominal';", null, + ).use { cursor -> assertTrue(cursor.moveToFirst()); cursor.getLong(0) } + db.execSQL( + "INSERT INTO sessions(id,session_id,started_at,session_type) " + + "VALUES(5,'se_zone_training','2032-01-01T08:00:00+01:00','training')," + + "(6,'se_zone_max','2032-01-02T08:00:00+01:00','max_test');", + ) + 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) " + + "VALUES(7,5,42,0,'sets','reps',0,?,'sxe_zone_training')," + + "(8,6,42,0,'sets','reps',0,?,'sxe_zone_max');", + arrayOf(equipmentRow, equipmentRow), + ) + db.execSQL( + "INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,weight_kg) " + + "VALUES(9,7,0,12,42.5);", + ) + db.execSQL("INSERT INTO max_results VALUES(8,85.0);") + db.execSQL( + "INSERT INTO body_observations(id,observation_id,observed_at,body_weight_kg,waist_cm) " + + "VALUES(12,'bo_zone_preserved','2032-01-02T09:00:00+01:00',81.5,91.0);", + ) + db.execSQL( + "INSERT INTO equipment(id,equipment_id,label_name,display_name,equipment_type,load_semantics) " + + "VALUES(999,'eq_44444444-4444-4444-8444-444444444444'," + + "'Machine custom','Machine custom','strength_machine','external');", + ) + db.execSQL( + "INSERT INTO active_session_draft(id,session_type,selected_exercise_row_id," + + "selected_exercise_label,selected_equipment_id,source_session_id,weight_text," + + "max_weight_text,set_count_text,reps_text,duration_text,speed_text,distance_text,updated_at) " + + "VALUES(1,'training',42,'Abdominal crunch','abdominal',NULL,'42,5','','1','10','','',''," + + "'2032-01-03T08:00:00+01:00');", + ) + 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) " + + "VALUES(10,1,42,0,'sets','reps',0,?,'sxe_zone_draft');", + arrayOf(equipmentRow), + ) + db.execSQL( + "INSERT INTO draft_performed_sets(id,draft_exercise_row_id,position,reps,weight_kg) " + + "VALUES(11,10,0,10,37.5);", + ) + db.execSQL("PRAGMA user_version=9;") + } + val repository = TrainlogRepository(context, name) + val exercise = repository.listExercises().single() + assertEquals("core", exercise.primaryZoneId) + val training = repository.getSessionDetail("se_zone_training")!!.exercises.single() + assertEquals("sxe_zone_training", training.entryId) + assertEquals(42.5, training.sets.single().weightKg!!, 0.0) + assertEquals("Abdominal — Crunch machine", training.equipmentDisplayName) + assertEquals(85.0, repository.listLatestExerciseMaxima().single().maxWeightKg, 0.0) + val draft = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals("sxe_zone_draft", draft.draft.exercises.single().entryId) + assertEquals(37.5, draft.draft.exercises.single().sets.single().weightKg!!, 0.0) + repository.close() + SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, + SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("PRAGMA user_version", null).use { cursor -> + assertTrue(cursor.moveToFirst()); assertEquals(10, cursor.getInt(0)) + } + db.rawQuery("SELECT id FROM exercises", null).use { cursor -> + assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) + } + db.rawQuery( + "SELECT body_weight_kg,waist_cm FROM body_observations " + + "WHERE id=12 AND observation_id='bo_zone_preserved';", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(81.5, cursor.getDouble(0), 0.0) + assertEquals(91.0, cursor.getDouble(1), 0.0) + } + db.rawQuery( + "SELECT label_name,display_name,equipment_type,load_semantics FROM equipment " + + "WHERE id=999 AND equipment_id='eq_44444444-4444-4444-8444-444444444444';", + null, + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("Machine custom", cursor.getString(0)) + assertEquals("Machine custom", cursor.getString(1)) + assertEquals("strength_machine", cursor.getString(2)) + assertEquals("external", cursor.getString(3)) + } + db.rawQuery("PRAGMA integrity_check", null).use { cursor -> + assertTrue(cursor.moveToFirst()); assertEquals("ok", cursor.getString(0)) + } + db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) } + } + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt new file mode 100644 index 0000000..8d07c8e --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt @@ -0,0 +1,122 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +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.nio.file.StandardCopyOption + +/** + * Optional real-data migration harness. + * + * CONTRACT: the source path comes only from TRAINLOG_ANDROID_V9_FIXTURE and is + * never opened by the production helper. Two test-owned copies are made first; + * the fixture and the user's installed database cannot be mutated or deleted. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RealAndroidV9BodyZonesMigrationTest { + private lateinit var context: Context + private val databaseNames = mutableListOf() + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + databaseNames.forEach(context::deleteDatabase) + } + + @Test + fun realVersionNineCopyMigratesWithoutChangingExistingTables() { + val source = File(System.getenv("TRAINLOG_ANDROID_V9_FIXTURE") ?: "") + assumeTrue("TRAINLOG_ANDROID_V9_FIXTURE is not available", source.isFile) + + val beforeName = "body-zones-real-v9-before.db" + val migratedName = "body-zones-real-v9-migrated.db" + databaseNames += listOf(beforeName, migratedName) + val beforePath = context.getDatabasePath(beforeName) + val migratedPath = context.getDatabasePath(migratedName) + beforePath.parentFile?.mkdirs() + Files.copy(source.toPath(), beforePath.toPath(), StandardCopyOption.REPLACE_EXISTING) + Files.copy(source.toPath(), migratedPath.toPath(), StandardCopyOption.REPLACE_EXISTING) + + SQLiteDatabase.openDatabase( + beforePath.path, null, SQLiteDatabase.OPEN_READONLY, + ).use { before -> + assertEquals(9, scalarInt(before, "PRAGMA user_version;")) + assertEquals("ok", scalarString(before, "PRAGMA integrity_check;")) + before.rawQuery("PRAGMA foreign_key_check;", null).use { + assertFalse(it.moveToFirst()) + } + } + + val repository = TrainlogRepository(context, migratedName) + assertEquals(23, repository.listExercises().size) + repository.close() + + SQLiteDatabase.openDatabase( + migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE, + ).use { migrated -> + assertEquals(10, scalarInt(migrated, "PRAGMA user_version;")) + assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;")) + migrated.rawQuery("PRAGMA foreign_key_check;", null).use { + assertFalse(it.moveToFirst()) + } + migrated.execSQL("ATTACH DATABASE ? AS before_v9;", arrayOf(beforePath.path)) + val historicalTables = listOf( + "active_session_draft", "body_observations", + "catalog_exercise_equipment", "continuous_activity", + "draft_continuous_activity", "draft_max_results", + "draft_performed_sets", "draft_session_exercises", "equipment", + "equipment_aliases", "exercise_equipment", "exercises", + "max_results", "performed_sets", "session_exercises", "sessions", + ) + historicalTables.forEach { table -> + /* Table names are a closed test constant; values remain bound + * and the EXCEPT comparison covers every column and row. */ + assertEquals(0, scalarInt(migrated, + "SELECT COUNT(*) FROM (SELECT * FROM main.$table " + + "EXCEPT SELECT * FROM before_v9.$table);")) + assertEquals(0, scalarInt(migrated, + "SELECT COUNT(*) FROM (SELECT * FROM before_v9.$table " + + "EXCEPT SELECT * FROM main.$table);")) + } + assertEquals(32, scalarInt(migrated, + "SELECT COUNT(*) FROM exercise_body_zones;")) + assertEquals(20, scalarInt(migrated, + "SELECT COUNT(DISTINCT exercise_row_id) FROM exercise_body_zones;")) + assertEquals(23, scalarInt(migrated, + "SELECT COUNT(*) FROM exercise_body_zone_sync;")) + assertEquals(3, scalarInt(migrated, + "SELECT COUNT(*) FROM exercises e WHERE NOT EXISTS(" + + "SELECT 1 FROM exercise_body_zones z WHERE z.exercise_row_id=e.id);")) + migrated.execSQL("DETACH DATABASE before_v9;") + } + } + + private fun scalarInt(database: SQLiteDatabase, sql: String): Int = + database.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getInt(0) + } + + private fun scalarString(database: SQLiteDatabase, sql: String): String = + database.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(0) + } +} 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 ac920c8..2dfc18f 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 @@ -1502,7 +1502,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(9, cursor.getInt(0)) + assertEquals(10, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1629,7 +1629,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(9, cursor.getInt(0)) + assertEquals(10, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/catalog/body-zones-v1.json b/catalog/body-zones-v1.json new file mode 100644 index 0000000..fbc73a5 --- /dev/null +++ b/catalog/body-zones-v1.json @@ -0,0 +1,39 @@ +{ + "format": "trainlog-body-zone-catalog", + "version": 1, + "zones": [ + {"zone_id":"full_body","display_name":"Corps entier","parent_zone_id":null,"sort_order":0,"kind":"standalone"}, + {"zone_id":"upper_body","display_name":"Membres supérieurs","parent_zone_id":null,"sort_order":10,"kind":"group"}, + {"zone_id":"chest","display_name":"Pectoraux","parent_zone_id":"upper_body","sort_order":11,"kind":"leaf"}, + {"zone_id":"back","display_name":"Dos","parent_zone_id":"upper_body","sort_order":12,"kind":"leaf"}, + {"zone_id":"shoulders","display_name":"Épaules","parent_zone_id":"upper_body","sort_order":13,"kind":"leaf"}, + {"zone_id":"arms","display_name":"Bras","parent_zone_id":"upper_body","sort_order":14,"kind":"leaf"}, + {"zone_id":"core","display_name":"Abdominaux / tronc","parent_zone_id":null,"sort_order":20,"kind":"standalone"}, + {"zone_id":"lower_body","display_name":"Membres inférieurs","parent_zone_id":null,"sort_order":30,"kind":"group"}, + {"zone_id":"glutes","display_name":"Fessiers","parent_zone_id":"lower_body","sort_order":31,"kind":"leaf"}, + {"zone_id":"thighs","display_name":"Cuisses","parent_zone_id":"lower_body","sort_order":32,"kind":"leaf"}, + {"zone_id":"calves","display_name":"Mollets","parent_zone_id":"lower_body","sort_order":33,"kind":"leaf"} + ], + "exercise_mappings": [ + {"exercise_id":"ex_4bd03d55-e644-436e-876e-07837824bde5","exercise_name":"Abdominal","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable et équipement custom durable eq_3f987a36-bbb4-4e29-9c9f-1f201f1596e0 nommé Abdominal."}, + {"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","exercise_name":"Abdominal crunch","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique abdominal (crunch machine)."}, + {"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","exercise_name":"Arm curl","primary_zone_id":"arms","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique arm_curl (curl biceps)."}, + {"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","exercise_name":"Back extension","primary_zone_id":"back","secondary_zone_ids":["core"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique back_extension (extension lombaire et stabilisation du tronc)."}, + {"exercise_id":"ex_8552dd77-fcd7-4f06-a1cc-d956eb1009af","exercise_name":"Chest press","primary_zone_id":"chest","secondary_zone_ids":["shoulders","arms"],"decision_source":"Identité stable et équipement custom durable eq_0a462c1e-9fb6-4fd7-b0a2-53a0e86f33c6 nommé Chest press."}, + {"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","exercise_name":"Converging Shoulder Press","primary_zone_id":"shoulders","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique converging_shoulder_press."}, + {"exercise_id":"ex_34a5c9c3-c032-4dfb-be32-8bf09832f72b","exercise_name":"Converting chest press","primary_zone_id":"chest","secondary_zone_ids":["shoulders","arms"],"decision_source":"Identité stable et équipement custom durable eq_c660cd61-5b17-498e-8d51-9eafcf7e2731 nommé Converting chest press."}, + {"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","exercise_name":"Diverged seated row","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique diverging_seated_row."}, + {"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","exercise_name":"Diverging lat pulldown","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique diverging_lat_pulldown."}, + {"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","exercise_name":"Hip abduction","primary_zone_id":"glutes","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique hip_abduction (abducteurs/fessiers)."}, + {"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","exercise_name":"Hip adduction","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique hip_adduction (adducteurs de la cuisse)."}, + {"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","exercise_name":"Lat pull","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique lat_pull."}, + {"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","exercise_name":"Leg extension","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique leg_extension (quadriceps)."}, + {"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","exercise_name":"Leg press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"decision_source":"Identité stable réconciliée et occurrences liées aux équipements canoniques leg_press/plate_loaded_leg_press."}, + {"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","exercise_name":"Prone leg curl","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique prone_leg_curl (ischio-jambiers)."}, + {"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","exercise_name":"Rear Delt","primary_zone_id":"shoulders","secondary_zone_ids":["back"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique rear_delt_pec_fly, utilisée comme Rear Delt."}, + {"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","exercise_name":"Rotary torso","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable correspondant à la rotation du buste; équipement canonique rotary_torso présent dans le manifeste partagé."}, + {"exercise_id":"ex_617007f9-7420-4408-91b9-8ffb77900f13","exercise_name":"Seated Leg","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique seated_leg_curl (ischio-jambiers)."}, + {"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","exercise_name":"Seated leg curl","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à l’équipement canonique seated_leg_curl (ischio-jambiers)."}, + {"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","exercise_name":"Seated row","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à l’équipement canonique seated_row."} + ] +} diff --git a/docs/android.md b/docs/android.md index 44bb771..74110ef 100644 --- a/docs/android.md +++ b/docs/android.md @@ -33,7 +33,7 @@ Accueil Android local database version: ```text -8 +10 ``` Domain tables cover: @@ -45,16 +45,21 @@ session_exercises performed_sets continuous_activity body_observations +exercise_body_zones +exercise_body_zone_sync ``` 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 -> v8 chain preserves catalog, completed sessions/actuals, body +additive v4 -> v10 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. +Schema v9 adds explicit completed/draft MAX results. Schema v10 additively +stores direct primary/secondary body-zone relations and their private sync +baseline; the taxonomy itself remains the shared manifest asset. ## 4. Exercise catalog @@ -65,6 +70,8 @@ name recording_mode tracking_mode data_fields +primary_zone_id nullable only for explicit unclassified/history cases +secondary_zone_ids zero or more distinct canonical IDs ``` Stable identity: @@ -78,6 +85,19 @@ collisions. An exercise may be created standalone or inline while building a session. +The form groups translated values from `catalog/body-zones-v1.json` under +**Membres supérieurs** and **Membres inférieurs**, with autonomous **Corps +entier** and **Abdominaux / tronc** entries. Group nodes organize and filter; +they cannot be assigned directly. A new set-based exercise requires one +primary zone. Selecting it removes/disables that same ID among secondaries. +Historical unclassified exercises remain visible as **Zone : Non renseignée** +and may be classified later. + +The existing exercise list combines SQL-backed normalized prefix search with a +zone filter. Parent filters include descendants, **Non renseignés** selects +only exercises with no relations, and **Toutes les zones** never hides those +rows. Each row displays primary, secondaries and the primary's derived group. + ### Editing an exercise Every existing catalog item exposes **Modifier**. Editing a name trims its @@ -197,6 +217,9 @@ 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. +Zone relations follow the same row-identity move only when one side is empty or +both states are equal. Two different non-empty states are an explicit conflict; +the repository never invents an automatic union of secondary zones. ## 7. Session history @@ -261,6 +284,14 @@ definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict are idempotent and divergent same-ID definitions conflict. Supplied-manifest IDs are reserved. +Android also writes `trainlog-exercise-body-zones-v1.json`, the sole zone +companion used in both directions. It contains stable exercise/zone IDs only. +After the file write succeeds, an identical local replay establishes/refreshes +the publisher's shared baseline. A one-sided change is applied transactionally, +and simultaneous divergence returns an explicit conflict without changing +local relations. The unclassified state contains neither a primary nor orphan +secondaries. + 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 @@ -296,6 +327,11 @@ Android writes: trainlog-sync-request-v1.json ``` +If MediaStore cannot reopen an older MTP-created canonical object, it may +publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The +desktop engine selects the newest canonical-or-suffixed request +deterministically, while the stable `request_id` remains the replay boundary. + and waits for a matching: ```text @@ -309,8 +345,10 @@ 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 v9 provides +before a received V2 association references them. Android schema v10 provides the non-destructive v7 -> v8 migration required for `load_semantics = none`. +After catalog/session/equipment reconciliation, Android applies the same +body-zone companion so custom exercises receive their classifications. A receipt belonging to another request is ignored as pending rather than misreported as the current result. @@ -338,7 +376,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 a connected test device: @@ -349,7 +387,8 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk `local.properties` is local machine configuration and must not be committed. -The prior host regression suite had 8 tests and the prior device +The current JVM host regression suite has 44 passing tests when the retained +real v9 fixture is enabled. 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`, @@ -438,12 +477,18 @@ left intact as the crash-safe baseline until finalization. ## 17. Audited limitations +Body Zones V1 defines no custom zone creation and no session generator. The +repository already exposes manifest lookup/hierarchy, descendant and +primary-only exercise filtering, direct exercise relations and the existing +latest-MAX/history reads needed for future composition; it calculates no +suggested load. + 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 v9. +collisions. It is not silently changed inside schema v10. The bundled exercise/equipment relationship metadata is seeded and preserved, including during exercise-identity reconciliation, but the current equipment @@ -459,7 +504,7 @@ 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-v9 change. +requires a compatibility decision rather than an incidental schema-v10 change. Android requires `data_fields = 0` for `SETS`, while the desktop model/API currently accepts known supplemental bits on either recording mode. Supplied diff --git a/docs/architecture.md b/docs/architecture.md index 35b5aae..77f7b7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ Responsibilities: - exercise catalog entry; - stable-ID exercise rename/editing; +- canonical body-zone selection, display and descendant filtering; - workout-session recording; - performed set entry; - continuous-activity entry; @@ -56,6 +57,7 @@ The C17 core owns: - desktop SQLite persistence; - exercise/catalog rules; +- generated body-zone taxonomy and relation APIs; - profile-aware session data; - body data; - ID and time helpers; @@ -74,6 +76,7 @@ It consumes core services for: - session entry/editing; - history; - exercise performance; +- exercise body-zone creation/edit/detail and filters; - body tracking; - graphs; - manual synchronization; @@ -116,17 +119,39 @@ assistance Continuous work is persisted separately from performed sets. +Body-zone semantics are a second independent metadata axis: + +```text +exercise -> exercise_body_zones -> canonical body-zone manifest +role = primary | secondary +``` + +The repository-level `catalog/body-zones-v1.json` is the sole taxonomy source. +Android reads it as an asset and the desktop generates a bounded C +representation at build time. Stable `zone_id` values cross persistence and +synchronization boundaries; translated display names do not. `upper_body` and +`lower_body` are hierarchy groups whose descendant membership is derived at +query time, never persisted redundantly. `full_body` and `core` are autonomous. +An unclassified exercise has no relation at all: a secondary-only state is +invalid at repository, database and exchange boundaries rather than being +silently rendered as unclassified. + ## 4. Persistence ownership ### Desktop -Desktop SQLite schema v10 is canonical long-term history. Its v9 -> v10 +Desktop SQLite schema v11 is canonical long-term history. Its v9 -> v10 migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may be finite `>= 0`; the column already existed and targets/max results retain their strictly-positive contracts. `session_exercises` stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore occur more than once in one session without identity fusion. +The additive v10 -> v11 migration creates direct exercise/body-zone relations +and a private synchronization baseline, then seeds only stable-ID mappings +whose decision evidence is recorded in the manifest. It never rewrites an +exercise, occurrence or history row. + Main tables: ```text @@ -138,15 +163,21 @@ continuous_activity max_results body_observations custom_equipment +exercise_body_zones +exercise_body_zone_sync ``` ### Android -Android has an independent local SQLite schema, currently v9. Completed and +Android has an independent local SQLite schema, currently v10. Completed and draft MAX values use one-to-one `max_results` and `draft_max_results` rows; resuming a completed Test max records its stable source session in the one durable draft. +Its v9 -> v10 migration adds equivalent exercise/body-zone relations and seeds +the same manifest mappings. User creation and editing replace name/profile and +zone relations in one repository transaction. + It mirrors domain concepts needed for capture, but its schema version is not coupled to the desktop schema. @@ -196,6 +227,7 @@ trainlog-mobile-export v2 trainlog-pc-catalog v1 trainlog-equipment-associations v2 trainlog-equipment-definitions v1 +trainlog-exercise-body-zones v1 trainlog-sync-request v1 trainlog-sync-receipt v1 ``` @@ -212,6 +244,11 @@ to Android. The filename identifies direction; the JSON format and version do not change. V1 historical artifacts remain readable under their frozen contracts. +`trainlog-exercise-body-zones-v1.json` is the one direction-neutral zone +companion in both directions. It carries only `exercise_id`, a nullable +`primary_zone_id` and `secondary_zone_ids`; taxonomy definitions stay in the +canonical manifest. + ## 6. Direct MTP transport Linux transport: @@ -274,6 +311,8 @@ 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. +Both directions also transfer the same body-zone companion after exercise +definitions are established and before completion of the direction. The engine performs the applicable direction steps: @@ -281,8 +320,8 @@ The engine performs the applicable direction steps: 1. direct-MTP device/storage discovery 2. exchange-folder resolution 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 +4. strict transactional Android -> PC import and body-zone reconciliation when selected +5. PC catalog, body-zone, 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 @@ -294,7 +333,7 @@ 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 +There is no exercise, session, body-observation, body-zone-relation, 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 @@ -400,13 +439,23 @@ schema change. 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 +one definitions/mobile/body-zones/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. +Body-zone concurrency uses an internal canonical-state baseline. Equal states +are idempotent; the sole changed side wins; if both local and incoming states +diverge from the baseline, the companion reports a conflict and rolls back. +Secondary lists are never merged by union. A safe exercise-identity merge may +carry the only non-empty zone state, but rejects two different non-empty states +and clears the baseline because identity reconciliation is not acknowledgement. +After a companion has actually been published, the publisher records that +exact snapshot as its own baseline too; a failed file/MTP publication never +acknowledges data that the peer could not have observed. + 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 diff --git a/docs/current_state.md b/docs/current_state.md index 5279925..e65ebe6 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V10=PASS -ANDROID_LOCAL_DATABASE_V9=PASS +DESKTOP_SCHEMA_V11=PASS +ANDROID_LOCAL_DATABASE_V10=PASS ANDROID_SESSION_DRAFT_V1=PASS ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS @@ -56,10 +56,15 @@ EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V1=PASS MAX_TEST_RESUME_STABLE_ID=PASS +BODY_ZONES_V1=PASS +BODY_ZONE_SYNC_V1=PASS +BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS +BODY_ZONES_TUI_REAL_VALIDATION=PASS +BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS ANDROID_BUILD=PASS -HARDWARE_SYNC_VALIDATION=PASS +HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS ``` ## Desktop @@ -67,11 +72,13 @@ HARDWARE_SYNC_VALIDATION=PASS Implemented: - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); -- SQLite schema v10, with stable ordered `session_exercises.entry_id`, +- SQLite schema v11, with stable ordered `session_exercises.entry_id`, occurrence-level equipment identity, and desktop-local custom-equipment definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration rebuilds only `performed_sets` to permit explicit zero actual loads while preserving historic NULL and positive rows; +- canonical generated body-zone taxonomy, direct primary/secondary exercise + relations, stable-ID-only initial migration and descendant-aware filtering; - direct session entry; - normal desktop SETS planning followed by the table-only explicit actual-row editor; zero-row completion is rejected while MAX and continuous entries keep @@ -109,11 +116,13 @@ Primary navigation: Implemented: - native Kotlin/Compose application; -- local SQLite database v9, with non-destructive v3 -> v9 migration; +- local SQLite database v10, with non-destructive v3 -> v10 migration; - one durable active-session draft, Home resume and raw-form restoration; - explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; - stable-ID exercise rename/editing with referenced-profile protection; +- primary/secondary body-zone selection, display, hierarchy filtering and + prefix-search composition, including visible unclassified exercises; - inline exercise creation during session entry; - profile-aware session recording; - heterogeneous repetition-set entry; @@ -158,12 +167,14 @@ Android -> PC trainlog-mobile-equipment-definitions-v1.json trainlog-mobile-export-v2.json trainlog-equipment-associations-v2.json + trainlog-exercise-body-zones-v1.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 + trainlog-exercise-body-zones-v1.json Android -> PC agent trainlog-sync-request-v1.json @@ -192,6 +203,15 @@ 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. +Body-zone relations have one direction-neutral companion and are keyed only by +canonical zone IDs and `exercise_id`. Identical state is an idempotent skip; a +successful publication records that exact state as the publisher baseline, so +a later peer-only edit of a new custom exercise is accepted. Simultaneous +divergence is an explicit conflict and secondary lists are never unioned. The +unclassified state has no relations; orphan secondary rows are invalid. Parent +groups are derived from the manifest and never serialized as exercise +relations. + 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; @@ -226,43 +246,85 @@ No mounted Android filesystem is required. Desktop: ```text -36/36 Meson tests PASS for the current desktop schema v10 baseline +39/39 Meson tests PASS for the current desktop schema v11 baseline JSON valid/invalid checks PASS import-contract validator 6/6 PASS -ASan/UBSan 14/14 Meson tests PASS postrepair +ASan/UBSan 39/39 Meson tests PASS with leak detection +standalone public-header C17 syntax PASS +real Notcurses binary zone workflows PASS in Kitty git diff --check PASS ``` Android: ```text -37 Android unit tests PASS +Android unit tests 44/44 PASS with a real v9 copy enabled, including body-zone +v9 -> v10 migration and sync assembleDebug PASS +APK Signature Scheme v2 verification PASS +APK certificate SHA-256 matches the local debug keystore +APK SHA-256 5355f54e83f00ef31dc0e2f1db866c43ea3e9d880875f67878b1ffe169b5dc50 ``` Current real-data/environment boundary: ```text +BODY_ZONES_DESKTOP_BACKUP=PASS +BODY_ZONES_DESKTOP_REAL_V10_TO_V11=PASS +BODY_ZONES_DESKTOP_HISTORY_ROW_EQUALITY=PASS +BODY_ZONES_DESKTOP_RELATIONS=32/20_EXERCISES +BODY_ZONES_DESKTOP_UNCLASSIFIED=3 +BODY_ZONES_ANDROID_INSTALL=PASS +BODY_ZONES_ANDROID_REAL_V9_COPY_TO_V10=PASS +BODY_ZONES_ANDROID_INSTALLED_DB_V10=PASS +BODY_ZONES_LIVE_MTP_ROUNDTRIP=PASS +BODY_ZONES_LIVE_MTP_SECOND_PASS_IDEMPOTENT=PASS 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. +The real desktop v10 database was backed up with SQLite to +`backups/body-zones-v1-20260909T101310Z/trainlog-v10-before.db` under the Trainlog +user-data directory (SHA-256 +`9d5dcacd8be941be17bd3b98bbbc1815944fec9dc1b18133111ca481f50684e9`). +The production binary migrated it to v11; integrity/FK checks pass and every +row of all eight historical tables compares equal in both directions with the +backup. The migration added 32 direct relations for 20 of 23 exercises and +left Gym échauffement, Gym/Échauffement and Marche unclassified. + +The Samsung SM-G990B application data was freshly backed up before installation +under `backups/body-zones-v1-android-20260909TVFprJ8/`. The coherent v9 SQLite +copy has SHA-256 +`c402b69cdbfa4912eec1553a84754af1e892fa8da0cae6c3cbd14c4f10198401`; +its integrity check passed and its foreign-key check was empty. The installed, +built and established-keystore certificate fingerprints all matched +`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25` +before `adb install -r`. The normal installed migration reached v10 with every +row of the 16 existing application tables plus `android_metadata` equal in both +directions to the v9 backup. Integrity/FK checks pass, 32 direct relations cover +20 of 23 exercises, and Gym échauffement, Gym/Échauffement and Marche remain +unclassified. + +The real Android UI displayed primary, secondary and derived-group metadata; +the chest, upper-body descendant, lower-body descendant and unclassified +filters; and the `Dos` + `lat` prefix intersection. Editing `Lat pull` +preloaded `Dos` and secondary `Bras`; cancellation left every application table +unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and +`sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The +second run reported zero additions/reconciliations, both stores retained the +same 32-relation stable-ID hash, and Android application tables plus companion +exercise states compared equal to the first pass. The engine accepts the exact +MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID +replay protection remains authoritative. ## Current implementation cursor -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. +Body Zones V1, exercise reconciliation, definition-first V2 synchronization +and the current real-data importer/exporter copy validation are complete. The +body-zone taxonomy now supplies the read boundary required by a future +zone-driven planner; no session generator or proposed-load calculation is part +of this tranche. ```text MEASURED_MAX_V1=PASS @@ -287,7 +349,7 @@ ASSISTANCE_DIRECTION_AWARE=PASS ANDROID_MAX_TEST_SESSION=PASS EXPLICIT_MAX_RESULTS_V1=PASS MAX_TEST_RESUME_STABLE_ID=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS ``` A measured maximum belongs to an exercise occurrence in an explicit `max_test` @@ -317,7 +379,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS ``` Android remains capture-only for this feature. diff --git a/docs/database.md b/docs/database.md index 03248e1..f1877c2 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,8 +3,8 @@ ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=10 -DATABASE_SCHEMA_V10=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=11 +DATABASE_SCHEMA_V11=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -23,7 +23,7 @@ PRAGMA user_version; Current value: ```text -10 +11 ``` The independent actual-set loads documented in the current desktop, Android @@ -61,6 +61,13 @@ checks lossless migration, rollback after an injected rebuild-name collision, `PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key enforcement, and rejection of negative loads or invalid metric shapes. +Version 11 is additive. It creates `exercise_body_zones` and the private +`exercise_body_zone_sync` comparison baseline, then inserts only the exact +stable-ID mappings declared with evidence in `catalog/body-zones-v1.json`. +There is no exercise-ID, occurrence-ID, session, performed-set, MAX, equipment +or body-observation rewrite. The migration is one transaction and uncertain +historical exercises remain valid with no relation. + A schema fixture must represent the real historical structure. Rewriting only `user_version` is not an acceptable migration test. @@ -96,6 +103,31 @@ Rules include: - unknown supplemental field bits are rejected; - normalized names remain unique. +### `exercise_body_zones` + +Direct exercise-to-zone relations: + +```text +exercise_row_id foreign key -> exercises(id), cascade delete +zone_id stable ID from body-zones-v1.json +role primary | secondary +PRIMARY KEY exercise_row_id, zone_id +partial UNIQUE one role=primary row per exercise +``` + +The composite key prevents one zone from being both primary and secondary. +Public writers validate that every ID exists and is not a group. An exercise +with no rows is explicitly unclassified; a secondary-only state is rejected as +corruption. Parent relations are derived from the manifest during filtering +and are not stored here. + +`exercise_body_zone_sync(exercise_row_id, synced_state)` is internal sync +metadata, not domain data. Its deterministic state records a nullable primary +and byte-sorted secondary IDs so one-sided edits can be distinguished from a +simultaneous conflict. It is never exposed as a translated value or used to +merge secondary sets by union. The publishing peer records the exact snapshot +as its baseline only after the companion has been published successfully. + ### `sessions` ```text @@ -292,6 +324,10 @@ A failed replacement rolls back to the previously persisted session. Body-observation editing preserves its stable identity, timestamp, and optional session link. +Exercise creation/editing and replacement of all direct body-zone relations is +one transaction. Invalid or duplicate IDs leave both exercise metadata and the +prior relation set unchanged. + ## 7. Mobile import semantics `tools/import_mobile_export.py` validates the complete mobile snapshot before @@ -300,7 +336,7 @@ committing database changes. Properties: ```text -schema-v5-through-v8 aware +schema-v5-through-v11 aware transactional idempotent by stable IDs profile-aware catalog reconciliation @@ -317,6 +353,8 @@ 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. +On v11, a safe duplicate-row merge also preserves the only non-empty body-zone +state. Different non-empty states conflict; no relation list is unioned. ## 8. Units @@ -334,13 +372,17 @@ distance km The Android SQLite database is independent. -Current Android-local version: **9**. The explicit migration chain adds the +Current Android-local version: **10**. 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. Version 9 adds completed/draft explicit max rows, raw max and load form text, and the optional stable source session used to resume a completed Test max. +Version 10 adds `exercise_body_zones` and `exercise_body_zone_sync`, validates +IDs against the shared manifest asset, and seeds the same stable-ID mappings as +desktop v11 without modifying completed or draft work. + | Table | Ownership | | --- | --- | | `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text including MAX, optional resumed source session, update time | @@ -351,6 +393,8 @@ completed Test max. | `max_results` | Positive explicit max weight, one-to-one with a completed occurrence | | `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 | +| `exercise_body_zones` | Direct primary/secondary stable zone IDs; no derived parent rows | +| `exercise_body_zone_sync` | Private common-state baseline for explicit conflict detection | Foreign keys remain enabled. Draft deletion cascades only through draft child tables; it cannot delete catalog entries or completed history. The repository @@ -397,12 +441,15 @@ Migration-specific regression coverage includes: ```text schema_v5_migration schema_v7_migration +body_zones +body_zone_catalog_validation +body_zone_sync exercise_reconciliation max_results max_sync ``` -The current normal desktop suite contains 34 tests. +The current normal desktop suite contains 39 tests. ## 11. Explicit and legacy measured maxima @@ -473,10 +520,14 @@ durable-draft occurrences. A resumed max-test draft preserves its source `session_id`; atomic finalization replaces that session's ordered children instead of generating a second session. +Desktop schema v11 and Android schema v10 then add only body-zone relation and +sync-baseline tables. Both seed exact manifest mappings by stable exercise ID; +neither migration changes the occurrence/equipment/MAX graph described above. + ## 13. Body analytics persistence rule -Body analytics still require no schema change beyond schema v8; schema v9 does -not alter their storage. +Body analytics still require no dedicated schema change; schemas v9 through +v11 do not alter their measurement storage. 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 981f43c..aa8c996 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -458,7 +458,8 @@ The following are deliberately not represented by v1: - measured per-set rest; - supersets/circuits as first-class objects; - arbitrary custom set metrics; -- muscle-group classification; +- exercise body-zone relations (carried by the separate versioned Body Zones + V1 companion, never added to this frozen format); - machine seat/settings metadata; - photos; - cloud synchronization. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 3f64c03..ac82e04 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -9,6 +9,7 @@ PROFILE_AWARE_ANDROID=PASS CONTINUOUS_ACTIVITY=PASS VARIABLE_REPETITION_SETS=PASS TRAINLOG_FORMAT_V1=FROZEN +BODY_ZONES_V1=PASS ``` ## 1. Metadata axes @@ -70,7 +71,46 @@ Rameur CONTINUOUS + DURATION + DISTANCE_KM ``` -## 3. Load semantics +## 3. Body-zone metadata + +Exercise behavior and body-zone classification are independent. The sole V1 +taxonomy is `catalog/body-zones-v1.json`: + +```text +full_body Corps entier +upper_body Membres supérieurs (group) + chest Pectoraux + back Dos + shoulders Épaules + arms Bras +core Abdominaux / tronc +lower_body Membres inférieurs (group) + glutes Fessiers + thighs Cuisses + calves Mollets +``` + +An exercise stores at most one direct `primary` relation and any number of +distinct `secondary` relations. Secondary relations require that primary; an +unclassified exercise has no relation. The same zone cannot have both roles. +Group nodes are not assignable: a direct `chest` relation is sufficient for an +`upper_body` descendant query. `full_body` is not a synonym for all zones, and +`core` is not implicitly upper or lower body. Cardio is an activity profile, +not a body zone. + +New interactive `SETS` creation asks for a primary zone. Historical or +objectively ambiguous exercises may remain without relations and are displayed +and filterable as **Non renseignés**. Initial migration decisions use exact +stable `exercise_id` values and recorded equipment/catalog evidence, never a +general name rule. + +The read surface supports zone lookup, children, ancestors, direct relations, +primary/secondary selection and exercises for a zone with optional descendants +or primary-only participation. Those exercise IDs compose with existing +performance/MAX history readers, so a future session generator needs no new +duplicated MAX or history storage. No generator or load proposal exists yet. + +## 4. Load semantics Load mode is session-specific: @@ -88,7 +128,7 @@ better when comparing otherwise equivalent performance. Machine-displayed kilograms are stored faithfully without claiming mechanical equivalence across different machines. -## 4. Set-based work +## 5. Set-based work `SETS + REPS` stores one performed-set row per actual set. @@ -114,7 +154,7 @@ or progression calculations. `SETS + DURATION` likewise stores one actual duration per performed set. -## 5. Planned versus actual +## 6. Planned versus actual Desktop-created set sessions may carry explicit planned targets. @@ -131,7 +171,7 @@ target_duration_seconds = NULL Do not derive a fake target from heterogeneous actual sets. -## 6. Continuous work +## 7. Continuous work Continuous activity does not ask for: @@ -156,7 +196,7 @@ and configured supplemental values. No fake performed set is created. -## 7. Historical interpretation +## 8. Historical interpretation Desktop `session_exercises` snapshot: @@ -177,7 +217,7 @@ 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 +## 9. Android/TUI parity Both interfaces use the same metadata axes. @@ -192,6 +232,8 @@ data_fields ``` Creating an exercise inline on Android or desktop follows the same model rules. +Body zones use their separate direction-neutral V1 companion rather than +changing this catalog artifact or frozen session V1. ### Safe V2 identity reconciliation @@ -216,7 +258,7 @@ 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 +## 10. Exchange boundaries Frozen Trainlog session JSON v1 remains unchanged. @@ -228,7 +270,7 @@ Continuous activity must never be: - converted into a fake set; - silently discarded. -## 10. Measured max semantics +## 11. Measured max semantics `session_type = max_test` is an explicit semantic boundary. diff --git a/docs/roadmap.md b/docs/roadmap.md index 53ab33f..1a42aa8 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_V10=PASS -ANDROID_LOCAL_DATABASE_V9=PASS +DESKTOP_SCHEMA_V11=PASS +ANDROID_LOCAL_DATABASE_V10=PASS DIRECT_MTP_TRANSPORT=PASS BIDIRECTIONAL_SYNC_V1=PASS @@ -27,8 +27,11 @@ MAX_TEST_RESUME_STABLE_ID=PASS BODY_ANALYTICS_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS +BODY_ZONES_V1=PASS +BODY_ZONE_SYNC_V1=PASS +BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=36/36 PASS +DESKTOP_TESTS=39/39 PASS TUI_NOTCURSES_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS @@ -40,6 +43,7 @@ The current product baseline includes: - usable Notcurses desktop TUI; - native Android capture client; - exercise catalog; +- canonical hierarchical body zones, primary/secondary relations and filters; - profile-aware set and continuous activity; - heterogeneous repetition sets; - session history and editing; @@ -53,6 +57,12 @@ The current product baseline includes: - supplied and custom equipment definitions with occurrence-level links; - shared synchronization engine and `trainlog-syncd`. +`BODY_ZONES_V1` is complete infrastructure for later planning: one shared +manifest, desktop v11/Android v10 relations, Android/TUI edit and display, +descendant-aware filters, unclassified history and one explicit-conflict sync +companion. It does not implement a session generator, custom zones or proposed +loads. + `EXERCISE_EDIT_V1` is a completed capture correction: Android permits stable-ID renames, protects referenced profiles, and reconciles same-ID display metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only @@ -131,8 +141,8 @@ useful duplicate count supported exercise(s) Trainlog recording profile load semantics -primary muscles -secondary muscles +primary body zone (BODY_ZONES_V1) +secondary body zones (BODY_ZONES_V1) ``` Important rule: @@ -143,6 +153,9 @@ one photographed machine != one exercise A single piece of equipment may support multiple exercises. Equipment and exercise identity must remain distinct concepts. +The primary/secondary body-zone dimension is already implemented by Body Zones +V1; gym inventory should link stable exercise identities to that model rather +than inventing another free-text body-region field. Gate: @@ -155,19 +168,18 @@ GYM_CATALOG_V1=PASS After the real gym inventory is normalized, enrich the exercise catalog with structured metadata needed by planning and analytics. -Candidate metadata: +Remaining candidate metadata: ```text equipment -primary_muscles -secondary_muscles movement_family -body_region laterality ``` The final schema must be designed before implementation. Do not encode these concepts into names or free-form notes as a substitute for a real model. +Body-zone metadata is no longer future scope here; its frozen V1 taxonomy and +direct relations must be reused. Gate: @@ -191,6 +203,12 @@ A planned session should allow: Android should then open a prepared session and require only actual performance entry during training. +The existing Body Zones V1 read APIs can later list exercises by direct or +descendant zone, optionally primary-only, and compose those IDs with existing +latest MAX and performance history. A future generator may use that surface, +but this planner tranche must still define its own selection policy and must not +duplicate MAX/history or infer a load automatically. + Target workflow: ```text @@ -271,8 +289,9 @@ Candidate metrics: - useful volume metrics where semantically valid; - progression by exercise. -Once exercise metadata exists, aggregate by muscle group or movement family to -help evaluate training balance. +Use the existing Body Zones V1 read surface to aggregate by canonical body zone +and hierarchy when evaluating training balance. A separate movement-family +taxonomy, if ever needed, must be versioned rather than inferred from names. Gate: diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 1a8cd8d..2bb920a 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -17,6 +17,8 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V2=PASS +BODY_ZONE_SYNC_V1=PASS +BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED ``` @@ -45,10 +47,12 @@ Framework folder grant. | 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 | +| Android -> PC | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 | | 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 | +| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 | @@ -58,7 +62,9 @@ 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 +`trainlog-equipment-associations-v2 (N).json`, or +`trainlog-exercise-body-zones-v1 (N).json`, or +`trainlog-sync-request-v1 (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 @@ -101,6 +107,43 @@ 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. +## 4A. Exercise body zones V1 + +`trainlog-exercise-body-zones-v1.json` is the sole body-zone exchange source +and uses the same filename and format in both directions. Taxonomy definitions +are not copied into each exchange; both applications validate stable IDs from +`catalog/body-zones-v1.json`. + +The strict root contains exactly: + +```text +format = trainlog-exercise-body-zones +version = 1 +generated_at +exercises[] +``` + +Each exercise row contains exactly: + +```text +exercise_id +primary_zone_id canonical assignable ID or null +secondary_zone_ids ordered distinct canonical assignable IDs +``` + +`exercise_id` must be the stable lowercase `ex_` creator identity and +`generated_at` must carry an explicit UTC offset. Group IDs are rejected as +direct relations. An empty primary and empty list is the explicit unclassified +state; secondary relations without a primary are invalid. Equal state is an +idempotent skip. Successful publication records that exact snapshot as the +publisher's last shared baseline, including for a newly created custom +exercise. When only one side differs from that baseline, the complete incoming +or local state wins explicitly. If both sides differ, import reports a conflict +and rolls back; secondary lists are never unioned because that would invent +user intent. A mobile creator ID already reconciled by the immediately +preceding V2 exercise import is accepted only with that retained V2 definition +as proof, never from a name-only guess. + ## 5. Android -> PC mobile snapshot Header: @@ -120,9 +163,10 @@ 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 +Android captures its custom-definition V1 companion, this V2 snapshot, body +zones, and the equipment-association companion before it publishes any of +them. It then publishes in that order: definitions, V2 snapshot, body zones, +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. @@ -371,9 +415,9 @@ Android request The engine has three explicit modes: ```text -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 +a Android -> PC: definition V1 -> mobile V2 -> body zones V1 -> association V2; no publish +p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V2 + (including bodies) -> association V2; no receive b bidirectional: complete inbound sequence, then complete outbound sequence ``` @@ -384,7 +428,7 @@ 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 +equipment association, body-zone relation, 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 @@ -533,17 +577,22 @@ 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. +leave exact snapshots of every Android business table unchanged. On the +Samsung SM-G990B, the Body Zones APK certificate matched the installed package +and established keystore before `adb install -r`. The installed database then +migrated v9 -> v10 without changing any pre-existing application row. Two live +bidirectional runs exchanged `trainlog-exercise-body-zones-v1.json`; the second +reported no additions/reconciliations, every Android application table stayed +equal to the first pass, and both peers retained the same 32 relations for 20 +of 23 stable exercise IDs. The final companion states were semantically equal +between passes and the three explicit unclassified states remained intact. ## 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 +definitions/mobile/body-zones/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 @@ -551,7 +600,7 @@ 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 +Snapshots carry no exercise, session, body-observation, body-zone-relation, 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)`. diff --git a/docs/tests.md b/docs/tests.md index b398d25..d5bd15a 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -69,6 +69,7 @@ not contractual): database catalog equipment_catalog +body_zones custom_equipment session_detail duration @@ -93,6 +94,8 @@ mobile_import_multi_occurrence equipment_associations_exchange equipment_definitions_exchange exercise_reconciliation +body_zone_sync +body_zone_catalog_validation sync_direction sync_history sync_screen_action @@ -107,7 +110,7 @@ tui_workflows Validated current suite: ```text -36/36 Meson tests PASS +39/39 Meson tests PASS ``` The desktop executable is additionally smoke-checked in isolated tmux PTYs at @@ -125,12 +128,32 @@ Notable regression coverage: - table-only desktop SETS workflow: planning does not create actual rows, explicit add requires an actual metric, and normal zero-row completion is rejected; Android legacy compact-draft decoding remains separately covered; -- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration; +- direct v4 -> current database migration and v7 -> v8 custom-equipment migration; - bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation; - lossless v9 -> v10 `performed_sets` rebuild: historic NULL and positive weights/IDs/owners/positions/metrics survive, explicit zero is accepted, and injected failure rolls back with integrity, foreign-key and enforcement checks; +- additive desktop v10 -> v11 and Android v9 -> v10 body-zone migrations: + exact stable row identities and history remain unchanged while only proven + manifest mappings are seeded; the Android fixture also preserves an active + draft, per-set loads, explicit MAX, a body observation and a custom equipment + definition; +- canonical body-zone validation rejects duplicate IDs/order, missing parents, + cycles, group/leaf disagreement, a taxonomy other than the exact V1 manifest, + invalid `full_body`, boolean versions, malformed stable IDs, and direct group + assignment; +- one primary/multiple secondary persistence, duplicate/unknown/group/orphan- + secondary rejection, primary/secondary exclusivity, edit, unclassified and + reopen; +- exact and parent-descendant zone filters, lower-body leaf coverage, + primary-only participation, normalized-prefix composition and custom rows; +- body-zone companion export/import, empty mapping, identical replay, + one-sided update, simultaneous conflict rollback and source-V2-proven + exercise-ID reconciliation without name-only inference; custom-exercise + publication establishes both peer baselines before a reverse one-sided edit; + malformed `ex_` identities and timestamps without offsets are + rejected; - heterogeneous mobile-set import; - per-set load persistence and correction: ordered rows retain mixed actual repetitions, nullable loads, positions and assistance semantics through @@ -214,6 +237,9 @@ Android repository host tests additionally cover exercise editing: - completed history and active-draft references resolve the renamed catalog row; - a referenced profile change is explicitly rejected; - same-ID PC-catalog rename reconciles in place without a duplicate. +- canonical body-zone taxonomy, create/edit/reopen, parent filters, + search+filter, unclassified rows, companion replay/update/conflict and + zone-safe exercise-identity merging. ## 7. Hardware MTP validation @@ -230,7 +256,7 @@ trainlog-mtp-roundtrip-probe trainlog-mtp-mobile-export-probe ``` -Current physical baseline: +Previously established physical baseline: ```text USB_MTP_DETECTION=PASS @@ -303,7 +329,7 @@ When Android changed, add: ```bash cd android -JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug ``` Documentation must describe the resulting state, not retain contradictory old @@ -332,7 +358,7 @@ Coverage proves: Validated current normal suite: ```text -36/36 Meson tests PASS +39/39 Meson tests PASS ``` ## 12. Body analytics regression @@ -357,13 +383,13 @@ Coverage includes: Validated current normal suite: ```text -36/36 Meson tests PASS +39/39 Meson tests PASS ``` ## 13. Android session draft v1 Android schema v4 introduced one durable active draft; the current additive -chain reaches schema v9 without clearing completed history or the draft. The +chain reaches schema v10 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 @@ -376,6 +402,10 @@ explicit max creation/edit/finalization without sets, distinct movement values on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume and bounded conversion that leaves multiple legacy attempts untouched. +Schema-v10 host coverage adds the canonical body-zone asset, stable-ID mapping, +relation constraints, filters, transactional editing and companion conflict +policy without changing explicit MAX or per-set tables. + The current set-row-editor coverage additionally proves that row edits, deletion and append preserve neighbouring rows; French-comma loads, blank loads and explicit zero loads remain distinct; reopening preserves aligned raw @@ -400,10 +430,31 @@ V2 regression: mobile_import_variable_sets and the desktop/Android Sanitizers: clang ASan/UBSan Meson build and test invocation ``` -Executed postrepair evidence is: 37 Android unit tests, `assembleDebug`, 36/36 -Meson tests, valid and invalid JSON checks, import-contract 6/6, and 14/14 -ASan/UBSan Meson tests. No device, installation, real-store migration, or -instrumentation execution is asserted by this checkpoint. +Executed Body Zones V1 evidence is recorded after each closeout run: Android +`testDebugUnitTest` 44/44 with the retained real v9 fixture enabled and +`assembleDebug`, 39/39 Meson tests, valid and invalid JSON checks, +import-contract checks, and the ASan/UBSan Meson suite. Device installation and +installed Android-store migration remain explicit hardware steps and are never +inferred from host tests. + +The 2026-09-09 desktop closeout additionally backed up the real v10 database, +opened it through the production Notcurses binary, and verified v11 integrity, +foreign keys, all historical row counts and bidirectional row equality against +the backup. A real Kitty terminal validation exercised the zone list, parent +filter, classified/unclassified details and edit preloading/cancellation. The +Samsung SM-G990B then passed certificate matching, `adb install -r`, the real +v9 -> v10 migration, SQLite integrity/FK checks, row equality for every +pre-existing application table, the Android zone UI matrix and two live MTP +round trips. The second run reported zero additions/reconciliations and left +the Android application tables and semantic companion exercise states equal to +the first run. A regression also covers a current +`trainlog-sync-request-v1 (N).json` beside an older canonical request. + +The optional `RealAndroidV9BodyZonesMigrationTest` is enabled by setting +`TRAINLOG_ANDROID_V9_FIXTURE` to a coherent copied v9 database. It makes two +test-owned copies before opening the production repository, then compares all +16 pre-existing application tables in both directions; it never opens or +modifies the supplied fixture through the migration helper. ```bash cd android diff --git a/docs/tui.md b/docs/tui.md index f9bc13c..4b99aa2 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -95,6 +95,20 @@ Catalog identities are stable. Unicode-aware normalized-name uniqueness prevents duplicate logical names. +`3 Exercices` also owns Body Zones V1. Creation opens a keyboard-only manifest +selector: `p` chooses the sole primary, Space toggles secondaries, `n` selects +the explicit unclassified state and clears all relations, Enter validates and Escape +cancels without mutation. Only French display names are shown; stable IDs such +as `chest` remain internal. New set-based exercises require a primary zone. + +The exercise list supports `/` prefix search and `z` cycling through all +manifest zones plus **Non renseignés**; `x` clears search and zone filter. Parent filters +include descendants, so **Membres supérieurs** finds direct chest/back/ +shoulders/arms relations without stored parent rows. Search and filter combine. +Exercise detail displays the primary, ordered secondaries and the primary's +derived group. `e` edits name and zones transactionally; Escape at either edit +prompt leaves the durable row unchanged. + ## 5. Session entry The TUI can record sessions directly. @@ -257,10 +271,10 @@ 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. +`a` imports definitions V1, mobile V2, the body-zone companion and associations +V2 only. `p` publishes definitions V1, catalog V1, the body-zone companion, +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 @@ -355,7 +369,7 @@ meson test -C build --print-errorlogs Validated current normal suite: ```text -36/36 Meson tests PASS +39/39 Meson tests PASS ``` ## 16. Measured max view diff --git a/tests/test_body_zone_catalog.py b/tests/test_body_zone_catalog.py new file mode 100644 index 0000000..3a33355 --- /dev/null +++ b/tests/test_body_zone_catalog.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Negative regressions for the canonical body-zone manifest contract.""" + +import copy +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from validate_json import TrainlogSemanticError, validate_body_zone_catalog # noqa: E402 + + +def rejected(document): + try: + validate_body_zone_catalog(document) + except TrainlogSemanticError: + return + raise AssertionError("invalid body-zone manifest was accepted") + + +def main(): + source = json.loads((ROOT / "catalog/body-zones-v1.json").read_text(encoding="utf-8")) + validate_body_zone_catalog(source) + expected_v1 = [ + ("full_body", "Corps entier", None, 0, "standalone"), + ("upper_body", "Membres supérieurs", None, 10, "group"), + ("chest", "Pectoraux", "upper_body", 11, "leaf"), + ("back", "Dos", "upper_body", 12, "leaf"), + ("shoulders", "Épaules", "upper_body", 13, "leaf"), + ("arms", "Bras", "upper_body", 14, "leaf"), + ("core", "Abdominaux / tronc", None, 20, "standalone"), + ("lower_body", "Membres inférieurs", None, 30, "group"), + ("glutes", "Fessiers", "lower_body", 31, "leaf"), + ("thighs", "Cuisses", "lower_body", 32, "leaf"), + ("calves", "Mollets", "lower_body", 33, "leaf"), + ] + assert [ + (zone["zone_id"], zone["display_name"], zone["parent_zone_id"], + zone["sort_order"], zone["kind"]) + for zone in source["zones"] + ] == expected_v1 + assert "cardio" not in {zone["zone_id"] for zone in source["zones"]} + + duplicate_id = copy.deepcopy(source) + duplicate_id["zones"][1]["zone_id"] = duplicate_id["zones"][0]["zone_id"] + rejected(duplicate_id) + + duplicate_order = copy.deepcopy(source) + duplicate_order["zones"][1]["sort_order"] = duplicate_order["zones"][0]["sort_order"] + rejected(duplicate_order) + + missing_parent = copy.deepcopy(source) + missing_parent["zones"][2]["parent_zone_id"] = "missing" + rejected(missing_parent) + + cycle = copy.deepcopy(source) + cycle["zones"][1]["parent_zone_id"] = "chest" + rejected(cycle) + + wrong_kind = copy.deepcopy(source) + wrong_kind["zones"][1]["kind"] = "leaf" + rejected(wrong_kind) + + derived_group_relation = copy.deepcopy(source) + derived_group_relation["exercise_mappings"][0]["primary_zone_id"] = "upper_body" + rejected(derived_group_relation) + + full_body_child = copy.deepcopy(source) + full_body_child["zones"][0]["parent_zone_id"] = "upper_body" + rejected(full_body_child) + + boolean_version = copy.deepcopy(source) + boolean_version["version"] = True + rejected(boolean_version) + + invalid_zone_id = copy.deepcopy(source) + invalid_zone_id["zones"][2]["zone_id"] = "Chest libre" + rejected(invalid_zone_id) + + invalid_exercise_id = copy.deepcopy(source) + invalid_exercise_id["exercise_mappings"][0]["exercise_id"] = "ex_not-a-uuid" + rejected(invalid_exercise_id) + + +if __name__ == "__main__": + main() diff --git a/tests/test_body_zone_sync.py b/tests/test_body_zone_sync.py new file mode 100644 index 0000000..3eefa07 --- /dev/null +++ b/tests/test_body_zone_sync.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Desktop body-zone companion replay, one-sided update and conflict regression.""" + +import json +import sqlite3 +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +EXPORT = ROOT / "tools/export_exercise_body_zones.py" +IMPORT = ROOT / "tools/import_exercise_body_zones.py" +EXERCISE_ID = "ex_11111111-1111-4111-8111-111111111111" + + +def database(path, exercise_id=EXERCISE_ID, with_mapping=True, with_baseline=True): + connection = sqlite3.connect(path) + connection.executescript( + """ + 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, + recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL); + CREATE TABLE exercise_body_zones( + exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + zone_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN('primary','secondary')), + PRIMARY KEY(exercise_row_id,zone_id)); + CREATE UNIQUE INDEX exercise_body_zones_one_primary + ON exercise_body_zones(exercise_row_id) WHERE role='primary'; + CREATE TABLE exercise_body_zone_sync( + exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE, + synced_state TEXT NOT NULL); + """ + ) + connection.execute( + "INSERT INTO exercises VALUES(1,?,'Alias exercise','alias exercise','sets','reps',0)", + (exercise_id,), + ) + if with_mapping: + connection.executescript(""" + INSERT INTO exercise_body_zones VALUES(1,'chest','primary'); + INSERT INTO exercise_body_zones VALUES(1,'arms','secondary'); + """) + if with_baseline: + connection.execute( + "INSERT INTO exercise_body_zone_sync VALUES(1,'chest|arms')" + ) + connection.execute("PRAGMA user_version=11") + connection.commit() + connection.close() + + +def run(script, artifact, db, *extra): + result = subprocess.run( + ["python3", str(script), str(artifact), "--database", str(db), *map(str, extra)], + cwd=ROOT, text=True, capture_output=True, + ) + return result + + +def mapping(db): + connection = sqlite3.connect(db) + rows = connection.execute( + "SELECT zone_id,role FROM exercise_body_zones ORDER BY role,zone_id", + ).fetchall() + baseline = connection.execute("SELECT synced_state FROM exercise_body_zone_sync").fetchone()[0] + connection.close() + return rows, baseline + + +def main(): + with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary: + root = Path(temporary) + source = root / "source.db" + target = root / "target.db" + artifact = root / "trainlog-exercise-body-zones-v1.json" + database(source) + database(target) + + exported = run(EXPORT, artifact, source) + assert exported.returncode == 0, exported.stdout + exported.stderr + replay = run(IMPORT, artifact, target) + assert replay.returncode == 0 and "zones_skipped=1" in replay.stdout, replay.stdout + + payload = json.loads(artifact.read_text(encoding="utf-8")) + payload["exercises"][0]["primary_zone_id"] = "shoulders" + artifact.write_text(json.dumps(payload), encoding="utf-8") + update = run(IMPORT, artifact, target) + assert update.returncode == 0 and "zones_updated=1" in update.stdout, update.stdout + assert mapping(target) == ([('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms") + + connection = sqlite3.connect(target) + connection.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=1") + connection.execute("INSERT INTO exercise_body_zones VALUES(1,'back','primary')") + connection.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')") + connection.commit() + connection.close() + payload["exercises"][0]["primary_zone_id"] = "chest" + artifact.write_text(json.dumps(payload), encoding="utf-8") + conflict = run(IMPORT, artifact, target) + assert conflict.returncode == 1 + assert f"conflit zones simultané: {EXERCISE_ID}" in conflict.stdout + assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms") + + orphan = json.loads(artifact.read_text(encoding="utf-8")) + orphan["exercises"][0]["primary_zone_id"] = None + orphan["exercises"][0]["secondary_zone_ids"] = ["arms"] + artifact.write_text(json.dumps(orphan), encoding="utf-8") + rejected_orphan = run(IMPORT, artifact, target) + assert rejected_orphan.returncode == 1 + assert "secondaires sans zone principale" in rejected_orphan.stdout + assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms") + + invalid_identity = json.loads(artifact.read_text(encoding="utf-8")) + invalid_identity["exercises"][0]["primary_zone_id"] = "back" + invalid_identity["exercises"][0]["secondary_zone_ids"] = [] + invalid_identity["exercises"][0]["exercise_id"] = "ex_not-a-uuid" + artifact.write_text(json.dumps(invalid_identity), encoding="utf-8") + assert run(IMPORT, artifact, target).returncode == 1 + + invalid_time = json.loads(json.dumps(invalid_identity)) + invalid_time["exercises"][0]["exercise_id"] = EXERCISE_ID + invalid_time["generated_at"] = "2032-01-01T00:00:00" + artifact.write_text(json.dumps(invalid_time), encoding="utf-8") + invalid_time_result = run(IMPORT, artifact, target) + assert invalid_time_result.returncode == 1 + assert "sans offset" in invalid_time_result.stdout + + corrupt_db = sqlite3.connect(target) + corrupt_db.execute( + "DELETE FROM exercise_body_zones WHERE role='primary'" + ) + corrupt_db.commit() + corrupt_db.close() + rejected_export = run(EXPORT, artifact, target) + assert rejected_export.returncode == 1 + assert "relations de zones SQLite invalides" in rejected_export.stdout + corrupt_db = sqlite3.connect(target) + corrupt_db.execute( + "INSERT INTO exercise_body_zones VALUES(1,'back','primary')" + ) + corrupt_db.commit() + corrupt_db.close() + + payload["exercises"][0]["primary_zone_id"] = None + payload["exercises"][0]["secondary_zone_ids"] = [] + artifact.write_text(json.dumps(payload), encoding="utf-8") + connection = sqlite3.connect(source) + connection.execute("DELETE FROM exercise_body_zones") + connection.execute("UPDATE exercise_body_zone_sync SET synced_state='|'") + connection.commit(); connection.close() + no_mapping = run(IMPORT, artifact, source) + assert no_mapping.returncode == 0 and "zones_skipped=1" in no_mapping.stdout + + # import_mobile_export may have safely coalesced a remote creator ID + # into the normalized local identity before this companion arrives. + # The retained source V2 definition is the required proof; name alone + # is never accepted by the zone importer. + alias_db = root / "alias.db" + local_id = "ex_22222222-2222-4222-8222-222222222222" + remote_id = "ex_33333333-3333-4333-8333-333333333333" + database(alias_db, local_id, with_mapping=False) + alias_artifact = root / "alias-zones.json" + alias_artifact.write_text(json.dumps({ + "format": "trainlog-exercise-body-zones", "version": 1, + "generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{ + "exercise_id": remote_id, "primary_zone_id": "back", + "secondary_zone_ids": ["arms"], + }], + }), encoding="utf-8") + proof = root / "proof.json" + proof.write_text(json.dumps({ + "format": "trainlog-mobile-export", "version": 2, + "generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{ + "exercise_id": remote_id, "name": "Alias exercise", + "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, + }], "sessions": [], "body_observations": [], + }), encoding="utf-8") + alias_import = run( + IMPORT, alias_artifact, alias_db, "--mobile-export", proof, + ) + assert alias_import.returncode == 0, alias_import.stdout + alias_import.stderr + assert mapping(alias_db) == ([('back', 'primary'), ('arms', 'secondary')], "back|arms") + + # A custom exercise starts with no baseline on its creator. Publishing + # the exact snapshot acknowledges it locally; a later peer-only edit + # must then flow back instead of becoming a false simultaneous conflict. + creator = root / "creator.db" + peer = root / "peer.db" + shared = root / "shared.json" + database(creator, with_mapping=True, with_baseline=False) + database(peer, with_mapping=False) + assert run(EXPORT, shared, creator).returncode == 0 + assert run(IMPORT, shared, creator).returncode == 0 # publication ack + assert run(IMPORT, shared, peer).returncode == 0 + peer_db = sqlite3.connect(peer) + peer_db.execute("DELETE FROM exercise_body_zones") + peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'shoulders','primary')") + peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')") + peer_db.commit() + peer_db.close() + assert run(EXPORT, shared, peer).returncode == 0 + assert run(IMPORT, shared, peer).returncode == 0 # publication ack + returned = run(IMPORT, shared, creator) + assert returned.returncode == 0 and "zones_updated=1" in returned.stdout + assert mapping(creator) == ( + [('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index ed209cb..0f6059b 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -27,8 +27,8 @@ def main(): args = parser.parse_args() connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10): - raise ValueError("schema desktop v8, v9 ou v10 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11): + raise ValueError("schema desktop v8 à v11 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 747c293..7b129c2 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -23,8 +23,8 @@ def main(): connection = sqlite3.connect(args.database) connection.row_factory = sqlite3.Row try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10): - raise ValueError("schema desktop v8, v9 ou v10 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11): + raise ValueError("schema desktop v8 à v11 requis") equipment = [dict(row) for row in connection.execute( "SELECT equipment_id,display_name,label_name,equipment_type,load_semantics " "FROM custom_equipment ORDER BY equipment_id")] diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py new file mode 100644 index 0000000..1e083c9 --- /dev/null +++ b/tools/export_exercise_body_zones.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Export the sole bidirectional exercise/body-zone companion v1.""" + +import argparse +import json +import os +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "catalog/body-zones-v1.json" +EXERCISE_ID_PATTERN = re.compile( + r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +) + + +def assignable_zone_ids(): + root = json.loads(CATALOG.read_text(encoding="utf-8")) + if root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1 or \ + not isinstance(root.get("zones"), list): + raise ValueError("catalogue zones v1 invalide") + return { + item["zone_id"] for item in root["zones"] + if isinstance(item, dict) and item.get("kind") != "group" + } + + +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() + assignable = assignable_zone_ids() + connection = sqlite3.connect(args.database) + connection.row_factory = sqlite3.Row + try: + if connection.execute("PRAGMA user_version").fetchone()[0] != 11: + raise ValueError("schema desktop v11 requis") + exercises = [] + for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"): + if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None: + raise ValueError(f"exercise_id SQLite invalide: {exercise['exercise_id']}") + primary = connection.execute( + "SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", + (exercise["id"],), + ).fetchone() + secondary = [row[0] for row in connection.execute( + "SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' ORDER BY zone_id", + (exercise["id"],), + )] + direct = ([] if primary is None else [primary[0]]) + secondary + if any(zone_id not in assignable for zone_id in direct) or \ + len(direct) != len(set(direct)) or \ + (primary is None and secondary): + raise ValueError( + f"relations de zones SQLite invalides: {exercise['exercise_id']}" + ) + exercises.append({ + "exercise_id": exercise["exercise_id"], + "primary_zone_id": None if primary is None else primary[0], + "secondary_zone_ids": secondary, + }) + payload = { + "format": "trainlog-exercise-body-zones", + "version": 1, + "generated_at": datetime.now().astimezone().isoformat(), + "exercises": exercises, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + print("EXERCISE_BODY_ZONES_EXPORT=PASS") + print(f"exercises={len(exercises)}") + finally: + connection.close() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"EXERCISE_BODY_ZONES_EXPORT=FAIL {error}") + raise SystemExit(1) diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 06c3f81..e089b2d 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -64,7 +64,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in (8, 9, 10): + if version not in (8, 9, 10, 11): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 8031083..bd89a9d 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -30,8 +30,11 @@ def main(): con = sqlite3.connect(args.database) con.row_factory = sqlite3.Row try: - if con.execute("PRAGMA user_version").fetchone()[0] != 10: - raise ValueError("schema desktop v10 requis") + # The V2 shape itself does not read body-zone tables. Accept the true + # immediately-previous v10 fixture while production v11 publishes the + # separate body-zone companion. + if con.execute("PRAGMA user_version").fetchone()[0] not in (10, 11): + raise ValueError("schema desktop v10 ou v11 requis") known_equipment = supplied_equipment_ids() known_equipment.update(row[0] for row in con.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/generate_body_zone_catalog.py b/tools/generate_body_zone_catalog.py new file mode 100644 index 0000000..46d5873 --- /dev/null +++ b/tools/generate_body_zone_catalog.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Validate body-zones-v1 and generate its bounded C representation.""" + +import json +import re +import sys + + +source, output = sys.argv[1:] +with open(source, encoding="utf-8") as handle: + root = json.load(handle) + +if set(root) != {"format", "version", "zones", "exercise_mappings"} or \ + root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1 or \ + isinstance(root.get("version"), bool): + raise SystemExit("unsupported body-zone catalogue") +zones = root.get("zones") +if not isinstance(zones, list) or not zones: + raise SystemExit("zones must be a non-empty list") + +zone_ids = set() +orders = set() +by_id = {} +for zone in zones: + if set(zone) != {"zone_id", "display_name", "parent_zone_id", "sort_order", "kind"}: + raise SystemExit("invalid body-zone entry shape") + zone_id = zone["zone_id"] + if not isinstance(zone_id, str) or not re.fullmatch(r"[a-z][a-z0-9_]*", zone_id): + raise SystemExit("invalid body-zone id") + if zone_id in zone_ids or zone["sort_order"] in orders: + raise SystemExit("duplicate body-zone id or sort_order") + if not isinstance(zone["display_name"], str) or not zone["display_name"].strip(): + raise SystemExit("invalid body-zone display_name") + if not isinstance(zone["sort_order"], int) or isinstance(zone["sort_order"], bool) or zone["sort_order"] < 0: + raise SystemExit("invalid body-zone sort_order") + if zone["kind"] not in {"group", "leaf", "standalone"}: + raise SystemExit("invalid body-zone kind") + zone_ids.add(zone_id) + orders.add(zone["sort_order"]) + by_id[zone_id] = zone + +for zone in zones: + parent = zone["parent_zone_id"] + if parent is not None and parent not in zone_ids: + raise SystemExit("body-zone parent does not exist") + seen = {zone["zone_id"]} + while parent is not None: + if parent in seen: + raise SystemExit("body-zone hierarchy contains a cycle") + seen.add(parent) + parent = by_id[parent]["parent_zone_id"] + children = [item for item in zones if item["parent_zone_id"] == zone["zone_id"]] + if (zone["kind"] == "group") != bool(children): + raise SystemExit("body-zone group/leaf declaration disagrees with hierarchy") + +if by_id.get("full_body", {}).get("parent_zone_id") is not None or \ + by_id.get("full_body", {}).get("kind") != "standalone": + raise SystemExit("full_body must be autonomous") +if by_id.get("upper_body", {}).get("kind") != "group" or by_id.get("lower_body", {}).get("kind") != "group": + raise SystemExit("upper_body and lower_body must be groups") + +mappings = root["exercise_mappings"] +if not isinstance(mappings, list): + raise SystemExit("exercise_mappings must be a list") +seen_exercises = set() +for mapping in mappings: + if set(mapping) != {"exercise_id", "exercise_name", "primary_zone_id", "secondary_zone_ids", "decision_source"}: + raise SystemExit("invalid exercise body-zone mapping shape") + exercise_id = mapping["exercise_id"] + secondary = mapping["secondary_zone_ids"] + if not isinstance(exercise_id, str) or re.fullmatch( + r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + exercise_id, + ) is None or exercise_id in seen_exercises: + raise SystemExit("duplicate or invalid mapped exercise_id") + if not isinstance(mapping["exercise_name"], str) or not mapping["exercise_name"].strip(): + raise SystemExit("mapping lacks exercise name") + if not isinstance(mapping["primary_zone_id"], str) or \ + mapping["primary_zone_id"] not in zone_ids or \ + by_id[mapping["primary_zone_id"]]["kind"] == "group" or \ + not isinstance(secondary, list): + raise SystemExit("mapping references unknown primary zone") + if any(not isinstance(item, str) for item in secondary) or \ + len(secondary) != len(set(secondary)) or \ + any(item not in zone_ids or by_id[item]["kind"] == "group" for item in secondary): + raise SystemExit("mapping references duplicate/unknown secondary zone") + if mapping["primary_zone_id"] in secondary: + raise SystemExit("mapping repeats primary as secondary") + if not isinstance(mapping["decision_source"], str) or not mapping["decision_source"].strip(): + raise SystemExit("mapping lacks decision source") + seen_exercises.add(exercise_id) + + +def c(value): + return json.dumps(value, ensure_ascii=False) + + +with open(output, "w", encoding="utf-8") as generated: + generated.write('#include "trainlog/body_zone_catalog.h"\n#include \n\n') + generated.write("static const TrainlogBodyZone zones[] = {\n") + for zone in sorted(zones, key=lambda item: item["sort_order"]): + parent = "NULL" if zone["parent_zone_id"] is None else c(zone["parent_zone_id"]) + generated.write(" {%s, %s, %s, %d, %s},\n" % ( + c(zone["zone_id"]), c(zone["display_name"]), parent, + zone["sort_order"], "true" if zone["kind"] == "group" else "false")) + generated.write("};\nstatic const TrainlogBodyZoneInitialMapping mappings[] = {\n") + for mapping in mappings: + generated.write(" {%s, %s, %s, %s, %s},\n" % ( + c(mapping["exercise_id"]), c(mapping["exercise_name"]), + c(mapping["primary_zone_id"]), c("\n".join(mapping["secondary_zone_ids"])), + c(mapping["decision_source"]))) + generated.write("};\n") + generated.write(r''' +size_t trainlog_body_zone_catalog_count(void) { return sizeof(zones) / sizeof(zones[0]); } +const TrainlogBodyZone *trainlog_body_zone_catalog_at(size_t index) { + return index < trainlog_body_zone_catalog_count() ? &zones[index] : NULL; +} +const TrainlogBodyZone *trainlog_body_zone_catalog_lookup(const char *zone_id) { + size_t index; + if (zone_id == NULL) return NULL; + for (index = 0; index < trainlog_body_zone_catalog_count(); ++index) + if (strcmp(zones[index].zone_id, zone_id) == 0) return &zones[index]; + return NULL; +} +size_t trainlog_body_zone_catalog_children(const char *zone_id, const TrainlogBodyZone **output, size_t capacity) { + size_t index, count = 0; + if (zone_id == NULL || (capacity > 0 && output == NULL)) return 0; + for (index = 0; index < trainlog_body_zone_catalog_count(); ++index) { + if (zones[index].parent_zone_id != NULL && strcmp(zones[index].parent_zone_id, zone_id) == 0) { + if (count < capacity) output[count] = &zones[index]; + ++count; + } + } + return count; +} +size_t trainlog_body_zone_catalog_ancestors(const char *zone_id, const TrainlogBodyZone **output, size_t capacity) { + const TrainlogBodyZone *current = trainlog_body_zone_catalog_lookup(zone_id); + size_t count = 0; + if (current == NULL || (capacity > 0 && output == NULL)) return 0; + while (current->parent_zone_id != NULL) { + current = trainlog_body_zone_catalog_lookup(current->parent_zone_id); + if (current == NULL) return 0; + if (count < capacity) output[count] = current; + ++count; + } + return count; +} +bool trainlog_body_zone_catalog_is_descendant(const char *zone_id, const char *ancestor_zone_id) { + const TrainlogBodyZone *current = trainlog_body_zone_catalog_lookup(zone_id); + if (current == NULL || ancestor_zone_id == NULL) return false; + while (current->parent_zone_id != NULL) { + if (strcmp(current->parent_zone_id, ancestor_zone_id) == 0) return true; + current = trainlog_body_zone_catalog_lookup(current->parent_zone_id); + if (current == NULL) return false; + } + return false; +} +size_t trainlog_body_zone_initial_mapping_count(void) { return sizeof(mappings) / sizeof(mappings[0]); } +const TrainlogBodyZoneInitialMapping *trainlog_body_zone_initial_mapping_at(size_t index) { + return index < trainlog_body_zone_initial_mapping_count() ? &mappings[index] : NULL; +} +''') diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 5bf680a..b3aedc9 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -113,8 +113,8 @@ def main(): fail("clés extension équipement invalides") connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10): - fail("schema desktop v8, v9 ou v10 requis") + if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11): + fail("schema desktop v8 à v11 requis") known = load_catalog(args.catalog) known.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 713fea5..6a12372 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -60,8 +60,8 @@ def main(): definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog)) connection = sqlite3.connect(args.database) try: - if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10): - fail("schema desktop v8, v9 ou v10 requis") + if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11): + fail("schema desktop v8 à v11 requis") imported = skipped = 0 # Validate every same-ID row before inserting any definition. for definition in definitions: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py new file mode 100644 index 0000000..4fa3b23 --- /dev/null +++ b/tools/import_exercise_body_zones.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Atomically reconcile exercise/body-zone companion v1 with a sync baseline.""" + +import argparse +import json +import os +import re +import sqlite3 +import unicodedata +from datetime import datetime +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "catalog/body-zones-v1.json" + + +class ImportFailure(RuntimeError): + pass + + +EXERCISE_ID_PATTERN = re.compile( + r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +) + + +def default_database(): + return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db" + + +def exact(value, keys, label): + if not isinstance(value, dict) or set(value) != keys: + raise ImportFailure(f"{label}: forme invalide") + + +def load_catalog(): + root = json.loads(CATALOG.read_text(encoding="utf-8")) + if root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1: + raise ImportFailure("catalogue zones v1 invalide") + return {item["zone_id"]: item for item in root["zones"]} + + +def state(primary, secondary): + return (primary or "") + "|" + ",".join(sorted(secondary)) + + +def current(connection, row_id): + primary = connection.execute( + "SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,), + ).fetchone() + secondary = [row[0] for row in connection.execute( + "SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' ORDER BY zone_id", + (row_id,), + )] + return (None if primary is None else primary[0], secondary) + + +def normalize_name(value): + """Match the desktop importer's stable normalized-name comparison.""" + folded = unicodedata.normalize("NFC", value.casefold()) + output = [] + pending_space = False + wrote_content = False + for char in folded: + if char.isspace(): + if wrote_content: + pending_space = True + continue + if pending_space: + output.append(" ") + pending_space = False + output.append(char) + wrote_content = True + normalized = "".join(output) + if not normalized: + raise ImportFailure("preuve mobile: nom d'exercice vide") + return normalized + + +def load_mobile_exercise_proof(path): + """Load only the already-imported V2 definitions needed to prove aliases.""" + 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("exercises"), list): + raise ImportFailure("snapshot mobile V2 de preuve invalide") + proof = {} + for index, item in enumerate(payload["exercises"]): + if not isinstance(item, dict): + raise ImportFailure(f"preuve mobile exercises[{index}] invalide") + exercise_id = item.get("exercise_id") + name = item.get("name") + recording = item.get("recording_mode") + tracking = item.get("tracking_mode") + data_fields = item.get("data_fields") + if not isinstance(exercise_id, str) or not exercise_id or exercise_id in proof or \ + not isinstance(name, str) or not name or recording not in {"sets", "continuous"} or \ + tracking not in {"reps", "duration"} or isinstance(data_fields, bool) or \ + not isinstance(data_fields, int) or data_fields < 0: + raise ImportFailure(f"preuve mobile exercises[{index}] invalide") + proof[exercise_id] = (normalize_name(name), recording, tracking, data_fields) + return proof + + +def resolve_exercise_row(connection, exercise_id, mobile_proof): + row = connection.execute( + "SELECT id FROM exercises WHERE exercise_id=?", (exercise_id,), + ).fetchone() + if row is not None: + return row[0] + proof = mobile_proof.get(exercise_id) + if proof is None: + raise ImportFailure(f"exercice inconnu: {exercise_id}") + normalized, recording, tracking, data_fields = proof + row = connection.execute( + "SELECT id,recording_mode,tracking_mode,data_fields FROM exercises " + "WHERE normalized_name=?", (normalized,), + ).fetchone() + if row is None or row[1] != recording or row[2] != tracking or \ + not ((row[3] & ~data_fields) == 0 or (data_fields & ~row[3]) == 0): + raise ImportFailure(f"preuve de réconciliation invalide: {exercise_id}") + # CONTRACT: import_mobile_export.py ran first in the same sync and removed + # only a profile-compatible duplicate. The source V2 definition proves that + # this now-absent creator ID resolves to the one normalized desktop row. + return row[0] + + +def replace(connection, row_id, primary, secondary): + connection.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=?", (row_id,)) + if primary is not None: + connection.execute( + "INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (row_id, primary), + ) + connection.executemany( + "INSERT INTO exercise_body_zones VALUES(?,?,'secondary')", + [(row_id, zone_id) for zone_id in secondary], + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("--database", type=Path, default=default_database()) + parser.add_argument( + "--mobile-export", + type=Path, + help="snapshot V2 importé juste avant, utilisé seulement pour prouver un ID réconcilié", + ) + args = parser.parse_args() + zones = load_catalog() + payload = json.loads(args.input.read_text(encoding="utf-8")) + exact(payload, {"format", "version", "generated_at", "exercises"}, "racine") + if payload["format"] != "trainlog-exercise-body-zones" or payload["version"] != 1 or \ + isinstance(payload["version"], bool) or \ + not isinstance(payload["generated_at"], str) or not payload["generated_at"] or \ + not isinstance(payload["exercises"], list): + raise ImportFailure("companion zones v1 invalide") + try: + generated_at = datetime.fromisoformat(payload["generated_at"].replace("Z", "+00:00")) + except ValueError as error: + raise ImportFailure("generated_at invalide") from error + if generated_at.utcoffset() is None: + raise ImportFailure("generated_at sans offset") + parsed = [] + seen = set() + for index, item in enumerate(payload["exercises"]): + exact(item, {"exercise_id", "primary_zone_id", "secondary_zone_ids"}, f"exercises[{index}]") + exercise_id = item["exercise_id"] + primary = item["primary_zone_id"] + secondary = item["secondary_zone_ids"] + if not isinstance(exercise_id, str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or \ + exercise_id in seen: + raise ImportFailure(f"exercises[{index}].exercise_id invalide") + if primary is not None and (not isinstance(primary, str) or primary not in zones): + raise ImportFailure(f"exercises[{index}].primary_zone_id invalide") + if not isinstance(secondary, list) or any(not isinstance(zone, str) or zone not in zones for zone in secondary): + raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide") + if len(secondary) != len(set(secondary)) or primary in secondary: + raise ImportFailure(f"exercises[{index}]: zones dupliquées") + if primary is None and secondary: + raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale") + if any(zones[zone]["kind"] == "group" for zone in ([primary] if primary else []) + secondary): + raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite") + seen.add(exercise_id) + parsed.append((exercise_id, primary, sorted(secondary))) + + proof_path = args.mobile_export + if proof_path is None: + candidate = args.input.with_name("trainlog-mobile-export-v2.json") + if candidate.exists(): + proof_path = candidate + mobile_proof = load_mobile_exercise_proof(proof_path) if proof_path is not None else {} + + connection = sqlite3.connect(args.database) + updated = skipped = kept_local = 0 + resolved_rows = set() + try: + if connection.execute("PRAGMA user_version").fetchone()[0] != 11: + raise ImportFailure("schema desktop v11 requis") + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("BEGIN IMMEDIATE") + for exercise_id, primary, secondary in parsed: + row_id = resolve_exercise_row(connection, exercise_id, mobile_proof) + if row_id in resolved_rows: + raise ImportFailure(f"deux identités entrantes résolvent le même exercice: {exercise_id}") + resolved_rows.add(row_id) + local_primary, local_secondary = current(connection, row_id) + local_state = state(local_primary, local_secondary) + incoming_state = state(primary, secondary) + baseline_row = connection.execute( + "SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?", (row_id,), + ).fetchone() + baseline = None if baseline_row is None else baseline_row[0] + if local_state == incoming_state: + connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state)) + skipped += 1 + elif baseline is not None and local_state == baseline: + replace(connection, row_id, primary, secondary) + connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state)) + updated += 1 + elif baseline is not None and incoming_state == baseline: + kept_local += 1 + elif baseline is None and local_state == "|": + replace(connection, row_id, primary, secondary) + connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state)) + updated += 1 + else: + raise ImportFailure(f"conflit zones simultané: {exercise_id}") + connection.commit() + print("EXERCISE_BODY_ZONES_IMPORT=PASS") + print(f"zones_updated={updated}") + print(f"zones_skipped={skipped}") + print(f"zones_kept_local={kept_local}") + except Exception: + connection.rollback() + raise + finally: + connection.close() + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(f"EXERCISE_BODY_ZONES_IMPORT=FAIL {error}") + raise SystemExit(1) diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index d676c66..23eaa69 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -776,9 +776,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in (5, 6, 7, 8, 9, 10): + if version not in (5, 6, 7, 8, 9, 10, 11): raise ImportFailure( - f"base desktop schema v5 à v10 attendue, version trouvée: {version}" + f"base desktop schema v5 à v11 attendue, version trouvée: {version}" ) @@ -894,9 +894,49 @@ def enrich_desktop_profile(connection, row, exercise): 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. + # WHY: schema v11 adds a second exercise-row owner. Keeping this operation + # explicit prevents identity reconciliation from silently dropping a body- + # zone decision while occurrence IDs and all history remain unchanged. + has_body_zones = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_body_zones';" + ).fetchone() is not None + if has_body_zones: + def zone_state(row_id): + rows = connection.execute( + "SELECT zone_id,role FROM exercise_body_zones " + "WHERE exercise_row_id=? ORDER BY role,zone_id;", + (row_id,), + ).fetchall() + return tuple((row[0], row[1]) for row in rows) + + canonical_state = zone_state(canonical["id"]) + retired_state = zone_state(retired["id"]) + if canonical_state and retired_state and canonical_state != retired_state: + raise ImportFailure( + "conflit zones pendant réconciliation des identités " + f"{canonical['exercise_id']} et {retired['exercise_id']}" + ) + if not canonical_state and retired_state: + connection.execute( + "UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;", + (canonical["id"], retired["id"]), + ) + else: + connection.execute( + "DELETE FROM exercise_body_zones WHERE exercise_row_id=?;", + (retired["id"],), + ) + if connection.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' " + "AND name='exercise_body_zone_sync';" + ).fetchone() is not None: + # INVARIANT: a row merge is not a sync acknowledgement. Clearing + # both ancestors makes a later divergent companion conflict rather + # than treating one creator's stale baseline as shared truth. + connection.execute( + "DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);", + (canonical["id"], retired["id"]), + ) connection.execute( "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", (canonical["id"], retired["id"]), diff --git a/tools/validate_json.py b/tools/validate_json.py index 2f79c75..5c757c8 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import math +import re import sys import unicodedata from datetime import datetime @@ -27,12 +28,85 @@ ROOT = Path(__file__).resolve().parents[1] SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json" VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid" INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid" +BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json" class TrainlogSemanticError(ValueError): """Raised when structurally valid JSON violates its format semantics.""" +def validate_body_zone_catalog(document: Any) -> None: + """Validate the one canonical taxonomy, hierarchy and initial mappings.""" + if not isinstance(document, dict) or set(document) != { + "format", "version", "zones", "exercise_mappings", + } or document.get("format") != "trainlog-body-zone-catalog" or \ + document.get("version") != 1 or isinstance(document.get("version"), bool): + raise TrainlogSemanticError("body-zone catalog v1: invalid root") + zones = document["zones"] + mappings = document["exercise_mappings"] + if not isinstance(zones, list) or not zones: + raise TrainlogSemanticError("body-zone catalog v1: zones must be non-empty") + if not isinstance(mappings, list): + raise TrainlogSemanticError("body-zone catalog v1: exercise_mappings must be a list") + by_id: dict[str, dict[str, Any]] = {} + orders: set[int] = set() + for index, zone in enumerate(zones): + if not isinstance(zone, dict) or set(zone) != { + "zone_id", "display_name", "parent_zone_id", "sort_order", "kind", + }: + raise TrainlogSemanticError(f"zones[{index}]: invalid shape") + zone_id = zone["zone_id"] + order = zone["sort_order"] + if not isinstance(zone_id, str) or re.fullmatch(r"[a-z][a-z0-9_]*", zone_id) is None or \ + zone_id in by_id: + raise TrainlogSemanticError(f"zones[{index}]: duplicate/invalid zone_id") + if not isinstance(order, int) or isinstance(order, bool) or order < 0 or order in orders: + raise TrainlogSemanticError(f"zones[{index}]: duplicate/invalid sort_order") + if not isinstance(zone["display_name"], str) or not zone["display_name"].strip() or \ + zone["kind"] not in {"group", "leaf", "standalone"}: + raise TrainlogSemanticError(f"zones[{index}]: invalid metadata") + by_id[zone_id] = zone + orders.add(order) + for zone in zones: + parent = zone["parent_zone_id"] + seen = {zone["zone_id"]} + while parent is not None: + if parent not in by_id or parent in seen: + raise TrainlogSemanticError(f"zone {zone['zone_id']}: invalid/cyclic parent") + seen.add(parent) + parent = by_id[parent]["parent_zone_id"] + children = any(item["parent_zone_id"] == zone["zone_id"] for item in zones) + if (zone["kind"] == "group") != children: + raise TrainlogSemanticError(f"zone {zone['zone_id']}: kind disagrees with hierarchy") + if by_id.get("full_body", {}).get("parent_zone_id") is not None or \ + by_id.get("full_body", {}).get("kind") != "standalone" or \ + by_id.get("upper_body", {}).get("kind") != "group" or \ + by_id.get("lower_body", {}).get("kind") != "group": + raise TrainlogSemanticError("body-zone special/group contract invalid") + seen_exercises: set[str] = set() + for index, mapping in enumerate(mappings): + if not isinstance(mapping, dict) or set(mapping) != { + "exercise_id", "exercise_name", "primary_zone_id", "secondary_zone_ids", "decision_source", + }: + raise TrainlogSemanticError(f"exercise_mappings[{index}]: invalid shape") + exercise_id = mapping["exercise_id"] + primary = mapping["primary_zone_id"] + secondary = mapping["secondary_zone_ids"] + if not isinstance(exercise_id, str) or re.fullmatch( + r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + exercise_id, + ) is None or exercise_id in seen_exercises or \ + not isinstance(mapping["exercise_name"], str) or not mapping["exercise_name"].strip() or \ + not isinstance(primary, str) or primary not in by_id or by_id[primary]["kind"] == "group" or \ + not isinstance(secondary, list) or \ + any(not isinstance(item, str) or item not in by_id or by_id[item]["kind"] == "group" + for item in secondary) or len(secondary) != len(set(secondary)) or \ + primary in secondary or not isinstance(mapping["decision_source"], str) or \ + not mapping["decision_source"].strip(): + raise TrainlogSemanticError(f"exercise_mappings[{index}]: invalid relation") + seen_exercises.add(exercise_id) + + def load_json(path: Path) -> Any: """Load one UTF-8 JSON file and return its decoded value.""" with path.open("r", encoding="utf-8") as handle: @@ -309,7 +383,14 @@ def validate_document( except (OSError, json.JSONDecodeError) as exc: return [str(exc)] + is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog" is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2 + if is_body_zones: + try: + validate_body_zone_catalog(document) + except TrainlogSemanticError as exc: + return [str(exc)] + return [] if not is_mobile_v2: errors = structural_errors(validator, document) if errors: @@ -339,6 +420,15 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int: valid, invalid = discover_suite() failed = False + body_zone_errors = validate_document(validator, BODY_ZONE_CATALOG_PATH) + if body_zone_errors: + print(f"FAIL body-zone catalog: {BODY_ZONE_CATALOG_PATH}") + for error in body_zone_errors: + print(f" {error}") + failed = True + else: + print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}") + if not valid: print("FAIL test suite: no valid fixtures found") return 1 diff --git a/tui/include/trainlog/body_zone_catalog.h b/tui/include/trainlog/body_zone_catalog.h new file mode 100644 index 0000000..ecd354c --- /dev/null +++ b/tui/include/trainlog/body_zone_catalog.h @@ -0,0 +1,50 @@ +#ifndef TRAINLOG_BODY_ZONE_CATALOG_H +#define TRAINLOG_BODY_ZONE_CATALOG_H + +#include +#include + +/* CONTRACT: strings are generated from catalog/body-zones-v1.json and are + * borrowed for process lifetime. Stable zone_id values, never display names, + * cross persistence and synchronization boundaries. */ +typedef struct TrainlogBodyZone { + const char *zone_id; + const char *display_name; + const char *parent_zone_id; + int sort_order; + bool is_group; +} TrainlogBodyZone; + +typedef struct TrainlogBodyZoneInitialMapping { + const char *exercise_id; + const char *exercise_name; + const char *primary_zone_id; + const char *secondary_zone_ids; /* newline-separated canonical IDs */ + const char *decision_source; +} TrainlogBodyZoneInitialMapping; + +/* All returned catalogue pointers are borrowed and remain valid for process + * lifetime. A zero count from children/ancestors is either an empty relation + * or invalid input; callers can distinguish unknown IDs with lookup(). */ +size_t trainlog_body_zone_catalog_count(void); +const TrainlogBodyZone *trainlog_body_zone_catalog_at(size_t index); +const TrainlogBodyZone *trainlog_body_zone_catalog_lookup(const char *zone_id); +size_t trainlog_body_zone_catalog_children( + const char *zone_id, + const TrainlogBodyZone **output, + size_t capacity +); +size_t trainlog_body_zone_catalog_ancestors( + const char *zone_id, + const TrainlogBodyZone **output, + size_t capacity +); +bool trainlog_body_zone_catalog_is_descendant( + const char *zone_id, + const char *ancestor_zone_id +); +/* Initial mappings are migration evidence, not a mutable runtime catalogue. */ +size_t trainlog_body_zone_initial_mapping_count(void); +const TrainlogBodyZoneInitialMapping *trainlog_body_zone_initial_mapping_at(size_t index); + +#endif diff --git a/tui/include/trainlog/catalog.h b/tui/include/trainlog/catalog.h index e2630c8..f02ef99 100644 --- a/tui/include/trainlog/catalog.h +++ b/tui/include/trainlog/catalog.h @@ -49,4 +49,19 @@ TrainlogStatus trainlog_catalog_create_exercise_profiled( TrainlogExercise *output_exercise ); +/* CONTRACT: creation and its zone relations commit as one user operation. + * A NULL primary is reserved for explicit unclassified/historic workflows; + * interactive strength creation supplies one assignable manifest zone. */ +TrainlogStatus trainlog_catalog_create_exercise_profiled_with_zones( + TrainlogDatabase *database, + const char *name, + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count, + TrainlogExercise *output_exercise +); + #endif diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 7329190..be80f17 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 10 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 11 typedef struct TrainlogDatabase TrainlogDatabase; @@ -138,6 +138,77 @@ TrainlogStatus trainlog_database_list_exercises( size_t *output_count ); +/** + * @brief Atomically replace one exercise's direct body-zone relations. + * + * @param primary_zone_id NULL or empty preserves the explicitly allowed + * unclassified state, which requires secondary_count == 0. Group zones + * cannot be assigned because their descendant relationship is derived + * from the canonical manifest. + * @param secondary_zone_ids Caller-owned array borrowed for this call only. + * @param secondary_count Bounded by the number of canonical manifest zones. + * @return INVALID_ARGUMENT for unknown/group/duplicate zones, NOT_FOUND for + * an unknown exercise, or the explicit persistence status. + */ +TrainlogStatus trainlog_database_replace_exercise_body_zones( + TrainlogDatabase *database, + const char *exercise_id, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count +); + +/* NOT_FOUND distinguishes an unknown exercise identity from the valid empty + * relation set. Output is caller-owned; output_count reports required capacity + * and no relation is truncated as a successful result. */ +TrainlogStatus trainlog_database_list_exercise_body_zones( + TrainlogDatabase *database, + const char *exercise_id, + TrainlogExerciseBodyZone *output, + size_t capacity, + size_t *output_count +); + +/* CONTRACT: the caller owns output storage; output_count reports the required + * count and INVALID_ARGUMENT is returned when capacity is insufficient. + * Filtering combines a normalized name prefix with either one + * direct zone, its manifest descendants, or the explicit unclassified state. + * `primary_only` excludes secondary participation without changing storage. + * zone_id and unclassified_only are mutually exclusive. + * The resulting exercise IDs compose with list_exercise_body_zones() and + * list_exercise_performance(); the latter exposes newest history and explicit + * MAX without duplicating either datum for a future session generator. */ +TrainlogStatus trainlog_database_list_exercises_filtered( + TrainlogDatabase *database, + const char *normalized_prefix, + const char *zone_id, + bool include_descendants, + bool primary_only, + bool unclassified_only, + TrainlogExercise *output, + size_t capacity, + size_t *output_count +); + +/* CONTRACT: name/profile/direct zones are one transaction and all string/list + * inputs are borrowed only for the duration of the call. A profile change is + * rejected once completed history references the exercise; a same-profile + * rename or zone replacement preserves exercise_id and history. Zone rules + * match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND; + * invalid metadata and name collisions remain explicit errors. */ +TrainlogStatus trainlog_database_update_exercise_profiled( + TrainlogDatabase *database, + const char *exercise_id, + const char *name, + const char *normalized_name, + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count +); + TrainlogStatus trainlog_database_insert_session( TrainlogDatabase *database, const TrainlogSessionInput *session diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index e249bd1..0c22631 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -14,6 +14,7 @@ #define TRAINLOG_NAME_MAX 200U #define TRAINLOG_TIMESTAMP_MAX 40U #define TRAINLOG_NOTE_MAX 4000U +#define TRAINLOG_ZONE_ID_MAX 64U typedef enum TrainlogTrackingMode { TRAINLOG_TRACKING_REPS = 0, @@ -52,6 +53,19 @@ typedef struct TrainlogExercise { TrainlogExerciseDataFields data_fields; } TrainlogExercise; +typedef enum TrainlogBodyZoneRole { + TRAINLOG_BODY_ZONE_PRIMARY = 0, + TRAINLOG_BODY_ZONE_SECONDARY +} TrainlogBodyZoneRole; + +/* CONTRACT: zone_id is a stable manifest identity. Database readers copy + * relations into caller-owned fixed-width records; display names never cross + * this persistence API. */ +typedef struct TrainlogExerciseBodyZone { + char zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + TrainlogBodyZoneRole role; +} TrainlogExerciseBodyZone; + typedef struct TrainlogSetInput { int reps; int duration_seconds; diff --git a/tui/meson.build b/tui/meson.build index 3dc5e93..d9df4cb 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -23,6 +23,13 @@ equipment_catalog_generated = custom_target( command: [find_program('python3'), meson.project_source_root() / 'tools/generate_equipment_catalog.py', '@INPUT@', '@OUTPUT@'], ) +body_zone_catalog_generated = custom_target( + 'body_zone_catalog_generated', + input: meson.project_source_root() / 'catalog/body-zones-v1.json', + output: 'body_zone_catalog_generated.c', + command: [find_program('python3'), meson.project_source_root() / 'tools/generate_body_zone_catalog.py', '@INPUT@', '@OUTPUT@'], +) + strict_c_args = [ '-D_POSIX_C_SOURCE=200809L', '-Wconversion', @@ -46,6 +53,7 @@ trainlog_core_sources = files( 'src/sync_history.c', ) trainlog_core_sources += equipment_catalog_generated +trainlog_core_sources += body_zone_catalog_generated trainlog_core = static_library( 'trainlog_core', @@ -126,6 +134,14 @@ test_equipment_catalog = executable( ) test('equipment_catalog', test_equipment_catalog) +test_body_zones = executable( + 'test_body_zones', + 'tests/test_body_zones.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('body_zones', test_body_zones) + test_custom_equipment = executable( 'test_custom_equipment', 'tests/test_custom_equipment.c', @@ -421,6 +437,18 @@ test( args: [meson.project_source_root() / 'tests/test_exercise_reconciliation.py'], ) +test( + 'body_zone_sync', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_body_zone_sync.py'], +) + +test( + 'body_zone_catalog_validation', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_body_zone_catalog.py'], +) + test_sync_direction = executable( 'test_sync_direction', 'tests/test_sync_direction.c', diff --git a/tui/src/catalog.c b/tui/src/catalog.c index 59558b1..461268f 100644 --- a/tui/src/catalog.c +++ b/tui/src/catalog.c @@ -212,6 +212,55 @@ TrainlogStatus trainlog_catalog_create_exercise_profiled( return TRAINLOG_STATUS_OK; } +TrainlogStatus trainlog_catalog_create_exercise_profiled_with_zones( + TrainlogDatabase *database, + const char *name, + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count, + TrainlogExercise *output_exercise +) +{ + char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U]; + char exercise_id[TRAINLOG_GENERATED_ID_CAPACITY]; + TrainlogStatus status; + if (database == NULL || name == NULL || output_exercise == NULL || + strlen(name) > TRAINLOG_NAME_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = trainlog_catalog_normalize_name(name, normalized, sizeof(normalized)); + if (status != TRAINLOG_STATUS_OK) return status; + status = trainlog_id_generate("ex", exercise_id, sizeof(exercise_id)); + if (status != TRAINLOG_STATUS_OK) return status; + status = trainlog_database_begin(database); + if (status != TRAINLOG_STATUS_OK) return status; + status = trainlog_database_insert_exercise_profiled(database, exercise_id, + name, normalized, tracking_mode, recording_mode, data_fields); + if (status == TRAINLOG_STATUS_OK) { + status = trainlog_database_replace_exercise_body_zones(database, + exercise_id, primary_zone_id, secondary_zone_ids, secondary_count); + } + if (status == TRAINLOG_STATUS_OK) { + status = trainlog_database_commit(database); + if (status != TRAINLOG_STATUS_OK) { + (void)trainlog_database_rollback(database); + } + } else { + (void)trainlog_database_rollback(database); + } + if (status != TRAINLOG_STATUS_OK) return status; + (void)memset(output_exercise, 0, sizeof(*output_exercise)); + (void)snprintf(output_exercise->exercise_id, + sizeof(output_exercise->exercise_id), "%s", exercise_id); + (void)snprintf(output_exercise->name, + sizeof(output_exercise->name), "%s", name); + output_exercise->tracking_mode = tracking_mode; + output_exercise->recording_mode = recording_mode; + output_exercise->data_fields = data_fields; + return TRAINLOG_STATUS_OK; +} + TrainlogStatus trainlog_catalog_create_exercise( TrainlogDatabase *database, const char *name, diff --git a/tui/src/database.c b/tui/src/database.c index de8b6a6..1612bc8 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -4,6 +4,7 @@ */ #include "trainlog/database.h" +#include "trainlog/body_zone_catalog.h" #include "trainlog/duration.h" #include "trainlog/equipment_catalog.h" #include "trainlog/id.h" @@ -20,6 +21,12 @@ struct TrainlogDatabase { sqlite3 *connection; }; +static TrainlogStatus lookup_exercise_row_id( + TrainlogDatabase *database, + const char *exercise_id, + sqlite3_int64 *output_row_id +); + static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id) { sqlite3_stmt *statement = NULL; @@ -321,6 +328,29 @@ static const char *const MIGRATE_V9_TO_V10_SQL = "COMMIT;" "PRAGMA foreign_keys = ON;"; +/* + * WHY: one text column cannot preserve a primary plus multiple secondary + * zones, and parent groups are derivable catalogue metadata. + * CONTRACT: v11 is additive and touches no exercise/session/history identity. + * INVARIANT: the primary key forbids duplicate roles for one zone and the + * partial unique index permits at most one primary relation per exercise. + */ +static const char *const CREATE_BODY_ZONE_RELATIONS_SQL = + "CREATE TABLE exercise_body_zones(" + "exercise_row_id INTEGER NOT NULL " + "REFERENCES exercises(id) ON DELETE CASCADE," + "zone_id TEXT NOT NULL," + "role TEXT NOT NULL CHECK(role IN('primary','secondary'))," + "PRIMARY KEY(exercise_row_id,zone_id)" + ");" + "CREATE UNIQUE INDEX exercise_body_zones_one_primary " + "ON exercise_body_zones(exercise_row_id) WHERE role='primary';" + "CREATE TABLE exercise_body_zone_sync(" + "exercise_row_id INTEGER PRIMARY KEY " + "REFERENCES exercises(id) ON DELETE CASCADE," + "synced_state TEXT NOT NULL" + ");"; + static const char *const MIGRATE_V1_TO_V3_SQL = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -619,6 +649,93 @@ static TrainlogStatus execute_sql( return TRAINLOG_STATUS_OK; } +static bool bind_initial_body_zone( + sqlite3_stmt *statement, + const char *exercise_id, + const char *zone_id, + const char *role +) +{ + int rc; + if (sqlite3_reset(statement) != SQLITE_OK || + sqlite3_clear_bindings(statement) != SQLITE_OK || + sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) { + return false; + } + rc = sqlite3_step(statement); + return rc == SQLITE_DONE; +} + +static TrainlogStatus migrate_v10_to_v11(TrainlogDatabase *database) +{ + static const char *const INSERT_SQL = + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) " + "SELECT id,?2,?3 FROM exercises WHERE exercise_id=?1;"; + sqlite3_stmt *statement = NULL; + size_t index; + TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR; + + if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK || + execute_sql(database, CREATE_BODY_ZONE_RELATIONS_SQL) != TRAINLOG_STATUS_OK || + sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK) { + goto rollback; + } + + for (index = 0U; index < trainlog_body_zone_initial_mapping_count(); ++index) { + const TrainlogBodyZoneInitialMapping *mapping = + trainlog_body_zone_initial_mapping_at(index); + const char *cursor; + if (mapping == NULL || + !bind_initial_body_zone(statement, mapping->exercise_id, + mapping->primary_zone_id, "primary")) { + goto rollback; + } + cursor = mapping->secondary_zone_ids; + while (cursor != NULL && cursor[0] != '\0') { + const char *end = strchr(cursor, '\n'); + size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor); + char zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + if (length == 0U || length > TRAINLOG_ZONE_ID_MAX) goto rollback; + (void)memcpy(zone_id, cursor, length); + zone_id[length] = '\0'; + if (!bind_initial_body_zone(statement, mapping->exercise_id, + zone_id, "secondary")) { + goto rollback; + } + cursor = end == NULL ? NULL : end + 1; + } + } + + /* CONTRACT: synced_state is an internal comparison baseline, not domain + * data. `primary|secondary,...` is deterministic because zone IDs exclude + * delimiters and secondary IDs are sorted bytewise. */ + if (execute_sql(database, + "INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) " + "SELECT e.id,COALESCE((SELECT p.zone_id FROM exercise_body_zones p " + "WHERE p.exercise_row_id=e.id AND p.role='primary'),'')||'|'||" + "COALESCE((SELECT group_concat(s.zone_id,',') FROM " + "(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=e.id " + "AND role='secondary' ORDER BY zone_id) s),'') FROM exercises e;" + ) != TRAINLOG_STATUS_OK) goto rollback; + + if (sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + goto rollback; + } + statement = NULL; + if (execute_sql(database, "PRAGMA user_version = 11;COMMIT;") != TRAINLOG_STATUS_OK) { + goto rollback; + } + return TRAINLOG_STATUS_OK; + +rollback: + if (statement != NULL) (void)sqlite3_finalize(statement); + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + return status; +} + static TrainlogStatus read_single_int_pragma( TrainlogDatabase *database, const char *sql, @@ -748,6 +865,8 @@ static TrainlogStatus initialize_or_validate_schema( } } else if (version == 9) { status = execute_sql(database, MIGRATE_V9_TO_V10_SQL); + } else if (version == 10) { + status = TRAINLOG_STATUS_OK; } else { if (version == 1) { status = @@ -880,6 +999,10 @@ static TrainlogStatus initialize_or_validate_schema( } } + if (status == TRAINLOG_STATUS_OK) { + status = migrate_v10_to_v11(database); + } + if ( status != TRAINLOG_STATUS_OK @@ -887,7 +1010,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v10" : "migrate database to schema v10", + version == 0 ? "create schema v11" : "migrate database to schema v11", database->connection, SQLITE_ERROR ); @@ -1389,6 +1512,117 @@ TrainlogStatus trainlog_database_insert_exercise( ); } +TrainlogStatus trainlog_database_update_exercise_profiled( + TrainlogDatabase *database, + const char *exercise_id, + const char *name, + const char *normalized_name, + TrainlogTrackingMode tracking_mode, + TrainlogRecordingMode recording_mode, + TrainlogExerciseDataFields data_fields, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count +) +{ + sqlite3_stmt *statement = NULL; + sqlite3_int64 row_id; + TrainlogStatus status; + int rc; + bool profile_changed; + const char *tracking; + const char *recording; + + if (database == NULL || exercise_id == NULL || name == NULL || + normalized_name == NULL || name[0] == '\0' || normalized_name[0] == '\0' || + strlen(name) > TRAINLOG_NAME_MAX || + !exercise_profile_valid(tracking_mode, recording_mode, data_fields)) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + tracking = tracking_mode_to_sql(tracking_mode); + recording = recording_mode_to_sql(recording_mode); + status = lookup_exercise_row_id(database, exercise_id, &row_id); + if (status != TRAINLOG_STATUS_OK) return status; + if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + + rc = sqlite3_prepare_v2(database->connection, + "SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;", + -1, &statement, NULL); + if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + profile_changed = strcmp((const char *)sqlite3_column_text(statement, 0), tracking) != 0 || + strcmp((const char *)sqlite3_column_text(statement, 1), recording) != 0 || + sqlite3_column_int64(statement, 2) != (sqlite3_int64)data_fields; + if (sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + statement = NULL; + if (profile_changed) { + rc = sqlite3_prepare_v2(database->connection, + "SELECT 1 FROM session_exercises WHERE exercise_row_id=?1 LIMIT 1;", + -1, &statement, NULL); + if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + rc = sqlite3_step(statement); + if (rc == SQLITE_ROW) { + status = TRAINLOG_STATUS_CONFLICT; + goto rollback; + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + statement = NULL; + } + rc = sqlite3_prepare_v2(database->connection, + "UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3," + "recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL); + if (rc != SQLITE_OK || + sqlite3_bind_text(statement, 1, name, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, tracking, -1, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_text(statement, 4, recording, -1, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_int64(statement, 5, (sqlite3_int64)data_fields) != SQLITE_OK || + sqlite3_bind_int64(statement, 6, row_id) != SQLITE_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + rc = sqlite3_step(statement); + if (rc != SQLITE_DONE) { + status = rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : + TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + if (sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto rollback; + } + statement = NULL; + status = trainlog_database_replace_exercise_body_zones(database, exercise_id, + primary_zone_id, secondary_zone_ids, secondary_count); + if (status != TRAINLOG_STATUS_OK) goto rollback; + if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) + { + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return TRAINLOG_STATUS_OK; + +rollback: + if (statement != NULL) (void)sqlite3_finalize(statement); + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + return status; +} + static TrainlogStatus count_query( TrainlogDatabase *database, const char *sql, @@ -1662,6 +1896,292 @@ static TrainlogStatus lookup_exercise_row_id( : TRAINLOG_STATUS_DATABASE_ERROR; } +static bool assignable_body_zone(const char *zone_id) +{ + const TrainlogBodyZone *zone; + if (zone_id == NULL || zone_id[0] == '\0' || + strlen(zone_id) > TRAINLOG_ZONE_ID_MAX) return false; + zone = trainlog_body_zone_catalog_lookup(zone_id); + return zone != NULL && !zone->is_group; +} + +static TrainlogStatus insert_body_zone_relation( + TrainlogDatabase *database, + sqlite3_int64 exercise_row_id, + const char *zone_id, + const char *role +) +{ + sqlite3_stmt *statement = NULL; + int rc = sqlite3_prepare_v2(database->connection, + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) " + "VALUES(?1,?2,?3);", -1, &statement, NULL); + if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR; + if (sqlite3_bind_int64(statement, 1, exercise_row_id) != SQLITE_OK || + sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + rc = sqlite3_step(statement); + (void)sqlite3_finalize(statement); + return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : + rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT : + TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_replace_exercise_body_zones( + TrainlogDatabase *database, + const char *exercise_id, + const char *primary_zone_id, + const char *const *secondary_zone_ids, + size_t secondary_count +) +{ + sqlite3_int64 row_id; + sqlite3_stmt *statement = NULL; + bool owns_transaction; + size_t index; + TrainlogStatus status; + + if (database == NULL || database->connection == NULL || exercise_id == NULL || + exercise_id[0] == '\0' || secondary_count > trainlog_body_zone_catalog_count() || + (secondary_count > 0U && secondary_zone_ids == NULL) || + (secondary_count > 0U && + (primary_zone_id == NULL || primary_zone_id[0] == '\0')) || + (primary_zone_id != NULL && primary_zone_id[0] != '\0' && + !assignable_body_zone(primary_zone_id))) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (index = 0U; index < secondary_count; ++index) { + size_t earlier; + if (!assignable_body_zone(secondary_zone_ids[index]) || + (primary_zone_id != NULL && strcmp(primary_zone_id, secondary_zone_ids[index]) == 0)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (earlier = 0U; earlier < index; ++earlier) { + if (strcmp(secondary_zone_ids[earlier], secondary_zone_ids[index]) == 0) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + } + status = lookup_exercise_row_id(database, exercise_id, &row_id); + if (status != TRAINLOG_STATUS_OK) return status; + + owns_transaction = sqlite3_get_autocommit(database->connection) != 0; + if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + if (sqlite3_prepare_v2(database->connection, + "DELETE FROM exercise_body_zones WHERE exercise_row_id=?1;", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_DONE) { + if (statement != NULL) (void)sqlite3_finalize(statement); + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finish; + } + if (sqlite3_finalize(statement) != SQLITE_OK) { + statement = NULL; + status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finish; + } + statement = NULL; + if (primary_zone_id != NULL && primary_zone_id[0] != '\0') { + status = insert_body_zone_relation(database, row_id, primary_zone_id, "primary"); + if (status != TRAINLOG_STATUS_OK) goto finish; + } + for (index = 0U; index < secondary_count; ++index) { + status = insert_body_zone_relation(database, row_id, + secondary_zone_ids[index], "secondary"); + if (status != TRAINLOG_STATUS_OK) goto finish; + } + status = TRAINLOG_STATUS_OK; + +finish: + if (owns_transaction) { + if (status == TRAINLOG_STATUS_OK) { + if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) { + status = TRAINLOG_STATUS_DATABASE_ERROR; + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + } + } else { + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + } + } + return status; +} + +TrainlogStatus trainlog_database_list_exercise_body_zones( + TrainlogDatabase *database, + const char *exercise_id, + TrainlogExerciseBodyZone *output, + size_t capacity, + size_t *output_count +) +{ + sqlite3_stmt *statement = NULL; + sqlite3_int64 row_id; + size_t count = 0U; + bool has_primary = false; + TrainlogStatus status; + int rc; + if (database == NULL || exercise_id == NULL || output_count == NULL || + (capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; + status = lookup_exercise_row_id(database, exercise_id, &row_id); + if (status != TRAINLOG_STATUS_OK) return status; + rc = sqlite3_prepare_v2(database->connection, + "SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1 " + "ORDER BY CASE role WHEN 'primary' THEN 0 ELSE 1 END,zone_id;", + -1, &statement, NULL); + if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) { + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + const char *zone_id = (const char *)sqlite3_column_text(statement, 0); + const char *role = (const char *)sqlite3_column_text(statement, 1); + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(zone_id); + if (zone_id == NULL || role == NULL || + zone == NULL || zone->is_group || + (strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (count < capacity) { + (void)memset(&output[count], 0, sizeof(output[count])); + (void)snprintf(output[count].zone_id, sizeof(output[count].zone_id), + "%s", zone_id); + output[count].role = strcmp(role, "primary") == 0 + ? TRAINLOG_BODY_ZONE_PRIMARY : TRAINLOG_BODY_ZONE_SECONDARY; + } + if (strcmp(role, "primary") == 0) has_primary = true; + ++count; + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + /* INVARIANT: the explicitly unclassified state has no relations. A + * secondary-only raw SQLite state is corruption, not partial metadata. */ + if (count > 0U && !has_primary) return TRAINLOG_STATUS_DATABASE_ERROR; + *output_count = count; + return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK; +} + +static bool exercise_row_matches_body_zone( + TrainlogDatabase *database, + sqlite3_int64 row_id, + const char *zone_id, + bool include_descendants, + bool primary_only, + bool unclassified_only, + bool *output_match +) +{ + sqlite3_stmt *statement = NULL; + int rc; + bool has_relation = false; + bool has_primary = false; + bool match = false; + if (sqlite3_prepare_v2(database->connection, + "SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1;", + -1, &statement, NULL) != SQLITE_OK || + sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) { + if (statement != NULL) (void)sqlite3_finalize(statement); + return false; + } + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + const char *candidate = (const char *)sqlite3_column_text(statement, 0); + const char *role = (const char *)sqlite3_column_text(statement, 1); + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(candidate); + if (candidate == NULL || role == NULL || + zone == NULL || zone->is_group || + (strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) { + (void)sqlite3_finalize(statement); + return false; + } + has_relation = true; + if (strcmp(role, "primary") == 0) has_primary = true; + if (!unclassified_only && + (!primary_only || strcmp(role, "primary") == 0) && + (strcmp(candidate, zone_id) == 0 || + (include_descendants && + trainlog_body_zone_catalog_is_descendant(candidate, zone_id)))) { + match = true; + } + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || + (has_relation && !has_primary)) return false; + *output_match = unclassified_only ? !has_relation : match; + return true; +} + +TrainlogStatus trainlog_database_list_exercises_filtered( + TrainlogDatabase *database, + const char *normalized_prefix, + const char *zone_id, + bool include_descendants, + bool primary_only, + bool unclassified_only, + TrainlogExercise *output, + size_t capacity, + size_t *output_count +) +{ + static const char *const SQL = + "SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name " + "FROM exercises ORDER BY name COLLATE NOCASE,exercise_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + size_t prefix_length; + int rc; + if (database == NULL || normalized_prefix == NULL || output_count == NULL || + (capacity > 0U && output == NULL) || + (unclassified_only && zone_id != NULL) || + (!unclassified_only && zone_id != NULL && + trainlog_body_zone_catalog_lookup(zone_id) == NULL)) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + prefix_length = strlen(normalized_prefix); + *output_count = 0U; + if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + sqlite3_int64 row_id = sqlite3_column_int64(statement, 0); + const char *normalized = (const char *)sqlite3_column_text(statement, 6); + bool matches = zone_id == NULL && !unclassified_only; + sqlite3_int64 data_fields = sqlite3_column_int64(statement, 5); + if (normalized == NULL || strncmp(normalized, normalized_prefix, prefix_length) != 0) + continue; + if (!matches && !exercise_row_matches_body_zone(database, row_id, + zone_id != NULL ? zone_id : "", include_descendants, + primary_only, unclassified_only, &matches)) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (!matches) continue; + if (data_fields < 0 || (uint64_t)data_fields > UINT32_MAX) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (count < capacity) { + TrainlogExercise *item = &output[count]; + (void)memset(item, 0, sizeof(*item)); + (void)snprintf(item->exercise_id, sizeof(item->exercise_id), "%s", + (const char *)sqlite3_column_text(statement, 1)); + (void)snprintf(item->name, sizeof(item->name), "%s", + (const char *)sqlite3_column_text(statement, 2)); + item->tracking_mode = tracking_mode_from_sql( + (const char *)sqlite3_column_text(statement, 3)); + item->recording_mode = recording_mode_from_sql( + (const char *)sqlite3_column_text(statement, 4)); + item->data_fields = (TrainlogExerciseDataFields)data_fields; + } + ++count; + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + *output_count = count; + return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK; +} + static TrainlogStatus lookup_session_row_id( TrainlogDatabase *database, const char *session_id, diff --git a/tui/src/sync.c b/tui/src/sync.c index cdf23bb..02cc453 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -49,6 +49,9 @@ static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME = static const char *const PC_EQUIPMENT_DEFINITIONS_NAME = "trainlog-pc-equipment-definitions-v1.json"; +static const char *const EXERCISE_BODY_ZONES_NAME = + "trainlog-exercise-body-zones-v1.json"; + static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; @@ -76,6 +79,12 @@ static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL = static const char *const EQUIPMENT_DEFINITIONS_RESULT = "/tmp/trainlog-equipment-definitions-result.txt"; +static const char *const EXERCISE_BODY_ZONES_LOCAL = + "/tmp/trainlog-exercise-body-zones-v1.json"; + +static const char *const EXERCISE_BODY_ZONES_RESULT = + "/tmp/trainlog-exercise-body-zones-result.txt"; + static const char *const SYNC_REQUEST_LOCAL = "/tmp/trainlog-sync-request-v1.json"; @@ -2705,8 +2714,14 @@ TrainlogStatus trainlog_sync_run( } if (require_request) { + /* WHY: MediaStore cannot always reopen an older MTP-created object by + * canonical name, so Android may publish the next request as the exact + * scoped-storage collision form " (N).json". CONTRACT: select the + * newest canonical-or-suffixed request deterministically, exactly as + * for the other Android-originated artifacts; request_id replay + * protection below remains the trigger authority. */ status = - sync_receive_named( + sync_receive_current_android_artifact( &device, folder_id, SYNC_REQUEST_NAME, @@ -2941,6 +2956,36 @@ TrainlogStatus trainlog_sync_run( output ); + /* CONTRACT: body zones are one directional-neutral companion. Historic + * V2 publishers may omit it; when present it is applied only after the + * exercise definitions above established every ID or a source-V2-proven + * normalized-name alias. The helper consumes the retained mobile snapshot + * solely as reconciliation proof; it never infers identity from a name. */ + status = mobile_export_is_v2 + ? sync_receive_current_android_artifact(&device, folder_id, + EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size) + : TRAINLOG_STATUS_NOT_FOUND; + if (status == TRAINLOG_STATUS_OK) { + status = sync_run_python_tool("import_exercise_body_zones.py", + EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + sync_compose_diagnostic(output->error, sizeof(output->error), + "Android→PC : zones corporelles : ", + useful[0] != '\0' ? useful : "import échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + goto finalize; + } + } else if (status != TRAINLOG_STATUS_NOT_FOUND) { + (void)snprintf(output->error, sizeof(output->error), + "Android→PC : lecture zones corporelles échouée."); + final_status = status; + goto finalize; + } + /* 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 @@ -3071,6 +3116,42 @@ outbound: goto finalize; } + status = sync_run_python_tool("export_exercise_body_zones.py", + EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : export zones corporelles échoué."); + final_status = TRAINLOG_STATUS_SYSTEM_ERROR; + goto finalize; + } + status = sync_publish_named(&device, folder_id, EXERCISE_BODY_ZONES_LOCAL, + EXERCISE_BODY_ZONES_NAME); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(output->error, sizeof(output->error), + "PC→Android : publication zones corporelles échouée."); + final_status = status; + goto finalize; + } + /* WHY: a new custom exercise has no common sync ancestor yet. Only after + * the exact companion is durably visible to the peer may the publisher + * acknowledge that snapshot as its baseline. Reusing the strict importer + * records equal state and can never union secondary zones. */ + status = sync_run_python_tool("import_exercise_body_zones.py", + EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT, + tool_output, sizeof(tool_output)); + if (status != TRAINLOG_STATUS_OK || + strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) { + char useful[TRAINLOG_SYNC_ERROR_MAX + 1U]; + sync_last_nonempty_line(tool_output, useful, sizeof(useful)); + sync_compose_diagnostic(output->error, sizeof(output->error), + "PC→Android : baseline zones corporelles : ", + useful[0] != '\0' ? useful : "enregistrement échoué"); + final_status = TRAINLOG_STATUS_DATABASE_ERROR; + 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)); diff --git a/tui/src/tui.c b/tui/src/tui.c index a51a680..28641f0 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -26,6 +26,7 @@ #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" +#include "trainlog/body_zone_catalog.h" #include "trainlog/catalog.h" #include "trainlog/duration.h" #include "trainlog/equipment_catalog.h" @@ -45,6 +46,7 @@ #define MAX_SETS_PER_EXERCISE 64U #define MAX_SESSIONS 128U #define MAX_WEIGHT_POINTS 256U +#define MAX_BODY_ZONES 16U #define MAX_BODY_METRIC_POINTS 256U @@ -2205,9 +2207,182 @@ static void section_scrollbar( ); } +static bool secondary_zone_contains( + char secondary[][TRAINLOG_ZONE_ID_MAX + 1U], + size_t count, + const char *zone_id, + size_t *output_index +) +{ + size_t index; + for (index = 0U; index < count; ++index) { + if (strcmp(secondary[index], zone_id) == 0) { + if (output_index != NULL) *output_index = index; + return true; + } + } + return false; +} + +/* WHY: terminal users select translated catalogue rows; accepting raw IDs + * would leak wire identity into product behavior and permit unknown values. */ +static bool choose_body_zones( + char primary[TRAINLOG_ZONE_ID_MAX + 1U], + char secondary[][TRAINLOG_ZONE_ID_MAX + 1U], + size_t *secondary_count, + bool require_primary +) +{ + size_t selected = 0U; + size_t count = trainlog_body_zone_catalog_count(); + if (primary == NULL || secondary == NULL || secondary_count == NULL || + count == 0U || count > MAX_BODY_ZONES || *secondary_count > MAX_BODY_ZONES) + return false; + for (;;) { + size_t index; + int key; + draw_shell("Zones corporelles", + "↑↓ naviguer p principale Espace secondaire n non renseignée Entrée valider Échap annuler"); + for (index = 0U; index < count; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); + bool is_primary = zone != NULL && strcmp(primary, zone->zone_id) == 0; + bool is_secondary = zone != NULL && secondary_zone_contains( + secondary, *secondary_count, zone->zone_id, NULL); + if (zone == NULL) continue; + if (index == selected) + trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); + trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, + " %s%-27s %s ", zone->parent_zone_id != NULL ? " ↳ " : "", + zone->display_name, + zone->is_group ? "[groupe]" : is_primary ? "[principale]" : + is_secondary ? "[secondaire]" : "[ ]"); + if (index == selected) + trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); + } + if (primary[0] == '\0') + trainlog_terminal_printf(tui_terminal, 4 + (int)count + 1, 4, + "Zone principale : Non renseignée"); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 27) return false; + if (key == TRAINLOG_KEY_UP) + selected = selected > 0U ? selected - 1U : count - 1U; + else if (key == TRAINLOG_KEY_DOWN) + selected = selected + 1U < count ? selected + 1U : 0U; + else if (key == 'n' || key == 'N') { + primary[0] = '\0'; + *secondary_count = 0U; + } + else if (key == 'p' || key == 'P') { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected); + size_t secondary_index; + if (zone != NULL && !zone->is_group) { + (void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id); + if (secondary_zone_contains(secondary, *secondary_count, + zone->zone_id, &secondary_index)) { + size_t move; + for (move = secondary_index; move + 1U < *secondary_count; ++move) + (void)memcpy(secondary[move], secondary[move + 1U], + sizeof(secondary[move])); + --*secondary_count; + } + } + } else if (key == ' ') { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected); + size_t secondary_index; + if (zone != NULL && primary[0] != '\0' && !zone->is_group && + strcmp(primary, zone->zone_id) != 0) { + if (secondary_zone_contains(secondary, *secondary_count, + zone->zone_id, &secondary_index)) { + size_t move; + for (move = secondary_index; move + 1U < *secondary_count; ++move) + (void)memcpy(secondary[move], secondary[move + 1U], + sizeof(secondary[move])); + --*secondary_count; + } else if (*secondary_count < MAX_BODY_ZONES) { + (void)snprintf(secondary[*secondary_count], + TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id); + ++*secondary_count; + } + } + } else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + if (require_primary && primary[0] == '\0') { + status_line("Une zone principale est requise.", TRAINLOG_COLOR_ERROR); + continue; + } + return true; + } + } +} + +static bool load_exercise_body_zones( + TrainlogDatabase *database, + const char *exercise_id, + char primary[TRAINLOG_ZONE_ID_MAX + 1U], + char secondary[][TRAINLOG_ZONE_ID_MAX + 1U], + size_t *secondary_count +) +{ + TrainlogExerciseBodyZone relations[MAX_BODY_ZONES]; + size_t count = 0U; + size_t index; + primary[0] = '\0'; + *secondary_count = 0U; + if (trainlog_database_list_exercise_body_zones(database, exercise_id, + relations, MAX_BODY_ZONES, &count) != TRAINLOG_STATUS_OK) return false; + for (index = 0U; index < count; ++index) { + if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) { + (void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s", + relations[index].zone_id); + } else if (*secondary_count < MAX_BODY_ZONES) { + (void)snprintf(secondary[*secondary_count], TRAINLOG_ZONE_ID_MAX + 1U, + "%s", relations[index].zone_id); + ++*secondary_count; + } + } + return true; +} + +static bool edit_exercise_body_zones( + TrainlogDatabase *database, + TrainlogExercise *exercise +) +{ + char primary[TRAINLOG_ZONE_ID_MAX + 1U]; + char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + const char *secondary_ids[MAX_BODY_ZONES]; + size_t secondary_count = 0U; + size_t index; + char new_name[TRAINLOG_NAME_MAX + 1U]; + char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U]; + const char *saved_name; + TrainlogStatus status; + if (!load_exercise_body_zones(database, exercise->exercise_id, primary, + secondary, &secondary_count)) return false; + if (!choose_body_zones(primary, secondary, &secondary_count, false)) return false; + draw_shell("Modifier l'exercice", "Nom vide = conserver · Échap annule"); + if (!prompt_text(4, "Nouveau nom : ", new_name, sizeof(new_name), true)) return false; + saved_name = new_name[0] == '\0' ? exercise->name : new_name; + status = trainlog_catalog_normalize_name(saved_name, normalized, sizeof(normalized)); + if (status != TRAINLOG_STATUS_OK) return false; + for (index = 0U; index < secondary_count; ++index) secondary_ids[index] = secondary[index]; + status = trainlog_database_update_exercise_profiled(database, + exercise->exercise_id, saved_name, normalized, exercise->tracking_mode, + exercise->recording_mode, exercise->data_fields, + primary[0] == '\0' ? NULL : primary, secondary_ids, secondary_count); + if (status == TRAINLOG_STATUS_OK) + (void)snprintf(exercise->name, sizeof(exercise->name), "%s", saved_name); + status_line(status == TRAINLOG_STATUS_OK ? "✓ Exercice modifié." : + status == TRAINLOG_STATUS_CONFLICT ? "Conflit : nom ou profil déjà utilisé." : + "Impossible de modifier l'exercice.", + status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR); + wait_key(); + return status == TRAINLOG_STATUS_OK; +} + static void screen_exercise_detail( TrainlogDatabase *database, - const TrainlogExercise *exercise + TrainlogExercise *exercise ) { TrainlogResolvedEquipment explicit_items[64]; @@ -2236,16 +2411,42 @@ static void screen_exercise_detail( for (;;) { size_t total = explicit_count + historic_count; - int row = 7; + char primary[TRAINLOG_ZONE_ID_MAX + 1U]; + char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + size_t secondary_count = 0U; + int row = 10; 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"); + "↑↓ équipement Entrée fiche e modifier 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, + if (load_exercise_body_zones(database, exercise->exercise_id, primary, + secondary, &secondary_count) && primary[0] != '\0') { + const TrainlogBodyZone *primary_zone = trainlog_body_zone_catalog_lookup(primary); + const TrainlogBodyZone *group = primary_zone != NULL && + primary_zone->parent_zone_id != NULL + ? trainlog_body_zone_catalog_lookup(primary_zone->parent_zone_id) : NULL; + char secondary_names[256] = ""; + for (index = 0U; index < secondary_count; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(secondary[index]); + size_t used = strlen(secondary_names); + if (zone != NULL && used < sizeof(secondary_names) - 1U) + (void)snprintf(secondary_names + used, sizeof(secondary_names) - used, + "%s%s", used > 0U ? ", " : "", zone->display_name); + } + trainlog_terminal_printf(tui_terminal, 5, 4, "Zone principale : %s", + primary_zone != NULL ? primary_zone->display_name : primary); + trainlog_terminal_printf(tui_terminal, 6, 4, "Zones secondaires : %s", + secondary_names[0] != '\0' ? secondary_names : "Aucune"); + trainlog_terminal_printf(tui_terminal, 7, 4, "Groupe : %s", + group != NULL ? group->display_name : "Aucun"); + } else { + trainlog_terminal_printf(tui_terminal, 5, 4, "Zone : Non renseignée"); + } + trainlog_terminal_printf(tui_terminal, 9, 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) { @@ -2278,6 +2479,7 @@ static void screen_exercise_detail( ? &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); + else if (key == 'e' || key == 'E') (void)edit_exercise_body_zones(database, exercise); } } @@ -2289,11 +2491,18 @@ static void screen_exercises( size_t selected = 0U; int nav_selected = 3; int focus = 1; + int zone_filter = -1; /* -1 all, catalogue index, count = unclassified. */ + char search[TRAINLOG_NAME_MAX + 1U] = ""; for (;;) { size_t count = 0U; size_t top = 0U; size_t index; + size_t zone_count = trainlog_body_zone_catalog_count(); + char normalized_search[(TRAINLOG_NAME_MAX * 4U) + 1U] = ""; + const char *filter_zone_id = NULL; + const char *filter_label = "Toutes les zones"; + bool unclassified_only = zone_filter == (int)zone_count; bool large_layout = trainlog_terminal_columns(tui_terminal) >= 100 && @@ -2312,7 +2521,7 @@ static void screen_exercises( trainlog_terminal_rows(tui_terminal) - 4; int first_row = - list_top + 1; + list_top + 2; int visible_rows = framed @@ -2326,8 +2535,27 @@ static void screen_exercises( return; } - if (trainlog_database_list_exercises( + if (search[0] != '\0' && trainlog_catalog_normalize_name(search, + normalized_search, sizeof(normalized_search)) != TRAINLOG_STATUS_OK) { + normalized_search[0] = '\0'; + } + if (zone_filter >= 0 && zone_filter < (int)zone_count) { + const TrainlogBodyZone *zone = + trainlog_body_zone_catalog_at((size_t)zone_filter); + if (zone != NULL) { + filter_zone_id = zone->zone_id; + filter_label = zone->display_name; + } + } else if (unclassified_only) { + filter_label = "Non renseignés"; + } + if (trainlog_database_list_exercises_filtered( database, + normalized_search, + filter_zone_id, + true, + false, + unclassified_only, exercises, MAX_EXERCISES, &count @@ -2375,7 +2603,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-F5 direct b/Échap retour" + "Tab zone ↑↓ catalogue / recherche z filtre x effacer Entrée fiche (e modifier) a ajouter b/Échap retour" ); trainlog_terminal_style_off(tui_terminal, @@ -2386,10 +2614,14 @@ static void screen_exercises( } else { draw_shell( "TRAINLOG — Exercices", - "↑↓ naviguer Entrée performance m max mesuré a ajouter b/Échap retour" + "↑↓ naviguer / recherche z filtre x effacer Entrée fiche a ajouter b/Échap retour" ); } + trainlog_terminal_printf(tui_terminal, first_row - 1, framed ? 5 : 4, + "Filtre : %s · Recherche : %s", filter_label, + search[0] != '\0' ? search : "—"); + if (framed) { if (large_layout) { focused_panel( @@ -2479,6 +2711,24 @@ static void screen_exercises( trainlog_terminal_render(tui_terminal); key = trainlog_terminal_get_key(tui_terminal); + if (key == '/') { + draw_shell("Recherche exercices", "Préfixe vide = tous · Échap annule"); + if (prompt_text(4, "Préfixe : ", search, sizeof(search), true)) selected = 0U; + continue; + } + if (key == 'z' || key == 'Z') { + ++zone_filter; + if (zone_filter > (int)zone_count) zone_filter = -1; + selected = 0U; + continue; + } + if (key == 'x' || key == 'X') { + search[0] = '\0'; + zone_filter = -1; + selected = 0U; + continue; + } + if (large_layout && (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB)) { @@ -2609,6 +2859,10 @@ if (primary_top_nav_activate( TrainlogExerciseDataFields data_fields = 0U; TrainlogExercise created; TrainlogStatus status; + char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = ""; + char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + const char *secondary_ids[MAX_BODY_ZONES]; + size_t secondary_count = 0U; draw_shell( "Nouvel exercice", @@ -2682,8 +2936,12 @@ if (primary_top_nav_activate( TRAINLOG_EXERCISE_DATA_DISTANCE_KM; } } -status = - trainlog_catalog_create_exercise_profiled( + if (!choose_body_zones(primary_zone, secondary_zones, + &secondary_count, organization == 1)) continue; + for (index = 0U; index < secondary_count; ++index) + secondary_ids[index] = secondary_zones[index]; + status = + trainlog_catalog_create_exercise_profiled_with_zones( database, name, mode == 1 @@ -2693,6 +2951,9 @@ status = ? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS, data_fields, + primary_zone[0] == '\0' ? NULL : primary_zone, + secondary_ids, + secondary_count, &created ); @@ -2734,6 +2995,11 @@ static bool create_exercise_inline( TrainlogExerciseDataFields data_fields = 0U; TrainlogExercise created; TrainlogStatus status; + char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = ""; + char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + const char *secondary_ids[MAX_BODY_ZONES]; + size_t secondary_count = 0U; + size_t index; draw_shell( "Nouvel exercice", @@ -2807,8 +3073,12 @@ static bool create_exercise_inline( TRAINLOG_EXERCISE_DATA_DISTANCE_KM; } } -status = - trainlog_catalog_create_exercise_profiled( + if (!choose_body_zones(primary_zone, secondary_zones, + &secondary_count, organization == 1)) return false; + for (index = 0U; index < secondary_count; ++index) + secondary_ids[index] = secondary_zones[index]; + status = + trainlog_catalog_create_exercise_profiled_with_zones( database, name, mode == 1 @@ -2818,6 +3088,9 @@ status = ? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS, data_fields, + primary_zone[0] == '\0' ? NULL : primary_zone, + secondary_ids, + secondary_count, &created ); diff --git a/tui/tests/test_body_metrics.c b/tui/tests/test_body_metrics.c index e520f2e..1bc22e7 100644 --- a/tui/tests/test_body_metrics.c +++ b/tui/tests/test_body_metrics.c @@ -98,7 +98,9 @@ static bool test_body_metrics(void) int main(void) { - CHECK(test_body_metrics()); + if (!test_body_metrics()) { + return 1; + } (void)printf("PASS body_metrics\n"); return 0; } diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c new file mode 100644 index 0000000..598ad57 --- /dev/null +++ b/tui/tests/test_body_zones.c @@ -0,0 +1,230 @@ +#include + +#include +#include +#include +#include + +#include "trainlog/body_zone_catalog.h" +#include "trainlog/catalog.h" +#include "trainlog/database.h" + +#define CHECK(condition) do { if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return 1; } } while (0) + +static int taxonomy_contract(void) +{ + size_t index; + CHECK(trainlog_body_zone_catalog_count() == 11U); + for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); + const TrainlogBodyZone *ancestors[11]; + size_t other; + CHECK(zone != NULL); + CHECK(trainlog_body_zone_catalog_lookup(zone->zone_id) == zone); + CHECK(trainlog_body_zone_catalog_ancestors(zone->zone_id, ancestors, 11U) <= 2U); + if (zone->parent_zone_id != NULL) + CHECK(trainlog_body_zone_catalog_lookup(zone->parent_zone_id) != NULL); + for (other = index + 1U; other < trainlog_body_zone_catalog_count(); ++other) { + const TrainlogBodyZone *candidate = trainlog_body_zone_catalog_at(other); + CHECK(candidate != NULL); + CHECK(strcmp(zone->zone_id, candidate->zone_id) != 0); + CHECK(zone->sort_order != candidate->sort_order); + } + } + CHECK(trainlog_body_zone_catalog_lookup("full_body")->parent_zone_id == NULL); + CHECK(trainlog_body_zone_catalog_lookup("upper_body")->is_group); + CHECK(trainlog_body_zone_catalog_lookup("lower_body")->is_group); + CHECK(trainlog_body_zone_catalog_is_descendant("chest", "upper_body")); + CHECK(!trainlog_body_zone_catalog_is_descendant("core", "upper_body")); + return 0; +} + +static int relation_and_filter_contract(void) +{ + char path[] = "/tmp/trainlog-body-zones-XXXXXX"; + int fd = mkstemp(path); + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + TrainlogExercise created; + TrainlogExercise lower; + TrainlogExercise results[8]; + TrainlogExerciseBodyZone relations[4]; + const char *secondary[] = {"shoulders", "arms"}; + const char *duplicate[] = {"arms", "arms"}; + const char *same_as_primary[] = {"chest"}; + const char *orphan_secondary[] = {"arms"}; + size_t count = 0U; + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database, + "Chest custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, + "chest", secondary, 2U, &created) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id, + relations, 4U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 3U); + CHECK(trainlog_database_list_exercise_body_zones(database, "ex_missing", + relations, 4U, &count) == TRAINLOG_STATUS_NOT_FOUND); + CHECK(relations[0].role == TRAINLOG_BODY_ZONE_PRIMARY); + CHECK(strcmp(relations[0].zone_id, "chest") == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) " + "SELECT id,'back','primary' FROM exercises WHERE exercise_id LIKE 'ex_%';", + NULL, NULL, NULL) == SQLITE_CONSTRAINT); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + "chest", same_as_primary, 1U) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + "chest", duplicate, 2U) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + NULL, orphan_secondary, 1U) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + "upper_body", NULL, 0U) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + "unknown", NULL, 0U) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_list_exercises_filtered(database, "chest", "upper_body", + true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U && strcmp(results[0].exercise_id, created.exercise_id) == 0); + CHECK(trainlog_database_list_exercises_filtered(database, "", "chest", + false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U); + CHECK(trainlog_database_list_exercises_filtered(database, "", "arms", + false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U); + CHECK(trainlog_database_list_exercises_filtered(database, "", "arms", + false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U); + CHECK(trainlog_database_list_exercises_filtered(database, "chest", "back", + true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U); + CHECK(trainlog_database_list_exercises_filtered(database, "", "back", + true, false, true, results, 8U, &count) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database, + "Glute custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, + "glutes", NULL, 0U, &lower) == TRAINLOG_STATUS_OK); + CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database, + "Thigh custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, + "thighs", NULL, 0U, &lower) == TRAINLOG_STATUS_OK); + CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database, + "Calf custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, + "calves", NULL, 0U, &lower) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercises_filtered(database, "", "lower_body", + true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U); + CHECK(trainlog_database_update_exercise_profiled(database, created.exercise_id, + "Chest custom renamed", "chest custom renamed", TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, 0U, "back", NULL, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id, + relations, 4U, &count) == TRAINLOG_STATUS_OK && count == 1U && + strcmp(relations[0].zone_id, "back") == 0); + CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id, + NULL, NULL, 0U) == TRAINLOG_STATUS_OK); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + /* Inject against the real row with a prepared statement so the corruption + * fixture never embeds its generated UUID. */ + { + sqlite3_stmt *corrupt = NULL; + CHECK(sqlite3_prepare_v2(raw, + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) " + "SELECT id,'arms','secondary' FROM exercises WHERE exercise_id=?1;", + -1, &corrupt, NULL) == SQLITE_OK); + CHECK(sqlite3_bind_text(corrupt, 1, created.exercise_id, -1, + SQLITE_TRANSIENT) == SQLITE_OK); + CHECK(sqlite3_step(corrupt) == SQLITE_DONE); + CHECK(sqlite3_finalize(corrupt) == SQLITE_OK); + } + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id, + relations, 4U, &count) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(trainlog_database_list_exercises_filtered(database, "", "chest", + false, false, false, results, 8U, &count) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "DELETE FROM exercise_body_zones WHERE role='secondary';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) " + "SELECT id,'upper_body','primary' FROM exercises " + "WHERE normalized_name='chest custom renamed';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id, + relations, 4U, &count) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "DELETE FROM exercise_body_zones WHERE zone_id='upper_body';", + NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_list_exercises_filtered(database, "", NULL, + true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U); + trainlog_database_close(database); + database = NULL; + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id, + relations, 4U, &count) == TRAINLOG_STATUS_OK && count == 0U); + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return 0; +} + +static int migration_preserves_identity_and_history(void) +{ + char path[] = "/tmp/trainlog-body-zones-v10-XXXXXX"; + int fd = mkstemp(path); + sqlite3 *raw = NULL; + TrainlogDatabase *database = NULL; + TrainlogExerciseBodyZone relations[4]; + size_t count = 0U; + int version = 0; + CHECK(fd >= 0); + CHECK(close(fd) == 0); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, + "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);" + "INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde'," + "'Leg press','leg press','reps','sets',0);" + "INSERT INTO sessions VALUES(3,'se_preserved');" + "PRAGMA user_version=10;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_close(raw) == SQLITE_OK); + raw = NULL; + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 11); + CHECK(trainlog_database_list_exercise_body_zones(database, + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 2U); + trainlog_database_close(database); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + { + sqlite3_stmt *statement = NULL; + CHECK(sqlite3_prepare_v2(raw, + "SELECT (SELECT COUNT(*) FROM exercises WHERE id=7 AND " + "exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde')," + "(SELECT COUNT(*) FROM sessions WHERE id=3 AND session_id='se_preserved');", + -1, &statement, NULL) == SQLITE_OK); + CHECK(sqlite3_step(statement) == SQLITE_ROW); + CHECK(sqlite3_column_int(statement, 0) == 1); + CHECK(sqlite3_column_int(statement, 1) == 1); + CHECK(sqlite3_finalize(statement) == SQLITE_OK); + statement = NULL; + CHECK(sqlite3_prepare_v2(raw, "PRAGMA foreign_key_check;", -1, + &statement, NULL) == SQLITE_OK); + CHECK(sqlite3_step(statement) == SQLITE_DONE); + CHECK(sqlite3_finalize(statement) == SQLITE_OK); + } + CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK(unlink(path) == 0); + return 0; +} + +int main(void) +{ + CHECK(taxonomy_contract() == 0); + CHECK(relation_and_filter_contract() == 0); + CHECK(migration_preserves_identity_and_history() == 0); + return 0; +} diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c index 045b683..b55bebc 100644 --- a/tui/tests/test_custom_equipment.c +++ b/tui/tests/test_custom_equipment.c @@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void) CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 10); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); (void)memset(&custom, 0, sizeof(custom)); (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), "%s", "eq_123e4567-e89b-42d3-a456-426614174000"); @@ -116,7 +116,7 @@ static bool test_custom_equipment_round_trip(void) int main(void) { - CHECK(test_custom_equipment_round_trip()); + if (!test_custom_equipment_round_trip()) return 1; (void)printf("PASS custom_equipment\n"); return 0; } diff --git a/tui/tests/test_max_results.c b/tui/tests/test_max_results.c index 042a95b..22b8dc3 100644 --- a/tui/tests/test_max_results.c +++ b/tui/tests/test_max_results.c @@ -141,6 +141,11 @@ static bool test_explicit_max_round_trip_and_identity(void) static bool test_v8_migration_refuses_to_guess_multiple_attempts(void) { static const char *const SQL = + /* A real v8 database always owns the exercise table even though this + * MAX-only fixture has no exercise rows. */ + "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_type TEXT);" "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY," "session_row_id INTEGER,recording_mode TEXT);" diff --git a/tui/tests/test_schema_v5_migration.c b/tui/tests/test_schema_v5_migration.c index a2ba7c1..3bf9046 100644 --- a/tui/tests/test_schema_v5_migration.c +++ b/tui/tests/test_schema_v5_migration.c @@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void) TRAINLOG_DATABASE_SCHEMA_VERSION ); - CHECK(version == 10); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK( trainlog_database_get_session_details( @@ -226,9 +226,7 @@ static bool test_v4_to_current_preserves_session(void) int main(void) { - CHECK( - test_v4_to_current_preserves_session() - ); + if (!test_v4_to_current_preserves_session()) return 1; (void)printf( "PASS schema_v5_migration\n" diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index b064ec5..180998f 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -1,6 +1,6 @@ /** * @file test_schema_v7_migration.c - * @brief Synthetic v7 -> v8 migration and reopen regression coverage. + * @brief Synthetic v7 -> current migration and reopen regression coverage. */ #include @@ -88,7 +88,7 @@ static bool verify_preserved_values(const char *path) return true; } -static bool test_v7_migrates_and_v8_reopens(void) +static bool test_v7_migrates_and_current_reopens(void) { char path[] = "/tmp/trainlog-schema-v7-XXXXXX"; sqlite3 *raw = NULL; @@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void) CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 10); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(database); CHECK(verify_preserved_values(path)); @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v10: SQLite rc=", - strlen("migrate database to schema v10: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v11: SQLite rc=", + strlen("migrate database to schema v11: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) static bool test_newer_schema_has_application_diagnostic(void) { - char path[] = "/tmp/trainlog-schema-v11-XXXXXX"; + char path[] = "/tmp/trainlog-schema-v12-XXXXXX"; char diagnostic[256]; sqlite3 *raw = NULL; TrainlogDatabase *database = NULL; @@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void) CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=11;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=12;", 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 11 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 12 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; @@ -189,10 +189,10 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void) 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()); + if (!test_v7_migrates_and_current_reopens() || + !test_v7_migration_sqlite_failure_has_diagnostic() || + !test_newer_schema_has_application_diagnostic() || + !test_unrecognized_historic_schema_has_application_diagnostic()) return 1; (void)printf("PASS schema_v7_migration\n"); return 0; } diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 491d1ec..34a3823 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -64,9 +64,9 @@ static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected) return matches; } -static bool test_v9_to_v10_is_lossless(void) +static bool test_v9_to_current_is_lossless(void) { - char path[] = "/tmp/trainlog-schema-v9-v10-XXXXXX"; + char path[] = "/tmp/trainlog-schema-v9-current-XXXXXX"; sqlite3 *raw = NULL; sqlite3_stmt *rows = NULL; TrainlogDatabase *database = NULL; @@ -83,7 +83,7 @@ static bool test_v9_to_v10_is_lossless(void) CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 10); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK); CHECK(foreign_keys == 1); trainlog_database_close(database); @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v10") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v11") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); @@ -176,8 +176,8 @@ static bool test_v9_to_v10_failure_rolls_back(void) int main(void) { - CHECK(test_v9_to_v10_is_lossless()); - CHECK(test_v9_to_v10_failure_rolls_back()); + if (!test_v9_to_current_is_lossless() || + !test_v9_to_v10_failure_rolls_back()) return 1; (void)printf("PASS schema_v9_migration\n"); return 0; } diff --git a/tui/tests/test_sync_direction.c b/tui/tests/test_sync_direction.c index 8ec28b1..6df695d 100644 --- a/tui/tests/test_sync_direction.c +++ b/tui/tests/test_sync_direction.c @@ -79,6 +79,21 @@ int main(void) &selected)); CHECK(selected == 1U); + (void)snprintf(entries[0].name, sizeof(entries[0].name), "%s", + "trainlog-sync-request-v1.json"); + entries[0].item_id = 60U; + entries[0].modification_unix_seconds = 10U; + (void)snprintf(entries[1].name, sizeof(entries[1].name), "%s", + "trainlog-sync-request-v1 (2).json"); + entries[1].item_id = 61U; + entries[1].modification_unix_seconds = 40U; + CHECK(trainlog_sync_select_android_artifact( + entries, + 2U, + "trainlog-sync-request-v1.json", + &selected)); + CHECK(selected == 1U); + report.success = true; report.direction = TRAINLOG_SYNC_BIDIRECTIONAL; report.exercises_reconciled = 1U; diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 1b0e66c..bfd3b0f 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -219,30 +219,33 @@ static bool test_empty_sets_cannot_finish(void) { TrainlogSessionDraftExercise draft; TrainlogTerminal terminal; + TrainlogDatabase *database = NULL; size_t count = 1U; const int events[] = {'f', 'x', 'q'}; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); (void)memset(&draft, 0, sizeof(draft)); draft.input.recording_mode = TRAINLOG_RECORDING_SETS; draft.tracking_mode = TRAINLOG_TRACKING_REPS; (void)snprintf(draft.name, sizeof(draft.name), "Squat"); script(&terminal, events, sizeof(events) / sizeof(events[0])); tui_terminal = &terminal; - CHECK(!edit_session_draft(NULL, &draft, &count, + CHECK(!edit_session_draft(database, &draft, &count, TRAINLOG_SESSION_TRAINING)); CHECK(count == 1U && draft.input.set_count == 0U); CHECK(strstr(terminal.output, "Ajoutez au moins une série réalisée pour Squat.") != NULL); tui_terminal = NULL; + trainlog_database_close(database); return true; } int main(void) { - CHECK(test_assistance_creation_labels()); - CHECK(test_duration_creation_starts_empty()); - CHECK(test_append_requires_actual_and_rolls_back()); - CHECK(test_empty_sets_cannot_finish()); + if (!test_assistance_creation_labels() || + !test_duration_creation_starts_empty() || + !test_append_requires_actual_and_rolls_back() || + !test_empty_sets_cannot_finish()) return 1; (void)printf("PASS tui_workflows\n"); return 0; }