feat(exercises): add hierarchical body zones
This commit is contained in:
parent
1b08db009e
commit
1aad7b48b3
54 changed files with 4264 additions and 213 deletions
68
CHANGELOG.md
68
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:
|
||||
|
|
|
|||
33
README.md
33
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_<uuid-v4>` 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<BodyZone>,
|
||||
val initialMappings: List<InitialExerciseBodyZones>,
|
||||
) {
|
||||
private val byId = zones.associateBy { it.zoneId }
|
||||
|
||||
fun lookup(zoneId: String): BodyZone? = byId[zoneId]
|
||||
|
||||
fun children(zoneId: String): List<BodyZone> =
|
||||
zones.filter { it.parentZoneId == zoneId }
|
||||
|
||||
fun ancestors(zoneId: String): List<BodyZone> = 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<String> {
|
||||
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<String>()
|
||||
val seenOrders = mutableSetOf<Int>()
|
||||
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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ExerciseProfile> {
|
||||
/** Return manifest sort order; definitions are immutable application assets. */
|
||||
fun listBodyZones(): List<BodyZone> = 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<BodyZone> = 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<ExerciseProfile> {
|
||||
require(!(unclassifiedOnly && zoneId != null)) {
|
||||
"zoneId et unclassifiedOnly sont mutuellement exclusifs"
|
||||
}
|
||||
val output =
|
||||
mutableListOf<ExerciseProfile>()
|
||||
|
||||
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<String>()
|
||||
val arguments = mutableListOf<String>()
|
||||
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<Any?>(
|
||||
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<String>)
|
||||
val incoming = mutableListOf<Incoming>()
|
||||
val seenExercises = mutableSetOf<String>()
|
||||
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>): 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<Any>(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<String>,
|
||||
): 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<String?, List<String>> {
|
||||
var primary: String? = null
|
||||
val secondary = mutableListOf<String>()
|
||||
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<String>,
|
||||
) {
|
||||
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<Pair<String, String>> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<String> = 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<String> = emptyList(),
|
||||
) {
|
||||
fun validateProfile(): Boolean =
|
||||
NewExerciseProfile(
|
||||
|
|
|
|||
|
|
@ -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<String?>(null) }
|
||||
var secondaryZoneIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var filterZoneId by remember { mutableStateOf<String?>(null) }
|
||||
var unclassifiedFilter by remember { mutableStateOf(false) }
|
||||
val zones = repository.listBodyZones()
|
||||
|
||||
var message by
|
||||
remember {
|
||||
mutableStateOf<String?>(
|
||||
|
|
@ -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<BodyZone>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<String>()
|
||||
|
||||
@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<Any>(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<Any>(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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>()
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
39
catalog/body-zones-v1.json
Normal file
39
catalog/body-zones-v1.json
Normal file
|
|
@ -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."}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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_<uuid-v4>` 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)`.
|
||||
|
|
|
|||
|
|
@ -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_<uuid-v4>` 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
|
||||
|
|
|
|||
24
docs/tui.md
24
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
|
||||
|
|
|
|||
88
tests/test_body_zone_catalog.py
Normal file
88
tests/test_body_zone_catalog.py
Normal file
|
|
@ -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()
|
||||
215
tests/test_body_zone_sync.py
Normal file
215
tests/test_body_zone_sync.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
88
tools/export_exercise_body_zones.py
Normal file
88
tools/export_exercise_body_zones.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
162
tools/generate_body_zone_catalog.py
Normal file
162
tools/generate_body_zone_catalog.py
Normal file
|
|
@ -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 <string.h>\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;
|
||||
}
|
||||
''')
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
245
tools/import_exercise_body_zones.py
Normal file
245
tools/import_exercise_body_zones.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
tui/include/trainlog/body_zone_catalog.h
Normal file
50
tui/include/trainlog/body_zone_catalog.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#ifndef TRAINLOG_BODY_ZONE_CATALOG_H
|
||||
#define TRAINLOG_BODY_ZONE_CATALOG_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
297
tui/src/tui.c
297
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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
230
tui/tests/test_body_zones.c
Normal file
230
tui/tests/test_body_zones.c
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#include <sqlite3.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <stdbool.h>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue