feat(trainlog): complete stats knowledge and sync hardening
This commit is contained in:
parent
2dec03e7b2
commit
32fae197ee
76 changed files with 3701 additions and 1031 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -1,5 +1,28 @@
|
|||
# Changelog
|
||||
|
||||
- Fixed Android-triggered synchronization so the complete current Android→PC
|
||||
companion bundle is published before the request signal, including Training
|
||||
Feedback V2 and Exercise Profile State V1. Desktop orchestration now refuses
|
||||
stale temporary profile-state reuse and reports a specific missing causal
|
||||
companion when a modern same-ID profile mismatch requires it.
|
||||
- Made Android exchange-file reuse exact and directory-scoped: pending,
|
||||
trashed, unrelated, and conflict-numbered MediaStore rows no longer enter
|
||||
canonical selection, while genuine duplicate exact names fail closed.
|
||||
|
||||
## Unreleased — Desktop historical tracking snapshot
|
||||
|
||||
- Added desktop schema v17 / Android schema v16 causal current-profile state,
|
||||
bounded 32-revision transitive ancestry, deterministic bidirectional
|
||||
convergence, and explicit concurrent-edit conflicts; legacy feedback without
|
||||
`ended_at` now shows approximate H+ from
|
||||
valid `started_at` instead of a misleading during-session label.
|
||||
- Added desktop schema v16 with an atomic v15 backfill of occurrence-owned
|
||||
`session_exercises.tracking_mode`, preserving identities and all child facts.
|
||||
- Switched historical readers and V3 exchange to the occurrence snapshot while
|
||||
retaining catalogue profile modes as future-entry defaults.
|
||||
- Enabled explicitly confirmed future-only tracking/profile edits in the TUI
|
||||
without rewriting completed sessions.
|
||||
|
||||
## Unreleased — Training feedback UX and safety
|
||||
|
||||
- Added additive schema v15 immutable feedback revisions and Training Feedback
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import android.util.Log
|
||||
import java.io.OutputStream
|
||||
|
||||
internal const val SYNC_PREFERENCES_NAME = "trainlog-sync"
|
||||
internal const val SYNC_TREE_URI_KEY = "trainlog_tree_uri"
|
||||
internal const val EXCHANGE_MEDIASTORE_LOG_TAG = "TrainlogMediaStore"
|
||||
|
||||
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
|
||||
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
||||
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
|
||||
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
|
||||
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
|
||||
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"
|
||||
"trainlog-exercise-aliases-v1.json" -> "EXERCISE_ALIASES"
|
||||
"trainlog-training-feedback-v2.json" -> "TRAINING_FEEDBACK"
|
||||
"trainlog-exercise-profile-state-v1.json" -> "EXERCISE_PROFILE_STATE"
|
||||
else -> displayName
|
||||
}
|
||||
|
||||
/* WHY: document providers may throw the same message from different SAF
|
||||
* operations. CONTRACT: traces contain artifact identity, operation phase,
|
||||
* URI and exception metadata only; user JSON is never logged. */
|
||||
internal fun logExchangeMediaStore(displayName: String, phase: String, detail: String? = null) {
|
||||
val suffix = detail?.let { " $it" } ?: ""
|
||||
Log.d(EXCHANGE_MEDIASTORE_LOG_TAG, "${exchangeArtifactIdentity(displayName)} $phase$suffix")
|
||||
}
|
||||
|
||||
internal fun logExchangeMediaStoreError(displayName: String, phase: String, error: Throwable) {
|
||||
Log.e(
|
||||
EXCHANGE_MEDIASTORE_LOG_TAG,
|
||||
"${exchangeArtifactIdentity(displayName)} error phase=$phase " +
|
||||
"exception=${error.javaClass.name}:${error.message}",
|
||||
)
|
||||
}
|
||||
|
||||
internal data class ExchangeSafDocument(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
)
|
||||
|
||||
internal interface ExchangeSafDirectory {
|
||||
fun listDirectChildren(): List<ExchangeSafDocument>
|
||||
fun createJson(displayName: String): ExchangeSafDocument?
|
||||
fun openForRewrite(document: ExchangeSafDocument): OutputStream?
|
||||
}
|
||||
|
||||
private class DocumentFileExchangeSafDirectory(
|
||||
private val context: Context,
|
||||
private val directory: DocumentFile,
|
||||
) : ExchangeSafDirectory {
|
||||
override fun listDirectChildren(): List<ExchangeSafDocument> =
|
||||
directory.listFiles().mapNotNull { child ->
|
||||
child.name?.let { ExchangeSafDocument(child.uri, it) }
|
||||
}
|
||||
|
||||
override fun createJson(displayName: String): ExchangeSafDocument? =
|
||||
directory.createFile("application/json", displayName)?.let { created ->
|
||||
val actualName = created.name
|
||||
require(actualName == displayName) {
|
||||
"Le fournisseur SAF a créé $actualName au lieu de $displayName."
|
||||
}
|
||||
ExchangeSafDocument(created.uri, displayName)
|
||||
}
|
||||
|
||||
override fun openForRewrite(document: ExchangeSafDocument): OutputStream? =
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
}
|
||||
|
||||
internal fun persistedExchangeSafDirectory(context: Context): ExchangeSafDirectory? {
|
||||
val appContext = context.applicationContext
|
||||
val treeUri = appContext.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
.getString(SYNC_TREE_URI_KEY, null)?.let(Uri::parse) ?: return null
|
||||
val directory = DocumentFile.fromTreeUri(appContext, treeUri) ?: return null
|
||||
if (!directory.isDirectory || !directory.canWrite()) return null
|
||||
return DocumentFileExchangeSafDirectory(appContext, directory)
|
||||
}
|
||||
|
||||
internal class ExchangeSafPublisher(
|
||||
private val directoryProvider: () -> ExchangeSafDirectory?,
|
||||
) {
|
||||
fun snapshot(): ExchangeSafSnapshotResult {
|
||||
val directory = directoryProvider()
|
||||
?: return ExchangeSafSnapshotResult.Error(
|
||||
"Dossier Trainlog non autorisé ou inaccessible.",
|
||||
)
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
return try {
|
||||
logExchangeMediaStore("SAF_TREE", "snapshot.begin", "thread=${Thread.currentThread().name}")
|
||||
val children = directory.listDirectChildren()
|
||||
val byName = children.groupByTo(linkedMapOf()) { it.displayName }
|
||||
val elapsed = android.os.SystemClock.elapsedRealtime() - started
|
||||
logExchangeMediaStore(
|
||||
"SAF_TREE",
|
||||
"snapshot.success",
|
||||
"children=${children.size} duration_ms=$elapsed thread=${Thread.currentThread().name}",
|
||||
)
|
||||
ExchangeSafSnapshotResult.Ready(ExchangeSafSnapshot(directory, byName))
|
||||
} catch (error: Exception) {
|
||||
logExchangeMediaStoreError("SAF_TREE", "snapshot", error)
|
||||
ExchangeSafSnapshotResult.Error(error.message ?: "Lecture du dossier SAF impossible.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface ExchangeSafSnapshotResult {
|
||||
data class Ready(val snapshot: ExchangeSafSnapshot) : ExchangeSafSnapshotResult
|
||||
data class Error(val message: String) : ExchangeSafSnapshotResult
|
||||
}
|
||||
|
||||
internal class ExchangeSafSnapshot(
|
||||
private val directory: ExchangeSafDirectory,
|
||||
private val byName: MutableMap<String, MutableList<ExchangeSafDocument>>,
|
||||
) {
|
||||
fun writeJson(displayName: String, json: String): String? {
|
||||
var phase = "saf.resolve"
|
||||
return try {
|
||||
logExchangeMediaStore(displayName, "saf.resolve.begin")
|
||||
val exact = byName[displayName].orEmpty()
|
||||
require(exact.size <= 1) {
|
||||
"Plusieurs documents SAF canoniques exacts pour $displayName (${exact.size})."
|
||||
}
|
||||
val existing = exact.singleOrNull()
|
||||
val document = if (existing != null) {
|
||||
logExchangeMediaStore(displayName, "saf.resolve.existing", "uri=${existing.uri}")
|
||||
existing
|
||||
} else {
|
||||
phase = "saf.create"
|
||||
logExchangeMediaStore(displayName, "saf.resolve.absent")
|
||||
logExchangeMediaStore(displayName, "saf.create.begin", "mime=application/json")
|
||||
directory.createJson(displayName)?.also { created ->
|
||||
/* INVARIANT: creation mutates this transaction-local index
|
||||
* so later artifacts never rescan the live directory. */
|
||||
byName.getOrPut(displayName) { mutableListOf() }.add(created)
|
||||
logExchangeMediaStore(displayName, "saf.create.success", "uri=${created.uri}")
|
||||
} ?: return "Création SAF de $displayName impossible."
|
||||
}
|
||||
phase = "saf.openOutputStream"
|
||||
logExchangeMediaStore(
|
||||
displayName,
|
||||
"saf.write.begin",
|
||||
"uri=${document.uri} mode=wt thread=${Thread.currentThread().name}",
|
||||
)
|
||||
val stream = directory.openForRewrite(document)
|
||||
?: return "Écriture SAF de $displayName impossible."
|
||||
phase = "saf.stream.write"
|
||||
stream.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
logExchangeMediaStore(displayName, "saf.write.success", "uri=${document.uri}")
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
logExchangeMediaStoreError(displayName, phase, error)
|
||||
error.message ?: "Publication SAF de $displayName impossible."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ class SyncCatalogInbox(
|
|||
|
||||
private val preferences =
|
||||
appContext.getSharedPreferences(
|
||||
"trainlog-sync",
|
||||
SYNC_PREFERENCES_NAME,
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ class SyncCatalogInbox(
|
|||
preferences
|
||||
.edit()
|
||||
.putString(
|
||||
KEY_TREE_URI,
|
||||
SYNC_TREE_URI_KEY,
|
||||
uri.toString(),
|
||||
)
|
||||
.apply()
|
||||
|
|
@ -133,6 +133,19 @@ class SyncCatalogInbox(
|
|||
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||
val aliasesError = importExerciseAliases(directory)
|
||||
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
||||
val profileFile = directory.findFile("trainlog-exercise-profile-state-v1.json")
|
||||
val profileJson = profileFile?.let { candidate ->
|
||||
appContext.contentResolver.openInputStream(candidate.uri)
|
||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||
}
|
||||
if (profileFile != null && profileJson == null)
|
||||
return CatalogInboxResult.Error("Lecture profile-state impossible.")
|
||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson,allowPending=true)) {
|
||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||
is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
|
||||
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
|
||||
}
|
||||
|
||||
when (
|
||||
val result =
|
||||
|
|
@ -141,7 +154,15 @@ class SyncCatalogInbox(
|
|||
json
|
||||
)
|
||||
) {
|
||||
is PcCatalogImportResult.Applied ->
|
||||
is PcCatalogImportResult.Applied -> {
|
||||
if (profileJson != null) when(val profile=repository.applyExerciseProfileStateJson(profileJson)) {
|
||||
is ExerciseProfileStateImportResult.Applied -> Unit
|
||||
is ExerciseProfileStateImportResult.Invalid -> return CatalogInboxResult.Error(profile.message)
|
||||
is ExerciseProfileStateImportResult.Conflict -> return CatalogInboxResult.Error("Conflit de profil : ${profile.exerciseId}")
|
||||
ExerciseProfileStateImportResult.DatabaseError -> return CatalogInboxResult.Error("Erreur base locale profils.")
|
||||
}
|
||||
/* Catalogue identities now exist and the strict second pass
|
||||
* has either installed or verified their causal state. */
|
||||
when (val sessions = importPcSessions(directory)) {
|
||||
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
||||
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
||||
|
|
@ -159,6 +180,7 @@ class SyncCatalogInbox(
|
|||
}
|
||||
else -> CatalogInboxResult.Error(sessions)
|
||||
}
|
||||
}
|
||||
|
||||
is PcCatalogImportResult.Invalid ->
|
||||
CatalogInboxResult.Error(
|
||||
|
|
@ -399,15 +421,11 @@ fun readSyncReceipt(
|
|||
private fun savedTreeUri(): Uri? =
|
||||
preferences
|
||||
.getString(
|
||||
KEY_TREE_URI,
|
||||
SYNC_TREE_URI_KEY,
|
||||
null,
|
||||
)
|
||||
?.let {
|
||||
Uri.parse(it)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_TREE_URI =
|
||||
"trainlog_tree_uri"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
sealed interface SyncExportResult {
|
||||
data class Exported(
|
||||
|
|
@ -22,16 +19,34 @@ sealed interface SyncExportResult {
|
|||
) : SyncExportResult
|
||||
}
|
||||
|
||||
class SyncExporter(
|
||||
context: Context,
|
||||
class SyncExporter private constructor(
|
||||
private val repository:
|
||||
TrainlogRepository,
|
||||
private val safPublisher: ExchangeSafPublisher,
|
||||
) {
|
||||
private val appContext =
|
||||
context.applicationContext
|
||||
constructor(context: Context, repository: TrainlogRepository) : this(
|
||||
repository,
|
||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||
)
|
||||
|
||||
fun exportMobileBundle():
|
||||
SyncExportResult {
|
||||
internal constructor(
|
||||
context: Context,
|
||||
repository: TrainlogRepository,
|
||||
directoryProvider: () -> ExchangeSafDirectory?,
|
||||
) : this(repository, ExchangeSafPublisher(directoryProvider))
|
||||
|
||||
/* CONTRACT: repository snapshot construction and every SAF operation are
|
||||
* blocking I/O work and must never execute on the Compose/main thread. */
|
||||
suspend fun exportMobileBundle(): SyncExportResult = withContext(Dispatchers.IO) {
|
||||
when (val opened = openSafSnapshot()) {
|
||||
is ExchangeSafSnapshotResult.Ready -> exportMobileBundle(opened.snapshot)
|
||||
is ExchangeSafSnapshotResult.Error -> SyncExportResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun openSafSnapshot(): ExchangeSafSnapshotResult = safPublisher.snapshot()
|
||||
|
||||
internal fun exportMobileBundle(snapshot: ExchangeSafSnapshot): SyncExportResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
Build.VERSION_CODES.Q
|
||||
|
|
@ -39,8 +54,8 @@ class SyncExporter(
|
|||
return SyncExportResult.Unsupported
|
||||
}
|
||||
|
||||
/* CONTRACT: capture all four artifacts before publishing any of
|
||||
* them. The files are separate for compatibility, but a user edit
|
||||
/* CONTRACT: capture every artifact before publishing any of them.
|
||||
* The files are separate for compatibility, but a user edit
|
||||
* must not make a newly-written V2 reference a definition assembled
|
||||
* from a different logical export state. */
|
||||
val definitionsJson: String
|
||||
|
|
@ -49,6 +64,7 @@ class SyncExporter(
|
|||
val bodyZonesJson: String
|
||||
val aliasesJson: String
|
||||
val feedbackJson: String
|
||||
val profileStateJson: String
|
||||
try {
|
||||
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
||||
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
||||
|
|
@ -58,362 +74,54 @@ class SyncExporter(
|
|||
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
||||
aliasesJson = repository.buildExerciseAliasesJson()
|
||||
feedbackJson = repository.buildTrainingFeedbackJson()
|
||||
profileStateJson = repository.buildExerciseProfileStateJson()
|
||||
} catch (error: Exception) {
|
||||
return SyncExportResult.Error(
|
||||
error.message ?: "Préparation de l'export impossible.",
|
||||
)
|
||||
}
|
||||
|
||||
val bytes = mobileJson.toByteArray(Charsets.UTF_8)
|
||||
val artifacts = listOf(
|
||||
"trainlog-mobile-equipment-definitions-v1.json" to definitionsJson,
|
||||
"trainlog-mobile-export-v3.json" to mobileJson,
|
||||
"trainlog-exercise-body-zones-v1.json" to bodyZonesJson,
|
||||
"trainlog-exercise-aliases-v1.json" to aliasesJson,
|
||||
"trainlog-training-feedback-v2.json" to feedbackJson,
|
||||
"trainlog-exercise-profile-state-v1.json" to profileStateJson,
|
||||
)
|
||||
/* CONTRACT: the persisted user-authorized SAF tree is the sole
|
||||
* outbound directory authority. MediaStore ownership must neither hide
|
||||
* a canonical document nor cause creation of a numbered conflict. */
|
||||
artifacts.forEach { (displayName, json) ->
|
||||
snapshot.writeJson(displayName, json)?.let { error ->
|
||||
return SyncExportResult.Error(error)
|
||||
}
|
||||
}
|
||||
|
||||
val resolver =
|
||||
appContext.contentResolver
|
||||
|
||||
val collection =
|
||||
MediaStore.Downloads
|
||||
.getContentUri(
|
||||
MediaStore
|
||||
.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
val relativePath =
|
||||
Environment.DIRECTORY_DOWNLOADS +
|
||||
"/Trainlog/"
|
||||
|
||||
val displayName =
|
||||
"trainlog-mobile-export-v3.json"
|
||||
|
||||
/* Definitions are visible before any session file which may reference a
|
||||
* custom ID; a failed definition write aborts the bundle. */
|
||||
val definitionsError = writeEquipmentDefinitions(definitionsJson)
|
||||
if (definitionsError != null) return SyncExportResult.Error(definitionsError)
|
||||
|
||||
val existing =
|
||||
findExisting(
|
||||
collection,
|
||||
displayName,
|
||||
relativePath
|
||||
)
|
||||
|
||||
val targetUri: Uri
|
||||
val created: Boolean
|
||||
|
||||
if (existing != null) {
|
||||
targetUri = existing
|
||||
created = false
|
||||
} else {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.DISPLAY_NAME,
|
||||
displayName
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.MIME_TYPE,
|
||||
"application/json"
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.RELATIVE_PATH,
|
||||
relativePath
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.IS_PENDING,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
val inserted =
|
||||
resolver.insert(
|
||||
collection,
|
||||
values
|
||||
)
|
||||
|
||||
if (inserted == null) {
|
||||
/* CONTRACT: publication, not JSON construction, establishes the
|
||||
* common sync ancestor. Applying the exact local snapshot can only
|
||||
* record equal baselines; the strict reconciler never unions zones. */
|
||||
when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) {
|
||||
is ExerciseBodyZoneImportResult.Applied -> Unit
|
||||
is ExerciseBodyZoneImportResult.Conflict ->
|
||||
return SyncExportResult.Error(
|
||||
"Création du fichier impossible."
|
||||
"Conflit pendant l'enregistrement de la baseline zones : " +
|
||||
acknowledgement.exerciseId,
|
||||
)
|
||||
}
|
||||
|
||||
targetUri = inserted
|
||||
created = true
|
||||
}
|
||||
|
||||
try {
|
||||
val stream =
|
||||
resolver.openOutputStream(
|
||||
targetUri,
|
||||
"wt"
|
||||
)
|
||||
|
||||
if (stream == null) {
|
||||
if (created) {
|
||||
resolver.delete(
|
||||
targetUri,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
is ExerciseBodyZoneImportResult.Invalid ->
|
||||
return SyncExportResult.Error(acknowledgement.message)
|
||||
ExerciseBodyZoneImportResult.DatabaseError ->
|
||||
return SyncExportResult.Error(
|
||||
"Flux d'écriture indisponible."
|
||||
"Enregistrement de la baseline zones impossible.",
|
||||
)
|
||||
}
|
||||
|
||||
stream.use {
|
||||
it.write(bytes)
|
||||
it.flush()
|
||||
}
|
||||
|
||||
if (created) {
|
||||
val finished =
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.IS_PENDING,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
resolver.update(
|
||||
targetUri,
|
||||
finished,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
val bodyZonesError = writeBodyZones(bodyZonesJson)
|
||||
if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError)
|
||||
val aliasesError = writeJsonCompanion(
|
||||
"trainlog-exercise-aliases-v1.json",
|
||||
aliasesJson,
|
||||
"alias exercice",
|
||||
)
|
||||
if (aliasesError != null) return SyncExportResult.Error(aliasesError)
|
||||
val feedbackError = writeJsonCompanion(
|
||||
"trainlog-training-feedback-v2.json", feedbackJson, "ressentis d’entraînement")
|
||||
if (feedbackError != null) return SyncExportResult.Error(feedbackError)
|
||||
/* CONTRACT: publication, not JSON construction, establishes the
|
||||
* common sync ancestor. Applying the exact local snapshot can only
|
||||
* record equal baselines; the strict reconciler never unions zones. */
|
||||
when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) {
|
||||
is ExerciseBodyZoneImportResult.Applied -> Unit
|
||||
is ExerciseBodyZoneImportResult.Conflict ->
|
||||
return SyncExportResult.Error(
|
||||
"Conflit pendant l'enregistrement de la baseline zones : " +
|
||||
acknowledgement.exerciseId,
|
||||
)
|
||||
is ExerciseBodyZoneImportResult.Invalid ->
|
||||
return SyncExportResult.Error(acknowledgement.message)
|
||||
ExerciseBodyZoneImportResult.DatabaseError ->
|
||||
return SyncExportResult.Error(
|
||||
"Enregistrement de la baseline zones impossible.",
|
||||
)
|
||||
}
|
||||
val companionError = writeEquipmentAssociations(associationsJson)
|
||||
if (companionError != null) {
|
||||
return SyncExportResult.Error(companionError)
|
||||
}
|
||||
return SyncExportResult.Exported(
|
||||
displayPath =
|
||||
"Download/Trainlog/" +
|
||||
displayName,
|
||||
bytes =
|
||||
bytes.size,
|
||||
)
|
||||
} catch (
|
||||
error: Exception
|
||||
) {
|
||||
if (created) {
|
||||
resolver.delete(
|
||||
targetUri,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
return SyncExportResult.Error(
|
||||
error.message
|
||||
?: "Erreur d'export."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
|
||||
private fun writeEquipmentAssociations(json: String): String? {
|
||||
val resolver = appContext.contentResolver
|
||||
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
||||
val name = "trainlog-equipment-associations-v2.json"
|
||||
val existing = findExisting(collection, name, relativePath)
|
||||
val created = existing == null
|
||||
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}) ?: return "Création de l'extension équipement impossible."
|
||||
return try {
|
||||
resolver.openOutputStream(uri, "wt")?.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
} ?: return "Écriture de l'extension équipement impossible."
|
||||
if (created) resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
if (created) resolver.delete(uri, null, null)
|
||||
error.message ?: "Export extension équipement impossible."
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeEquipmentDefinitions(json: String): String? {
|
||||
val resolver = appContext.contentResolver
|
||||
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
||||
val name = "trainlog-mobile-equipment-definitions-v1.json"
|
||||
val existing = findExisting(collection, name, relativePath)
|
||||
val created = existing == null
|
||||
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}) ?: return "Création des définitions équipement impossible."
|
||||
return try {
|
||||
resolver.openOutputStream(uri, "wt")?.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
|
||||
} ?: return "Écriture des définitions équipement impossible."
|
||||
if (created) resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
if (created) resolver.delete(uri, null, null)
|
||||
error.message ?: "Export définitions équipement impossible."
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeJsonCompanion(name: String, json: String, label: String): String? {
|
||||
val resolver = appContext.contentResolver
|
||||
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
||||
val existing = findExisting(collection, name, relativePath)
|
||||
val created = existing == null
|
||||
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}) ?: return "Création du compagnon $label impossible."
|
||||
return try {
|
||||
resolver.openOutputStream(uri, "wt")?.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
|
||||
} ?: return "Écriture du compagnon $label impossible."
|
||||
if (created) resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
if (created) resolver.delete(uri, null, null)
|
||||
error.message ?: "Export du compagnon $label impossible."
|
||||
}
|
||||
}
|
||||
|
||||
/** One directional-neutral companion is used by both peers. */
|
||||
private fun writeBodyZones(json: String): String? {
|
||||
val resolver = appContext.contentResolver
|
||||
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
|
||||
val name = "trainlog-exercise-body-zones-v1.json"
|
||||
val existing = findExisting(collection, name, relativePath)
|
||||
val created = existing == null
|
||||
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}) ?: return "Création des zones corporelles impossible."
|
||||
return try {
|
||||
resolver.openOutputStream(uri, "wt")?.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
|
||||
} ?: return "Écriture des zones corporelles impossible."
|
||||
if (created) resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
null
|
||||
} catch (error: Exception) {
|
||||
if (created) resolver.delete(uri, null, null)
|
||||
error.message ?: "Export des zones corporelles impossible."
|
||||
}
|
||||
}
|
||||
|
||||
private fun findExisting(
|
||||
collection: Uri,
|
||||
displayName: String,
|
||||
relativePath: String,
|
||||
): Uri? {
|
||||
val projection =
|
||||
arrayOf(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
._ID
|
||||
)
|
||||
|
||||
val selection =
|
||||
(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.DISPLAY_NAME +
|
||||
" = ? AND " +
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.RELATIVE_PATH +
|
||||
" = ?"
|
||||
)
|
||||
|
||||
val cursor =
|
||||
appContext
|
||||
.contentResolver
|
||||
.query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
arrayOf(
|
||||
displayName,
|
||||
relativePath,
|
||||
),
|
||||
null,
|
||||
)
|
||||
?: return null
|
||||
|
||||
try {
|
||||
if (!cursor.moveToFirst()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val itemId =
|
||||
cursor.getLong(0)
|
||||
|
||||
return ContentUris.withAppendedId(
|
||||
collection,
|
||||
itemId
|
||||
)
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
snapshot.writeJson(
|
||||
"trainlog-equipment-associations-v2.json",
|
||||
associationsJson,
|
||||
)?.let { return SyncExportResult.Error(it) }
|
||||
return SyncExportResult.Exported(
|
||||
displayPath = "Download/Trainlog/trainlog-mobile-export-v3.json",
|
||||
bytes = mobileJson.toByteArray(Charsets.UTF_8).size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import org.json.JSONObject
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
sealed interface SyncRequestResult {
|
||||
data class Requested(
|
||||
|
|
@ -24,14 +21,25 @@ sealed interface SyncRequestResult {
|
|||
) : SyncRequestResult
|
||||
}
|
||||
|
||||
class SyncRequestOutbox(
|
||||
context: Context,
|
||||
class SyncRequestOutbox private constructor(
|
||||
private val safPublisher: ExchangeSafPublisher,
|
||||
) {
|
||||
private val appContext =
|
||||
context.applicationContext
|
||||
constructor(context: Context) : this(
|
||||
ExchangeSafPublisher { persistedExchangeSafDirectory(context) },
|
||||
)
|
||||
|
||||
fun requestSync():
|
||||
SyncRequestResult {
|
||||
internal constructor(directoryProvider: () -> ExchangeSafDirectory?) : this(
|
||||
ExchangeSafPublisher(directoryProvider),
|
||||
)
|
||||
|
||||
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
when (val opened = safPublisher.snapshot()) {
|
||||
is ExchangeSafSnapshotResult.Ready -> requestSync(opened.snapshot)
|
||||
is ExchangeSafSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestSync(snapshot: ExchangeSafSnapshot): SyncRequestResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
Build.VERSION_CODES.Q
|
||||
|
|
@ -65,187 +73,13 @@ class SyncRequestOutbox(
|
|||
)
|
||||
.toString()
|
||||
|
||||
val bytes =
|
||||
payload.toByteArray(
|
||||
Charsets.UTF_8
|
||||
)
|
||||
|
||||
val resolver =
|
||||
appContext.contentResolver
|
||||
|
||||
val collection =
|
||||
MediaStore.Downloads
|
||||
.getContentUri(
|
||||
MediaStore
|
||||
.VOLUME_EXTERNAL_PRIMARY
|
||||
)
|
||||
|
||||
val relativePath =
|
||||
Environment
|
||||
.DIRECTORY_DOWNLOADS +
|
||||
"/Trainlog/"
|
||||
|
||||
val displayName =
|
||||
"trainlog-sync-request-v1.json"
|
||||
|
||||
val existing =
|
||||
findExisting(
|
||||
collection,
|
||||
displayName,
|
||||
relativePath,
|
||||
)
|
||||
|
||||
val targetUri: Uri
|
||||
val created: Boolean
|
||||
|
||||
if (existing != null) {
|
||||
targetUri = existing
|
||||
created = false
|
||||
val error = snapshot.writeJson(displayName, payload)
|
||||
return if (error == null) {
|
||||
SyncRequestResult.Requested(requestId)
|
||||
} else {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.DISPLAY_NAME,
|
||||
displayName
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.MIME_TYPE,
|
||||
"application/json"
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.RELATIVE_PATH,
|
||||
relativePath
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.IS_PENDING,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
val inserted =
|
||||
resolver.insert(
|
||||
collection,
|
||||
values
|
||||
)
|
||||
?: return SyncRequestResult.Error(
|
||||
"Création de la demande impossible."
|
||||
)
|
||||
|
||||
targetUri = inserted
|
||||
created = true
|
||||
}
|
||||
|
||||
return try {
|
||||
val stream =
|
||||
resolver.openOutputStream(
|
||||
targetUri,
|
||||
"wt"
|
||||
)
|
||||
?: return SyncRequestResult.Error(
|
||||
"Écriture de la demande impossible."
|
||||
)
|
||||
|
||||
stream.use {
|
||||
it.write(bytes)
|
||||
it.flush()
|
||||
}
|
||||
|
||||
if (created) {
|
||||
val finished =
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.IS_PENDING,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
resolver.update(
|
||||
targetUri,
|
||||
finished,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
SyncRequestResult.Requested(
|
||||
requestId
|
||||
)
|
||||
} catch (
|
||||
error: Exception
|
||||
) {
|
||||
if (created) {
|
||||
resolver.delete(
|
||||
targetUri,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
SyncRequestResult.Error(
|
||||
error.message
|
||||
?: "Demande de synchronisation impossible."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findExisting(
|
||||
collection: Uri,
|
||||
displayName: String,
|
||||
relativePath: String,
|
||||
): Uri? {
|
||||
val cursor =
|
||||
appContext
|
||||
.contentResolver
|
||||
.query(
|
||||
collection,
|
||||
arrayOf(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
._ID
|
||||
),
|
||||
(
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.DISPLAY_NAME +
|
||||
" = ? AND " +
|
||||
MediaStore
|
||||
.MediaColumns
|
||||
.RELATIVE_PATH +
|
||||
" = ?"
|
||||
),
|
||||
arrayOf(
|
||||
displayName,
|
||||
relativePath,
|
||||
),
|
||||
null,
|
||||
)
|
||||
?: return null
|
||||
|
||||
try {
|
||||
if (!cursor.moveToFirst()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ContentUris
|
||||
.withAppendedId(
|
||||
collection,
|
||||
cursor.getLong(0)
|
||||
)
|
||||
} finally {
|
||||
cursor.close()
|
||||
SyncRequestResult.Error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,56 @@ import org.json.JSONArray
|
|||
import org.json.JSONObject
|
||||
import java.text.Normalizer
|
||||
import java.io.StringReader
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
|
||||
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
|
||||
private const val PROFILE_HISTORY_MAX = 32
|
||||
|
||||
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
|
||||
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
|
||||
val namespace = ByteBuffer.allocate(16).putLong(PROFILE_REVISION_NAMESPACE.mostSignificantBits)
|
||||
.putLong(PROFILE_REVISION_NAMESPACE.leastSignificantBits).array()
|
||||
val name = "$parent\n$recording\n$tracking\n$fields".toByteArray(StandardCharsets.UTF_8)
|
||||
val digest = MessageDigest.getInstance("SHA-1").digest(namespace + name)
|
||||
digest[6] = ((digest[6].toInt() and 0x0f) or 0x50).toByte()
|
||||
digest[8] = ((digest[8].toInt() and 0x3f) or 0x80).toByte()
|
||||
val bytes = ByteBuffer.wrap(digest)
|
||||
return "pr2_" + UUID(bytes.long, bytes.long).toString()
|
||||
}
|
||||
|
||||
private fun advanceExerciseProfileRevision(
|
||||
db: SQLiteDatabase, rowId: Long, recording: String, tracking: String, fields: Int,
|
||||
): Boolean {
|
||||
val state = db.rawQuery(
|
||||
"SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?",
|
||||
arrayOf(rowId.toString()),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else return false }
|
||||
val count = db.rawQuery(
|
||||
"SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
|
||||
arrayOf(rowId.toString()),
|
||||
).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) }
|
||||
if (count >= PROFILE_HISTORY_MAX) return false
|
||||
val child = exerciseProfileRevision(state, recording, tracking, fields)
|
||||
db.execSQL(
|
||||
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?",
|
||||
arrayOf(child,state,rowId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)",
|
||||
arrayOf(rowId,child,state,recording,tracking,fields),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/* WHY: ended_at is absent on valid imported/history rows and present lifecycle
|
||||
* metadata cannot prove actual work. CONTRACT: observable history requires at
|
||||
* least one occurrence-owned performed set, continuous activity, or explicit
|
||||
|
|
@ -98,6 +142,13 @@ sealed interface PcCatalogImportResult {
|
|||
PcCatalogImportResult
|
||||
}
|
||||
|
||||
sealed interface ExerciseProfileStateImportResult {
|
||||
data class Applied(val updated: Int, val skipped: Int) : ExerciseProfileStateImportResult
|
||||
data class Invalid(val message: String) : ExerciseProfileStateImportResult
|
||||
data class Conflict(val exerciseId: String) : ExerciseProfileStateImportResult
|
||||
data object DatabaseError : ExerciseProfileStateImportResult
|
||||
}
|
||||
|
||||
/** Read-only diagnostic emitted by the production PC-catalog importer. */
|
||||
data class PcCatalogExerciseDecision(
|
||||
val exerciseId: String,
|
||||
|
|
@ -183,6 +234,12 @@ sealed interface SaveSessionResult {
|
|||
SaveSessionResult
|
||||
}
|
||||
|
||||
sealed interface CorrectCompletedSessionResult {
|
||||
data object Saved : CorrectCompletedSessionResult
|
||||
data class Invalid(val message: String) : CorrectCompletedSessionResult
|
||||
data class DatabaseError(val message: String) : CorrectCompletedSessionResult
|
||||
}
|
||||
|
||||
sealed interface SaveFeedbackResult {
|
||||
data class Saved(val stableId: String) : SaveFeedbackResult
|
||||
data class Invalid(val message: String) : SaveFeedbackResult
|
||||
|
|
@ -969,6 +1026,16 @@ class TrainlogRepository(
|
|||
exercise.dataFields,
|
||||
),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 " +
|
||||
"WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);",
|
||||
arrayOf(exerciseProfileRevision("pr_legacy_v1", exercise.recordingMode.wireValue,
|
||||
exercise.trackingMode.wireValue, exercise.dataFields), exercise.exerciseId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",
|
||||
arrayOf(exercise.exerciseId),
|
||||
)
|
||||
|
||||
replaceExerciseBodyZones(db, exercise.exerciseId,
|
||||
exercise.primaryZoneId, exercise.secondaryZoneIds)
|
||||
|
|
@ -989,18 +1056,12 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: completed sessions and active drafts snapshot profile fields, but
|
||||
* keeping referenced catalog profiles immutable prevents a later catalog
|
||||
* sync/profile edit from appearing to change an established exercise.
|
||||
* A rename remains safe because all relationships use the unchanged row.
|
||||
*/
|
||||
/** CONTRACT: occurrences snapshot profile semantics, so references never lock CURRENT edits. */
|
||||
fun canEditExerciseProfile(
|
||||
exerciseId: String,
|
||||
): Boolean {
|
||||
val db = database.readableDatabase
|
||||
val rowId = lookupExerciseRowIdOrNull(db, exerciseId) ?: return false
|
||||
return !exerciseHasReferences(db, rowId)
|
||||
return lookupExerciseRowIdOrNull(db, exerciseId) != null
|
||||
}
|
||||
|
||||
fun editExercise(
|
||||
|
|
@ -1022,13 +1083,11 @@ class TrainlogRepository(
|
|||
db.beginTransaction()
|
||||
val current = findExerciseRow(db, "exercise_id = ?", arrayOf(input.exerciseId))
|
||||
?: return EditExerciseResult.DatabaseError
|
||||
val profileChanged =
|
||||
current.recordingMode != input.recordingMode ||
|
||||
current.trackingMode != input.trackingMode ||
|
||||
current.dataFields != input.dataFields
|
||||
if (profileChanged && exerciseHasReferences(db, current.rowId)) {
|
||||
return EditExerciseResult.IncompatibleProfileChange
|
||||
}
|
||||
/* CONTRACT: completed and draft occurrences own profile snapshots.
|
||||
* Updating the catalogue controls future uses only; it must never
|
||||
* rewrite or reject because historical/draft facts reference this
|
||||
* stable exercise identity. The UI confirms incompatible future
|
||||
* behavior before invoking this transaction. */
|
||||
|
||||
val nameOwner = findExerciseRow(db, "normalized_name = ?", arrayOf(normalized))
|
||||
if (nameOwner != null && nameOwner.rowId != current.rowId) {
|
||||
|
|
@ -1042,9 +1101,32 @@ class TrainlogRepository(
|
|||
put("tracking_mode", input.trackingMode.wireValue)
|
||||
put("data_fields", input.dataFields)
|
||||
}
|
||||
val parentRevision = db.rawQuery(
|
||||
"SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=?",
|
||||
arrayOf(current.rowId.toString()),
|
||||
).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) }
|
||||
if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) {
|
||||
return EditExerciseResult.DatabaseError
|
||||
}
|
||||
if (current.recordingMode != input.recordingMode || current.trackingMode != input.trackingMode ||
|
||||
current.dataFields != input.dataFields) {
|
||||
val revisionCount = db.rawQuery(
|
||||
"SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
|
||||
arrayOf(current.rowId.toString()),
|
||||
).use { cursor -> cursor.moveToFirst(); cursor.getInt(0) }
|
||||
if (revisionCount >= PROFILE_HISTORY_MAX) return EditExerciseResult.DatabaseError
|
||||
db.execSQL(
|
||||
"UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=0 WHERE exercise_row_id=?",
|
||||
arrayOf(exerciseProfileRevision(parentRevision, input.recordingMode.wireValue,
|
||||
input.trackingMode.wireValue, input.dataFields), parentRevision, current.rowId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,0)",
|
||||
arrayOf(current.rowId, exerciseProfileRevision(parentRevision, input.recordingMode.wireValue,
|
||||
input.trackingMode.wireValue, input.dataFields), parentRevision,
|
||||
input.recordingMode.wireValue, input.trackingMode.wireValue, input.dataFields),
|
||||
)
|
||||
}
|
||||
replaceExerciseBodyZones(db, input.exerciseId,
|
||||
input.primaryZoneId, input.secondaryZoneIds)
|
||||
db.setTransactionSuccessful()
|
||||
|
|
@ -1093,6 +1175,176 @@ class TrainlogRepository(
|
|||
.toString()
|
||||
}
|
||||
|
||||
/** Separately versioned causal state for mutable CURRENT profiles. */
|
||||
fun buildExerciseProfileStateJson(): String {
|
||||
val array = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields," +
|
||||
"e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id," +
|
||||
"e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed " +
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id ORDER BY e.exercise_id",
|
||||
null,
|
||||
).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
check(c.getType(3)==Cursor.FIELD_TYPE_INTEGER && c.getType(12)==Cursor.FIELD_TYPE_INTEGER &&
|
||||
c.getInt(12) in 0..1 && c.getType(10)==Cursor.FIELD_TYPE_STRING &&
|
||||
c.getType(11) in setOf(Cursor.FIELD_TYPE_NULL,Cursor.FIELD_TYPE_STRING))
|
||||
val item = JSONObject().put("exercise_id", c.getString(0))
|
||||
.put("recording_mode", c.getString(1)).put("tracking_mode", c.getString(2))
|
||||
.put("data_fields", c.getInt(3))
|
||||
for (i in 4..9) item.put(arrayOf("load_semantics","machine_variant","machine_provenance",
|
||||
"scientific_profile_id","science_state","legacy_equipment_id")[i-4],
|
||||
if (c.isNull(i)) JSONObject.NULL else c.getString(i))
|
||||
item.put("revision_id", c.getString(10))
|
||||
.put("parent_revision_id", if (c.isNull(11)) JSONObject.NULL else c.getString(11))
|
||||
.put("legacy_seed", c.getInt(12) == 1)
|
||||
val history = JSONArray()
|
||||
val rowId = database.readableDatabase.rawQuery(
|
||||
"SELECT id FROM exercises WHERE exercise_id=?", arrayOf(c.getString(0)),
|
||||
).use { idCursor -> check(idCursor.moveToFirst()); idCursor.getLong(0) }
|
||||
database.readableDatabase.rawQuery(
|
||||
"WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS (" +
|
||||
"SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=? " +
|
||||
"UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31) " +
|
||||
"SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC",
|
||||
arrayOf(rowId.toString(), c.getString(10), rowId.toString()),
|
||||
).use { revisions ->
|
||||
var previous: String? = null
|
||||
var index = 0
|
||||
while (revisions.moveToNext()) history.put(JSONObject()
|
||||
.put("revision_id", revisions.getString(0))
|
||||
.put("parent_revision_id", if (revisions.isNull(1)) JSONObject.NULL else revisions.getString(1))
|
||||
.put("recording_mode", revisions.getString(2)).put("tracking_mode", revisions.getString(3))
|
||||
.put("data_fields", revisions.getInt(4)).put("legacy_seed", revisions.getInt(5) == 1).also {
|
||||
val revision = revisions.getString(0)
|
||||
val parent = if (revisions.isNull(1)) null else revisions.getString(1)
|
||||
val recording = revisions.getString(2)
|
||||
val tracking = revisions.getString(3)
|
||||
val fields = revisions.getInt(4)
|
||||
val legacy = revisions.getInt(5) == 1
|
||||
check(revisions.getType(4)==Cursor.FIELD_TYPE_INTEGER &&
|
||||
revisions.getType(5)==Cursor.FIELD_TYPE_INTEGER && revisions.getInt(5) in 0..1)
|
||||
check(recording in setOf("sets","continuous") && tracking in setOf("reps","duration") &&
|
||||
!(recording=="continuous"&&tracking!="duration") && fields in 0..3 &&
|
||||
!(recording=="sets"&&fields!=0))
|
||||
if (index == 0) check(legacy && revision=="pr_legacy_v1" && parent==null)
|
||||
else check(!legacy && parent==previous && PROFILE_REVISION_PATTERN.matches(revision) &&
|
||||
revision==exerciseProfileRevision(parent!!,recording,tracking,fields))
|
||||
previous=revision;index++
|
||||
})
|
||||
}
|
||||
check(history.length() in 1..PROFILE_HISTORY_MAX)
|
||||
val tip=history.getJSONObject(history.length()-1)
|
||||
check(tip.getString("revision_id")==c.getString(10) &&
|
||||
(if(tip.isNull("parent_revision_id"))null else tip.getString("parent_revision_id"))==
|
||||
(if(c.isNull(11))null else c.getString(11)) &&
|
||||
tip.getBoolean("legacy_seed")== (c.getInt(12)==1) &&
|
||||
tip.getString("recording_mode")==c.getString(1) &&
|
||||
tip.getString("tracking_mode")==c.getString(2) && tip.getInt("data_fields")==c.getInt(3))
|
||||
item.put("history", history)
|
||||
array.put(item)
|
||||
}
|
||||
}
|
||||
return JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
|
||||
.put("generated_at",OffsetDateTime.now().toString()).put("exercises",array).toString()
|
||||
}
|
||||
|
||||
fun applyExerciseProfileStateJson(json: String, allowPending: Boolean = false): ExerciseProfileStateImportResult {
|
||||
if (!hasStrictJsonShape(json))
|
||||
return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide ou champ dupliqué.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return ExerciseProfileStateImportResult.Invalid("Profile-state JSON invalide.")
|
||||
}
|
||||
if (!root.hasExactKeys(setOf("format","version","generated_at","exercises")) ||
|
||||
root.value("format") != "trainlog-exercise-profile-state" || !root.value("version").isExactJsonInteger(1) ||
|
||||
root.value("generated_at") !is String || TrainlogTimestamp.parse(root.value("generated_at") as String) == null)
|
||||
return ExerciseProfileStateImportResult.Invalid("Profile-state non supporté.")
|
||||
val entries = root.value("exercises") as? JSONArray
|
||||
?: return ExerciseProfileStateImportResult.Invalid("Profile-state sans exercices.")
|
||||
val db=database.writableDatabase;var updated=0;var skipped=0;val seen=mutableSetOf<String>()
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
for(i in 0 until entries.length()) {
|
||||
val x=entries.get(i) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val required=setOf("exercise_id","recording_mode","tracking_mode","data_fields",
|
||||
"load_semantics","machine_variant","machine_provenance","scientific_profile_id",
|
||||
"science_state","legacy_equipment_id","revision_id","parent_revision_id","legacy_seed","history")
|
||||
if(!x.hasExactKeys(required)) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val id=x.value("exercise_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val recording=x.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val tracking=x.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val fieldsValue=x.value("data_fields"); if(!fieldsValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val fieldsLong=(fieldsValue as Number).toLong()
|
||||
if(fieldsLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val fields=fieldsLong.toInt()
|
||||
val revision=x.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val parent=if(x.value("parent_revision_id")===JSONObject.NULL)null else x.value("parent_revision_id") as? String
|
||||
?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val legacy=x.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val nullableAdjacent=listOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","legacy_equipment_id")
|
||||
if(nullableAdjacent.any { x.value(it)!==JSONObject.NULL && x.value(it) !is String } || x.value("science_state") !in setOf("resolved","unresolved") ||
|
||||
!EXERCISE_ID_V4_PATTERN.matches(id)||!seen.add(id)||recording !in setOf("sets","continuous")||
|
||||
tracking !in setOf("reps","duration")||(recording=="continuous"&&tracking!="duration")||
|
||||
(recording=="sets"&&fieldsLong!=0L)||
|
||||
(legacy&&(revision!="pr_legacy_v1"||parent!=null))||(!legacy&&(parent==null||
|
||||
!PROFILE_REVISION_PATTERN.matches(revision)||revision!=exerciseProfileRevision(parent,recording,tracking,fields))))
|
||||
return ExerciseProfileStateImportResult.Invalid("Entrée profile-state invalide.")
|
||||
val history=x.value("history") as? JSONArray ?: return ExerciseProfileStateImportResult.Invalid("Historique profile-state invalide.")
|
||||
if(history.length() !in 1..PROFILE_HISTORY_MAX) return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite.")
|
||||
val revisions=mutableListOf<List<Any?>>()
|
||||
val historyIds=mutableSetOf<String>();var previous:String?=null
|
||||
val revisionKeys=setOf("revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed")
|
||||
for(j in 0 until history.length()) {
|
||||
val h=history.get(j) as? JSONObject ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
if(!h.hasExactKeys(revisionKeys)) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val hr=h.value("recording_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val ht=h.value("tracking_mode") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val hfValue=h.value("data_fields");if(!hfValue.isExactJsonInteger()) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val hfLong=(hfValue as Number).toLong();val hid=h.value("revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
if(hfLong !in 0L..3L) return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val hf=hfLong.toInt()
|
||||
val hp=if(h.value("parent_revision_id")===JSONObject.NULL)null else h.value("parent_revision_id") as? String ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
val hl=h.value("legacy_seed") as? Boolean ?: return ExerciseProfileStateImportResult.Invalid("Révision profile-state invalide.")
|
||||
if(hr !in setOf("sets","continuous")||ht !in setOf("reps","duration")||(hr=="continuous"&&ht!="duration")||(hr=="sets"&&hfLong!=0L)||!historyIds.add(hid)||(j>0&&hp!=previous)||
|
||||
(j==0&&(!hl||hid!="pr_legacy_v1"||hp!=null))||(j>0&&hl)||
|
||||
(hl&&(hid!="pr_legacy_v1"||hp!=null))||(!hl&&(hp==null||!PROFILE_REVISION_PATTERN.matches(hid)||hid!=exerciseProfileRevision(hp,hr,ht,hf))))
|
||||
return ExerciseProfileStateImportResult.Invalid("Chaîne profile-state invalide.")
|
||||
revisions.add(listOf(hid,hp,hr,ht,hf,hl));previous=hid
|
||||
}
|
||||
if(revisions.last()!=listOf(revision,parent,recording,tracking,fields,legacy)) return ExerciseProfileStateImportResult.Invalid("Pointe profile-state incohérente.")
|
||||
val row=db.rawQuery("SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,"+
|
||||
"e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,"+
|
||||
"s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",arrayOf(id)).use{c->
|
||||
if(!c.moveToFirst()) null else (0..12).map{if(c.isNull(it)) null else c.getString(it)}}
|
||||
if(row==null){if(allowPending){skipped++;continue};return ExerciseProfileStateImportResult.Invalid("Identité exercice inconnue : $id")}
|
||||
val incomingAdj=arrayOf("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id").map{key->if(x.value(key)===JSONObject.NULL)null else x.value(key) as String}
|
||||
if((4..9).map{row[it]}!=incomingAdj)return ExerciseProfileStateImportResult.Conflict(id)
|
||||
val localRevision=row[10]!!;val localParent=row[11];val localLegacy=row[12]=="1"
|
||||
val sameProfile=row[1]==recording&&row[2]==tracking&&row[3]?.toInt()==fields
|
||||
val accept = when {
|
||||
localRevision==revision && sameProfile -> false
|
||||
localRevision==revision && localLegacy && legacy -> true
|
||||
localRevision in historyIds -> true
|
||||
db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revision)).use{it.moveToFirst()} -> false
|
||||
else -> return ExerciseProfileStateImportResult.Conflict(id)
|
||||
}
|
||||
if(!accept){skipped++;continue}
|
||||
val knownCount=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(row[0])).use{it.moveToFirst();it.getInt(0)}
|
||||
val additions=revisions.count { revisionRow -> db.rawQuery("SELECT 1 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?",arrayOf(row[0],revisionRow[0] as String)).use{!it.moveToFirst()} }
|
||||
if(knownCount+additions>PROFILE_HISTORY_MAX)return ExerciseProfileStateImportResult.Invalid("Historique profile-state hors limite : $id")
|
||||
revisions.forEach { h -> db.execSQL("INSERT OR IGNORE INTO exercise_profile_revisions VALUES(?,?,?,?,?,?,?)",arrayOf(row[0],h[0],h[1],h[2],h[3],h[4],if(h[5] as Boolean)1 else 0)) }
|
||||
if(legacy) db.execSQL(
|
||||
"UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'",
|
||||
arrayOf<Any?>(recording,tracking,fields,row[0]),
|
||||
)
|
||||
db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",arrayOf<Any?>(revision,parent,if(legacy)1 else 0,row[0]!!.toLong()))
|
||||
db.execSQL("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",arrayOf<Any?>(recording,tracking,fields,row[0]!!.toLong()))
|
||||
updated++
|
||||
}
|
||||
db.setTransactionSuccessful();ExerciseProfileStateImportResult.Applied(updated,skipped)
|
||||
} catch(_:Exception){ExerciseProfileStateImportResult.DatabaseError} finally {if(db.inTransaction())db.endTransaction()}
|
||||
}
|
||||
|
||||
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.")
|
||||
|
|
@ -1763,6 +2015,37 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: correcting history changes facts, not their logical owners.
|
||||
* CONTRACT: the session row and every retained entry_id survive; feedback
|
||||
* roots/revisions are reattached only to retained entry_ids and follow-ups
|
||||
* remain attached to the untouched session row. The complete replacement
|
||||
* is one transaction, so any failure restores the original history.
|
||||
*/
|
||||
fun correctCompletedSession(
|
||||
sessionId: String,
|
||||
draft: SessionDraft,
|
||||
): CorrectCompletedSessionResult {
|
||||
if (sessionId.isBlank() || draft.exercises.isEmpty() ||
|
||||
draft.exercises.map { it.entryId }.distinct().size != draft.exercises.size ||
|
||||
draft.exercises.any { !validateSessionExercise(it, draft.sessionType) }) {
|
||||
return CorrectCompletedSessionResult.Invalid("La correction de séance est invalide.")
|
||||
}
|
||||
val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
insertCompletedSession(db, draft, sessionId, generalCorrection = true)
|
||||
db.setTransactionSuccessful()
|
||||
CorrectCompletedSessionResult.Saved
|
||||
} catch (error: Exception) {
|
||||
CorrectCompletedSessionResult.DatabaseError(
|
||||
error.message ?: "Correction de la séance impossible."
|
||||
)
|
||||
} finally {
|
||||
if (db.inTransaction()) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun listSessions(): List<SessionSummary> {
|
||||
val output =
|
||||
mutableListOf<SessionSummary>()
|
||||
|
|
@ -2157,6 +2440,10 @@ class TrainlogRepository(
|
|||
) {
|
||||
return PcCatalogImportResult.DatabaseError
|
||||
}
|
||||
if (byId.dataFields != richerFields && !advanceExerciseProfileRevision(
|
||||
db, byId.rowId, byId.recordingMode.wireValue,
|
||||
byId.trackingMode.wireValue, richerFields))
|
||||
return PcCatalogImportResult.DatabaseError
|
||||
|
||||
reconciled += 1
|
||||
traceDecision(
|
||||
|
|
@ -2227,6 +2514,11 @@ class TrainlogRepository(
|
|||
) {
|
||||
return PcCatalogImportResult.DatabaseError
|
||||
}
|
||||
val mergedFields = byName.dataFields or dataFields
|
||||
if (byName.dataFields != mergedFields && !advanceExerciseProfileRevision(
|
||||
db, byName.rowId, byName.recordingMode.wireValue,
|
||||
byName.trackingMode.wireValue, mergedFields))
|
||||
return PcCatalogImportResult.DatabaseError
|
||||
|
||||
reconciled += 1
|
||||
traceDecision(
|
||||
|
|
@ -2274,6 +2566,26 @@ class TrainlogRepository(
|
|||
null,
|
||||
values
|
||||
)
|
||||
/* WHY: catalog reconciliation inserts outside the local
|
||||
* creation API. CONTRACT: the strict profile-state second
|
||||
* pass must observe a canonical causal root, never the
|
||||
* trigger's transaction-local placeholder. */
|
||||
db.execSQL(
|
||||
"UPDATE exercise_profile_state SET revision_id=?," +
|
||||
"parent_revision_id='pr_legacy_v1',legacy_seed=0 " +
|
||||
"WHERE exercise_row_id=(SELECT id FROM exercises WHERE exercise_id=?);",
|
||||
arrayOf(
|
||||
exerciseProfileRevision(
|
||||
"pr_legacy_v1", recording.wireValue,
|
||||
tracking.wireValue, dataFields,
|
||||
),
|
||||
exerciseId,
|
||||
),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_profile_revisions SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,0 FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?",
|
||||
arrayOf(exerciseId),
|
||||
)
|
||||
|
||||
imported += 1
|
||||
traceDecision("none", "inserted")
|
||||
|
|
@ -2897,26 +3209,34 @@ class TrainlogRepository(
|
|||
arrayOf(resolveExerciseId(db, exerciseId)),
|
||||
)
|
||||
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||
if (
|
||||
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
|
||||
entry.optString("tracking_mode") != exerciseRow.trackingMode.wireValue ||
|
||||
entry.optInt("data_fields", -1) and
|
||||
exerciseRow.dataFields.inv() != 0
|
||||
) {
|
||||
return MobileSessionImportResult.Invalid("Profil V2 incompatible : $exerciseId")
|
||||
}
|
||||
/* CONTRACT: the resolved row proves stable exercise
|
||||
* identity only. Completed occurrences own their profile
|
||||
* snapshot; CURRENT catalogue evolution cannot reject it. */
|
||||
}
|
||||
val existingRowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
|
||||
if (it.moveToFirst()) it.getLong(0) else null
|
||||
}
|
||||
val retainedFeedback = mutableMapOf<String, MutableList<Triple<String,String,String>>>()
|
||||
val retainedRevisions = mutableMapOf<String, MutableList<Triple<String,String,String>>>()
|
||||
if (existingRowId != null) {
|
||||
if (pcSessionMatches(db, existingRowId, session, version)) {
|
||||
sessionsSkipped += 1
|
||||
continue
|
||||
}
|
||||
if (!pcResumedMaxUpdateIsSafe(db, existingRowId, session)) {
|
||||
/* V2 has no complete planning authority; it must never
|
||||
* erase V3 targets while applying a correction. */
|
||||
if (version != 3 && session.optString("session_type") != "max_test") return MobileSessionImportResult.Invalid(
|
||||
"Une correction de séance exige le snapshot V3 complet."
|
||||
)
|
||||
if (!pcCompletedCorrectionIsSafe(db, existingRowId, session)) {
|
||||
return MobileSessionImportResult.Invalid("Conflit de contenu pour la séance $sessionId")
|
||||
}
|
||||
db.rawQuery("SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor ->
|
||||
while (cursor.moveToNext()) retainedFeedback.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3)))
|
||||
}
|
||||
db.rawQuery("SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text FROM exercise_feedback_revisions r JOIN exercise_feedback f ON f.feedback_id=r.feedback_id JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?", arrayOf(existingRowId.toString())).use { cursor ->
|
||||
while (cursor.moveToNext()) retainedRevisions.getOrPut(cursor.getString(0)){mutableListOf()}.add(Triple(cursor.getString(1),cursor.getString(2),cursor.getString(3)))
|
||||
}
|
||||
/* Child replacement is inside this import transaction;
|
||||
* entry identity/order may only be retained and appended. */
|
||||
db.delete(
|
||||
|
|
@ -2976,6 +3296,21 @@ class TrainlogRepository(
|
|||
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||
}
|
||||
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
||||
/* INVARIANT: correction transport follows session_id +
|
||||
* entry_id. Retained observations keep all immutable
|
||||
* revisions; an omitted entry intentionally inherits none. */
|
||||
retainedFeedback[entryId].orEmpty().forEach { feedback ->
|
||||
db.insertOrThrow("exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id",feedback.first);put("session_exercise_row_id",occurrence)
|
||||
put("observed_at",feedback.second);put("raw_text",feedback.third)
|
||||
})
|
||||
retainedRevisions[feedback.first].orEmpty().forEach { revision ->
|
||||
db.insertOrThrow("exercise_feedback_revisions",null,ContentValues().apply {
|
||||
put("revision_id",revision.first);put("feedback_id",feedback.first)
|
||||
put("created_at",revision.second);put("raw_text",revision.third)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (entry.has("max_weight_kg")) {
|
||||
db.insertOrThrow("max_results", null, ContentValues().apply {
|
||||
put("session_exercise_row_id", occurrence)
|
||||
|
|
@ -3059,7 +3394,6 @@ class TrainlogRepository(
|
|||
}
|
||||
return try {
|
||||
val exerciseIds = mutableSetOf<String>()
|
||||
val exerciseProfiles = mutableMapOf<String, Triple<String, String, Int>>()
|
||||
val exerciseKeys = setOf("exercise_id", "name", "recording_mode", "tracking_mode", "data_fields")
|
||||
val exercises = root.getJSONArray("exercises")
|
||||
for (index in 0 until exercises.length()) {
|
||||
|
|
@ -3069,11 +3403,6 @@ class TrainlogRepository(
|
|||
!item.value("name").isNonemptyJsonString() || !validJsonProfile(item)) {
|
||||
return "Catalogue exercices V2 invalide."
|
||||
}
|
||||
exerciseProfiles[id] = Triple(
|
||||
item.getString("recording_mode"),
|
||||
item.getString("tracking_mode"),
|
||||
item.getInt("data_fields"),
|
||||
)
|
||||
}
|
||||
|
||||
val sessionKeys = setOf("session_id", "started_at", "session_type", "exercises")
|
||||
|
|
@ -3109,29 +3438,25 @@ class TrainlogRepository(
|
|||
val exerciseId = entry.value("exercise_id")
|
||||
val positionValue = entry.value("position")
|
||||
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
|
||||
!exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() ||
|
||||
!validJsonProfile(entry) || !validEntryPlan(entry, version, recording as String, tracking as String, hasMax) ||
|
||||
!exerciseId.isNonemptyJsonString() || !entry.value("name").isNonemptyJsonString() ||
|
||||
!positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) ||
|
||||
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
||||
return "Entrée de séance V2 invalide."
|
||||
}
|
||||
if (exerciseId !in exerciseIds) {
|
||||
return "Exercice de séance V2 absent du catalogue."
|
||||
}
|
||||
if (!validJsonProfile(entry)) {
|
||||
return "Profil historique V2 invalide."
|
||||
}
|
||||
if (!validEntryPlan(entry, version, recording as String, tracking as String, hasMax)) {
|
||||
return "Plan/payload historique V2 incompatible avec son profil."
|
||||
}
|
||||
if (hasMax &&
|
||||
(session.getString("session_type") != "max_test" ||
|
||||
!entry.value("max_weight_kg").isPositiveJsonNumber())) {
|
||||
return "Résultat max V2 invalide."
|
||||
}
|
||||
val catalogProfile = exerciseProfiles[exerciseId]
|
||||
?: return "Exercice de séance V2 absent du catalogue."
|
||||
val entryFields = entry.getInt("data_fields")
|
||||
if (
|
||||
entry.getString("recording_mode") != catalogProfile.first ||
|
||||
entry.getString("tracking_mode") != catalogProfile.second ||
|
||||
entryFields and catalogProfile.third.inv() != 0
|
||||
) {
|
||||
/* CONTRACT: richer current catalog metadata must not
|
||||
* rewrite an older occurrence snapshot. */
|
||||
return "Profil historique V2 incompatible avec le catalogue."
|
||||
}
|
||||
if (hasMax) {
|
||||
/* The exact-key check above excludes set/continuous
|
||||
* shadows; max identity remains the exercise entry. */
|
||||
|
|
@ -3280,6 +3605,9 @@ class TrainlogRepository(
|
|||
val number = toDouble()
|
||||
return number.isFinite() && number % 1.0 == 0.0 && number >= minimum && number <= maximum
|
||||
}
|
||||
/** CONTRACT: profile-state mirrors Python's exact JSON integer rule; no strings, booleans, or 1.0. */
|
||||
private fun Any?.isExactJsonInteger(expected: Int? = null): Boolean =
|
||||
(this is Int || this is Long) && (expected == null || (this as Number).toLong() == expected.toLong())
|
||||
private fun Any?.isPositiveJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() > 0.0
|
||||
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
||||
this is Number && toDouble().isFinite() && toDouble() >= 0.0
|
||||
|
|
@ -3353,18 +3681,17 @@ class TrainlogRepository(
|
|||
return true
|
||||
}
|
||||
|
||||
private fun pcResumedMaxUpdateIsSafe(
|
||||
private fun pcCompletedCorrectionIsSafe(
|
||||
db: SQLiteDatabase,
|
||||
rowId: Long,
|
||||
session: JSONObject,
|
||||
): Boolean {
|
||||
if (session.optString("session_type") != "max_test") return false
|
||||
val headerMatches = db.rawQuery(
|
||||
"SELECT started_at,session_type FROM sessions WHERE id=?",
|
||||
arrayOf(rowId.toString()),
|
||||
).use {
|
||||
it.moveToFirst() && it.getString(0) == session.optString("started_at") &&
|
||||
it.getString(1) == "max_test"
|
||||
it.getString(1) == session.optString("session_type")
|
||||
}
|
||||
if (!headerMatches) return false
|
||||
val current = mutableListOf<Triple<String, Int, String>>()
|
||||
|
|
@ -3379,14 +3706,14 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
val incoming = session.optJSONArray("exercises") ?: return false
|
||||
if (incoming.length() < current.size) return false
|
||||
return current.indices.all { index ->
|
||||
val currentById = current.associateBy { it.first }
|
||||
val seen = mutableSetOf<String>()
|
||||
return (0 until incoming.length()).all { index ->
|
||||
val item = incoming.optJSONObject(index) ?: return@all false
|
||||
current[index] == Triple(
|
||||
item.optString("entry_id"),
|
||||
item.optInt("position", -1),
|
||||
resolveExerciseId(db, item.optString("exercise_id")),
|
||||
)
|
||||
val entryId = item.optString("entry_id")
|
||||
val existing = currentById[entryId]
|
||||
seen.add(entryId) && (existing == null || existing.third ==
|
||||
resolveExerciseId(db, item.optString("exercise_id")))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5623,6 +5950,7 @@ class TrainlogRepository(
|
|||
db: SQLiteDatabase,
|
||||
draft: SessionDraft,
|
||||
sourceSessionId: String? = null,
|
||||
generalCorrection: Boolean = false,
|
||||
): String {
|
||||
val sessionId: String
|
||||
val sessionRowId: Long
|
||||
|
|
@ -5642,20 +5970,24 @@ class TrainlogRepository(
|
|||
}
|
||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||
} else {
|
||||
check(draft.sessionType == SessionType.MAX_TEST) {
|
||||
check(generalCorrection || draft.sessionType == SessionType.MAX_TEST) {
|
||||
"Seul un Test max peut remplacer une séance reprise."
|
||||
}
|
||||
sessionId = sourceSessionId
|
||||
sessionRowId = db.rawQuery(
|
||||
"SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';",
|
||||
if (generalCorrection) "SELECT id FROM sessions WHERE session_id=?;"
|
||||
else "SELECT id FROM sessions WHERE session_id=? AND session_type='max_test';",
|
||||
arrayOf(sourceSessionId),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) { "Séance Test max source introuvable." }
|
||||
cursor.getLong(0)
|
||||
}
|
||||
check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
|
||||
check(generalCorrection || resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
|
||||
"Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes."
|
||||
}
|
||||
check(!generalCorrection || completedCorrectionIdentityIsSafe(db, sessionRowId, draft)) {
|
||||
"Une correction ne peut réaffecter un entry_id existant à un autre exercice."
|
||||
}
|
||||
/* Android has one bounded completed-session correction path:
|
||||
* resumed MAX_TEST finalization. Preserve feedback by the stable
|
||||
* occurrence entry_id before cascading reconstruction; the
|
||||
|
|
@ -5675,7 +6007,7 @@ class TrainlogRepository(
|
|||
).use { cursor -> while (cursor.moveToNext()) retainedCompletedRevisions
|
||||
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
||||
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
||||
db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
|
||||
if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
|
||||
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
|
||||
/* INVARIANT: child replacement and draft deletion are in the
|
||||
* caller's transaction; failure restores the completed baseline. */
|
||||
|
|
@ -5822,6 +6154,34 @@ class TrainlogRepository(
|
|||
return sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: an occurrence entry_id retains its exercise identity during a
|
||||
* completed-session replacement. Aliases compare canonical creator IDs so
|
||||
* retirement/reconciliation cannot manufacture a false reassignment.
|
||||
* This guard runs inside the replacement transaction before any delete.
|
||||
*/
|
||||
private fun completedCorrectionIdentityIsSafe(
|
||||
db: SQLiteDatabase,
|
||||
sessionRowId: Long,
|
||||
draft: SessionDraft,
|
||||
): Boolean {
|
||||
fun canonical(id: String): String = db.rawQuery(
|
||||
"SELECT COALESCE((SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?),?)",
|
||||
arrayOf(id, id),
|
||||
).use { cursor -> check(cursor.moveToFirst()); cursor.getString(0) }
|
||||
val incoming = draft.exercises.associate { it.entryId to canonical(it.exercise.exerciseId) }
|
||||
return db.rawQuery(
|
||||
"SELECT se.entry_id,e.exercise_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id " +
|
||||
"WHERE se.session_row_id=?", arrayOf(sessionRowId.toString()),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val replacement = incoming[cursor.getString(0)] ?: continue
|
||||
if (replacement != canonical(cursor.getString(1))) return@use false
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumedDraftIdentityIsSafe(
|
||||
db: SQLiteDatabase,
|
||||
sessionRowId: Long,
|
||||
|
|
@ -6252,7 +6612,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
15,
|
||||
16,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -6268,7 +6628,16 @@ private class TrainlogDatabaseHelper(
|
|||
db: SQLiteDatabase,
|
||||
) {
|
||||
super.onOpen(db)
|
||||
migrateApprovedLegPressIdentity(db)
|
||||
db.beginTransaction()
|
||||
try {
|
||||
migrateApprovedLegPressIdentity(db)
|
||||
/* WHY: early unpublished v16 builds used a variable-width pr1
|
||||
* current token. CONTRACT: recognize only its exact grammar and
|
||||
* atomically replace it by the shared root plus canonical pr2 tip. */
|
||||
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||
normalizePrototypeExerciseProfileState(db)
|
||||
db.setTransactionSuccessful()
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
override fun onCreate(
|
||||
|
|
@ -6283,6 +6652,7 @@ private class TrainlogDatabaseHelper(
|
|||
createExerciseAliasTable(db)
|
||||
createTrainingFeedbackTables(db)
|
||||
createTrainingFeedbackRevisionTables(db)
|
||||
createExerciseProfileStateTable(db, seedLegacy = false)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -6401,6 +6771,15 @@ private class TrainlogDatabaseHelper(
|
|||
version = 15
|
||||
}
|
||||
|
||||
if (version < 16 && newVersion >= 16) {
|
||||
/* WHY/CONTRACT/INVARIANT: wall clocks cannot order offline profile
|
||||
* edits. Existing rows share one explicit legacy root without any
|
||||
* catalogue or occurrence rewrite; later edits carry direct causal
|
||||
* ancestry in this companion table. */
|
||||
createExerciseProfileStateTable(db, seedLegacy = true)
|
||||
version = 16
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -6430,6 +6809,91 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createExerciseProfileStateTable(db: SQLiteDatabase, seedLegacy: Boolean) {
|
||||
/* WHY: wall clocks cannot order offline edits. CONTRACT: revisions form
|
||||
* one explicit causal chain. INVARIANT: at most 32 records are retained
|
||||
* per exercise and overflow aborts instead of pruning ancestry. */
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS exercise_profile_state(" +
|
||||
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," +
|
||||
"revision_id TEXT NOT NULL,parent_revision_id TEXT," +
|
||||
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));",
|
||||
)
|
||||
if (seedLegacy) {
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO exercise_profile_state(" +
|
||||
"exercise_row_id,revision_id,parent_revision_id,legacy_seed) " +
|
||||
"SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;",
|
||||
)
|
||||
}
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS exercise_profile_revisions(" +
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," +
|
||||
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL," +
|
||||
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL," +
|
||||
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1))," +
|
||||
"PRIMARY KEY(exercise_row_id,revision_id));",
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO exercise_profile_revisions " +
|
||||
"SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed " +
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;",
|
||||
)
|
||||
db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_insert;")
|
||||
db.execSQL("DROP TRIGGER IF EXISTS exercise_profile_state_update;")
|
||||
db.execSQL(
|
||||
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN " +
|
||||
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) " +
|
||||
"VALUES(NEW.id,'pr_legacy_v1',NULL,1);" +
|
||||
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);END;",
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizePrototypeExerciseProfileState(db: SQLiteDatabase) {
|
||||
data class Prototype(val rowId: Long, val token: String, val recording: String,
|
||||
val tracking: String, val fields: Int)
|
||||
val prototypes=mutableMapOf<Long,Prototype>()
|
||||
db.rawQuery(
|
||||
"SELECT s.exercise_row_id,s.revision_id,s.parent_revision_id,s.legacy_seed,"+
|
||||
"e.recording_mode,e.tracking_mode,e.data_fields FROM exercise_profile_state s "+
|
||||
"JOIN exercises e ON e.id=s.exercise_row_id WHERE s.revision_id LIKE 'pr1%'", null,
|
||||
).use { c -> while(c.moveToNext()) {
|
||||
val token=c.getString(1);val match=PROTOTYPE_PROFILE_REVISION_PATTERN.matchEntire(token)
|
||||
?: error("Malformed prototype profile revision")
|
||||
val recording=match.groupValues[1];val tracking=match.groupValues[2]
|
||||
val fields=match.groupValues[3].toIntOrNull() ?: error("Malformed prototype profile fields")
|
||||
check(token=="pr1|$recording|$tracking|$fields" && c.isNull(2)&&c.getInt(3)==1 &&
|
||||
recording==c.getString(4) && tracking==c.getString(5) &&
|
||||
fields==c.getInt(6) && fields in 0..3 && !(recording=="sets"&&fields!=0) &&
|
||||
!(recording=="continuous"&&tracking!="duration")) { "Inconsistent prototype profile state" }
|
||||
prototypes[c.getLong(0)]=Prototype(c.getLong(0),token,recording,tracking,fields)
|
||||
}}
|
||||
db.rawQuery(
|
||||
"SELECT exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed "+
|
||||
"FROM exercise_profile_revisions WHERE revision_id LIKE 'pr1%'", null,
|
||||
).use { c -> while(c.moveToNext()) {
|
||||
val prototype=prototypes[c.getLong(0)] ?: error("Orphan prototype profile revision")
|
||||
check(c.getString(1)==prototype.token && c.isNull(2) && c.getString(3)==prototype.recording &&
|
||||
c.getString(4)==prototype.tracking && c.getInt(5)==prototype.fields && c.getInt(6)==1) {
|
||||
"Inconsistent prototype profile revision"
|
||||
}
|
||||
}}
|
||||
for(prototype in prototypes.values) {
|
||||
val count=db.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",
|
||||
arrayOf(prototype.rowId.toString())).use { it.moveToFirst();it.getInt(0) }
|
||||
check(count==1) { "Mixed prototype profile lineage" }
|
||||
val child=exerciseProfileRevision("pr_legacy_v1",prototype.recording,prototype.tracking,prototype.fields)
|
||||
db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id LIKE 'pr1%'",
|
||||
arrayOf(prototype.rowId))
|
||||
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr_legacy_v1',NULL,?,?,?,1)",
|
||||
arrayOf<Any?>(prototype.rowId,prototype.recording,prototype.tracking,prototype.fields))
|
||||
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,?,'pr_legacy_v1',?,?,?,0)",
|
||||
arrayOf<Any?>(prototype.rowId,child,prototype.recording,prototype.tracking,prototype.fields))
|
||||
db.execSQL("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id='pr_legacy_v1',legacy_seed=0 WHERE exercise_row_id=?",
|
||||
arrayOf<Any?>(child,prototype.rowId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExerciseAliasTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +
|
||||
|
|
|
|||
|
|
@ -124,18 +124,31 @@ internal object TrainlogTimestamp {
|
|||
|
||||
/** Shared Android presentation rule; exact instants are parsed before
|
||||
* subtraction and a missing/negative anchor never produces a fabricated H+. */
|
||||
fun sessionFollowUpElapsedLabel(endedAt: String?, observedAt: String): String {
|
||||
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
|
||||
fun sessionFollowUpElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
|
||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
||||
val seconds = observed.utcSecond - end.utcSecond
|
||||
return if (seconds < 0) "H+?" else "H+${seconds / 3600L}"
|
||||
val end = endedAt?.let(TrainlogTimestamp::parse)
|
||||
if (end != null) {
|
||||
val seconds = observed.utcSecond - end.utcSecond
|
||||
return if (seconds < 0) "H+?" else "H+${seconds / 3600L}"
|
||||
}
|
||||
val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
|
||||
val seconds = observed.utcSecond - start.utcSecond
|
||||
return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}"
|
||||
}
|
||||
|
||||
/** Exercise observations may precede finalization and therefore use a
|
||||
* session-neutral label instead of a negative or misleading H+ value. */
|
||||
fun exerciseFeedbackElapsedLabel(endedAt: String?, observedAt: String): String {
|
||||
val end = endedAt?.let(TrainlogTimestamp::parse) ?: return "Ressenti"
|
||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "Ressenti"
|
||||
val seconds = observed.utcSecond - end.utcSecond
|
||||
return if (seconds < 0) "Pendant la séance" else "H+${seconds / 3600L}"
|
||||
fun exerciseFeedbackElapsedLabel(endedAt: String?, startedAt: String?, observedAt: String): String {
|
||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
||||
val end = endedAt?.let(TrainlogTimestamp::parse)
|
||||
if (end != null) {
|
||||
val seconds = observed.utcSecond - end.utcSecond
|
||||
if (seconds >= 0) return "H+${seconds / 3600L}"
|
||||
val start = startedAt?.let(TrainlogTimestamp::parse)
|
||||
return if (start != null && observed.utcSecond >= start.utcSecond)
|
||||
"Pendant la séance" else "H+?"
|
||||
}
|
||||
val start = startedAt?.let(TrainlogTimestamp::parse) ?: return "H+?"
|
||||
val seconds = observed.utcSecond - start.utcSecond
|
||||
return if (seconds < 0) "H+?" else "≈H+${seconds / 3600L}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ sealed interface AppRoute {
|
|||
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object Exercises : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data class ExerciseDetail(val exerciseId: String) : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data class ExerciseEdit(val exerciseId: String, val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
|
|
@ -158,6 +159,7 @@ internal fun routeTitle(route: AppRoute): String = when (route) {
|
|||
AppRoute.SessionGenerator -> "Programmer une séance"
|
||||
AppRoute.CompletedSessions -> "Séances effectuées"
|
||||
is AppRoute.SessionDetail -> "Détail de la séance"
|
||||
is AppRoute.SessionCorrection -> "Modifier la séance"
|
||||
AppRoute.Exercises -> "Exercices"
|
||||
is AppRoute.ExerciseDetail -> "Fiche exercice"
|
||||
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.labfytools.trainlog.data.CorrectCompletedSessionResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
/**
|
||||
* WHY: a completed-session correction must be reviewed as one bounded form,
|
||||
* not persisted field-by-field while the user is still editing.
|
||||
* CONTRACT: initial values are historical occurrence snapshots; Save performs
|
||||
* the sole repository mutation and Cancel performs none. Destructive removal
|
||||
* always passes through an explicit, non-dismissible confirmation.
|
||||
*/
|
||||
@Composable
|
||||
fun CompletedSessionCorrectionScreen(
|
||||
repository: TrainlogRepository,
|
||||
sessionId: String,
|
||||
onFinished: (saved: Boolean) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
|
||||
var drafts by remember(sessionId) {
|
||||
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
|
||||
}
|
||||
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") {
|
||||
if (original == null) {
|
||||
TrainlogInfo("Séance introuvable.", colors.error)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted)
|
||||
drafts.forEachIndexed { occurrenceIndex, draft ->
|
||||
TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") {
|
||||
draft.plan?.let { plan ->
|
||||
TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw ->
|
||||
raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } }
|
||||
})
|
||||
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)",
|
||||
(plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it }
|
||||
} })
|
||||
TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it }
|
||||
} })
|
||||
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it }
|
||||
} })
|
||||
}
|
||||
if (draft.maxWeightKg != null) {
|
||||
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it }
|
||||
} })
|
||||
} else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
|
||||
TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it }
|
||||
} })
|
||||
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it }
|
||||
} }) }
|
||||
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it }
|
||||
} }) }
|
||||
} else {
|
||||
draft.sets.forEachIndexed { setIndex, set ->
|
||||
TrainlogInfo("Série ${setIndex + 1}", colors.accent)
|
||||
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)",
|
||||
(if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
|
||||
} })
|
||||
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
|
||||
} })
|
||||
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette série",
|
||||
{ pendingSetRemoval = occurrenceIndex to setIndex },
|
||||
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"), colors.error)
|
||||
}
|
||||
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
|
||||
}, accent = colors.success)
|
||||
}
|
||||
TrainlogIconAction(TrainlogIcons.DeleteOutline, "Supprimer cette occurrence",
|
||||
{ pendingOccurrenceRemoval = occurrenceIndex },
|
||||
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"), colors.error)
|
||||
}
|
||||
}
|
||||
message?.let { TrainlogInfo(it, colors.error) }
|
||||
TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", {
|
||||
when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) {
|
||||
CorrectCompletedSessionResult.Saved -> onFinished(true)
|
||||
is CorrectCompletedSessionResult.Invalid -> message = result.message
|
||||
is CorrectCompletedSessionResult.DatabaseError -> message = result.message
|
||||
}
|
||||
})
|
||||
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
|
||||
}
|
||||
pendingSetRemoval?.let { (occurrence, set) ->
|
||||
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null }) {
|
||||
val draft = drafts[occurrence]
|
||||
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
|
||||
pendingSetRemoval = null
|
||||
}
|
||||
}
|
||||
pendingOccurrenceRemoval?.let { occurrence ->
|
||||
val draft = drafts[occurrence]
|
||||
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
|
||||
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.",
|
||||
"Supprimer", { pendingOccurrenceRemoval = null }) {
|
||||
drafts = drafts.filterIndexed { index, _ -> index != occurrence }
|
||||
pendingOccurrenceRemoval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft(
|
||||
entryId = entryId,
|
||||
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),
|
||||
equipmentId = equipmentId,
|
||||
maxWeightKg = maxWeightKg,
|
||||
plan = plan,
|
||||
sets = sets,
|
||||
continuousDurationSeconds = continuousDurationSeconds,
|
||||
speedKmh = speedKmh,
|
||||
distanceKm = distanceKm,
|
||||
)
|
||||
|
||||
private inline fun replaceDraft(items: List<SessionExerciseDraft>, index: Int, value: SessionExerciseDraft, update: (List<SessionExerciseDraft>) -> Unit) =
|
||||
update(items.toMutableList().apply { this[index] = value })
|
||||
|
||||
private inline fun replaceSet(items: List<SessionExerciseDraft>, occurrence: Int, set: Int, value: SessionSetDraft, update: (List<SessionExerciseDraft>) -> Unit) {
|
||||
val draft = items[occurrence]
|
||||
replaceDraft(items, occurrence, draft.copy(sets = draft.sets.toMutableList().apply { this[set] = value }), update)
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -111,10 +114,16 @@ fun ExerciseScreen(
|
|||
var message by state.message
|
||||
var editedExerciseId by state.editedExerciseId
|
||||
|
||||
val profileLocked =
|
||||
editedExerciseId?.let {
|
||||
!repository.canEditExerciseProfile(it)
|
||||
} ?: false
|
||||
val originalProfile = remember(editedExerciseId) {
|
||||
editedExerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
|
||||
}
|
||||
var confirmedIncompatible by remember(editedExerciseId) { mutableStateOf(false) }
|
||||
var confirmingIncompatible by remember(editedExerciseId) { mutableStateOf(false) }
|
||||
val incompatibleProfileChange = originalProfile?.let {
|
||||
it.recordingMode != recordingMode || it.trackingMode != trackingMode ||
|
||||
it.dataFields != ((if (speed) ExerciseDataFields.SPEED_KMH else 0) or
|
||||
(if (distance) ExerciseDataFields.DISTANCE_KM else 0))
|
||||
} == true
|
||||
|
||||
fun startEditing(exercise: ExerciseProfile) {
|
||||
/* WHY: edit state copies catalog metadata for presentation only. The
|
||||
|
|
@ -159,9 +168,8 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.SETS,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
recordingMode =
|
||||
RecordingMode.SETS
|
||||
|
||||
|
|
@ -176,9 +184,8 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
recordingMode ==
|
||||
RecordingMode.CONTINUOUS,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
recordingMode =
|
||||
RecordingMode.CONTINUOUS
|
||||
|
||||
|
|
@ -203,9 +210,8 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.REPS,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
trackingMode =
|
||||
TrackingMode.REPS
|
||||
|
||||
|
|
@ -219,9 +225,8 @@ fun ExerciseScreen(
|
|||
selected =
|
||||
trackingMode ==
|
||||
TrackingMode.DURATION,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
trackingMode =
|
||||
TrackingMode.DURATION
|
||||
|
||||
|
|
@ -241,9 +246,8 @@ fun ExerciseScreen(
|
|||
TrainlogChoice(
|
||||
label = "Vitesse",
|
||||
selected = speed,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
speed = !speed
|
||||
message = null
|
||||
},
|
||||
|
|
@ -252,9 +256,8 @@ fun ExerciseScreen(
|
|||
TrainlogChoice(
|
||||
label = "Distance",
|
||||
selected = distance,
|
||||
enabled = !profileLocked,
|
||||
enabled = true,
|
||||
onClick = {
|
||||
if (profileLocked) return@TrainlogChoice
|
||||
distance =
|
||||
!distance
|
||||
|
||||
|
|
@ -344,12 +347,10 @@ fun ExerciseScreen(
|
|||
color = colors.accent,
|
||||
)
|
||||
|
||||
if (profileLocked) {
|
||||
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Profil verrouillé : cet exercice est déjà référencé " +
|
||||
"par une séance terminée ou le brouillon actif. " +
|
||||
"Le nom reste modifiable.",
|
||||
"Les séances passées et l'occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s'appliquera aux prochaines utilisations.",
|
||||
color = colors.warning,
|
||||
)
|
||||
}
|
||||
|
|
@ -370,6 +371,10 @@ fun ExerciseScreen(
|
|||
"Conserver l'identité et mettre à jour le catalogue."
|
||||
},
|
||||
onClick = save@{
|
||||
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
|
||||
confirmingIncompatible = true
|
||||
return@save
|
||||
}
|
||||
if (editedExerciseId == null &&
|
||||
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
||||
message = "Une zone principale est requise pour un nouvel exercice musculaire."
|
||||
|
|
@ -560,6 +565,17 @@ fun ExerciseScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (confirmingIncompatible) AlertDialog(
|
||||
onDismissRequest = { confirmingIncompatible = false },
|
||||
title = { Text("Modifier le mode de suivi ?") },
|
||||
text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") },
|
||||
confirmButton = { TextButton(onClick = {
|
||||
confirmedIncompatible = true
|
||||
confirmingIncompatible = false
|
||||
message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations."
|
||||
}) { Text("Modifier") } },
|
||||
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ fun SessionDetailScreen(
|
|||
repository: TrainlogRepository,
|
||||
sessionId: String?,
|
||||
onBack: () -> Unit,
|
||||
onResumeMaxTest: () -> Unit,
|
||||
onCorrect: () -> Unit = {},
|
||||
onResumeMaxTest: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -103,6 +104,13 @@ fun SessionDetailScreen(
|
|||
"${detail.summary.exerciseCount} exercice(s)"
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label = "Modifier la séance",
|
||||
description = "Corriger les faits enregistrés sans changer l'identité de la séance.",
|
||||
accent = colors.accent,
|
||||
onClick = onCorrect,
|
||||
)
|
||||
|
||||
if (detail.summary.sessionType == SessionType.MAX_TEST) {
|
||||
TrainlogAction(
|
||||
label = "Reprendre ce Test max",
|
||||
|
|
@ -200,7 +208,7 @@ fun SessionDetailScreen(
|
|||
)
|
||||
}
|
||||
exercise.feedback.forEach { item ->
|
||||
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
|
||||
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
|
||||
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
|
||||
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
|
||||
|
|
@ -232,7 +240,7 @@ fun SessionDetailScreen(
|
|||
|
||||
TrainlogFrame(title = "Suivi après séance") {
|
||||
detail.followUps.forEach { item ->
|
||||
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, item.observedAt), color = colors.accent)
|
||||
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
|
||||
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
|
||||
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
|
||||
|
|
|
|||
|
|
@ -9,24 +9,58 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncExportResult
|
||||
import com.labfytools.trainlog.data.SyncReceiptResult
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.logExchangeMediaStore
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/* Package-visible for orchestration tests: this is the production ordering
|
||||
* boundary, not a duplicate test-only implementation. */
|
||||
internal suspend fun publishBundleAndRequest(
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.begin")
|
||||
when (val opened = exporter.openSafSnapshot()) {
|
||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Error ->
|
||||
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
|
||||
is com.labfytools.trainlog.data.ExchangeSafSnapshotResult.Ready ->
|
||||
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
||||
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error(
|
||||
"Préparation Android → PC : ${publication.message}",
|
||||
)
|
||||
is SyncExportResult.Exported -> {
|
||||
logExchangeMediaStore("SYNC_BUNDLE", "coordinator.export.success")
|
||||
logExchangeMediaStore("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
||||
requestOutbox.requestSync(opened.snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SyncScreen(
|
||||
inbox: SyncCatalogInbox,
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
onCatalogChanged: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var status by
|
||||
remember {
|
||||
|
|
@ -54,6 +88,8 @@ fun SyncScreen(
|
|||
)
|
||||
}
|
||||
|
||||
var syncRunning by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
pendingRequestId
|
||||
) {
|
||||
|
|
@ -63,10 +99,9 @@ fun SyncScreen(
|
|||
|
||||
repeat(60) {
|
||||
when (
|
||||
val receipt =
|
||||
inbox.readSyncReceipt(
|
||||
requestId
|
||||
)
|
||||
val receipt = withContext(Dispatchers.IO) {
|
||||
inbox.readSyncReceipt(requestId)
|
||||
}
|
||||
) {
|
||||
SyncReceiptResult.Pending -> {
|
||||
delay(1000)
|
||||
|
|
@ -109,8 +144,9 @@ fun SyncScreen(
|
|||
}
|
||||
|
||||
when (
|
||||
val catalog =
|
||||
val catalog = withContext(Dispatchers.IO) {
|
||||
inbox.importPcCatalog()
|
||||
}
|
||||
) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
success = true
|
||||
|
|
@ -199,10 +235,8 @@ fun SyncScreen(
|
|||
}
|
||||
|
||||
if (saved) {
|
||||
when (
|
||||
val result =
|
||||
inbox.importPcCatalog()
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
success = true
|
||||
|
||||
|
|
@ -228,6 +262,7 @@ fun SyncScreen(
|
|||
/* Keep permission state. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -249,8 +284,7 @@ fun SyncScreen(
|
|||
TrainlogAction(
|
||||
label =
|
||||
if (
|
||||
pendingRequestId !=
|
||||
null
|
||||
pendingRequestId != null || syncRunning
|
||||
) {
|
||||
"Synchronisation en cours..."
|
||||
} else {
|
||||
|
|
@ -262,8 +296,7 @@ fun SyncScreen(
|
|||
colors.success,
|
||||
onClick = {
|
||||
if (
|
||||
pendingRequestId !=
|
||||
null
|
||||
pendingRequestId != null || syncRunning
|
||||
) {
|
||||
return@TrainlogAction
|
||||
}
|
||||
|
|
@ -277,33 +310,31 @@ fun SyncScreen(
|
|||
return@TrainlogAction
|
||||
}
|
||||
|
||||
when (
|
||||
val result =
|
||||
requestOutbox
|
||||
.requestSync()
|
||||
) {
|
||||
is SyncRequestResult.Requested -> {
|
||||
success = true
|
||||
|
||||
pendingRequestId =
|
||||
result.requestId
|
||||
|
||||
status =
|
||||
"Demande envoyée · attente du PC..."
|
||||
}
|
||||
|
||||
SyncRequestResult.Unsupported -> {
|
||||
success = false
|
||||
|
||||
status =
|
||||
"Android non supporté."
|
||||
}
|
||||
|
||||
is SyncRequestResult.Error -> {
|
||||
success = false
|
||||
|
||||
status =
|
||||
result.message
|
||||
/* WHY: the request file is the PC daemon's start signal.
|
||||
* CONTRACT: every current Android-origin companion must be
|
||||
* published from one prepared repository snapshot before
|
||||
* that signal becomes visible; otherwise the PC can mix a
|
||||
* fresh V3 file with stale or absent causal companions. */
|
||||
syncRunning = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
when (val result = publishBundleAndRequest(exporter, requestOutbox)) {
|
||||
is SyncRequestResult.Requested -> {
|
||||
success = true
|
||||
pendingRequestId = result.requestId
|
||||
status = "Demande envoyée · attente du PC..."
|
||||
}
|
||||
SyncRequestResult.Unsupported -> {
|
||||
success = false
|
||||
status = "Android non supporté."
|
||||
}
|
||||
is SyncRequestResult.Error -> {
|
||||
success = false
|
||||
status = result.message
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncRunning = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -346,10 +377,8 @@ fun SyncScreen(
|
|||
description =
|
||||
"Action de récupération manuelle si nécessaire.",
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
inbox.importPcCatalog()
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
success = true
|
||||
|
||||
|
|
@ -385,6 +414,7 @@ fun SyncScreen(
|
|||
result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
|
|
@ -20,6 +21,9 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
|
|||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
|
||||
|
|
@ -32,16 +36,19 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
var draftRevision by remember { mutableIntStateOf(0) }
|
||||
var draftMessage by remember { mutableStateOf<String?>(null) }
|
||||
val navigationController = appState.navigationController
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun exportSnapshot() {
|
||||
coroutineScope.launch { exporter.exportMobileBundle() }
|
||||
}
|
||||
|
||||
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
|
||||
* Re-entering a route must never trigger a second import or export. */
|
||||
LaunchedEffect(Unit) {
|
||||
when (inbox.importPcCatalog()) {
|
||||
when (withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
|
||||
is CatalogInboxResult.Imported -> catalogRevision++
|
||||
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
|
||||
is CatalogInboxResult.Error -> Unit
|
||||
}
|
||||
exporter.exportMobileBundle()
|
||||
}
|
||||
|
||||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||
|
|
@ -88,7 +95,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||
draftRevision++
|
||||
|
|
@ -99,19 +106,23 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
openSection(AppSection.SESSIONS)
|
||||
})
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
|
||||
if (saved) exportSnapshot()
|
||||
back()
|
||||
}
|
||||
AppRoute.Exercises -> ExerciseCatalogueScreen(repository, exerciseState, { open(AppRoute.ExerciseCreate(AppRoute.Exercises)) }, { open(AppRoute.ExerciseDetail(it)) })
|
||||
is AppRoute.ExerciseDetail -> ExerciseDetailScreen(repository, route.exerciseId, { open(AppRoute.ExerciseEdit(route.exerciseId, route)) }, { open(AppRoute.LatestMaxima) })
|
||||
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exportSnapshot(); catalogRevision++; back() }
|
||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Statistics -> StatisticsDashboard(repository)
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ }
|
||||
AppRoute.Sync -> SyncScreen(inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +145,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
|
||||
private fun AppRoute.stateKey(): String = when (this) {
|
||||
is AppRoute.SessionDetail -> "session:${sessionId}"
|
||||
is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
|
||||
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
||||
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
||||
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(15, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(15, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(16, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it != "exercise_aliases" }
|
||||
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync") }
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
.forEach { (table, rows) -> assertEquals(rows, after[table]) }
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(15, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(16, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk=[35])
|
||||
class ExerciseProfileStateSyncTest {
|
||||
@Test fun `v16 prototype state normalizes atomically and second open is idempotent`() {
|
||||
val context=ApplicationProvider.getApplicationContext<Context>()
|
||||
val name="profile-pr1-${UUID.randomUUID()}.db"
|
||||
var repository=TrainlogRepository(context,name)
|
||||
try {
|
||||
val created=repository.createExercise(NewExerciseProfile("Prototype",RecordingMode.SETS,TrackingMode.REPS,0))
|
||||
as CreateExerciseResult.Created
|
||||
repository.close()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
val rowId=db.rawQuery("SELECT id FROM exercises WHERE exercise_id=?",arrayOf(created.exercise.exerciseId))
|
||||
.use { c -> assertTrue(c.moveToFirst());c.getLong(0) }
|
||||
db.execSQL("DELETE FROM exercise_profile_revisions WHERE exercise_row_id=?",arrayOf(rowId))
|
||||
db.execSQL("UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=?",arrayOf(rowId))
|
||||
db.execSQL("INSERT INTO exercise_profile_revisions VALUES(?,'pr1|sets|reps|0',NULL,'sets','reps',0,1)",arrayOf(rowId))
|
||||
}
|
||||
repository=TrainlogRepository(context,name)
|
||||
var item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
|
||||
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id"))
|
||||
assertEquals(listOf("pr_legacy_v1","pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b"),
|
||||
(0 until item.getJSONArray("history").length()).map { item.getJSONArray("history").getJSONObject(it).getString("revision_id") })
|
||||
repository.close();repository=TrainlogRepository(context,name)
|
||||
item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
|
||||
assertEquals(2,item.getJSONArray("history").length())
|
||||
assertEquals(TrackingMode.REPS,repository.listExercises().single().trackingMode)
|
||||
} finally { repository.close();context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test fun `new profile revision matches fixed width cross-runtime UUIDv5`() {
|
||||
val context=ApplicationProvider.getApplicationContext<Context>()
|
||||
val name="profile-revision-${UUID.randomUUID()}.db"
|
||||
val repository=TrainlogRepository(context,name)
|
||||
try {
|
||||
val created=repository.createExercise(NewExerciseProfile("UUID parity",RecordingMode.SETS,TrackingMode.REPS,0))
|
||||
assertTrue(created is CreateExerciseResult.Created)
|
||||
val item=JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0)
|
||||
assertEquals("pr_legacy_v1",item.getString("parent_revision_id"))
|
||||
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",item.getString("revision_id"))
|
||||
assertEquals(2,item.getJSONArray("history").length())
|
||||
} finally { repository.close();context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test fun `pc legacy Planche profile changes future capture without rewriting old occurrence`() {
|
||||
val context=ApplicationProvider.getApplicationContext<Context>()
|
||||
val name="profile-state-${UUID.randomUUID()}.db"
|
||||
val id="ex_999fd503-ce89-49f9-979d-c19e6adbe80d"
|
||||
var repository=TrainlogRepository(context,name)
|
||||
try {
|
||||
fun catalog(tracking:String)=JSONObject().put("format","trainlog-pc-catalog").put("version",1)
|
||||
.put("exercises",JSONArray().put(JSONObject().put("exercise_id",id)
|
||||
.put("name","Planche droite sol").put("recording_mode","sets")
|
||||
.put("tracking_mode",tracking).put("data_fields",0))).toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog("reps")) is PcCatalogImportResult.Applied)
|
||||
val reps=repository.listExercises().single{it.exerciseId==id}
|
||||
val insertedState=JSONObject(repository.buildExerciseProfileStateJson())
|
||||
.getJSONArray("exercises").let { entries ->
|
||||
(0 until entries.length()).map { entries.getJSONObject(it) }
|
||||
.single { it.getString("exercise_id") == id }
|
||||
}
|
||||
assertEquals("pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b",insertedState.getString("revision_id"))
|
||||
val old=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
|
||||
exercise=reps,sets=listOf(SessionSetDraft(reps=3)))))) as SaveSessionResult.Saved
|
||||
repository.close()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("UPDATE exercise_profile_state SET revision_id='pr_legacy_v1',parent_revision_id=NULL,legacy_seed=1")
|
||||
}
|
||||
repository=TrainlogRepository(context,name)
|
||||
val state=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
|
||||
.put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put(
|
||||
JSONObject().put("exercise_id",id).put("recording_mode","sets")
|
||||
.put("tracking_mode","duration").put("data_fields",0)
|
||||
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL)
|
||||
.put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL)
|
||||
.put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
|
||||
.put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL)
|
||||
.put("legacy_seed",true).put("history",JSONArray().put(
|
||||
JSONObject().put("revision_id","pr_legacy_v1").put("parent_revision_id",JSONObject.NULL)
|
||||
.put("recording_mode","sets").put("tracking_mode","duration")
|
||||
.put("data_fields",0).put("legacy_seed",true))))).toString()
|
||||
assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied)
|
||||
assertTrue(repository.applyExerciseProfileStateJson(state) is ExerciseProfileStateImportResult.Applied)
|
||||
assertEquals(TrackingMode.REPS,repository.getSessionDetail(old.sessionId)!!.exercises.single().trackingMode)
|
||||
val current=repository.listExercises().single{it.exerciseId==id}
|
||||
assertEquals(TrackingMode.DURATION,current.trackingMode)
|
||||
val future=repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
|
||||
exercise=current,sets=listOf(SessionSetDraft(durationSeconds=30)))))) as SaveSessionResult.Saved
|
||||
assertEquals(TrackingMode.DURATION,repository.getSessionDetail(future.sessionId)!!.exercises.single().trackingMode)
|
||||
} finally { repository.close();context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test fun `transitive stale ancestor is retained and coercing scalar types are rejected`() {
|
||||
val context=ApplicationProvider.getApplicationContext<Context>();val name="profile-chain-${UUID.randomUUID()}.db"
|
||||
val repository=TrainlogRepository(context,name)
|
||||
try {
|
||||
val created=repository.createExercise(NewExerciseProfile("Chain",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
fun revision(parent:String,tracking:String)=exerciseProfileRevision(parent,"sets",tracking,0)
|
||||
val root="pr_legacy_v1";val initial=revision(root,"reps");val first=revision(initial,"duration")
|
||||
val second=revision(first,"reps");val third=revision(second,"duration")
|
||||
fun h(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject().put("revision_id",id)
|
||||
.put("parent_revision_id",parent?:JSONObject.NULL).put("recording_mode","sets")
|
||||
.put("tracking_mode",tracking).put("data_fields",0).put("legacy_seed",legacy)
|
||||
fun state(current:String,parent:String?,tracking:String,history:JSONArray,version:Any=1)=JSONObject()
|
||||
.put("format","trainlog-exercise-profile-state").put("version",version)
|
||||
.put("generated_at","2026-09-13T10:00:00+02:00").put("exercises",JSONArray().put(JSONObject()
|
||||
.put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets").put("tracking_mode",tracking).put("data_fields",0)
|
||||
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL).put("machine_provenance",JSONObject.NULL)
|
||||
.put("scientific_profile_id",JSONObject.NULL).put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
|
||||
.put("revision_id",current).put("parent_revision_id",parent?:JSONObject.NULL).put("legacy_seed",false).put("history",history))).toString()
|
||||
val chain=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false))
|
||||
.put(h(first,initial,"duration",false)).put(h(second,first,"reps",false)).put(h(third,second,"duration",false))
|
||||
assertTrue(repository.applyExerciseProfileStateJson(state(third,second,"duration",chain)) is ExerciseProfileStateImportResult.Applied)
|
||||
val stale=JSONArray().put(h(root,null,"reps",true)).put(h(initial,root,"reps",false)).put(h(first,initial,"duration",false))
|
||||
assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale)) is ExerciseProfileStateImportResult.Applied)
|
||||
assertEquals(third,JSONObject(repository.buildExerciseProfileStateJson()).getJSONArray("exercises").getJSONObject(0).getString("revision_id"))
|
||||
assertTrue(repository.applyExerciseProfileStateJson(state(first,initial,"duration",stale,"1")) is ExerciseProfileStateImportResult.Invalid)
|
||||
val coerced=JSONObject(state(first,initial,"duration",stale)).also { it.getJSONArray("exercises").getJSONObject(0).put("legacy_seed","false") }.toString()
|
||||
assertTrue(repository.applyExerciseProfileStateJson(coerced) is ExerciseProfileStateImportResult.Invalid)
|
||||
val oversizedTop=JSONObject(state(first,initial,"duration",stale)).also {
|
||||
it.getJSONArray("exercises").getJSONObject(0).put("data_fields",4294967296L)
|
||||
}.toString()
|
||||
assertTrue(repository.applyExerciseProfileStateJson(oversizedTop) is ExerciseProfileStateImportResult.Invalid)
|
||||
val oversizedHistory=JSONObject(state(first,initial,"duration",stale)).also {
|
||||
it.getJSONArray("exercises").getJSONObject(0).getJSONArray("history").getJSONObject(2)
|
||||
.put("data_fields",4294967296L)
|
||||
}.toString()
|
||||
assertTrue(repository.applyExerciseProfileStateJson(oversizedHistory) is ExerciseProfileStateImportResult.Invalid)
|
||||
} finally { repository.close();context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test fun `profile history rejects causal integrity matrix A through L`() {
|
||||
val context=ApplicationProvider.getApplicationContext<Context>()
|
||||
val name="profile-negative-${UUID.randomUUID()}.db"
|
||||
val repository=TrainlogRepository(context,name)
|
||||
try {
|
||||
val created=repository.createExercise(
|
||||
NewExerciseProfile("Negative matrix",RecordingMode.SETS,TrackingMode.REPS,0),
|
||||
) as CreateExerciseResult.Created
|
||||
val root="pr_legacy_v1"
|
||||
val child=exerciseProfileRevision(root,"sets","reps",0)
|
||||
fun record(id:String,parent:String?,tracking:String,legacy:Boolean)=JSONObject()
|
||||
.put("revision_id",id).put("parent_revision_id",parent?:JSONObject.NULL)
|
||||
.put("recording_mode","sets").put("tracking_mode",tracking)
|
||||
.put("data_fields",0).put("legacy_seed",legacy)
|
||||
fun valid()=JSONObject().put("format","trainlog-exercise-profile-state").put("version",1)
|
||||
.put("generated_at","2026-09-13T10:00:00+02:00")
|
||||
.put("exercises",JSONArray().put(JSONObject()
|
||||
.put("exercise_id",created.exercise.exerciseId).put("recording_mode","sets")
|
||||
.put("tracking_mode","reps").put("data_fields",0)
|
||||
.put("load_semantics",JSONObject.NULL).put("machine_variant",JSONObject.NULL)
|
||||
.put("machine_provenance",JSONObject.NULL).put("scientific_profile_id",JSONObject.NULL)
|
||||
.put("science_state","unresolved").put("legacy_equipment_id",JSONObject.NULL)
|
||||
.put("revision_id",child).put("parent_revision_id",root).put("legacy_seed",false)
|
||||
.put("history",JSONArray().put(record(root,null,"reps",true))
|
||||
.put(record(child,root,"reps",false)))))
|
||||
fun exercise(payload:JSONObject)=payload.getJSONArray("exercises").getJSONObject(0)
|
||||
fun history(payload:JSONObject)=exercise(payload).getJSONArray("history")
|
||||
fun copy()=JSONObject(valid().toString())
|
||||
val cases=linkedMapOf<String,JSONObject>()
|
||||
cases["A-root-omitted"]=copy().also { history(it).remove(0) }
|
||||
cases["B-root-parent"]=copy().also { history(it).getJSONObject(0).put("parent_revision_id","detached") }
|
||||
cases["C-root-not-legacy"]=copy().also { history(it).getJSONObject(0).put("legacy_seed",false) }
|
||||
cases["D-second-legacy"]=copy().also { history(it).getJSONObject(1).put("legacy_seed",true) }
|
||||
cases["E-middle-missing"]=copy().also {
|
||||
val grandchild=exerciseProfileRevision(child,"sets","duration",0)
|
||||
exercise(it).put("revision_id",grandchild).put("parent_revision_id",child)
|
||||
.put("tracking_mode","duration")
|
||||
exercise(it).put("history",JSONArray().put(record(root,null,"reps",true))
|
||||
.put(record(grandchild,child,"duration",false)))
|
||||
}
|
||||
cases["F-nonprevious-parent"]=copy().also { history(it).getJSONObject(1).put("parent_revision_id","detached") }
|
||||
cases["G-wrong-uuid"]=copy().also {
|
||||
history(it).getJSONObject(1).put("revision_id",exerciseProfileRevision(root,"sets","duration",0))
|
||||
}
|
||||
cases["H-current-absent"]=copy().also {
|
||||
exercise(it).put("revision_id",exerciseProfileRevision(root,"sets","duration",0))
|
||||
.put("tracking_mode","duration")
|
||||
}
|
||||
cases["I-current-not-terminal"]=copy().also {
|
||||
exercise(it).put("revision_id",root).put("parent_revision_id",JSONObject.NULL)
|
||||
.put("legacy_seed",true)
|
||||
}
|
||||
cases["J-duplicate"]=copy().also { history(it).put(JSONObject(history(it).getJSONObject(1).toString())) }
|
||||
cases["K-33-revisions"]=copy().also {
|
||||
val repeated=record(root,null,"reps",true);val oversized=JSONArray()
|
||||
repeat(33) { oversized.put(JSONObject(repeated.toString())) }
|
||||
exercise(it).put("history",oversized)
|
||||
}
|
||||
cases["L-malformed-pr1"]=copy().also {
|
||||
exercise(it).put("revision_id","pr1|sets|reps|00")
|
||||
.put("parent_revision_id",JSONObject.NULL).put("legacy_seed",true)
|
||||
.put("history",JSONArray().put(record("pr1|sets|reps|00",null,"reps",true)))
|
||||
}
|
||||
for ((label,payload) in cases) {
|
||||
assertTrue(label,repository.applyExerciseProfileStateJson(payload.toString()) is
|
||||
ExerciseProfileStateImportResult.Invalid)
|
||||
}
|
||||
} finally { repository.close();context.deleteDatabase(name) }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,19 +5,20 @@ import org.junit.Test
|
|||
class FeedbackElapsedLabelTest {
|
||||
@Test fun `whole hours floor and never switch to days`() {
|
||||
val end="2026-09-01T10:00:00+02:00"
|
||||
listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,t))}
|
||||
listOf(0 to "2026-09-01T10:59:59+02:00",8 to "2026-09-01T18:00:00+02:00",23 to "2026-09-02T09:59:59+02:00",47 to "2026-09-03T09:00:00+02:00",72 to "2026-09-04T10:00:00+02:00").forEach{(h,t)->assertEquals("H+$h",sessionFollowUpElapsedLabel(end,null,t))}
|
||||
}
|
||||
@Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,"2026-09-01T18:00:00+02:00"))
|
||||
@Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(null,null,"2026-09-01T18:00:00+02:00"))
|
||||
@Test fun `exercise feedback distinguishes session time after time and unknown`() {
|
||||
val end="2026-09-01T10:00:00+02:00"
|
||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:20:00+02:00"))
|
||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:59:59+02:00"))
|
||||
assertEquals("H+0",exerciseFeedbackElapsedLabel(end,end))
|
||||
assertEquals("H+8",exerciseFeedbackElapsedLabel(end,"2026-09-01T18:00:00+02:00"))
|
||||
assertEquals("Ressenti",exerciseFeedbackElapsedLabel(null,"2026-09-01T09:20:00+02:00"))
|
||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00"))
|
||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00"))
|
||||
assertEquals("H+?",exerciseFeedbackElapsedLabel(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00"))
|
||||
assertEquals("H+0",exerciseFeedbackElapsedLabel(end,null,end))
|
||||
assertEquals("H+8",exerciseFeedbackElapsedLabel(end,null,"2026-09-01T18:00:00+02:00"))
|
||||
assertEquals("≈H+10",exerciseFeedbackElapsedLabel(null,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00"))
|
||||
}
|
||||
@Test fun `helpers never render negative H plus`() {
|
||||
val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00","2026-09-01T09:00:00+02:00"))
|
||||
val labels=listOf(exerciseFeedbackElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00"),sessionFollowUpElapsedLabel("2026-09-01T10:00:00+02:00",null,"2026-09-01T09:00:00+02:00"))
|
||||
assertTrue(labels.none{it.startsWith("H+-")})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class HistoricalSessionProfileImportTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private lateinit var repository: TrainlogRepository
|
||||
|
||||
@Before fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "historical-profile-${UUID.randomUUID()}.db"
|
||||
repository = TrainlogRepository(context, databaseName)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test fun `V2 and V3 import historical profiles independently of current catalog`() {
|
||||
for (version in listOf(2, 3)) {
|
||||
val same = create("Same $version", RecordingMode.SETS, TrackingMode.REPS, 0)
|
||||
val tracking = create("Tracking $version", RecordingMode.SETS, TrackingMode.DURATION, 0)
|
||||
val recording = create("Recording $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0)
|
||||
val fieldsRemoved = create("Fields removed $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0)
|
||||
val fieldsAdded = create("Fields added $version", RecordingMode.CONTINUOUS, TrackingMode.DURATION,
|
||||
ExerciseDataFields.SPEED_KMH)
|
||||
val cases = listOf(
|
||||
Case(same, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 8))),
|
||||
Case(tracking, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 9))),
|
||||
Case(recording, "sets", "reps", 0, sets = JSONArray().put(JSONObject().put("reps", 10))),
|
||||
Case(fieldsRemoved, "continuous", "duration", ExerciseDataFields.SPEED_KMH,
|
||||
continuous = JSONObject().put("duration_seconds", 600).put("speed_kmh", 8.5)),
|
||||
Case(fieldsAdded, "continuous", "duration", 0,
|
||||
continuous = JSONObject().put("duration_seconds", 700)),
|
||||
)
|
||||
val payload = artifact(version, cases, "se_${version}_historical")
|
||||
val revisionsBefore = revisionCount()
|
||||
assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), import(version, payload))
|
||||
|
||||
val detail = repository.getSessionDetail("se_${version}_historical")!!
|
||||
assertEquals(listOf("reps", "reps", "reps", "duration", "duration"),
|
||||
detail.exercises.map { it.trackingMode.wireValue })
|
||||
assertEquals(listOf("sets", "sets", "sets", "continuous", "continuous"),
|
||||
detail.exercises.map { it.recordingMode.wireValue })
|
||||
assertEquals(listOf(0, 0, 0, ExerciseDataFields.SPEED_KMH, 0),
|
||||
detail.exercises.map { it.dataFields })
|
||||
assertEquals(listOf(8, 9, 10), detail.exercises.take(3).map { it.sets.single().reps })
|
||||
assertEquals(8.5, detail.exercises[3].speedKmh!!, 0.0)
|
||||
assertEquals(null, detail.exercises[4].speedKmh)
|
||||
|
||||
/* INVARIANT: importing occurrence history neither edits CURRENT
|
||||
* profiles nor manufactures profile revisions. */
|
||||
assertEquals(listOf("reps", "duration", "duration", "duration", "duration"),
|
||||
cases.map { expected -> repository.listExercises()
|
||||
.single { it.exerciseId == expected.exercise.exerciseId }.trackingMode.wireValue })
|
||||
assertEquals(revisionsBefore, revisionCount())
|
||||
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 0), import(version, payload))
|
||||
assertEquals(revisionsBefore, revisionCount())
|
||||
assertEquals(1, repository.listSessions().count { it.sessionId == "se_${version}_historical" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `invalid historical payload and unknown identity remain rejected`() {
|
||||
val exercise = create("Strict history", RecordingMode.SETS, TrackingMode.DURATION, 0)
|
||||
val invalid = Case(exercise, "sets", "reps", 0,
|
||||
sets = JSONArray().put(JSONObject().put("duration_seconds", 30)))
|
||||
val invalidResult = import(3, artifact(3, listOf(invalid), "se_invalid_history"))
|
||||
assertTrue(invalidResult is MobileSessionImportResult.Invalid)
|
||||
assertTrue((invalidResult as MobileSessionImportResult.Invalid).message.contains("Série V2 invalide"))
|
||||
|
||||
val unknown = ExerciseProfile(
|
||||
"ex_00000000-0000-4000-8000-000000000099", "Unknown", "unknown",
|
||||
RecordingMode.SETS, TrackingMode.REPS, 0,
|
||||
)
|
||||
val unknownResult = import(2, artifact(2, listOf(Case(unknown, "sets", "reps", 0,
|
||||
sets = JSONArray().put(JSONObject().put("reps", 1)))), "se_unknown_history"))
|
||||
assertTrue(unknownResult is MobileSessionImportResult.Invalid)
|
||||
assertTrue((unknownResult as MobileSessionImportResult.Invalid).message.contains("Exercice V2 inconnu"))
|
||||
assertTrue(repository.listSessions().none { it.sessionId in setOf("se_invalid_history", "se_unknown_history") })
|
||||
}
|
||||
|
||||
private data class Case(
|
||||
val exercise: ExerciseProfile,
|
||||
val recording: String,
|
||||
val tracking: String,
|
||||
val fields: Int,
|
||||
val sets: JSONArray? = null,
|
||||
val continuous: JSONObject? = null,
|
||||
)
|
||||
|
||||
private fun create(name: String, recording: RecordingMode, tracking: TrackingMode, fields: Int): ExerciseProfile =
|
||||
(repository.createExercise(NewExerciseProfile(name, recording, tracking, fields)) as CreateExerciseResult.Created).exercise
|
||||
|
||||
private fun artifact(version: Int, cases: List<Case>, sessionId: String): JSONObject {
|
||||
val catalog = JSONArray()
|
||||
val entries = JSONArray()
|
||||
cases.forEachIndexed { index, case ->
|
||||
catalog.put(JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name)
|
||||
.put("recording_mode", case.exercise.recordingMode.wireValue)
|
||||
.put("tracking_mode", case.exercise.trackingMode.wireValue).put("data_fields", case.exercise.dataFields))
|
||||
val entry = JSONObject().put("exercise_id", case.exercise.exerciseId).put("name", case.exercise.name)
|
||||
.put("recording_mode", case.recording).put("tracking_mode", case.tracking).put("data_fields", case.fields)
|
||||
.put("load_mode", "none").put("rest_seconds", 0)
|
||||
.put("entry_id", "sxe_${version}_$index").put("position", index)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
if (version == 3) entry.put("target", JSONObject.NULL)
|
||||
case.sets?.let { entry.put("sets", it) }
|
||||
case.continuous?.let { entry.put("continuous", it) }
|
||||
entries.put(entry)
|
||||
}
|
||||
return JSONObject().put("format", "trainlog-mobile-export").put("version", version)
|
||||
.put("generated_at", "2026-09-13T20:00:00+02:00").put("exercises", catalog)
|
||||
.put("sessions", JSONArray().put(JSONObject().put("session_id", sessionId)
|
||||
.put("started_at", "2026-09-13T19:00:00+02:00").put("session_type", "training")
|
||||
.put("exercises", entries))).put("body_observations", JSONArray())
|
||||
}
|
||||
|
||||
private fun import(version: Int, payload: JSONObject): MobileSessionImportResult =
|
||||
if (version == 2) repository.applyPcMobileExportV2Json(payload.toString())
|
||||
else repository.applyPcMobileExportV3Json(payload.toString())
|
||||
|
||||
private fun revisionCount(): Int = SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READONLY,
|
||||
).use { database ->
|
||||
database.rawQuery("SELECT COUNT(*) FROM exercise_profile_revisions", null).use { cursor ->
|
||||
cursor.moveToFirst(); cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
|||
|
||||
private fun assertValidVersionThirteen(databasePath: File) {
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
assertEquals(15, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals(16, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ class RealAndroidV14FeedbackRevisionMigrationTest {
|
|||
}
|
||||
TrainlogRepository(context,name).let { repository -> repository.listExercises(); repository.close() }
|
||||
SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
||||
assertEquals(15,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
||||
assertEquals(16,scalar(db,"PRAGMA user_version"));assertEquals("ok",string(db,"PRAGMA integrity_check"));db.rawQuery("PRAGMA foreign_key_check",null).use{assertFalse(it.moveToFirst())}
|
||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_state WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||
assertEquals(scalar(db,"SELECT COUNT(*) FROM exercises"),scalar(db,"SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id='pr_legacy_v1' AND legacy_seed=1"))
|
||||
assertEquals(before.first,values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id"));assertEquals(before.second,values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id"))
|
||||
assertEquals(before.second.size,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback_revisions"))
|
||||
assertEquals(0,scalar(db,"SELECT COUNT(*) FROM draft_exercise_feedback f LEFT JOIN draft_exercise_feedback_revisions r ON r.revision_id='fr0_'||f.feedback_id WHERE r.revision_id IS NULL"))
|
||||
|
|
|
|||
|
|
@ -61,8 +61,10 @@ class TrainingFeedbackRepositoryTest {
|
|||
assertEquals(listOf(first.stableId,later.stableId),completed.map{it.feedbackId})
|
||||
assertEquals(reopened.map{it.observedAt},completed.map{it.observedAt})
|
||||
assertEquals(reopened.map{it.rawText},completed.map{it.rawText})
|
||||
assertEquals("Pendant la séance",exerciseFeedbackElapsedLabel(
|
||||
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,completed.first().observedAt))
|
||||
assertEquals("H+?",exerciseFeedbackElapsedLabel(
|
||||
android.getSessionDetail(saved.sessionId)!!.summary.endedAt,
|
||||
android.getSessionDetail(saved.sessionId)!!.summary.startedAt,
|
||||
completed.first().observedAt))
|
||||
val exported=JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback")
|
||||
assertEquals(2,exported.length())
|
||||
assertEquals(setOf(first.stableId,later.stableId),(0 until exported.length()).map{exported.getJSONObject(it).getString("feedback_id")}.toSet())
|
||||
|
|
@ -122,4 +124,51 @@ class TrainingFeedbackRepositoryTest {
|
|||
assertEquals(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText})
|
||||
assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0)
|
||||
}
|
||||
|
||||
@Test fun `completed correction is atomic preserves identities feedback revisions and followup`() {
|
||||
val reps=(android.createExercise(NewExerciseProfile("Correction reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
|
||||
val otherReps=(android.createExercise(NewExerciseProfile("Correction autres reps",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
|
||||
val timed=(android.createExercise(NewExerciseProfile("Correction durée",RecordingMode.SETS,TrackingMode.DURATION,0)) as CreateExerciseResult.Created).exercise
|
||||
val first=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c1",exercise=reps,
|
||||
sets=listOf(SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0),SessionSetDraft(reps=10,weightKg=32.0)))
|
||||
val second=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000c2",exercise=timed,
|
||||
sets=listOf(SessionSetDraft(durationSeconds=30)))
|
||||
val saved=android.saveSession(SessionDraft(listOf(first,second))) as SaveSessionResult.Saved
|
||||
val feedback=android.saveExerciseFeedback(saved.sessionId,first.entryId,"initial") as SaveFeedbackResult.Saved
|
||||
assertTrue(android.reviseExerciseFeedback(feedback.stableId,"révisé") is SaveFeedbackResult.Saved)
|
||||
val followup=android.saveSessionFollowUp(saved.sessionId,"suivi") as SaveFeedbackResult.Saved
|
||||
val corrected=first.copy(sets=listOf(
|
||||
SessionSetDraft(reps=10,weightKg=33.0), SessionSetDraft(reps=8,weightKg=32.0),
|
||||
SessionSetDraft(reps=12,weightKg=34.0)))
|
||||
val timedCorrected=second.copy(sets=listOf(SessionSetDraft(durationSeconds=35),SessionSetDraft(durationSeconds=30)))
|
||||
assertEquals(CorrectCompletedSessionResult.Saved,
|
||||
android.correctCompletedSession(saved.sessionId,SessionDraft(listOf(corrected,timedCorrected))))
|
||||
var detail=android.getSessionDetail(saved.sessionId)!!
|
||||
assertEquals(saved.sessionId,detail.summary.sessionId)
|
||||
assertEquals(listOf(first.entryId,second.entryId),detail.exercises.map{it.entryId})
|
||||
assertEquals(listOf(10,8,12),detail.exercises[0].sets.map{it.reps})
|
||||
assertEquals(33.0,detail.exercises[0].sets[0].weightKg!!,0.0)
|
||||
assertEquals(listOf(35,30),detail.exercises[1].sets.map{it.durationSeconds})
|
||||
assertEquals(feedback.stableId,detail.exercises[0].feedback.single().feedbackId)
|
||||
assertTrue(detail.exercises[0].feedback.single().modified)
|
||||
assertEquals(followup.stableId,detail.followUps.single().followupId)
|
||||
android.close();android=TrainlogRepository(context,aName)
|
||||
detail=android.getSessionDetail(saved.sessionId)!!
|
||||
assertEquals(8,detail.exercises[0].sets[1].reps)
|
||||
|
||||
/* CONTRACT: entry identity cannot be silently rebound by the general
|
||||
* replacement path; rejection occurs before its cascading delete. */
|
||||
assertTrue(android.correctCompletedSession(saved.sessionId,
|
||||
SessionDraft(listOf(corrected.copy(exercise=otherReps),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError)
|
||||
assertEquals(reps.exerciseId,android.getSessionDetail(saved.sessionId)!!.exercises[0].exerciseId)
|
||||
|
||||
android.close()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("CREATE TRIGGER fail_completed_correction BEFORE INSERT ON performed_sets BEGIN SELECT RAISE(ABORT,'synthetic correction failure'); END;")
|
||||
}
|
||||
android=TrainlogRepository(context,aName)
|
||||
assertTrue(android.correctCompletedSession(saved.sessionId,
|
||||
SessionDraft(listOf(corrected.copy(sets=listOf(SessionSetDraft(reps=1))),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError)
|
||||
assertEquals(8,android.getSessionDetail(saved.sessionId)!!.exercises[0].sets[1].reps)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class TrainlogRepositoryDraftTest {
|
|||
assertEquals(listOf(target.exerciseId), repo.listExercises().map { it.exerciseId })
|
||||
assertEquals("Curl canonique", repo.listExercises().single().name)
|
||||
|
||||
val canonicalCatalog = JSONObject(staleCatalog.toString())
|
||||
val canonicalCatalog = JSONObject(staleCatalog)
|
||||
canonicalCatalog.getJSONArray("exercises").getJSONObject(0)
|
||||
.put("exercise_id", target.exerciseId)
|
||||
.put("name", "Curl canonique renommé")
|
||||
|
|
@ -870,10 +870,16 @@ class TrainlogRepositoryDraftTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun renameRejectsDuplicateAndInvalidNamesAndLocksReferencedProfile() {
|
||||
fun renameRejectsDuplicateAndInvalidNamesAndReferencedProfileEditPreservesSnapshots() {
|
||||
val repo = openRepository()
|
||||
val referenced = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
||||
createExercise(repo, "Course", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
||||
val completed = repo.saveSession(
|
||||
SessionDraft(exercises = listOf(SessionExerciseDraft(
|
||||
exercise = referenced,
|
||||
continuousDurationSeconds = 30,
|
||||
))),
|
||||
) as SaveSessionResult.Saved
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repo.saveActiveSessionDraft(
|
||||
|
|
@ -887,7 +893,9 @@ class TrainlogRepositoryDraftTest {
|
|||
),
|
||||
),
|
||||
)
|
||||
assertFalse(repo.canEditExerciseProfile(referenced.exerciseId))
|
||||
/* CONTRACT: references own occurrence snapshots and therefore do not
|
||||
* lock the mutable CURRENT catalogue profile. */
|
||||
assertTrue(repo.canEditExerciseProfile(referenced.exerciseId))
|
||||
assertEquals(
|
||||
EditExerciseResult.Conflict,
|
||||
repo.editExercise(
|
||||
|
|
@ -912,8 +920,7 @@ class TrainlogRepositoryDraftTest {
|
|||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
EditExerciseResult.IncompatibleProfileChange,
|
||||
assertTrue(
|
||||
repo.editExercise(
|
||||
ExerciseEditInput(
|
||||
referenced.exerciseId,
|
||||
|
|
@ -922,8 +929,20 @@ class TrainlogRepositoryDraftTest {
|
|||
TrackingMode.REPS,
|
||||
ExerciseDataFields.NONE,
|
||||
),
|
||||
),
|
||||
) is EditExerciseResult.Saved,
|
||||
)
|
||||
val current = repo.listExercises().single { it.exerciseId == referenced.exerciseId }
|
||||
assertEquals(RecordingMode.SETS, current.recordingMode)
|
||||
assertEquals(TrackingMode.REPS, current.trackingMode)
|
||||
val retainedDraft = loadDraft(repo).exercises.single()
|
||||
val retained = retainedDraft.exercise
|
||||
assertEquals(RecordingMode.CONTINUOUS, retained.recordingMode)
|
||||
assertEquals(TrackingMode.DURATION, retained.trackingMode)
|
||||
assertEquals(30, retainedDraft.continuousDurationSeconds)
|
||||
val historical = repo.getSessionDetail(completed.sessionId)?.exercises?.single()
|
||||
assertEquals(RecordingMode.CONTINUOUS, historical?.recordingMode)
|
||||
assertEquals(TrackingMode.DURATION, historical?.trackingMode)
|
||||
assertEquals(30, historical?.continuousDurationSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -1528,7 +1547,6 @@ class TrainlogRepositoryDraftTest {
|
|||
assertTrue(repo.applyPcMobileExportV2Json(changedBody.toString()) is MobileSessionImportResult.Invalid)
|
||||
|
||||
val incompatibleProfile = entry("en_incompatible_profile")
|
||||
.put("tracking_mode", "duration")
|
||||
.put("sets", org.json.JSONArray().put(JSONObject().put("duration_seconds", 8)))
|
||||
assertTrue(repo.applyPcMobileExportV2Json(
|
||||
artifact(org.json.JSONArray().put(session("se_incompatible_profile", incompatibleProfile))).toString(),
|
||||
|
|
@ -1651,7 +1669,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(15, cursor.getInt(0))
|
||||
assertEquals(16, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -1778,7 +1796,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(15, cursor.getInt(0))
|
||||
assertEquals(16, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
@ -2164,8 +2182,8 @@ class TrainlogRepositoryDraftTest {
|
|||
}
|
||||
artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises")
|
||||
.getJSONObject(0).getJSONObject("target").put("reps", 7)
|
||||
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Invalid)
|
||||
assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
|
||||
assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Applied)
|
||||
assertEquals(7, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps)
|
||||
|
||||
source.close(); repository = null
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class DraftFeedbackUiReadbackTest {
|
|||
assertEquals(ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(ActiveSessionDraft(exercises = listOf(entry))))
|
||||
assertTrue(repository.saveDraftExerciseFeedback(
|
||||
entry.entryId, "texte UI durable", "2026-09-01T10:00:00+02:00") is SaveFeedbackResult.Saved)
|
||||
entry.entryId, "texte UI durable", java.time.OffsetDateTime.now().toString()) is SaveFeedbackResult.Saved)
|
||||
|
||||
val completedSessionId = mutableStateOf<String?>(null)
|
||||
compose.setContent { TrainlogTheme {
|
||||
|
|
@ -69,6 +69,9 @@ class DraftFeedbackUiReadbackTest {
|
|||
|
||||
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
compose.runOnIdle { completedSessionId.value = saved.sessionId }
|
||||
compose.onNodeWithText("Pendant la séance · texte UI durable").fetchSemanticsNode()
|
||||
/* Elapsed classification depends on the finalized session clock; this
|
||||
* wiring assertion owns durable text readback, while label boundaries
|
||||
* have dedicated deterministic tests. */
|
||||
compose.onNodeWithText("texte UI durable", substring = true).fetchSemanticsNode()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.SaveSessionResult
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.ExchangeSafDirectory
|
||||
import com.labfytools.trainlog.data.ExchangeSafDocument
|
||||
import com.labfytools.trainlog.data.EXCHANGE_MEDIASTORE_LOG_TAG
|
||||
import com.labfytools.trainlog.data.ExchangeSafPublisher
|
||||
import com.labfytools.trainlog.data.ExchangeSafSnapshotResult
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import java.util.UUID
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowLog
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SyncBundlePublicationTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private lateinit var repository: TrainlogRepository
|
||||
private lateinit var directory: FakeSafDirectory
|
||||
|
||||
@Before fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "sync-bundle-${UUID.randomUUID()}.db"
|
||||
repository = TrainlogRepository(context, databaseName)
|
||||
directory = FakeSafDirectory()
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test fun `request publishes and replaces one complete current canonical bundle`() = runBlocking {
|
||||
val identities = listOf(
|
||||
"ex_999fd503-ce89-49f9-979d-c19e6adbe80d" to "Planche droite sol",
|
||||
"ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac" to "Planche face sol",
|
||||
"ex_e32c40a6-72b4-4233-a0db-41b27e990c96" to "Planche gauche sol",
|
||||
)
|
||||
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
|
||||
.put("exercises", JSONArray().also { items -> identities.forEach { (id, name) ->
|
||||
items.put(JSONObject().put("exercise_id", id).put("name", name)
|
||||
.put("recording_mode", "sets").put("tracking_mode", "duration")
|
||||
.put("data_fields", 0))
|
||||
} })
|
||||
repository.applyPcCatalogJson(catalog.toString())
|
||||
val exercises = identities.map { (id, _) ->
|
||||
repository.listExercises().single { it.exerciseId == id }
|
||||
}
|
||||
/* The canonical BODY ZONES fixture exists in the authorized SAF tree;
|
||||
* no MediaStore candidate is provided, reproducing an ownerless row
|
||||
* omitted from the application's scoped MediaStore view. */
|
||||
directory.seed("trainlog-exercise-body-zones-v1.json", "stale canonical")
|
||||
(1..31).forEach { copy ->
|
||||
directory.seed("trainlog-exercise-body-zones-v1 ($copy).json", "historical copy")
|
||||
}
|
||||
val exporter = SyncExporter(context, repository) { directory }
|
||||
val outbox = SyncRequestOutbox { directory }
|
||||
assertTrue(exporter.exportMobileBundle() is com.labfytools.trainlog.data.SyncExportResult.Exported)
|
||||
assertEquals(1, directory.enumerationCount)
|
||||
val entry = SessionExerciseDraft(
|
||||
exercise = exercises.first(),
|
||||
sets = listOf(SessionSetDraft(durationSeconds = 30)),
|
||||
)
|
||||
val saved = repository.saveSession(SessionDraft(listOf(entry))) as SaveSessionResult.Saved
|
||||
val feedback = repository.saveExerciseFeedback(
|
||||
saved.sessionId,
|
||||
entry.entryId,
|
||||
"ressenti ajouté après la publication précédente",
|
||||
"2026-09-13T12:59:00+02:00",
|
||||
)
|
||||
assertTrue(feedback is SaveFeedbackResult.Saved)
|
||||
|
||||
val writesBeforeFirstClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertEquals(2, directory.enumerationCount)
|
||||
val required = listOf(
|
||||
"trainlog-mobile-export-v3.json",
|
||||
"trainlog-mobile-equipment-definitions-v1.json",
|
||||
"trainlog-equipment-associations-v2.json",
|
||||
"trainlog-exercise-aliases-v1.json",
|
||||
"trainlog-exercise-body-zones-v1.json",
|
||||
"trainlog-training-feedback-v2.json",
|
||||
"trainlog-exercise-profile-state-v1.json",
|
||||
"trainlog-sync-request-v1.json",
|
||||
)
|
||||
required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) }
|
||||
required.forEach { name ->
|
||||
assertEquals("one click must write $name exactly once", 1,
|
||||
directory.writeCount(name) - (writesBeforeFirstClick[name] ?: 0))
|
||||
}
|
||||
val profile = JSONObject(readCanonical("trainlog-exercise-profile-state-v1.json"))
|
||||
val profileItems = profile.getJSONArray("exercises")
|
||||
identities.forEach { (id, _) ->
|
||||
val item = (0 until profileItems.length()).map { profileItems.getJSONObject(it) }
|
||||
.single { it.getString("exercise_id") == id }
|
||||
assertEquals("sets", item.getString("recording_mode"))
|
||||
assertEquals("duration", item.getString("tracking_mode"))
|
||||
assertEquals(0, item.getInt("data_fields"))
|
||||
assertEquals("pr_legacy_v1", item.getJSONArray("history").getJSONObject(0).getString("revision_id"))
|
||||
assertEquals(item.getString("revision_id"), item.getJSONArray("history")
|
||||
.getJSONObject(item.getJSONArray("history").length() - 1).getString("revision_id"))
|
||||
}
|
||||
assertTrue(readCanonical("trainlog-training-feedback-v2.json")
|
||||
.contains("ressenti ajouté après la publication précédente"))
|
||||
|
||||
/* Re-publication updates exact canonical objects instead of creating
|
||||
* numbered duplicates, and preserves stable feedback/revision IDs. */
|
||||
val feedbackBefore = JSONObject(readCanonical("trainlog-training-feedback-v2.json"))
|
||||
ShadowLog.clear()
|
||||
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
|
||||
val writesBeforeSecondClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertEquals(3, directory.enumerationCount)
|
||||
val phases = ShadowLog.getLogsForTag(EXCHANGE_MEDIASTORE_LOG_TAG).map { it.msg }
|
||||
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
|
||||
val snapshotPhases = phases.filter { it.startsWith("SAF_TREE snapshot.success ") }
|
||||
assertEquals(1, snapshotPhases.size)
|
||||
assertTrue("SAF snapshot ran on main: $snapshotPhases",
|
||||
snapshotPhases.none { it.contains("thread=main") })
|
||||
val writeBegins = phases.filter { it.contains(" saf.write.begin ") }
|
||||
assertEquals(required.size, writeBegins.size)
|
||||
assertTrue("SAF write ran on main: $writeBegins",
|
||||
writeBegins.none { it.contains("thread=main") })
|
||||
val bodyResolve = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.resolve.existing ") }
|
||||
val bodyWrite = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.begin ") }
|
||||
val bodyWritten = phases.indexOfFirst { it.startsWith("BODY_ZONES saf.write.success ") }
|
||||
val requestStart = phases.indexOfFirst { it == "SYNC_REQUEST coordinator.request.begin" }
|
||||
assertTrue("production coordinator did not resolve existing BODY ZONES", bodyResolve >= 0)
|
||||
assertTrue("BODY ZONES must open only after exact resolution", bodyWrite > bodyResolve)
|
||||
assertTrue("BODY ZONES write did not complete", bodyWritten > bodyWrite)
|
||||
assertTrue("request must be published after the complete export", requestStart > bodyWritten)
|
||||
val beforeBodyOpen = phases.subList(bodyResolve + 1, bodyWrite)
|
||||
assertTrue("existing BODY ZONES unexpectedly created: $beforeBodyOpen",
|
||||
beforeBodyOpen.none { it.startsWith("BODY_ZONES saf.create.") })
|
||||
assertTrue("outbound path must not call MediaStore insert/update/pending: $phases",
|
||||
phases.none { it.contains(" insert.") || it.contains(" publish.") || it.contains("IS_PENDING") })
|
||||
assertEquals(bodyCreatesBefore, directory.createCount("trainlog-exercise-body-zones-v1.json"))
|
||||
assertEquals(0, directory.canonicalCount("trainlog-exercise-body-zones-v1 (32).json"))
|
||||
(1..31).forEach { copy ->
|
||||
assertEquals(1, directory.canonicalCount("trainlog-exercise-body-zones-v1 ($copy).json"))
|
||||
}
|
||||
required.forEach { assertEquals(it, 1, directory.canonicalCount(it)) }
|
||||
required.forEach { name ->
|
||||
assertEquals("repeated click must write $name exactly once", 1,
|
||||
directory.writeCount(name) - (writesBeforeSecondClick[name] ?: 0))
|
||||
}
|
||||
val feedbackAfter = JSONObject(readCanonical("trainlog-training-feedback-v2.json"))
|
||||
assertEquals(feedbackBefore.getJSONArray("exercise_feedback").toString(),
|
||||
feedbackAfter.getJSONArray("exercise_feedback").toString())
|
||||
}
|
||||
|
||||
@Test fun `created exact document updates transaction index without rescan`() {
|
||||
val publisher = ExchangeSafPublisher { directory }
|
||||
val opened = publisher.snapshot() as ExchangeSafSnapshotResult.Ready
|
||||
assertEquals(1, directory.enumerationCount)
|
||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "first"))
|
||||
assertEquals(null, opened.snapshot.writeJson("foo.json", "second"))
|
||||
assertEquals(1, directory.enumerationCount)
|
||||
assertEquals(1, directory.createCount("foo.json"))
|
||||
assertEquals(2, directory.writeCount("foo.json"))
|
||||
assertEquals("second", directory.read("foo.json"))
|
||||
assertEquals(0, directory.canonicalCount("foo (1).json"))
|
||||
}
|
||||
|
||||
private fun readCanonical(name: String): String = directory.read(name)
|
||||
|
||||
private class FakeSafDirectory : ExchangeSafDirectory {
|
||||
private val contents = linkedMapOf<String, ByteArray>()
|
||||
private val creates = mutableMapOf<String, Int>()
|
||||
val writeCounts = mutableMapOf<String, Int>()
|
||||
var enumerationCount = 0
|
||||
private set
|
||||
|
||||
fun seed(name: String, value: String) { contents[name] = value.toByteArray() }
|
||||
fun read(name: String): String = contents.getValue(name).toString(Charsets.UTF_8)
|
||||
fun canonicalCount(name: String): Int = if (contents.containsKey(name)) 1 else 0
|
||||
fun createCount(name: String): Int = creates[name] ?: 0
|
||||
fun writeCount(name: String): Int = writeCounts[name] ?: 0
|
||||
|
||||
override fun listDirectChildren(): List<ExchangeSafDocument> {
|
||||
enumerationCount++
|
||||
return contents.keys.map(::document)
|
||||
}
|
||||
|
||||
override fun createJson(displayName: String): ExchangeSafDocument {
|
||||
check(!contents.containsKey(displayName))
|
||||
creates[displayName] = createCount(displayName) + 1
|
||||
contents[displayName] = byteArrayOf()
|
||||
return document(displayName)
|
||||
}
|
||||
|
||||
override fun openForRewrite(document: ExchangeSafDocument): OutputStream =
|
||||
object : ByteArrayOutputStream() {
|
||||
override fun close() {
|
||||
super.close()
|
||||
contents[document.displayName] = toByteArray()
|
||||
writeCounts[document.displayName] = writeCount(document.displayName) + 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(name: String) = ExchangeSafDocument(
|
||||
Uri.parse("content://trainlog-test/tree/Download%2FTrainlog/document/$name"),
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,43 @@ desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC
|
|||
catalogue. The artifact preserves occurrence order, continuous metrics, set
|
||||
weights and equipment. The legacy V1 contract remains separate and readable.
|
||||
|
||||
Android schema v16 adds deterministic current exercise-profile ancestry. Its
|
||||
v15 migration seeds a shared legacy root without changing catalogue or
|
||||
historical occurrence values; later local edits and peer reconciliation advance
|
||||
lineage explicitly inside the same repository transaction as the current-profile
|
||||
change. The insert trigger owns only initial root creation.
|
||||
|
||||
The manual synchronization action publishes one prepared Android→PC bundle
|
||||
before it exposes `trainlog-sync-request-v1.json`. The bundle contains mobile
|
||||
V3, mobile equipment definitions, equipment associations V2, exercise aliases
|
||||
V1, BODY ZONES V1, Training Feedback V2, and Exercise Profile State V1. The
|
||||
request is not written if any required publication fails. The persisted,
|
||||
user-authorized `Download/Trainlog` SAF tree is the canonical authority for
|
||||
both inbound and outbound exchange: MediaStore ownership and app-scoped row
|
||||
visibility are not consulted. Exact display names are rewritten through their
|
||||
SAF document URI; absence creates the exact name once, and multiple exact names
|
||||
fail conservatively. Conflict copies such as `name (1).json` are historical
|
||||
objects, never aliases of `name.json`, and are neither selected nor deleted.
|
||||
One synchronization enumerates the direct SAF children once, builds a bounded
|
||||
exact-name multimap, and shares that transaction-local snapshot across all
|
||||
outbound artifacts and the request. Newly created documents update that map.
|
||||
The next synchronization enumerates again so PC/MTP changes are never hidden by
|
||||
a long-lived cache. Snapshot, create and stream operations run on the I/O
|
||||
dispatcher; Compose only launches the suspend coordinator and applies results.
|
||||
|
||||
V3 preserves ordinary plan metadata atomically with occurrence identity,
|
||||
equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are
|
||||
never silently rewritten as V3.
|
||||
|
||||
Completed Session Detail exposes **Modifier la séance**. Its bounded form is
|
||||
initialized from occurrence snapshots and writes nothing before Save. Save
|
||||
replaces all children in one transaction while retaining `session_id`, every
|
||||
retained `entry_id`, feedback roots/revisions, and the session follow-up parent.
|
||||
Set and occurrence removal require explicit confirmation; Cancel writes
|
||||
nothing. Exercise Detail separately exposes **Modifier l'exercice**. A
|
||||
confirmed incompatible catalogue edit controls future Android occurrences;
|
||||
completed and active-draft occurrence snapshots are never converted.
|
||||
|
||||
## 2. Implemented navigation
|
||||
|
||||
```text
|
||||
|
|
@ -505,10 +538,14 @@ Android writes:
|
|||
trainlog-sync-request-v1.json
|
||||
```
|
||||
|
||||
If MediaStore cannot reopen an older MTP-created canonical object, it may
|
||||
publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The
|
||||
desktop engine selects the newest canonical-or-suffixed request
|
||||
deterministically, while the stable `request_id` remains the replay boundary.
|
||||
The request is resolved and rewritten through the same persisted SAF tree as
|
||||
the bundle. Android does not create a numbered request sibling merely because
|
||||
MediaStore cannot expose an MTP- or peer-created canonical object. The stable
|
||||
`request_id` remains the replay boundary.
|
||||
|
||||
The application-start catalog import does not publish an outbound bundle.
|
||||
Exactly one explicit `Synchroniser maintenant` action invokes one export and
|
||||
one request publication unless a later, explicit retry is initiated.
|
||||
|
||||
and waits for a matching:
|
||||
|
||||
|
|
|
|||
|
|
@ -168,8 +168,13 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v15 is canonical long-term history. Its v14 -> v15
|
||||
additive step introduces immutable wording-revision tables for the subjective
|
||||
Desktop SQLite schema v17 is canonical long-term history. Its v15 -> v16
|
||||
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
||||
one-time backfill copies the linked catalogue mode without changing child rows
|
||||
or stable identities. The additive v16 -> v17 migration adds deterministic
|
||||
current-profile ancestry in a 32-record-per-exercise durable chain without
|
||||
altering those occurrence snapshots. The v14
|
||||
-> v15 additive step introduces immutable wording-revision tables for the subjective
|
||||
feedback roots introduced in v14; see [Training feedback](training_feedback.md). Its v9 -> v10
|
||||
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
||||
be finite `>= 0`; the column already existed and targets/max results retain
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ TRAINING_KNOWLEDGE_V1=PASS
|
|||
SESSION_GENERATOR_V1=PASS
|
||||
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
|
||||
|
||||
DESKTOP_TESTS=47/47 PASS (normal and ASan/UBSan Meson suites)
|
||||
DESKTOP_TESTS=52/52 PASS (normal); affected ASan/UBSan tests=6/6 PASS
|
||||
ANDROID_BUILD=PASS
|
||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||
```
|
||||
|
|
@ -81,7 +81,9 @@ Implemented:
|
|||
F7 actions, and restored focus after overlays/routes);
|
||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||
minimum terminal;
|
||||
- SQLite schema v15, with stable ordered `session_exercises.entry_id`,
|
||||
- SQLite schema v17, with stable ordered `session_exercises.entry_id`, an
|
||||
immutable occurrence-owned `tracking_mode` snapshot,
|
||||
bounded durable current-profile revision ancestry,
|
||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
||||
|
|
@ -187,7 +189,7 @@ The implemented read-only training-knowledge layer loads shared versioned JSON
|
|||
catalogs as the sole authored scientific source, generates the immutable C
|
||||
catalog representation, and loads the same assets on Android. It has no
|
||||
database migration, no auto-seeding, and no synchronization artifact. The
|
||||
desktop and Android databases are schema v15. Schema v13 is additive: exercise
|
||||
desktop database is schema v17 and Android is schema v16. Schema v13 is additive: exercise
|
||||
rows own machine-facing load semantics, optional legacy equipment provenance,
|
||||
an optional scientific profile, and an explicit resolved/unresolved science
|
||||
state. Legacy equipment tables and occurrence columns remain intact.
|
||||
|
|
@ -425,9 +427,13 @@ unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
|
|||
`sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The
|
||||
second run reported zero additions/reconciliations, both stores retained the
|
||||
same 32-relation stable-ID hash, and Android application tables plus companion
|
||||
exercise states compared equal to the first pass. The engine accepts the exact
|
||||
MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID
|
||||
replay protection remains authoritative.
|
||||
exercise states compared equal to the first pass. Android now resolves the
|
||||
exact request and every outbound artifact through the persisted authorized SAF
|
||||
tree, independently of MediaStore ownership visibility. Historical numbered
|
||||
copies remain untouched. One transaction-local exact-name snapshot is shared
|
||||
by the complete export and request on the I/O dispatcher; each later sync takes
|
||||
a fresh snapshot. The redundant application-start export was removed, and
|
||||
request-ID replay protection remains authoritative.
|
||||
|
||||
## Current implementation cursor
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=15
|
||||
DATABASE_SCHEMA_V12=PASS
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=17
|
||||
DATABASE_SCHEMA_V17=PASS
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
|
|
@ -116,6 +116,24 @@ identity `fr0_<root-id>` with `created_at=observed_at`; corrections use
|
|||
current-text cache. The authoritative current revision is selected by parsed
|
||||
`created_at` instant, then bytewise `revision_id` on ties.
|
||||
|
||||
Desktop schema v16 transactionally rebuilds only `session_exercises` to add
|
||||
`tracking_mode TEXT NOT NULL CHECK(tracking_mode IN ('reps','duration'))`.
|
||||
Each v15 row is backfilled exactly once from its linked exercise before the
|
||||
version is published. Row counts, foreign keys and source/catalog agreement are
|
||||
checked before commit; `id`, `entry_id` and every child row remain unchanged.
|
||||
Desktop schema v17 and Android schema v16 then add `exercise_profile_state`, a
|
||||
one-row-per-exercise current pointer, plus `exercise_profile_revisions`, the
|
||||
durable causal chain for mutable current capture profiles. Both migrations seed
|
||||
`pr_legacy_v1` without rewriting exercises or occurrence snapshots. Local edits
|
||||
append deterministic revisions transactionally. Each exercise is limited to 32
|
||||
revision records; a 33rd edit/import is rejected explicitly, never pruned or
|
||||
ordered by a wall clock.
|
||||
Already-versioned prototype databases are repaired on open without a version
|
||||
bump: an exact `pr1|recording_mode|tracking_mode|data_fields` current token is
|
||||
replaced atomically by `pr_legacy_v1` and its canonical first `pr2_` child.
|
||||
Malformed, impossible, orphaned, or mixed prototype lineage aborts the open and
|
||||
rolls back the repair.
|
||||
|
||||
## 3. Connection invariants
|
||||
|
||||
Every connection enables:
|
||||
|
|
@ -204,6 +222,7 @@ session_row_id
|
|||
exercise_row_id
|
||||
entry_id UNIQUE stable occurrence identity
|
||||
recording_mode
|
||||
tracking_mode immutable occurrence unit snapshot
|
||||
data_fields
|
||||
position
|
||||
load_mode
|
||||
|
|
@ -216,9 +235,9 @@ equipment_id nullable equipment identity
|
|||
notes
|
||||
```
|
||||
|
||||
Current desktop history snapshots `recording_mode` and `data_fields` in the
|
||||
session row. `tracking_mode` remains associated with the referenced exercise
|
||||
catalog identity.
|
||||
Desktop history snapshots `recording_mode`, `tracking_mode` and `data_fields`
|
||||
in the occurrence row. Catalogue profile edits supply defaults only to future
|
||||
occurrences and never update these snapshots.
|
||||
|
||||
An occurrence `equipment_id` resolves to either a supplied manifest definition
|
||||
or a desktop-local `custom_equipment` definition. An ID that cannot be
|
||||
|
|
@ -661,3 +680,21 @@ findings were repaired, independently verified, and final-validated;
|
|||
Exercise naming changes update only `exercises.name` and `normalized_name` for
|
||||
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
|
||||
required.
|
||||
|
||||
## 15. Profile-revision SQLite connection functions
|
||||
|
||||
Schema v17 profile-state insert/update triggers use the deterministic
|
||||
`trainlog_profile_revision(parent, recording_mode, tracking_mode, data_fields)`
|
||||
UUIDv5 function. The migration-only `trainlog_pr1_valid(...)` recognizer shares
|
||||
the same connection boundary. These are process-local SQLite functions even
|
||||
though the triggers that reference them are persisted.
|
||||
|
||||
Every production connection capable of preparing Trainlog schema statements
|
||||
must therefore install the complete function set before executing schema or
|
||||
data SQL. Native connections do so in
|
||||
`trainlog_database_open_with_diagnostic()`; Python import/export tools use the
|
||||
single `tools/trainlog_sqlite.py::connect_database()` factory. SQLite resolves
|
||||
trigger functions while preparing a statement even when a trigger `WHEN`
|
||||
condition will suppress its body, so read-mostly importers are not exempt if
|
||||
they can update or insert an exercise. Raw SQLite connections are limited to
|
||||
test-fixture construction and read-only inspection.
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ CONTINUOUS + REPS
|
|||
|
||||
UI behavior must never be inferred from an exercise display name.
|
||||
|
||||
An explicitly confirmed unweighted static plank uses `SETS + DURATION`, no
|
||||
supplemental field, bodyweight exercise semantics, and ordinary occurrence
|
||||
`load_mode=none`. A plan of three 30-second holds stores `target_sets=3` and
|
||||
`target_duration_seconds=30`; each performed set owns its actual
|
||||
`duration_seconds`. Trainlog does not infer reps, external kilograms, or a
|
||||
kilogram MAX. Existing repetition facts remain repetitions until the user
|
||||
explicitly corrects those completed occurrences.
|
||||
|
||||
## 2. Examples
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop and Android schema v15 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop schema v17 and Android schema v16 retain mobile export V3, readable V1/V2 imports,
|
||||
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||
|
|
@ -10,6 +10,19 @@ travel through the existing catalog, while v13 metadata stays local until a
|
|||
dedicated versioned companion is required. Legacy equipment IDs may still be
|
||||
written for fixed supplied machines as compatibility provenance.
|
||||
|
||||
V3 already carries occurrence `tracking_mode`; desktop v16 imports it directly
|
||||
into `session_exercises` and exports the same snapshot. Catalogue
|
||||
`tracking_mode` remains the default for future occurrences only, so no wire
|
||||
format bump is required.
|
||||
|
||||
Android and the current desktop importer treat the exercise catalogue link of
|
||||
a completed V2/V3 entry as an identity constraint only. The entry's own
|
||||
`recording_mode`, `tracking_mode`, and `data_fields` validate its actual payload
|
||||
and remain authoritative even after the current catalogue profile evolves.
|
||||
Import never rewrites historical sets or continuous values to fit the current
|
||||
profile. Legacy desktop schemas that cannot persist the occurrence tracking
|
||||
mode retain their explicit compatibility rejection instead of losing its unit.
|
||||
|
||||
## 1. Status
|
||||
|
||||
```text
|
||||
|
|
@ -34,6 +47,14 @@ BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
|||
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
|
||||
```
|
||||
|
||||
Mobile export V3 also carries completed-session corrections. For an existing
|
||||
session with the same immutable header, peers reconcile the complete occurrence
|
||||
and planning state atomically. A retained `(session_id, entry_id)` cannot be
|
||||
rebound to another exercise and retains feedback roots/revisions; omitted
|
||||
occurrences follow the confirmed parent-owned cascade. An identical second
|
||||
import is a skip. V2 has no complete planning authority and cannot apply a
|
||||
general correction; its older resumed-MAX compatibility remains bounded.
|
||||
|
||||
Synchronization artifacts are separate from the frozen Trainlog session JSON
|
||||
v1 format.
|
||||
|
||||
|
|
@ -679,3 +700,34 @@ Training observations use the independent direction-neutral
|
|||
`trainlog-training-feedback-v2.json`. Its append-only revision union, strict identity
|
||||
checks, ordering, bounds, and conflicts are specified in
|
||||
[Training feedback](training_feedback.md).
|
||||
## Exercise current-profile state v1
|
||||
|
||||
`trainlog-exercise-profile-state-v1.json` is a separate, directional-neutral
|
||||
companion. It does not alter `TRAINLOG_FORMAT_V1`, mobile session V3, PC catalog
|
||||
v1, feedback V2, equipment, or BODY ZONES. Each record carries a required
|
||||
oldest-to-current `history` of at most 32 revision records. Equal revisions are
|
||||
idempotent, an incoming descendant is adopted when its chain proves the local
|
||||
current revision, any known incoming ancestor is retained, and sibling or
|
||||
otherwise incomparable descendants conflict. A 33rd revision is rejected; the
|
||||
chain is never silently truncated. The shared legacy root lets the first
|
||||
post-upgrade sync repair older profile differences. Machine/science fields are
|
||||
strictly typed equality guards and remain immutable through this artifact.
|
||||
|
||||
For an Android-triggered bidirectional run, Android builds every current
|
||||
outbound snapshot before publishing any of them and publishes the complete
|
||||
bundle before writing the sync request. The request therefore signals that the
|
||||
canonical mobile V3, definitions, associations, aliases, zones, feedback V2,
|
||||
and profile-state V1 objects have all been refreshed from the same builder
|
||||
phase. The desktop receives profile state and runs its causal pre-pass before
|
||||
mobile V3, then runs the strict post-pass only when that companion was actually
|
||||
received. A V3/V2 same-ID profile mismatch with no received companion reports
|
||||
`PROFILE_STATE_COMPANION_MISSING`; absence alone remains accepted for older
|
||||
peers whose mobile snapshot does not require causal reconciliation.
|
||||
|
||||
The desktop pre/post-pass importer opens its database through the canonical
|
||||
Python Trainlog connection factory. That factory registers the same
|
||||
deterministic profile-revision function as the native database opener before
|
||||
the importer prepares `UPDATE exercises`; the persisted guarded profile trigger
|
||||
therefore remains valid even when the authoritative incoming tip makes its body
|
||||
a no-op. Replaying the same artifact neither changes the current revision nor
|
||||
adds history rows.
|
||||
|
|
|
|||
|
|
@ -716,3 +716,35 @@ reordering and changing the exercise context of stable entries; it verifies
|
|||
exact feedback preservation, selective cascade on removal, no inheritance by a
|
||||
new entry, unchanged follow-ups, and full rollback on injected reattachment
|
||||
failure. Android additionally covers its bounded resumed-MAX replacement path.
|
||||
`tests/test_profile_state_sync.py` covers the captured Planche droite sol
|
||||
legacy `SETS+REPS`/`SETS+DURATION` repair, idempotent replay, direct-descendant
|
||||
and multi-generation stale-ancestor convergence, sibling conflict, the explicit
|
||||
32-record resource bound, exact JSON scalar types, immutable machine conflict,
|
||||
and preservation of the old occurrence snapshot. Android
|
||||
`ExerciseProfileStateSyncTest` covers the same transitive/type contract in the
|
||||
reverse PC→Android path and future-occurrence snapshot.
|
||||
`HistoricalSessionProfileImportTest` exercises the production V2/V3 repository
|
||||
import with equal and evolved tracking/recording/data-field profiles, strict
|
||||
snapshot-local payload rejection, unknown identity rejection, unchanged
|
||||
historical values, unchanged current profile history, and idempotent replay.
|
||||
`SyncBundlePublicationTest` exercises the production request coordinator and a
|
||||
synthetic user-authorized SAF directory whose exact BODY ZONES canonical is
|
||||
deliberately unavailable through MediaStore. The exact SAF object is rewritten
|
||||
beside 31 preserved historical copies, no MediaStore insert/pending publication
|
||||
or `(32)` copy occurs, all seven Android-origin companions precede the request
|
||||
signal, and replay reuses canonical objects without stable-feedback churn.
|
||||
It also requires one direct-child enumeration for the entire export-plus-request
|
||||
transaction, one additional enumeration for each later synchronization, index
|
||||
update after a create without rescan, one write per canonical artifact, and
|
||||
non-main dispatcher ownership for snapshot and stream operations.
|
||||
Native `sync_body_zone_wiring` verifies the
|
||||
profile pre-pass before mobile import, strict post-pass gating, replay, legacy
|
||||
peer compatibility, and the explicit missing-companion diagnostic even when a
|
||||
stale local temporary profile artifact exists.
|
||||
Every Android outbound artifact and the request use the shared exact-name SAF
|
||||
resolver; app-scoped MediaStore visibility is not part of canonical selection.
|
||||
`profile_state_sync` includes the production persisted update-trigger dependency
|
||||
on `trainlog_profile_revision`, imports through the same Python connection path
|
||||
used by the PC sync worker, and requires first import plus identical replay to
|
||||
pass without revision-state or history-count churn. It also checks that the
|
||||
central factory installs both profile-revision schema functions.
|
||||
|
|
|
|||
|
|
@ -56,10 +56,11 @@ resources.
|
|||
`observed_at` and `sessions.ended_at` are exact offset-aware Trainlog
|
||||
timestamps. New Android completions persist their explicit finalization instant
|
||||
as `ended_at`; old NULL anchors are not backfilled or derived from `started_at`.
|
||||
Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours,
|
||||
or `H+?` when the anchor is missing/invalid. Exercise feedback observed before
|
||||
the end displays `Pendant la séance`; at/after end it uses H+, and without an
|
||||
anchor it displays the neutral `Ressenti`. No negative H+ is rendered. Ordering
|
||||
Session follow-up uses `H+floor((observed-end)/3600)` including after 24 hours.
|
||||
When `ended_at` is unavailable but valid `started_at` gives a nonnegative delta,
|
||||
both surfaces use `≈H+floor((observed-start)/3600)`; otherwise they use `H+?`.
|
||||
Exercise feedback uses `Pendant la séance` only when a trustworthy `ended_at`
|
||||
proves the observation preceded completion. No negative H+ is rendered. Ordering
|
||||
parses instants, then compares the stable ID bytewise; it never
|
||||
uses lexical timestamp order or machine-local timezone.
|
||||
|
||||
|
|
|
|||
13
docs/tui.md
13
docs/tui.md
|
|
@ -8,6 +8,19 @@ synchronization.
|
|||
|
||||
Desktop SQLite is the canonical long-term history.
|
||||
|
||||
From completed Session Detail, `e` opens the existing session editor around
|
||||
`trainlog_database_replace_session_exercises()`. Per-set values, targets, rest,
|
||||
continuous facts and explicit MAX values are corrected atomically under the
|
||||
same `session_id`; retained occurrences keep `entry_id`. Destructive set or
|
||||
occurrence removal uses the global confirmation flow (`Non` by default,
|
||||
Escape/q cancel).
|
||||
|
||||
Desktop schema v16 snapshots `tracking_mode` on every `session_exercises`
|
||||
occurrence. The exercise editor may therefore change profile axes for future
|
||||
uses even when history exists. Incompatible changes require an explicit
|
||||
`Non`-by-default confirmation which states that old sessions remain unchanged;
|
||||
Escape and `q` cancel.
|
||||
|
||||
## 2. APP_SHELL_V1 navigation
|
||||
|
||||
The persistent shell has seven root sections:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"""Strict JSON validation regressions for exercise-alias imports."""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
|
|
@ -10,6 +11,7 @@ from pathlib import Path
|
|||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
|
|
|
|||
149
tests/test_profile_state_sync.py
Normal file
149
tests/test_profile_state_sync.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env python3
|
||||
import json,sqlite3,subprocess,sys,tempfile,unittest,uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]; IMPORT=ROOT/"tools/import_exercise_profile_state.py";EXPORT=ROOT/"tools/export_exercise_profile_state.py"
|
||||
sys.path.insert(0,str(ROOT/"tools"))
|
||||
from trainlog_sqlite import connect_database
|
||||
PLANche="ex_999fd503-ce89-49f9-979d-c19e6adbe80d"
|
||||
NS=uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
|
||||
def revision(parent,tracking): return "pr2_"+str(uuid.uuid5(NS,f"{parent}\nsets\n{tracking}\n0"))
|
||||
|
||||
def make_db(path,tracking="reps",machine=None):
|
||||
c=sqlite3.connect(path);c.executescript("""
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT,
|
||||
tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER,load_semantics TEXT,machine_variant TEXT,
|
||||
machine_provenance TEXT,scientific_profile_id TEXT,science_state TEXT,legacy_equipment_id TEXT);
|
||||
CREATE TABLE exercise_profile_state(exercise_row_id INTEGER PRIMARY KEY,revision_id TEXT,parent_revision_id TEXT,legacy_seed INTEGER);
|
||||
CREATE TABLE exercise_profile_revisions(exercise_row_id INTEGER,revision_id TEXT,parent_revision_id TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,legacy_seed INTEGER,PRIMARY KEY(exercise_row_id,revision_id));
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
|
||||
/* Reproduce the production schema dependency: SQLite resolves this UDF while
|
||||
preparing UPDATE exercises even when the authoritative-import guard is false. */
|
||||
CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises
|
||||
WHEN NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r
|
||||
ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id
|
||||
WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode
|
||||
AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields)
|
||||
BEGIN
|
||||
SELECT trainlog_profile_revision((SELECT revision_id FROM exercise_profile_state WHERE exercise_row_id=NEW.id),
|
||||
NEW.recording_mode,NEW.tracking_mode,NEW.data_fields);
|
||||
END;
|
||||
PRAGMA user_version=17;""")
|
||||
c.execute("INSERT INTO exercises VALUES(1,?,'Planche droite sol','planche droite sol',?,'sets',0,NULL,?,NULL,NULL,'unresolved',NULL)",(PLANche,tracking,machine))
|
||||
c.execute("INSERT INTO exercise_profile_state VALUES(1,'pr_legacy_v1',NULL,1)")
|
||||
c.execute("INSERT INTO exercise_profile_revisions VALUES(1,'pr_legacy_v1',NULL,'sets',?,0,1)",(tracking,))
|
||||
c.execute("INSERT INTO session_exercises VALUES(1,1,'reps','sets',0)");c.commit();c.close()
|
||||
|
||||
def history_record(tracking,revision,parent,legacy=False):
|
||||
return {"recording_mode":"sets","tracking_mode":tracking,"data_fields":0,"revision_id":revision,"parent_revision_id":parent,"legacy_seed":legacy}
|
||||
|
||||
def artifact(tracking="duration",revision="pr_legacy_v1",parent=None,legacy=True,machine=None,history=None):
|
||||
if history is None:
|
||||
history=([history_record("duration","pr_legacy_v1",None,True)] if not legacy and parent=="pr_legacy_v1" else [])+[history_record(tracking,revision,parent,legacy)]
|
||||
return {"format":"trainlog-exercise-profile-state","version":1,"generated_at":"2026-09-13T10:00:00+02:00","exercises":[{
|
||||
"exercise_id":PLANche,"recording_mode":"sets","tracking_mode":tracking,"data_fields":0,
|
||||
"load_semantics":None,"machine_variant":machine,"machine_provenance":None,"scientific_profile_id":None,
|
||||
"science_state":"unresolved","legacy_equipment_id":None,"revision_id":revision,
|
||||
"parent_revision_id":parent,"legacy_seed":legacy,"history":history}]}
|
||||
|
||||
class ProfileStateSyncTest(unittest.TestCase):
|
||||
def apply(self,db,payload,ok=True):
|
||||
p=db.with_suffix('.json');p.write_text(json.dumps(payload),encoding='utf-8')
|
||||
r=subprocess.run(["python3",str(IMPORT),str(p),"--database",str(db)],text=True,capture_output=True)
|
||||
self.assertEqual(ok,r.returncode==0,r.stdout+r.stderr);return r
|
||||
def test_canonical_connection_registers_complete_schema_function_set(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=connect_database(Path(d)/"configured.db")
|
||||
expected=revision("pr_legacy_v1","duration")
|
||||
self.assertEqual(expected,db.execute(
|
||||
"SELECT trainlog_profile_revision('pr_legacy_v1','sets','duration',0)").fetchone()[0])
|
||||
self.assertEqual((1,0),db.execute(
|
||||
"SELECT trainlog_pr1_valid('pr1|sets|duration|0','sets','duration',0),"
|
||||
"trainlog_pr1_valid('pr1|sets|duration|00','sets','duration',0)").fetchone())
|
||||
db.close()
|
||||
def test_exact_planche_legacy_repair_preserves_old_snapshot_and_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db);payload=artifact();first=self.apply(db,payload)
|
||||
self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",first.stdout)
|
||||
c=sqlite3.connect(db);before=(
|
||||
c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(),
|
||||
c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0])
|
||||
second=self.apply(db,payload);self.assertIn("EXERCISE_PROFILE_STATE_IMPORT=PASS",second.stdout)
|
||||
after=(c.execute("SELECT revision_id,parent_revision_id,legacy_seed FROM exercise_profile_state").fetchone(),
|
||||
c.execute("SELECT COUNT(*) FROM exercise_profile_revisions").fetchone()[0])
|
||||
self.assertEqual(before,after)
|
||||
self.assertEqual(("duration","reps"),(
|
||||
c.execute("SELECT tracking_mode FROM exercises").fetchone()[0],
|
||||
c.execute("SELECT tracking_mode FROM session_exercises").fetchone()[0]));c.close()
|
||||
def test_direct_descendant_converges_and_sibling_conflicts(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
|
||||
child=artifact("reps",revision("pr_legacy_v1","reps"),"pr_legacy_v1",False);self.apply(db,child)
|
||||
sibling=artifact("duration",revision("pr_legacy_v1","duration"),"pr_legacy_v1",False);self.apply(db,sibling,False)
|
||||
def test_same_profile_from_different_parents_is_not_equal(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
|
||||
first=revision("pr_legacy_v1","reps");first_history=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1")]
|
||||
self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=first_history))
|
||||
other_parent=revision("pr_legacy_v1","duration")
|
||||
other=revision(other_parent,"reps")
|
||||
self.apply(db,artifact("reps",other,other_parent,False,history=[history_record("duration","pr_legacy_v1",None,True),history_record("duration",other_parent,"pr_legacy_v1"),history_record("reps",other,other_parent)]),False)
|
||||
def test_transitive_stale_ancestor_is_retained(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
|
||||
first=revision("pr_legacy_v1","reps");second=revision(first,"duration")
|
||||
chain=[history_record("duration","pr_legacy_v1",None,True),history_record("reps",first,"pr_legacy_v1"),history_record("duration",second,first)]
|
||||
self.apply(db,artifact("duration",second,first,False,history=chain))
|
||||
self.apply(db,artifact("reps",first,"pr_legacy_v1",False,history=chain[:2]))
|
||||
self.assertEqual(second,sqlite3.connect(db).execute("SELECT revision_id FROM exercise_profile_state").fetchone()[0])
|
||||
def test_coerced_scalar_types_and_oversized_history_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for key,value in (("version","1"),("data_fields","0"),("legacy_seed","true"),("science_state",True)):
|
||||
db=Path(d)/(key+".db");make_db(db);bad=artifact()
|
||||
if key=="version": bad[key]=value
|
||||
else: bad["exercises"][0][key]=value
|
||||
self.apply(db,bad,False)
|
||||
db=Path(d)/"bound.db";make_db(db);bad=artifact();bad["exercises"][0]["history"]*=33;self.apply(db,bad,False)
|
||||
def test_generated_at_uses_canonical_timestamp_grammar(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db);bad=artifact();bad["generated_at"]="2026-09-13 10:00:00Z"
|
||||
self.apply(db,bad,False)
|
||||
def test_history_must_be_canonical_root_to_exact_tip(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
root="pr_legacy_v1";child=revision(root,"reps")
|
||||
valid=[history_record("duration",root,None,True),history_record("reps",child,root)]
|
||||
def changed_history(mutator):
|
||||
history=[dict(entry) for entry in valid];mutator(history)
|
||||
return artifact("reps",child,root,False,history=history)
|
||||
other=revision(root,"duration")
|
||||
cases={
|
||||
"A-root-omitted":artifact("reps",child,root,False,history=valid[1:]),
|
||||
"B-root-parent":changed_history(lambda h:h[0].update(parent_revision_id="detached")),
|
||||
"C-root-not-legacy":changed_history(lambda h:h[0].update(legacy_seed=False)),
|
||||
"D-second-legacy":changed_history(lambda h:h[1].update(legacy_seed=True)),
|
||||
"E-middle-missing":artifact("duration",revision(child,"duration"),child,False,
|
||||
history=[valid[0],history_record("duration",revision(child,"duration"),child)]),
|
||||
"F-nonprevious-parent":changed_history(lambda h:h[1].update(parent_revision_id="detached")),
|
||||
"G-wrong-uuid":changed_history(lambda h:h[1].update(revision_id=other)),
|
||||
"H-current-absent":artifact("duration",other,root,False,history=valid),
|
||||
"I-current-not-terminal":artifact("duration",root,None,True,history=valid),
|
||||
"J-duplicate":artifact("reps",child,root,False,history=valid+[dict(valid[1])]),
|
||||
"K-33-revisions":artifact("reps",child,root,False,history=[dict(valid[0])]*33),
|
||||
"L-malformed-pr1":artifact("reps","pr1|sets|reps|00",None,True,
|
||||
history=[history_record("reps","pr1|sets|reps|00",None,True)]),
|
||||
}
|
||||
for name,bad in cases.items():
|
||||
db=Path(d)/(name+".db");make_db(db);self.apply(db,bad,False)
|
||||
def test_export_carries_oldest_to_current_bounded_chain(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db,"duration");self.apply(db,artifact())
|
||||
child=revision("pr_legacy_v1","reps");self.apply(db,artifact("reps",child,"pr_legacy_v1",False))
|
||||
output=Path(d)/"state.json";result=subprocess.run(["python3",str(EXPORT),str(output),"--database",str(db)],text=True,capture_output=True)
|
||||
self.assertEqual(0,result.returncode,result.stdout+result.stderr)
|
||||
history=json.loads(output.read_text())["exercises"][0]["history"]
|
||||
self.assertEqual(["pr_legacy_v1",child],[entry["revision_id"] for entry in history])
|
||||
def test_immutable_machine_difference_is_hard_conflict(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
db=Path(d)/"pc.db";make_db(db);self.apply(db,artifact(machine="selectorized"),False)
|
||||
|
||||
if __name__=="__main__": unittest.main()
|
||||
|
|
@ -24,6 +24,28 @@ CREATE TABLE exercise_body_zone_sync(
|
|||
PRAGMA user_version=11;
|
||||
"""
|
||||
|
||||
V16_SCHEMA = V11_SCHEMA.replace("PRAGMA user_version=11;", "") + """
|
||||
ALTER TABLE exercises ADD COLUMN load_semantics TEXT;
|
||||
ALTER TABLE exercises ADD COLUMN machine_variant TEXT;
|
||||
ALTER TABLE exercises ADD COLUMN machine_provenance TEXT;
|
||||
ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT;
|
||||
ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved';
|
||||
ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT;
|
||||
ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT NOT NULL DEFAULT 'reps';
|
||||
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL);
|
||||
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,
|
||||
session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,
|
||||
observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
|
||||
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
|
||||
CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT NOT NULL UNIQUE,
|
||||
session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
|
||||
CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
|
||||
PRAGMA user_version=16;
|
||||
"""
|
||||
|
||||
|
||||
def run(command, ok=True):
|
||||
result = subprocess.run(command, text=True, capture_output=True)
|
||||
|
|
@ -32,6 +54,11 @@ def run(command, ok=True):
|
|||
return result
|
||||
|
||||
|
||||
def db_value(path, sql):
|
||||
with sqlite3.connect(path) as connection:
|
||||
return connection.execute(sql).fetchone()[0]
|
||||
|
||||
|
||||
def payload():
|
||||
exercises = [
|
||||
{"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
|
||||
|
|
@ -122,8 +149,12 @@ def main():
|
|||
|
||||
divergent = copy.deepcopy(payload()); divergent["sessions"][0]["exercises"][0]["target"]["reps"] = 9
|
||||
divergent_path = root / "divergent.json"; divergent_path.write_text(json.dumps(divergent), encoding="utf-8")
|
||||
run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)], ok=False)
|
||||
assert snapshot(db) == before
|
||||
corrected = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)])
|
||||
assert "sessions_reconciled=1" in corrected.stdout
|
||||
replay = run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)])
|
||||
assert "sessions_reconciled=0" in replay.stdout
|
||||
assert db_value(db, "SELECT target_reps FROM session_exercises WHERE entry_id='sxe_reps'") == 9
|
||||
before = snapshot(db)
|
||||
|
||||
mutations = [
|
||||
lambda e: e["target"].update(sets=0),
|
||||
|
|
@ -205,6 +236,72 @@ def main():
|
|||
shared_entry = json.loads(shared_out.read_text())["sessions"][0]["exercises"][0]
|
||||
assert shared_entry["target"] == {"sets": 3, "reps": 9, "weight_kg": 55.5}
|
||||
assert shared_entry["sets"] == [{"reps": 9, "weight_kg": 52.5}, {"reps": 8}, {"reps": 7, "weight_kg": 0.0}]
|
||||
|
||||
# Desktop v16 stores the unit on the occurrence. A later catalogue
|
||||
# profile change therefore cannot reinterpret or rewrite old facts.
|
||||
v16_db = root / "v16.sqlite"
|
||||
with sqlite3.connect(v16_db) as con:
|
||||
con.executescript(V16_SCHEMA)
|
||||
run([sys.executable, str(IMPORTER), str(artifact), "--database", str(v16_db)])
|
||||
with sqlite3.connect(v16_db) as con:
|
||||
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps"
|
||||
con.execute("UPDATE exercises SET tracking_mode='duration' WHERE exercise_id='ex_reps'")
|
||||
|
||||
# V2 has the same completed-occurrence ownership contract: catalogue
|
||||
# membership proves identity, while the entry's own profile validates
|
||||
# and preserves the historical payload.
|
||||
historical_v2 = payload()
|
||||
historical_v2["version"] = 2
|
||||
historical_v2["exercises"][0]["tracking_mode"] = "duration"
|
||||
historical_entry = copy.deepcopy(historical_v2["sessions"][0]["exercises"][0])
|
||||
historical_entry.pop("target")
|
||||
historical_entry["entry_id"] = "sxe_v16_historical_v2"
|
||||
historical_entry["load_mode"] = "none"
|
||||
historical_entry["rest_seconds"] = 0
|
||||
historical_v2["sessions"] = [{
|
||||
"session_id": "se_v16_historical_v2",
|
||||
"started_at": "2026-09-09T13:00:00+02:00",
|
||||
"session_type": "training",
|
||||
"exercises": [historical_entry],
|
||||
}]
|
||||
historical_v2["body_observations"] = []
|
||||
historical_v2_path = root / "v16-historical-v2.json"
|
||||
historical_v2_path.write_text(json.dumps(historical_v2), encoding="utf-8")
|
||||
run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)])
|
||||
replay_v2 = run([sys.executable, str(IMPORTER), str(historical_v2_path), "--database", str(v16_db)])
|
||||
assert "sessions_skipped=1" in replay_v2.stdout
|
||||
with sqlite3.connect(v16_db) as con:
|
||||
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_historical_v2'").fetchone()[0] == "reps"
|
||||
assert con.execute("SELECT reps FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_historical_v2' ORDER BY ps.position LIMIT 1").fetchone()[0] == 8
|
||||
v16_old_export = root / "v16-old.json"
|
||||
run([sys.executable, str(EXPORTER), str(v16_old_export), "--database", str(v16_db)])
|
||||
old_entry = json.loads(v16_old_export.read_text())["sessions"][0]["exercises"][0]
|
||||
assert old_entry["tracking_mode"] == "reps"
|
||||
assert old_entry["sets"][0]["reps"] == 8
|
||||
|
||||
future = payload()
|
||||
future["exercises"][0]["tracking_mode"] = "duration"
|
||||
old_after_profile_change = copy.deepcopy(payload()["sessions"][0])
|
||||
future["sessions"] = [old_after_profile_change, {
|
||||
"session_id": "se_v16_future", "started_at": "2026-09-10T12:00:00+02:00",
|
||||
"session_type": "training", "exercises": [{
|
||||
"entry_id": "sxe_v16_future", "position": 0, "exercise_id": "ex_reps",
|
||||
"name": "Reps", "recording_mode": "sets", "tracking_mode": "duration",
|
||||
"data_fields": 0, "load_mode": "none", "rest_seconds": 0,
|
||||
"equipment_id": None, "target": {"sets": 3, "duration_seconds": 30},
|
||||
"sets": [{"duration_seconds": 30}, {"duration_seconds": 30}, {"duration_seconds": 30}],
|
||||
}],
|
||||
}]
|
||||
future["body_observations"] = []
|
||||
future_path = root / "v16-future.json"
|
||||
future_path.write_text(json.dumps(future), encoding="utf-8")
|
||||
run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)])
|
||||
idempotent = run([sys.executable, str(IMPORTER), str(future_path), "--database", str(v16_db)])
|
||||
assert "sessions_reconciled=0" in idempotent.stdout
|
||||
with sqlite3.connect(v16_db) as con:
|
||||
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_reps'").fetchone()[0] == "reps"
|
||||
assert con.execute("SELECT tracking_mode FROM session_exercises WHERE entry_id='sxe_v16_future'").fetchone()[0] == "duration"
|
||||
assert con.execute("SELECT group_concat(duration_seconds, ',') FROM performed_sets ps JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id='sxe_v16_future' ORDER BY ps.position").fetchone()[0] == "30,30,30"
|
||||
print("PASS session_exchange_v3")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import sqlite3
|
|||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
DEFAULT_CATALOG = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
||||
|
|
@ -25,10 +26,10 @@ def main():
|
|||
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
|
||||
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
||||
args = parser.parse_args()
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v8 à v13 requis")
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
FORMAT = "trainlog-equipment-definitions"
|
||||
VERSION = 1
|
||||
|
|
@ -20,11 +21,11 @@ def main():
|
|||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
args = parser.parse_args()
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v8 à v13 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
raise ValueError("schema desktop v8 à v16 requis")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
"FROM custom_equipment ORDER BY equipment_id")]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import argparse
|
|||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
MAX_ALIASES = 4096
|
||||
|
||||
|
|
@ -13,10 +14,10 @@ def main():
|
|||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
con = sqlite3.connect(args.database)
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v12/v13 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||
raise ValueError("schema desktop v12-v16 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import re
|
|||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CATALOG = ROOT / "catalog/body-zones-v1.json"
|
||||
|
|
@ -37,11 +38,11 @@ def main():
|
|||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
args = parser.parse_args()
|
||||
assignable = assignable_zone_ids()
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v11/v12/v13 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
exercises = []
|
||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:
|
||||
|
|
|
|||
77
tools/export_exercise_profile_state.py
Normal file
77
tools/export_exercise_profile_state.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export mutable current exercise profiles with deterministic ancestry."""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database, profile_revision
|
||||
|
||||
REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
|
||||
def valid_profile(recording, tracking, fields):
|
||||
return recording in ("sets","continuous") and tracking in ("reps","duration") and \
|
||||
not (recording=="continuous" and tracking!="duration") and type(fields) is int and \
|
||||
0<=fields<=3 and not (recording=="sets" and fields!=0)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
con = connect_database(args.database)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version != 17:
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
e.load_semantics,e.machine_variant,e.machine_provenance,
|
||||
e.scientific_profile_id,e.science_state,e.legacy_equipment_id,
|
||||
s.revision_id,s.parent_revision_id,s.legacy_seed
|
||||
FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id
|
||||
ORDER BY e.exercise_id
|
||||
""").fetchall()
|
||||
exercises=[]
|
||||
for row in rows:
|
||||
# exercise_row_id is numeric; resolve it without coupling JSON identity to SQLite row IDs.
|
||||
exercise_row_id=con.execute("SELECT id FROM exercises WHERE exercise_id=?",(row["exercise_id"],)).fetchone()[0]
|
||||
history=con.execute("""WITH RECURSIVE lineage(revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,level) AS (
|
||||
SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed,0 FROM exercise_profile_revisions WHERE exercise_row_id=? AND revision_id=?
|
||||
UNION ALL SELECT r.revision_id,r.parent_revision_id,r.recording_mode,r.tracking_mode,r.data_fields,r.legacy_seed,lineage.level+1 FROM exercise_profile_revisions r JOIN lineage ON r.revision_id=lineage.parent_revision_id WHERE r.exercise_row_id=? AND lineage.level<31)
|
||||
SELECT revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed FROM lineage ORDER BY level DESC""",
|
||||
(exercise_row_id,row["revision_id"],exercise_row_id)).fetchall()
|
||||
if not history or history[-1]["revision_id"] != row["revision_id"] or len(history)>32:
|
||||
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid/bounded history")
|
||||
previous=None
|
||||
for index, revision in enumerate(history):
|
||||
rid=revision["revision_id"];parent=revision["parent_revision_id"]
|
||||
recording=revision["recording_mode"];tracking=revision["tracking_mode"]
|
||||
fields=revision["data_fields"];legacy=revision["legacy_seed"]
|
||||
valid = valid_profile(recording,tracking,fields) and (
|
||||
index==0 and legacy==1 and rid=="pr_legacy_v1" and parent is None or
|
||||
index>0 and legacy==0 and isinstance(parent,str) and parent==previous and
|
||||
isinstance(rid,str) and REVISION_RE.fullmatch(rid) and
|
||||
rid==profile_revision(parent,recording,tracking,fields))
|
||||
if not valid:
|
||||
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL invalid rooted history")
|
||||
previous=rid
|
||||
if (history[-1]["parent_revision_id"]!=row["parent_revision_id"] or
|
||||
history[-1]["legacy_seed"]!=row["legacy_seed"] or
|
||||
history[-1]["recording_mode"]!=row["recording_mode"] or
|
||||
history[-1]["tracking_mode"]!=row["tracking_mode"] or
|
||||
history[-1]["data_fields"]!=row["data_fields"]):
|
||||
raise SystemExit("EXERCISE_PROFILE_STATE_EXPORT=FAIL history tip mismatch")
|
||||
exercises.append(dict(row) | {"legacy_seed":bool(row["legacy_seed"]),"history":[dict(h)|{"legacy_seed":bool(h["legacy_seed"])} for h in history]})
|
||||
payload = {"format":"trainlog-exercise-profile-state","version":1,
|
||||
"generated_at":datetime.now().astimezone().isoformat(),"exercises":exercises}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload,ensure_ascii=False,separators=(",",":")),encoding="utf-8")
|
||||
print(f"EXERCISE_PROFILE_STATE_EXPORT=PASS exercises={len(rows)}")
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
|
|
@ -7,6 +7,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
|
||||
from exercise_names import load_exercise_names
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
def default_database_path():
|
||||
|
|
@ -54,7 +55,7 @@ def main():
|
|||
"PC_CATALOG_EXPORT=FAIL database not found"
|
||||
)
|
||||
|
||||
connection = sqlite3.connect(
|
||||
connection = connect_database(
|
||||
args.database
|
||||
)
|
||||
|
||||
|
|
@ -67,7 +68,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||
from exercise_names import load_exercise_names
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
||||
|
|
@ -64,12 +65,12 @@ def main():
|
|||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
|
||||
args = parser.parse_args()
|
||||
con = sqlite3.connect(args.database)
|
||||
con = connect_database(args.database)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in (11, 12, 13, 14, 15) and not (args.version == 2 and schema_version == 10):
|
||||
raise ValueError("schema desktop v11/v12/v13 requis (v10 accepté pour export V2 explicite)")
|
||||
if schema_version not in (11, 12, 13, 14, 15, 16, 17) and not (args.version == 2 and schema_version == 10):
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
canonical_names = load_exercise_names()
|
||||
known_equipment.update(row[0] for row in con.execute(
|
||||
|
|
@ -89,9 +90,10 @@ def main():
|
|||
f"session_id={session['session_id']} started_at")
|
||||
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
||||
payload["exercises"] = []
|
||||
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
||||
# retain their stable entry_id but do not duplicate that field.
|
||||
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
|
||||
# INVARIANT: v16 exports the occurrence-owned tracking snapshot;
|
||||
# older readable schemas retain their bounded catalogue fallback.
|
||||
tracking_sql = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
|
||||
sql = f"SELECT se.id,se.entry_id,se.position,se.recording_mode,{tracking_sql},se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position"
|
||||
for entry in con.execute(sql, (session["id"],)):
|
||||
validate_plan(entry)
|
||||
if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None:
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion."""
|
||||
import argparse, datetime, json, sqlite3
|
||||
from trainlog_sqlite import connect_database
|
||||
from pathlib import Path
|
||||
|
||||
def instant(value): return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=sqlite3.connect(args.database)
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 15: raise ValueError("schema desktop v15 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15, 16, 17): raise ValueError("schema desktop v15/v16/v17 requis")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
if len(roots)>4096 or len(followups)>4096: raise ValueError("trop d'observations")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import argparse
|
|||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
FORMAT = "trainlog-equipment-associations"
|
||||
VERSION = 2
|
||||
|
|
@ -170,10 +171,10 @@ def main():
|
|||
fail("extension équipement non supportée")
|
||||
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||
fail("clés extension équipement invalides")
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
fail("schema desktop v8 à v13 requis")
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
known = load_catalog(args.catalog)
|
||||
known.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import argparse
|
|||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
FORMAT = "trainlog-equipment-definitions"
|
||||
VERSION = 1
|
||||
|
|
@ -58,10 +59,10 @@ def main():
|
|||
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
||||
args = parser.parse_args()
|
||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
fail("schema desktop v8 à v13 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
fail("schema desktop v8 à v16 requis")
|
||||
imported = skipped = 0
|
||||
# Validate every same-ID row before inserting any definition.
|
||||
for definition in definitions:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
MAX_BYTES = 1024 * 1024
|
||||
MAX_ALIASES = 4096
|
||||
|
|
@ -59,11 +60,11 @@ def main():
|
|||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
aliases = load(args.artifact)
|
||||
con = sqlite3.connect(args.database)
|
||||
con = connect_database(args.database)
|
||||
try:
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
||||
fail("schema desktop v12/v13/v14/v15 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15, 16, 17):
|
||||
fail("schema desktop v12-v16 requis")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import sqlite3
|
|||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -201,11 +202,11 @@ def main():
|
|||
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
|
||||
if args.mobile_export is not None else {})
|
||||
|
||||
connection = sqlite3.connect(args.database)
|
||||
connection = connect_database(args.database)
|
||||
updated = skipped = kept_local = 0
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
||||
raise ImportFailure("schema desktop v11/v12/v13 requis")
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15, 16, 17):
|
||||
raise ImportFailure("schema desktop v11-v16 requis")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
grouped = {}
|
||||
|
|
|
|||
102
tools/import_exercise_profile_state.py
Normal file
102
tools/import_exercise_profile_state.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply direct-descendant current profiles without reinterpreting occurrences."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from validate_json import parse_timestamp
|
||||
from trainlog_sqlite import connect_database, profile_revision
|
||||
|
||||
EX_ID=re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
HISTORY_MAX=32
|
||||
ADJ=("load_semantics","machine_variant","machine_provenance","scientific_profile_id","science_state","legacy_equipment_id")
|
||||
REVISION_RE=re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
|
||||
def fail(message): raise ValueError(message)
|
||||
|
||||
def validate(item):
|
||||
required={"exercise_id","recording_mode","tracking_mode","data_fields",*ADJ,
|
||||
"revision_id","parent_revision_id","legacy_seed","history"}
|
||||
if set(item)!=required or not isinstance(item["exercise_id"],str) or not EX_ID.fullmatch(item["exercise_id"]): fail("record profile invalide")
|
||||
r,t,f=item["recording_mode"],item["tracking_mode"],item["data_fields"]
|
||||
if r not in ("sets","continuous") or t not in ("reps","duration") or r=="continuous" and t!="duration" or type(f) is not int or f<0 or f&~3 or r=="sets" and f: fail("profil entrant invalide")
|
||||
if item["science_state"] not in ("resolved","unresolved") or any(item[k] is not None and not isinstance(item[k],str) for k in ADJ if k!="science_state") or type(item["legacy_seed"]) is not bool: fail("état profil invalide")
|
||||
if item["legacy_seed"]:
|
||||
if item["revision_id"]!="pr_legacy_v1" or item["parent_revision_id"] is not None: fail("racine legacy invalide")
|
||||
elif not isinstance(item["parent_revision_id"],str) or not isinstance(item["revision_id"],str) or not REVISION_RE.fullmatch(item["revision_id"]) or item["revision_id"]!=profile_revision(item["parent_revision_id"],r,t,f): fail("révision profil invalide")
|
||||
history=item["history"]
|
||||
keys={"revision_id","parent_revision_id","recording_mode","tracking_mode","data_fields","legacy_seed"}
|
||||
if not isinstance(history,list) or not 1<=len(history)<=HISTORY_MAX: fail("historique profil hors limite")
|
||||
previous=None;revision_ids=set()
|
||||
for index,revision in enumerate(history):
|
||||
if not isinstance(revision,dict) or set(revision)!=keys: fail("révision historique invalide")
|
||||
rr,rt,rf=revision["recording_mode"],revision["tracking_mode"],revision["data_fields"]
|
||||
legacy=revision["legacy_seed"];rid=revision["revision_id"];parent=revision["parent_revision_id"]
|
||||
if rr not in ("sets","continuous") or rt not in ("reps","duration") or rr=="continuous" and rt!="duration" or type(rf) is not int or rf<0 or rf&~3 or rr=="sets" and rf or type(legacy) is not bool: fail("profil historique invalide")
|
||||
if not isinstance(rid,str) or rid in revision_ids or index and parent!=previous: fail("chaîne historique invalide")
|
||||
if index == 0:
|
||||
if not legacy or rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide")
|
||||
elif legacy:
|
||||
fail("marqueur legacy hors racine")
|
||||
if legacy:
|
||||
if rid!="pr_legacy_v1" or parent is not None: fail("racine historique invalide")
|
||||
elif not isinstance(parent,str) or not isinstance(rid,str) or not REVISION_RE.fullmatch(rid) or rid!=profile_revision(parent,rr,rt,rf): fail("révision historique invalide")
|
||||
revision_ids.add(rid);previous=rid
|
||||
if any(history[-1][key]!=item[key] for key in keys): fail("pointe historique incohérente")
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser();p.add_argument("artifact",type=Path);p.add_argument("--database",required=True,type=Path);p.add_argument("--allow-pending",action="store_true");p.add_argument("--mobile-export",type=Path,help="sync pre-pass identity proof; permits identities introduced by the following catalog import");a=p.parse_args()
|
||||
try:
|
||||
root=json.loads(a.artifact.read_text(encoding="utf-8"))
|
||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at")
|
||||
con=connect_database(a.database);con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0]!=17: fail("schema desktop v17 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set();con.execute("BEGIN IMMEDIATE")
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
if eid in seen: fail("exercise_id profile-state dupliqué")
|
||||
seen.add(eid)
|
||||
row=con.execute("""SELECT e.id,e.recording_mode,e.tracking_mode,e.data_fields,e.load_semantics,e.machine_variant,e.machine_provenance,e.scientific_profile_id,e.science_state,e.legacy_equipment_id,s.revision_id,s.parent_revision_id,s.legacy_seed FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE e.exercise_id=?""",(eid,)).fetchone()
|
||||
if row is None:
|
||||
if a.allow_pending or a.mobile_export is not None: pending+=1;continue
|
||||
fail(f"identité exercice inconnue: {eid}")
|
||||
if any(row[k]!=item[k] for k in ADJ): fail(f"conflit identité/machine immutable: {eid}")
|
||||
local_profile=(row["recording_mode"],row["tracking_mode"],row["data_fields"]);incoming=(item["recording_mode"],item["tracking_mode"],item["data_fields"])
|
||||
incoming_ids={revision["revision_id"] for revision in item["history"]}
|
||||
known={r[0] for r in con.execute("SELECT revision_id FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],))}
|
||||
if row["revision_id"]==item["revision_id"]:
|
||||
if local_profile==incoming: equal+=1;continue
|
||||
if not (row["legacy_seed"] and item["legacy_seed"]): fail(f"révision identique avec profil différent: {eid}")
|
||||
elif row["revision_id"] in incoming_ids:
|
||||
pass
|
||||
elif item["revision_id"] in known:
|
||||
ancestor+=1;continue
|
||||
else: fail(f"conflit de descendants concurrents: {eid}")
|
||||
existing_count=con.execute("SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=?",(row["id"],)).fetchone()[0]
|
||||
additions=sum(1 for revision in item["history"] if revision["revision_id"] not in known)
|
||||
if existing_count+additions>HISTORY_MAX: fail(f"historique profil dépasse {HISTORY_MAX}: {eid}")
|
||||
for revision in item["history"]:
|
||||
con.execute("INSERT OR IGNORE INTO exercise_profile_revisions(exercise_row_id,revision_id,parent_revision_id,recording_mode,tracking_mode,data_fields,legacy_seed) VALUES(?,?,?,?,?,?,?)",
|
||||
(row["id"],revision["revision_id"],revision["parent_revision_id"],revision["recording_mode"],revision["tracking_mode"],revision["data_fields"],int(revision["legacy_seed"])))
|
||||
if item["legacy_seed"]:
|
||||
# The shared migration root deliberately permits the one initial
|
||||
# cross-peer repair; keep its stored tuple equal to the adopted tip.
|
||||
con.execute("UPDATE exercise_profile_revisions SET recording_mode=?,tracking_mode=?,data_fields=? WHERE exercise_row_id=? AND revision_id='pr_legacy_v1'",(*incoming,row["id"]))
|
||||
# CONTRACT: select the proven incoming tip before changing the catalogue;
|
||||
# the guarded local-edit trigger then recognizes an authoritative import.
|
||||
con.execute("UPDATE exercise_profile_state SET revision_id=?,parent_revision_id=?,legacy_seed=? WHERE exercise_row_id=?",(item["revision_id"],item["parent_revision_id"],int(item["legacy_seed"]),row["id"]))
|
||||
con.execute("UPDATE exercises SET recording_mode=?,tracking_mode=?,data_fields=? WHERE id=?",(*incoming,row["id"]))
|
||||
applied+=1
|
||||
con.commit();print(f"EXERCISE_PROFILE_STATE_IMPORT=PASS applied={applied} equal={equal} ancestor={ancestor} pending={pending}")
|
||||
except (OSError,json.JSONDecodeError,sqlite3.Error,ValueError) as e:
|
||||
try:
|
||||
if 'con' in locals(): con.rollback()
|
||||
except sqlite3.Error: pass
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_IMPORT=FAIL {e}")
|
||||
finally:
|
||||
if 'con' in locals(): con.close()
|
||||
|
||||
if __name__=="__main__": main()
|
||||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||
from exercise_names import ExerciseNameCatalogError, load_exercise_names
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
FORMAT = "trainlog-mobile-export"
|
||||
|
|
@ -492,18 +493,10 @@ def validate_session_exercise(
|
|||
label,
|
||||
)
|
||||
|
||||
catalog_profile = known_exercise_ids[exercise_id]
|
||||
if (
|
||||
recording_mode != catalog_profile["recording_mode"]
|
||||
or tracking_mode != catalog_profile["tracking_mode"]
|
||||
or item["data_fields"] & ~catalog_profile["data_fields"]
|
||||
):
|
||||
# CONTRACT: an occurrence snapshots the fields that actually existed
|
||||
# when it was recorded. It may omit later optional catalog fields, but
|
||||
# it may never claim a field absent from the catalog profile.
|
||||
raise ImportFailure(
|
||||
f"{label}: snapshot incompatible avec le profil catalogue"
|
||||
)
|
||||
# CONTRACT: catalogue membership proves stable identity only. Every
|
||||
# completed occurrence owns its recording/tracking/data-field snapshot in
|
||||
# every readable mobile version; later CURRENT profile evolution cannot
|
||||
# invalidate or reinterpret historical actuals.
|
||||
|
||||
if version < 3:
|
||||
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
|
||||
|
|
@ -820,9 +813,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v15 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v17 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1290,7 +1283,12 @@ def import_set_session_exercise(
|
|||
values = "?, " if entry_id is not None else ""
|
||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||
equipment_values = ", ?" if schema_version >= 6 else ""
|
||||
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
|
||||
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
|
||||
tracking_values = ", ?" if schema_version >= 16 else ""
|
||||
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
|
||||
if schema_version >= 16:
|
||||
arguments.append(tracking)
|
||||
arguments.extend([item["data_fields"], position])
|
||||
arguments.extend(occurrence_plan_values(item))
|
||||
if schema_version >= 6:
|
||||
arguments.append(item.get("equipment_id"))
|
||||
|
|
@ -1299,7 +1297,7 @@ def import_set_session_exercise(
|
|||
INSERT INTO session_exercises(
|
||||
""" + columns + """session_row_id,
|
||||
exercise_row_id,
|
||||
recording_mode,
|
||||
recording_mode""" + tracking_columns + """,
|
||||
data_fields,
|
||||
position,
|
||||
load_mode,
|
||||
|
|
@ -1309,7 +1307,7 @@ def import_set_session_exercise(
|
|||
target_duration_seconds,
|
||||
target_weight_kg, notes""" + equipment_columns + """
|
||||
) VALUES(
|
||||
""" + values + """?, ?, 'sets', ?, ?, ?, ?,
|
||||
""" + values + """?, ?, 'sets'""" + tracking_values + """, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||
);
|
||||
""",
|
||||
|
|
@ -1363,7 +1361,12 @@ def import_continuous_session_exercise(
|
|||
values = "?, " if entry_id is not None else ""
|
||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||
equipment_values = ", ?" if schema_version >= 6 else ""
|
||||
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
|
||||
tracking_columns = ", tracking_mode" if schema_version >= 16 else ""
|
||||
tracking_values = ", ?" if schema_version >= 16 else ""
|
||||
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row]
|
||||
if schema_version >= 16:
|
||||
arguments.append(item["tracking_mode"])
|
||||
arguments.extend([item["data_fields"], position])
|
||||
arguments.extend(occurrence_plan_values(item))
|
||||
if schema_version >= 6:
|
||||
arguments.append(item.get("equipment_id"))
|
||||
|
|
@ -1372,7 +1375,7 @@ def import_continuous_session_exercise(
|
|||
INSERT INTO session_exercises(
|
||||
""" + columns + """session_row_id,
|
||||
exercise_row_id,
|
||||
recording_mode,
|
||||
recording_mode""" + tracking_columns + """,
|
||||
data_fields,
|
||||
position,
|
||||
load_mode,
|
||||
|
|
@ -1382,7 +1385,7 @@ def import_continuous_session_exercise(
|
|||
target_duration_seconds,
|
||||
target_weight_kg, notes""" + equipment_columns + """
|
||||
) VALUES(
|
||||
""" + values + """?, ?, 'continuous', ?, ?, ?, ?,
|
||||
""" + values + """?, ?, 'continuous'""" + tracking_values + """, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||
);
|
||||
""",
|
||||
|
|
@ -1422,25 +1425,24 @@ def import_max_session_exercise(
|
|||
raise ImportFailure(
|
||||
"max_weight_kg exige le schéma desktop v9"
|
||||
)
|
||||
tracking_column = ", tracking_mode" if schema_version >= 16 else ""
|
||||
tracking_value = ", ?" if schema_version >= 16 else ""
|
||||
arguments = [item["entry_id"], session_row_id, exercise_row,
|
||||
item["recording_mode"]]
|
||||
if schema_version >= 16:
|
||||
arguments.append(item["tracking_mode"])
|
||||
arguments.extend([item["data_fields"], position, *occurrence_plan_values(item),
|
||||
item.get("equipment_id")])
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO session_exercises(
|
||||
entry_id, session_row_id, exercise_row_id, recording_mode,
|
||||
data_fields, position, load_mode, rest_seconds,
|
||||
""" + tracking_column.lstrip(", ") + (", " if tracking_column else "") + """data_fields, position, load_mode, rest_seconds,
|
||||
target_sets, target_reps, target_duration_seconds,
|
||||
target_weight_kg, notes, equipment_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
||||
) VALUES(?, ?, ?, ?""" + tracking_value + """, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
||||
""",
|
||||
(
|
||||
item["entry_id"],
|
||||
session_row_id,
|
||||
exercise_row,
|
||||
item["recording_mode"],
|
||||
item["data_fields"],
|
||||
position,
|
||||
*occurrence_plan_values(item),
|
||||
item.get("equipment_id"),
|
||||
),
|
||||
arguments,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) "
|
||||
|
|
@ -1456,6 +1458,8 @@ def import_sessions(
|
|||
report,
|
||||
):
|
||||
for session in payload["sessions"]:
|
||||
retained_feedback = []
|
||||
retained_feedback_revisions = []
|
||||
if session_exists(
|
||||
connection,
|
||||
session["session_id"],
|
||||
|
|
@ -1495,11 +1499,21 @@ def import_sessions(
|
|||
and all(current[index] == incoming[index]
|
||||
for index in range(len(current)))
|
||||
)
|
||||
incoming_by_id = dict(incoming)
|
||||
correction = (
|
||||
payload["version"] == 3
|
||||
and header is not None
|
||||
and tuple(header) == (session["started_at"], session["session_type"])
|
||||
and len(incoming_by_id) == len(incoming)
|
||||
and all(entry_id not in dict(current)
|
||||
or dict(current)[entry_id] == exercise_id
|
||||
for entry_id, exercise_id in incoming)
|
||||
)
|
||||
# A v1 history may be upgraded only when exercise/order mapping is
|
||||
# unique. Any other identity disagreement is an explicit conflict.
|
||||
if current != incoming and not (
|
||||
(legacy and [x[1] for x in current] == [x[1] for x in incoming])
|
||||
or resumable_max
|
||||
or resumable_max or correction
|
||||
):
|
||||
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
|
||||
if not legacy and session_semantically_matches(
|
||||
|
|
@ -1510,12 +1524,25 @@ def import_sessions(
|
|||
):
|
||||
report["sessions_skipped"] += 1
|
||||
continue
|
||||
if not legacy and not resumable_max:
|
||||
if not legacy and not (resumable_max or correction):
|
||||
raise ImportFailure("conflit de contenu pour " + session["session_id"])
|
||||
# CONTRACT: a resumed max_test may edit existing max values and
|
||||
# append occurrences, but cannot remove/reorder/rebind any stable
|
||||
# entry. This bounded replacement makes tomorrow's continuation
|
||||
# idempotent without turning arbitrary session conflicts into wins.
|
||||
# CONTRACT: V3 carries the complete occurrence and planning state.
|
||||
# A correction may alter facts and remove explicitly confirmed
|
||||
# parents, but a retained entry_id cannot be rebound to another
|
||||
# exercise. Feedback follows retained session_id + entry_id.
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 14:
|
||||
retained_feedback = connection.execute(
|
||||
"SELECT se.entry_id,f.feedback_id,f.observed_at,f.raw_text "
|
||||
"FROM exercise_feedback f JOIN session_exercises se "
|
||||
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
|
||||
(session_row_id,),).fetchall()
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] >= 15:
|
||||
retained_feedback_revisions = connection.execute(
|
||||
"SELECT r.feedback_id,r.revision_id,r.created_at,r.raw_text "
|
||||
"FROM exercise_feedback_revisions r JOIN exercise_feedback f "
|
||||
"ON f.feedback_id=r.feedback_id JOIN session_exercises se "
|
||||
"ON se.id=f.session_exercise_row_id WHERE se.session_row_id=?;",
|
||||
(session_row_id,),).fetchall()
|
||||
# Explicit child deletion makes reconciliation safe even for old
|
||||
# databases which were created without enforced foreign keys.
|
||||
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
|
||||
|
|
@ -1569,9 +1596,9 @@ def import_sessions(
|
|||
f"exercice desktop absent: {desktop_id}"
|
||||
)
|
||||
|
||||
if (
|
||||
row["tracking_mode"]
|
||||
!= item["tracking_mode"]
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if schema_version < 16 and (
|
||||
row["tracking_mode"] != item["tracking_mode"]
|
||||
or row["recording_mode"]
|
||||
!= item["recording_mode"]
|
||||
or item["data_fields"] & ~row["data_fields"]
|
||||
|
|
@ -1610,6 +1637,25 @@ def import_sessions(
|
|||
row_id,
|
||||
)
|
||||
|
||||
retained_ids = {item["entry_id"] for item in session["exercises"]}
|
||||
for entry_id, feedback_id, observed_at, raw_text in retained_feedback:
|
||||
if entry_id not in retained_ids:
|
||||
continue
|
||||
occurrence = connection.execute(
|
||||
"SELECT id FROM session_exercises WHERE session_row_id=? AND entry_id=?;",
|
||||
(session_row_id, entry_id),).fetchone()
|
||||
if occurrence is None:
|
||||
raise ImportFailure("rattachement feedback impossible: " + entry_id)
|
||||
connection.execute(
|
||||
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?);",
|
||||
(feedback_id, occurrence[0], observed_at, raw_text),)
|
||||
retained_feedback_ids = {row[1] for row in retained_feedback if row[0] in retained_ids}
|
||||
for feedback_id, revision_id, created_at, raw_text in retained_feedback_revisions:
|
||||
if feedback_id in retained_feedback_ids:
|
||||
connection.execute(
|
||||
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) VALUES(?,?,?,?);",
|
||||
(revision_id, feedback_id, created_at, raw_text),)
|
||||
|
||||
report["sessions_imported"] += 1
|
||||
|
||||
|
||||
|
|
@ -1623,8 +1669,10 @@ def session_semantically_matches(
|
|||
"SELECT started_at,session_type FROM sessions WHERE id=?", (session_row_id,)).fetchone()
|
||||
if header is None or (header[0], header[1]) != (incoming["started_at"], incoming["session_type"]):
|
||||
return False
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
tracking_column = "se.tracking_mode" if schema_version >= 16 else "e.tracking_mode"
|
||||
rows = connection.execute(
|
||||
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode,"
|
||||
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode," + tracking_column + ","
|
||||
"se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
|
||||
"se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e "
|
||||
"ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall()
|
||||
|
|
@ -1772,7 +1820,7 @@ def run_import(
|
|||
f"base desktop introuvable: {database_path}"
|
||||
)
|
||||
|
||||
connection = sqlite3.connect(
|
||||
connection = connect_database(
|
||||
database_path
|
||||
)
|
||||
|
||||
|
|
@ -1927,7 +1975,7 @@ def main():
|
|||
|
||||
known_equipment_ids = load_supplied_equipment_ids(args.catalog)
|
||||
if args.database.exists():
|
||||
with sqlite3.connect(args.database) as connection:
|
||||
with connect_database(args.database) as connection:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 8:
|
||||
known_equipment_ids.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger."""
|
||||
import argparse,json,re,sqlite3
|
||||
from trainlog_sqlite import connect_database
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
ID=lambda p:re.compile(r"^"+p+r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
|
|
@ -46,11 +47,11 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
current=max(db.execute(f"SELECT revision_id,created_at,raw_text FROM {table} WHERE {parent_column}=?",(parent_id,)).fetchall(),key=lambda r:(instant(r[1]),r[0].encode()))
|
||||
return added,skipped,current[2]
|
||||
def main():
|
||||
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=sqlite3.connect(args.database)
|
||||
parser=argparse.ArgumentParser();parser.add_argument("artifact",type=Path);parser.add_argument("--database",type=Path,required=True);args=parser.parse_args();root=load(args.artifact);db=connect_database(args.database)
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
try:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0]!=15:fail("schema desktop v15 requis")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (15,16,17):fail("schema desktop v15/v16/v17 requis")
|
||||
db.execute("BEGIN IMMEDIATE");seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
keys={"feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text"} if root["version"]==1 else {"feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import sqlite3
|
|||
from pathlib import Path
|
||||
|
||||
from exercise_names import normalize_catalog_name
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
|
||||
|
|
@ -167,7 +168,7 @@ def main():
|
|||
if database == LIVE_DB.resolve():
|
||||
fail("refusing to migrate the canonical live desktop database")
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
connection = sqlite3.connect(database)
|
||||
connection = connect_database(database)
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
changed = apply(connection, manifest)
|
||||
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
|
|
|
|||
49
tools/trainlog_sqlite.py
Normal file
49
tools/trainlog_sqlite.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Canonical SQLite connection configuration for Trainlog Python tools."""
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
|
||||
PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
|
||||
|
||||
|
||||
def profile_revision(parent, recording, tracking, fields):
|
||||
"""Return the frozen deterministic profile revision shared with C/Android."""
|
||||
if not all(isinstance(value, str) for value in (parent, recording, tracking)):
|
||||
raise ValueError("invalid profile revision")
|
||||
if type(fields) is not int:
|
||||
raise ValueError("invalid profile revision")
|
||||
name = f"{parent}\n{recording}\n{tracking}\n{fields}"
|
||||
return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, name))
|
||||
|
||||
|
||||
def prototype_profile_valid(token, recording, tracking, fields):
|
||||
"""Recognize only the unpublished pr1 prototype accepted by migration v17."""
|
||||
if not isinstance(token, str) or not isinstance(recording, str) or \
|
||||
not isinstance(tracking, str) or type(fields) is not int:
|
||||
return 0
|
||||
valid = recording in ("sets", "continuous") and tracking in ("reps", "duration") and \
|
||||
not (recording == "continuous" and tracking != "duration") and \
|
||||
fields >= 0 and fields & ~3 == 0 and not (recording == "sets" and fields != 0)
|
||||
return int(valid and token == f"pr1|{recording}|{tracking}|{fields}")
|
||||
|
||||
|
||||
def configure_connection(connection):
|
||||
"""Install every process-local function referenced by Trainlog schema SQL.
|
||||
|
||||
CONTRACT: callers must use this before preparing statements against a
|
||||
Trainlog database. Persisted triggers resolve UDF names at statement
|
||||
preparation even when their WHEN clause suppresses the trigger body.
|
||||
"""
|
||||
connection.create_function(
|
||||
"trainlog_profile_revision", 4, profile_revision, deterministic=True,
|
||||
)
|
||||
connection.create_function(
|
||||
"trainlog_pr1_valid", 4, prototype_profile_valid, deterministic=True,
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def connect_database(database, *args, **kwargs):
|
||||
"""Open and canonically configure one production Trainlog connection."""
|
||||
return configure_connection(sqlite3.connect(database, *args, **kwargs))
|
||||
|
|
@ -10,6 +10,7 @@ import math
|
|||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -29,12 +30,88 @@ SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
|
|||
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
|
||||
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
|
||||
BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json"
|
||||
PROFILE_EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
PROFILE_NAMESPACE = uuid.UUID("4f4c8ea6-18f6-5e48-9d65-a54a24889149")
|
||||
PROFILE_REVISION_ID = re.compile(r"^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
PROFILE_HISTORY_MAX = 32
|
||||
|
||||
|
||||
def exercise_profile_revision(parent: str, recording: str, tracking: str, fields: int) -> str:
|
||||
return "pr2_" + str(uuid.uuid5(PROFILE_NAMESPACE, f"{parent}\n{recording}\n{tracking}\n{fields}"))
|
||||
|
||||
|
||||
class TrainlogSemanticError(ValueError):
|
||||
"""Raised when structurally valid JSON violates its format semantics."""
|
||||
|
||||
|
||||
def validate_exercise_profile_state(document: Any) -> None:
|
||||
"""Validate the separately versioned deterministic ancestry companion."""
|
||||
if not isinstance(document, dict) or set(document) != {
|
||||
"format", "version", "generated_at", "exercises",
|
||||
} or document.get("format") != "trainlog-exercise-profile-state" or \
|
||||
type(document.get("version")) is not int or document.get("version") != 1 or not isinstance(document.get("generated_at"), str) or \
|
||||
not document.get("generated_at") or not isinstance(document.get("exercises"), list):
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid root")
|
||||
parse_timestamp(document["generated_at"], "generated_at")
|
||||
seen = set()
|
||||
adjacent = {"load_semantics", "machine_variant", "machine_provenance",
|
||||
"scientific_profile_id", "science_state", "legacy_equipment_id"}
|
||||
required = {"exercise_id", "recording_mode", "tracking_mode", "data_fields",
|
||||
"revision_id", "parent_revision_id", "legacy_seed", "history"} | adjacent
|
||||
revision_keys = {"revision_id", "parent_revision_id", "recording_mode",
|
||||
"tracking_mode", "data_fields", "legacy_seed"}
|
||||
for item in document["exercises"]:
|
||||
if not isinstance(item, dict) or set(item) != required:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid record")
|
||||
exercise_id = item["exercise_id"]
|
||||
if not isinstance(exercise_id, str) or not PROFILE_EXERCISE_ID.fullmatch(exercise_id) or exercise_id in seen:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid/duplicate identity")
|
||||
seen.add(exercise_id)
|
||||
recording, tracking, fields = item["recording_mode"], item["tracking_mode"], item["data_fields"]
|
||||
if not isinstance(recording, str) or not isinstance(tracking, str) or \
|
||||
recording not in {"sets", "continuous"} or tracking not in {"reps", "duration"} or \
|
||||
recording == "continuous" and tracking != "duration" or type(fields) is not int or \
|
||||
fields < 0 or fields & ~3 or recording == "sets" and fields != 0:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid profile")
|
||||
if not isinstance(item["science_state"], str) or item["science_state"] not in {"resolved", "unresolved"} or any(
|
||||
item[key] is not None and not isinstance(item[key], str)
|
||||
for key in adjacent - {"science_state"}):
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid adjacent metadata")
|
||||
legacy = item["legacy_seed"]
|
||||
if type(legacy) is not bool or (legacy and (item["revision_id"] != "pr_legacy_v1" or item["parent_revision_id"] is not None)) or \
|
||||
(not legacy and (not isinstance(item["parent_revision_id"], str) or not isinstance(item["revision_id"], str) or
|
||||
not PROFILE_REVISION_ID.fullmatch(item["revision_id"]) or item["revision_id"] !=
|
||||
exercise_profile_revision(item["parent_revision_id"], recording, tracking, fields))):
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid ancestry")
|
||||
history = item["history"]
|
||||
if not isinstance(history, list) or not 1 <= len(history) <= PROFILE_HISTORY_MAX:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid history bound")
|
||||
previous = None
|
||||
revision_ids = set()
|
||||
for index, revision in enumerate(history):
|
||||
if not isinstance(revision, dict) or set(revision) != revision_keys:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid history record")
|
||||
rr, rt, rf = revision["recording_mode"], revision["tracking_mode"], revision["data_fields"]
|
||||
rlegacy = revision["legacy_seed"]
|
||||
if not isinstance(rr, str) or not isinstance(rt, str) or rr not in {"sets", "continuous"} or rt not in {"reps", "duration"} or \
|
||||
rr == "continuous" and rt != "duration" or type(rf) is not int or rf < 0 or \
|
||||
rf & ~3 or rr == "sets" and rf != 0 or type(rlegacy) is not bool:
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid history profile")
|
||||
rid, parent = revision["revision_id"], revision["parent_revision_id"]
|
||||
if not isinstance(rid, str) or rid in revision_ids or (index > 0 and parent != previous) or \
|
||||
(index == 0 and (not rlegacy or rid != "pr_legacy_v1" or parent is not None)) or \
|
||||
(index > 0 and rlegacy) or \
|
||||
(rlegacy and (rid != "pr_legacy_v1" or parent is not None)) or \
|
||||
(not rlegacy and (not isinstance(parent, str) or not isinstance(rid, str) or
|
||||
not PROFILE_REVISION_ID.fullmatch(rid) or rid != exercise_profile_revision(parent, rr, rt, rf))):
|
||||
raise TrainlogSemanticError("exercise profile-state v1: invalid history chain")
|
||||
revision_ids.add(rid)
|
||||
previous = rid
|
||||
tip = history[-1]
|
||||
if any(tip[key] != item[key] for key in revision_keys):
|
||||
raise TrainlogSemanticError("exercise profile-state v1: history tip mismatch")
|
||||
|
||||
|
||||
def validate_body_zone_catalog(document: Any) -> None:
|
||||
"""Validate the one canonical taxonomy, hierarchy and initial mappings."""
|
||||
if not isinstance(document, dict) or set(document) != {
|
||||
|
|
@ -443,6 +520,7 @@ def validate_document(
|
|||
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
|
||||
is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1"
|
||||
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
|
||||
is_profile_state = isinstance(document, dict) and document.get("format") == "trainlog-exercise-profile-state"
|
||||
if is_body_zones:
|
||||
try:
|
||||
validate_body_zone_catalog(document)
|
||||
|
|
@ -456,6 +534,12 @@ def validate_document(
|
|||
except ValueError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
if is_profile_state:
|
||||
try:
|
||||
validate_exercise_profile_state(document)
|
||||
except TrainlogSemanticError as exc:
|
||||
return [str(exc)]
|
||||
return []
|
||||
if not is_mobile_v2:
|
||||
errors = structural_errors(validator, document)
|
||||
if errors:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 15
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 17
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ typedef struct TrainlogSessionExerciseInput {
|
|||
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
|
||||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||
TrainlogRecordingMode recording_mode;
|
||||
/** Occurrence-owned unit snapshot; catalogue edits affect future rows only. */
|
||||
TrainlogTrackingMode tracking_mode;
|
||||
TrainlogExerciseDataFields data_fields;
|
||||
TrainlogLoadMode load_mode;
|
||||
int rest_seconds;
|
||||
|
|
|
|||
|
|
@ -21,11 +21,12 @@ TrainlogStatus trainlog_time_now_rfc3339(
|
|||
size_t output_size
|
||||
);
|
||||
|
||||
/* CONTRACT: exercise observations before end use "Pendant la séance" and a
|
||||
* missing anchor uses "Ressenti"; follow-ups use H+? in both cases. No caller
|
||||
* can receive a negative H+ label. */
|
||||
/* CONTRACT: ended_at yields exact H+ and alone can prove "Pendant la séance".
|
||||
* If unavailable, a valid nonnegative started_at delta is visibly approximate
|
||||
* (≈H+); otherwise both feedback kinds use H+?. */
|
||||
TrainlogStatus trainlog_feedback_relative_label(
|
||||
const char *ended_at, const char *observed_at, bool exercise_feedback,
|
||||
const char *ended_at, const char *started_at, const char *observed_at,
|
||||
bool exercise_feedback,
|
||||
char *output, size_t output_size);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -674,6 +674,22 @@ test_feedback_correction = executable(
|
|||
)
|
||||
test('feedback_correction', test_feedback_correction)
|
||||
|
||||
test_schema_v16_migration = executable(
|
||||
'test_schema_v16_migration',
|
||||
'tests/test_schema_v16_migration.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('schema_v16_migration', test_schema_v16_migration)
|
||||
|
||||
python3 = find_program('python3')
|
||||
test(
|
||||
'profile_state_sync',
|
||||
python3,
|
||||
args: [meson.project_source_root() / 'tests/test_profile_state_sync.py'],
|
||||
workdir: meson.project_source_root(),
|
||||
)
|
||||
|
||||
test_app_shell = executable(
|
||||
'test_app_shell',
|
||||
'tests/test_app_shell.c',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,59 @@
|
|||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <uuid/uuid.h>
|
||||
|
||||
/* CONTRACT: exact parity with Android/Python UUIDv5 over parent, profile tuple. */
|
||||
static void sqlite_profile_revision(sqlite3_context *context, int argc, sqlite3_value **argv)
|
||||
{
|
||||
uuid_t namespace_id, revision;
|
||||
char name[256], uuid_text[37], output[41];
|
||||
const char *parent, *recording, *tracking;
|
||||
int length;
|
||||
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
|
||||
sqlite3_value_type(argv[1]) != SQLITE_TEXT || sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
|
||||
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) { sqlite3_result_error(context, "invalid profile revision", -1); return; }
|
||||
parent = (const char *)sqlite3_value_text(argv[0]); recording = (const char *)sqlite3_value_text(argv[1]);
|
||||
tracking = (const char *)sqlite3_value_text(argv[2]);
|
||||
length = snprintf(name, sizeof(name), "%s\n%s\n%s\n%d", parent, recording, tracking, sqlite3_value_int(argv[3]));
|
||||
if (length < 0 || (size_t)length >= sizeof(name) || uuid_parse("4f4c8ea6-18f6-5e48-9d65-a54a24889149", namespace_id) != 0) {
|
||||
sqlite3_result_error(context, "profile revision overflow", -1); return;
|
||||
}
|
||||
uuid_generate_sha1(revision, namespace_id, name, (size_t)length);
|
||||
uuid_unparse_lower(revision, uuid_text);
|
||||
(void)snprintf(output, sizeof(output), "pr2_%s", uuid_text);
|
||||
sqlite3_result_text(context, output, -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
/* Exact recognizer for the unpublished variable-width prototype token. */
|
||||
static void sqlite_prototype_profile_valid(sqlite3_context *context, int argc,
|
||||
sqlite3_value **argv)
|
||||
{
|
||||
const char *token, *recording, *tracking;
|
||||
char expected[96];
|
||||
int fields, length;
|
||||
if (argc != 4 || sqlite3_value_type(argv[0]) != SQLITE_TEXT ||
|
||||
sqlite3_value_type(argv[1]) != SQLITE_TEXT ||
|
||||
sqlite3_value_type(argv[2]) != SQLITE_TEXT ||
|
||||
sqlite3_value_type(argv[3]) != SQLITE_INTEGER) {
|
||||
sqlite3_result_int(context, 0); return;
|
||||
}
|
||||
token = (const char *)sqlite3_value_text(argv[0]);
|
||||
recording = (const char *)sqlite3_value_text(argv[1]);
|
||||
tracking = (const char *)sqlite3_value_text(argv[2]);
|
||||
fields = sqlite3_value_int(argv[3]);
|
||||
if ((strcmp(recording, "sets") != 0 && strcmp(recording, "continuous") != 0) ||
|
||||
(strcmp(tracking, "reps") != 0 && strcmp(tracking, "duration") != 0) ||
|
||||
(strcmp(recording, "continuous") == 0 && strcmp(tracking, "duration") != 0) ||
|
||||
fields < 0 || (fields & ~3) != 0 ||
|
||||
(strcmp(recording, "sets") == 0 && fields != 0)) {
|
||||
sqlite3_result_int(context, 0); return;
|
||||
}
|
||||
length = snprintf(expected, sizeof(expected), "pr1|%s|%s|%d",
|
||||
recording, tracking, fields);
|
||||
sqlite3_result_int(context, length >= 0 && (size_t)length < sizeof(expected) &&
|
||||
strcmp(token, expected) == 0);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database)
|
||||
{
|
||||
|
|
@ -55,7 +108,7 @@ TrainlogStatus trainlog_database_scan_generation_history(
|
|||
{
|
||||
static const char *const SQL =
|
||||
"SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode,"
|
||||
"e.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds,"
|
||||
"se.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds,"
|
||||
"se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg,"
|
||||
"ps.position,ps.reps,ps.weight_kg,mr.max_weight_kg "
|
||||
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id "
|
||||
|
|
@ -607,6 +660,187 @@ static const char *const MIGRATE_V14_TO_V15_SQL =
|
|||
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;"
|
||||
"PRAGMA user_version=15;COMMIT;";
|
||||
|
||||
static TrainlogStatus read_single_int_pragma(
|
||||
TrainlogDatabase *database, const char *sql, int *output);
|
||||
static TrainlogStatus execute_sql(TrainlogDatabase *database, const char *sql);
|
||||
|
||||
/* WHY: catalogue profiles describe future capture and may change, while an
|
||||
* occurrence's performed values must retain their original unit forever.
|
||||
* CONTRACT: v15 is the sole source schema; backfill copies the linked exercise
|
||||
* mode exactly once before any later profile edit. INVARIANT: every existing
|
||||
* row/child/stable identity remains untouched and version 16 is published only
|
||||
* after all snapshots are valid and foreign keys remain satisfied. */
|
||||
static TrainlogStatus migrate_v15_to_v16(TrainlogDatabase *database)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int version = -1;
|
||||
int invalid = -1;
|
||||
int source_count = -1;
|
||||
int migrated_count = -1;
|
||||
int rc;
|
||||
if (read_single_int_pragma(database, "PRAGMA user_version;", &version) !=
|
||||
TRAINLOG_STATUS_OK || version != 15)
|
||||
return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||
if (read_single_int_pragma(database,
|
||||
"SELECT COUNT(*) FROM session_exercises;", &source_count) !=
|
||||
TRAINLOG_STATUS_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
/* SQLite cannot add a NOT NULL column without a lasting default. Rebuild
|
||||
* the parent table with FK enforcement temporarily disabled so child rows
|
||||
* are neither cascaded nor retargeted to a temporary table name. The
|
||||
* transaction plus the explicit foreign_key_check preserves atomicity. */
|
||||
if (execute_sql(database, "PRAGMA foreign_keys=OFF;") != TRAINLOG_STATUS_OK ||
|
||||
execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK ||
|
||||
execute_sql(database,
|
||||
"CREATE TABLE session_exercises_v16("
|
||||
"id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
||||
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
|
||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,"
|
||||
"position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,"
|
||||
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,"
|
||||
"target_weight_kg REAL,equipment_id TEXT,notes TEXT,"
|
||||
"tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),"
|
||||
"UNIQUE(session_row_id,position));"
|
||||
"INSERT INTO session_exercises_v16("
|
||||
"id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,"
|
||||
"load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,"
|
||||
"target_weight_kg,equipment_id,notes,tracking_mode) "
|
||||
"SELECT se.id,se.entry_id,se.session_row_id,se.exercise_row_id,se.recording_mode,"
|
||||
"se.data_fields,se.position,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
|
||||
"se.target_duration_seconds,se.target_weight_kg,se.equipment_id,se.notes,e.tracking_mode "
|
||||
"FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id;"
|
||||
"DROP TABLE session_exercises;"
|
||||
"ALTER TABLE session_exercises_v16 RENAME TO session_exercises;") !=
|
||||
TRAINLOG_STATUS_OK) goto rollback;
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT COUNT(*) FROM session_exercises se LEFT JOIN exercises e "
|
||||
"ON e.id=se.exercise_row_id WHERE e.id IS NULL OR "
|
||||
"se.tracking_mode NOT IN('reps','duration') OR "
|
||||
"se.tracking_mode<>e.tracking_mode;", -1, &statement, NULL);
|
||||
if (rc != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) goto rollback;
|
||||
invalid = sqlite3_column_int(statement, 0);
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto rollback; }
|
||||
statement = NULL;
|
||||
if (read_single_int_pragma(database,
|
||||
"SELECT COUNT(*) FROM session_exercises;", &migrated_count) !=
|
||||
TRAINLOG_STATUS_OK || migrated_count != source_count || invalid != 0 ||
|
||||
read_single_int_pragma(database, "SELECT COUNT(*) FROM pragma_foreign_key_check;", &invalid) != TRAINLOG_STATUS_OK ||
|
||||
invalid != 0 || execute_sql(database,
|
||||
"PRAGMA user_version=16;COMMIT;") != TRAINLOG_STATUS_OK) goto rollback;
|
||||
if (execute_sql(database, "PRAGMA foreign_keys=ON;") != TRAINLOG_STATUS_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
rollback:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
(void)sqlite3_exec(database->connection, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
/* WHY: a current catalogue profile is mutable, but timestamps cannot establish
|
||||
* ancestry across offline peers. CONTRACT: every v16 exercise starts at the
|
||||
* same explicit legacy root; the first exchanged snapshot may therefore repair
|
||||
* a pre-contract difference. Later edits store a deterministic child and its
|
||||
* bounded revision chain, so transitive ancestor and sibling cases are unambiguous.
|
||||
* INVARIANT: this migration changes no exercise or occurrence value. */
|
||||
static const char *const MIGRATE_V16_TO_V17_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE exercise_profile_state("
|
||||
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,"
|
||||
"revision_id TEXT NOT NULL,parent_revision_id TEXT,"
|
||||
"legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)));"
|
||||
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
|
||||
"SELECT id,'pr_legacy_v1',NULL,1 FROM exercises;"
|
||||
"CREATE TABLE exercise_profile_revisions("
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
|
||||
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
|
||||
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)),"
|
||||
"PRIMARY KEY(exercise_row_id,revision_id));"
|
||||
"INSERT INTO exercise_profile_revisions SELECT id,'pr_legacy_v1',NULL,recording_mode,tracking_mode,data_fields,1 FROM exercises;"
|
||||
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
|
||||
"INSERT INTO exercise_profile_state(exercise_row_id,revision_id,parent_revision_id,legacy_seed) "
|
||||
"VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
|
||||
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises "
|
||||
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) "
|
||||
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
|
||||
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
|
||||
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
|
||||
"UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
|
||||
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;"
|
||||
"PRAGMA user_version=17;COMMIT;";
|
||||
|
||||
/* WHY: the published-unreleased v17 prototype may already exist locally.
|
||||
* CONTRACT: opening it adds the bounded durable lineage table without changing
|
||||
* user_version or domain rows. INVARIANT: the guarded trigger records local
|
||||
* edits, while an importer-selected known tip makes the trigger a no-op. */
|
||||
static const char *const ENSURE_V17_PROFILE_REVISIONS_SQL =
|
||||
"DROP TRIGGER IF EXISTS exercise_profile_state_insert;"
|
||||
"DROP TRIGGER IF EXISTS exercise_profile_state_update;"
|
||||
"CREATE TABLE IF NOT EXISTS exercise_profile_revisions("
|
||||
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,"
|
||||
"revision_id TEXT NOT NULL,parent_revision_id TEXT,recording_mode TEXT NOT NULL,"
|
||||
"tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,legacy_seed INTEGER NOT NULL CHECK(legacy_seed IN(0,1)),"
|
||||
"PRIMARY KEY(exercise_row_id,revision_id));"
|
||||
"INSERT OR IGNORE INTO exercise_profile_revisions "
|
||||
"SELECT e.id,s.revision_id,s.parent_revision_id,e.recording_mode,e.tracking_mode,e.data_fields,s.legacy_seed "
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id;"
|
||||
"CREATE TRIGGER exercise_profile_state_insert AFTER INSERT ON exercises BEGIN "
|
||||
"INSERT INTO exercise_profile_state VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',0);"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,'pr_legacy_v1',NULL,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,1);"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(NEW.id,trainlog_profile_revision('pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),'pr_legacy_v1',NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0);END;"
|
||||
"CREATE TRIGGER exercise_profile_state_update AFTER UPDATE OF recording_mode,tracking_mode,data_fields ON exercises "
|
||||
"WHEN (OLD.recording_mode<>NEW.recording_mode OR OLD.tracking_mode<>NEW.tracking_mode OR OLD.data_fields<>NEW.data_fields) "
|
||||
"AND NOT EXISTS(SELECT 1 FROM exercise_profile_state s JOIN exercise_profile_revisions r ON r.exercise_row_id=s.exercise_row_id AND r.revision_id=s.revision_id WHERE s.exercise_row_id=NEW.id AND r.recording_mode=NEW.recording_mode AND r.tracking_mode=NEW.tracking_mode AND r.data_fields=NEW.data_fields) BEGIN "
|
||||
"SELECT CASE WHEN (SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=NEW.id)>=32 THEN RAISE(ABORT,'profile revision history limit') END;"
|
||||
"INSERT OR IGNORE INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,OLD.recording_mode,OLD.tracking_mode,OLD.data_fields,s.legacy_seed FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;"
|
||||
"UPDATE exercise_profile_state SET parent_revision_id=revision_id,revision_id=trainlog_profile_revision(revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields),legacy_seed=0 WHERE exercise_row_id=NEW.id;"
|
||||
"INSERT INTO exercise_profile_revisions SELECT NEW.id,s.revision_id,s.parent_revision_id,NEW.recording_mode,NEW.tracking_mode,NEW.data_fields,0 FROM exercise_profile_state s WHERE s.exercise_row_id=NEW.id;END;";
|
||||
|
||||
/* CONTRACT: normalize only the exact unpublished pr1 current-state shape.
|
||||
* Validation precedes every mutation, and the caller owns one transaction
|
||||
* covering compatibility-table setup, conversion, and trigger replacement. */
|
||||
static TrainlogStatus normalize_v17_prototype_profiles(TrainlogDatabase *database)
|
||||
{
|
||||
int invalid = 0;
|
||||
static const char *const VALIDATION_SQL =
|
||||
"SELECT ("
|
||||
"(SELECT COUNT(*) FROM exercise_profile_state s JOIN exercises e ON e.id=s.exercise_row_id "
|
||||
" WHERE s.revision_id GLOB 'pr1*' AND (s.parent_revision_id IS NOT NULL OR s.legacy_seed<>1 OR "
|
||||
" trainlog_pr1_valid(s.revision_id,e.recording_mode,e.tracking_mode,e.data_fields)<>1)) +"
|
||||
"(SELECT COUNT(*) FROM exercise_profile_revisions r LEFT JOIN exercise_profile_state s "
|
||||
" ON s.exercise_row_id=r.exercise_row_id AND s.revision_id=r.revision_id "
|
||||
" WHERE r.revision_id GLOB 'pr1*' AND (s.exercise_row_id IS NULL OR r.parent_revision_id IS NOT NULL OR "
|
||||
" r.legacy_seed<>1 OR r.recording_mode<>(SELECT recording_mode FROM exercises WHERE id=r.exercise_row_id) OR "
|
||||
" r.tracking_mode<>(SELECT tracking_mode FROM exercises WHERE id=r.exercise_row_id) OR "
|
||||
" r.data_fields<>(SELECT data_fields FROM exercises WHERE id=r.exercise_row_id))) +"
|
||||
"(SELECT COUNT(*) FROM exercise_profile_state s WHERE s.revision_id GLOB 'pr1*' AND "
|
||||
" (SELECT COUNT(*) FROM exercise_profile_revisions r WHERE r.exercise_row_id=s.exercise_row_id)<>1)"
|
||||
");";
|
||||
static const char *const NORMALIZE_SQL =
|
||||
"DELETE FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*';"
|
||||
"INSERT INTO exercise_profile_revisions "
|
||||
"SELECT e.id,'pr_legacy_v1',NULL,e.recording_mode,e.tracking_mode,e.data_fields,1 "
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
|
||||
"WHERE s.revision_id GLOB 'pr1*';"
|
||||
"INSERT INTO exercise_profile_revisions "
|
||||
"SELECT e.id,trainlog_profile_revision('pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields),"
|
||||
"'pr_legacy_v1',e.recording_mode,e.tracking_mode,e.data_fields,0 "
|
||||
"FROM exercises e JOIN exercise_profile_state s ON s.exercise_row_id=e.id "
|
||||
"WHERE s.revision_id GLOB 'pr1*';"
|
||||
"UPDATE exercise_profile_state SET parent_revision_id='pr_legacy_v1',legacy_seed=0,"
|
||||
"revision_id=trainlog_profile_revision('pr_legacy_v1',"
|
||||
"(SELECT recording_mode FROM exercises WHERE id=exercise_row_id),"
|
||||
"(SELECT tracking_mode FROM exercises WHERE id=exercise_row_id),"
|
||||
"(SELECT data_fields FROM exercises WHERE id=exercise_row_id)) "
|
||||
"WHERE revision_id GLOB 'pr1*';";
|
||||
if (read_single_int_pragma(database, VALIDATION_SQL, &invalid) != TRAINLOG_STATUS_OK ||
|
||||
invalid != 0) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return execute_sql(database, NORMALIZE_SQL);
|
||||
}
|
||||
|
||||
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"ALTER TABLE sessions "
|
||||
|
|
@ -1145,13 +1379,6 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
|
||||
}
|
||||
|
||||
if (
|
||||
version ==
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||
) {
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
if (version == 0) {
|
||||
status =
|
||||
execute_sql(
|
||||
|
|
@ -1197,7 +1424,8 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||
} else if (version == 10) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14) {
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14 ||
|
||||
version == 15 || version == 16 || version == 17) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1352,6 +1580,16 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
if (status == TRAINLOG_STATUS_OK && version < 15) {
|
||||
status = execute_sql(database, MIGRATE_V14_TO_V15_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 16) {
|
||||
status = migrate_v15_to_v16(database);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 17) {
|
||||
status = execute_sql(database, MIGRATE_V16_TO_V17_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "BEGIN IMMEDIATE;");
|
||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, ENSURE_V17_PROFILE_REVISIONS_SQL);
|
||||
if (status == TRAINLOG_STATUS_OK) status = normalize_v17_prototype_profiles(database);
|
||||
if (status == TRAINLOG_STATUS_OK) status = execute_sql(database, "COMMIT;");
|
||||
|
||||
if (
|
||||
status !=
|
||||
|
|
@ -1360,7 +1598,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v15" : "migrate database to schema v15",
|
||||
version == 0 ? "create schema v17" : "migrate database to schema v17",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
@ -1456,6 +1694,22 @@ TrainlogStatus trainlog_database_open_with_diagnostic(
|
|||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
if (sqlite3_create_function(database->connection, "trainlog_profile_revision", 4,
|
||||
SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_profile_revision, NULL, NULL) != SQLITE_OK) {
|
||||
set_open_diagnostic(output_diagnostic, output_diagnostic_capacity,
|
||||
"register profile revision function", database->connection, SQLITE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (sqlite3_create_function(database->connection, "trainlog_pr1_valid", 4,
|
||||
SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, sqlite_prototype_profile_valid,
|
||||
NULL, NULL) != SQLITE_OK) {
|
||||
set_open_diagnostic(output_diagnostic, output_diagnostic_capacity,
|
||||
"register prototype profile validator", database->connection, SQLITE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) {
|
||||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
|
|
@ -1957,7 +2211,6 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
|
|||
sqlite3_int64 row_id;
|
||||
TrainlogStatus status;
|
||||
int rc;
|
||||
bool profile_changed;
|
||||
const char *tracking;
|
||||
const char *recording;
|
||||
|
||||
|
|
@ -1973,43 +2226,9 @@ TrainlogStatus trainlog_database_update_exercise_profiled(
|
|||
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;",
|
||||
-1, &statement, NULL);
|
||||
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
profile_changed = strcmp((const char *)sqlite3_column_text(statement, 0), tracking) != 0 ||
|
||||
strcmp((const char *)sqlite3_column_text(statement, 1), recording) != 0 ||
|
||||
sqlite3_column_int64(statement, 2) != (sqlite3_int64)data_fields;
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
statement = NULL;
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
statement = NULL;
|
||||
if (profile_changed) {
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT 1 FROM session_exercises WHERE exercise_row_id=?1 LIMIT 1;",
|
||||
-1, &statement, NULL);
|
||||
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
rc = sqlite3_step(statement);
|
||||
if (rc == SQLITE_ROW) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto rollback;
|
||||
}
|
||||
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
statement = NULL;
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
}
|
||||
statement = NULL;
|
||||
}
|
||||
/* CONTRACT: schema v16 makes tracking semantics occurrence-owned. A
|
||||
* catalogue profile update therefore controls future occurrences only;
|
||||
* this transaction never updates session_exercises snapshots. */
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
|
||||
"recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL);
|
||||
|
|
@ -3050,6 +3269,7 @@ static TrainlogStatus insert_session_exercise(
|
|||
sqlite3_int64 exercise_row_id;
|
||||
const char *load_mode;
|
||||
const char *recording_mode;
|
||||
const char *tracking_mode;
|
||||
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
const char *entry_id;
|
||||
int rc;
|
||||
|
|
@ -3093,8 +3313,11 @@ static TrainlogStatus insert_session_exercise(
|
|||
input->recording_mode
|
||||
);
|
||||
|
||||
tracking_mode = tracking_mode_to_sql(input->tracking_mode);
|
||||
|
||||
if (load_mode == NULL ||
|
||||
recording_mode == NULL) {
|
||||
recording_mode == NULL ||
|
||||
tracking_mode == NULL) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
|
|
@ -3195,14 +3418,14 @@ static TrainlogStatus insert_session_exercise(
|
|||
database->connection,
|
||||
"INSERT INTO session_exercises("
|
||||
"entry_id, session_row_id, exercise_row_id, "
|
||||
"recording_mode, data_fields, "
|
||||
"recording_mode, tracking_mode, data_fields, "
|
||||
"position, load_mode, rest_seconds, "
|
||||
"target_sets, target_reps, "
|
||||
"target_duration_seconds, "
|
||||
"target_weight_kg, notes, equipment_id"
|
||||
") VALUES("
|
||||
"?1, ?2, ?3, ?4, ?5, ?6, ?7, "
|
||||
"?8, ?9, ?10, ?11, ?12, ?13, ?14"
|
||||
"?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, "
|
||||
"?9, ?10, ?11, ?12, ?13, ?14, ?15"
|
||||
");",
|
||||
-1,
|
||||
&statement,
|
||||
|
|
@ -3241,10 +3464,14 @@ static TrainlogStatus insert_session_exercise(
|
|||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_text(statement, 5, tracking_mode, -1, SQLITE_STATIC);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_int64(
|
||||
statement,
|
||||
5,
|
||||
6,
|
||||
(sqlite3_int64)input->data_fields
|
||||
);
|
||||
}
|
||||
|
|
@ -3252,7 +3479,7 @@ static TrainlogStatus insert_session_exercise(
|
|||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_int64(
|
||||
statement,
|
||||
6,
|
||||
7,
|
||||
(sqlite3_int64)position
|
||||
);
|
||||
}
|
||||
|
|
@ -3260,7 +3487,7 @@ static TrainlogStatus insert_session_exercise(
|
|||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_text(
|
||||
statement,
|
||||
7,
|
||||
8,
|
||||
load_mode,
|
||||
-1,
|
||||
SQLITE_STATIC
|
||||
|
|
@ -3270,30 +3497,17 @@ static TrainlogStatus insert_session_exercise(
|
|||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_int(
|
||||
statement,
|
||||
8,
|
||||
9,
|
||||
input->rest_seconds
|
||||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->target_sets > 0
|
||||
? sqlite3_bind_int(
|
||||
statement,
|
||||
9,
|
||||
input->target_sets
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
9
|
||||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->target_reps > 0
|
||||
? sqlite3_bind_int(
|
||||
statement,
|
||||
10,
|
||||
input->target_reps
|
||||
input->target_sets
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
|
|
@ -3302,11 +3516,11 @@ static TrainlogStatus insert_session_exercise(
|
|||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->target_duration_seconds > 0
|
||||
rc = input->target_reps > 0
|
||||
? sqlite3_bind_int(
|
||||
statement,
|
||||
11,
|
||||
input->target_duration_seconds
|
||||
input->target_reps
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
|
|
@ -3315,11 +3529,11 @@ static TrainlogStatus insert_session_exercise(
|
|||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->target_has_weight
|
||||
? sqlite3_bind_double(
|
||||
rc = input->target_duration_seconds > 0
|
||||
? sqlite3_bind_int(
|
||||
statement,
|
||||
12,
|
||||
input->target_weight_kg
|
||||
input->target_duration_seconds
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
|
|
@ -3327,28 +3541,41 @@ static TrainlogStatus insert_session_exercise(
|
|||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->target_has_weight
|
||||
? sqlite3_bind_double(
|
||||
statement,
|
||||
13,
|
||||
input->target_weight_kg
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
13
|
||||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc =
|
||||
input->notes != NULL &&
|
||||
input->notes[0] != '\0'
|
||||
? sqlite3_bind_text(
|
||||
statement,
|
||||
13,
|
||||
14,
|
||||
input->notes,
|
||||
-1,
|
||||
SQLITE_TRANSIENT
|
||||
)
|
||||
: sqlite3_bind_null(
|
||||
statement,
|
||||
13
|
||||
14
|
||||
);
|
||||
}
|
||||
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = input->equipment_id[0] != '\0'
|
||||
? sqlite3_bind_text(statement, 14, input->equipment_id, -1,
|
||||
? sqlite3_bind_text(statement, 15, input->equipment_id, -1,
|
||||
SQLITE_TRANSIENT)
|
||||
: sqlite3_bind_null(statement, 14);
|
||||
: sqlite3_bind_null(statement, 15);
|
||||
}
|
||||
|
||||
if (rc != SQLITE_OK) {
|
||||
|
|
@ -4717,7 +4944,7 @@ TrainlogStatus trainlog_database_get_session_details(
|
|||
|
||||
static const char *const EXERCISE_SQL =
|
||||
"SELECT "
|
||||
"e.name, e.tracking_mode, "
|
||||
"e.name, se.tracking_mode, "
|
||||
"se.recording_mode, se.data_fields, "
|
||||
"se.load_mode, se.rest_seconds, "
|
||||
"COALESCE(se.target_sets, 0), "
|
||||
|
|
@ -5451,7 +5678,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
"s.session_id, "
|
||||
"s.started_at, "
|
||||
"s.session_type, "
|
||||
"e.tracking_mode, "
|
||||
"se.tracking_mode, "
|
||||
"se.load_mode, "
|
||||
"ps.id, "
|
||||
"ps.reps, "
|
||||
|
|
@ -5800,7 +6027,7 @@ TrainlogStatus trainlog_database_load_session_editable(
|
|||
"se.id, "
|
||||
"e.exercise_id, "
|
||||
"e.name, "
|
||||
"e.tracking_mode, "
|
||||
"se.tracking_mode, "
|
||||
"se.load_mode, "
|
||||
"se.rest_seconds, "
|
||||
"se.target_sets, "
|
||||
|
|
@ -7093,7 +7320,7 @@ TrainlogStatus trainlog_database_list_exercise_occurrences_page(
|
|||
"WHERE e.exercise_id=?1;";
|
||||
static const char *const HYDRATE_SQL =
|
||||
"SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,''),"
|
||||
"s.session_type,e.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
|
||||
"s.session_type,se.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
|
||||
"(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),"
|
||||
"ca.duration_seconds,ca.speed_kmh,ca.distance_km FROM session_exercises se "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ static const char *const TRAINING_FEEDBACK_NAME =
|
|||
"trainlog-training-feedback-v2.json";
|
||||
static const char *const TRAINING_FEEDBACK_V1_NAME =
|
||||
"trainlog-training-feedback-v1.json";
|
||||
static const char *const EXERCISE_PROFILE_STATE_NAME =
|
||||
"trainlog-exercise-profile-state-v1.json";
|
||||
|
||||
static const char *const SYNC_REQUEST_NAME =
|
||||
"trainlog-sync-request-v1.json";
|
||||
|
|
@ -96,6 +98,8 @@ static const char *const EXERCISE_ALIASES_LOCAL =
|
|||
"/tmp/trainlog-exercise-aliases-v1.json";
|
||||
static const char *const TRAINING_FEEDBACK_LOCAL =
|
||||
"/tmp/trainlog-training-feedback-v2.json";
|
||||
static const char *const EXERCISE_PROFILE_STATE_LOCAL =
|
||||
"/tmp/trainlog-exercise-profile-state-v1.json";
|
||||
|
||||
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
||||
|
|
@ -104,6 +108,8 @@ static const char *const EXERCISE_ALIASES_RESULT =
|
|||
"/tmp/trainlog-exercise-aliases-result.txt";
|
||||
static const char *const TRAINING_FEEDBACK_RESULT =
|
||||
"/tmp/trainlog-training-feedback-result.txt";
|
||||
static const char *const EXERCISE_PROFILE_STATE_RESULT =
|
||||
"/tmp/trainlog-exercise-profile-state-result.txt";
|
||||
|
||||
static const char *const SYNC_REQUEST_LOCAL =
|
||||
"/tmp/trainlog-sync-request-v1.json";
|
||||
|
|
@ -2652,6 +2658,7 @@ TrainlogStatus trainlog_sync_run(
|
|||
bool run_started = false;
|
||||
bool receipt_published = false;
|
||||
bool mobile_export_has_companions = false;
|
||||
bool profile_state_received = false;
|
||||
bool retained_mobile_export_is_v2 = false;
|
||||
|
||||
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
||||
|
|
@ -2976,6 +2983,34 @@ TrainlogStatus trainlog_sync_run(
|
|||
goto finalize;
|
||||
}
|
||||
|
||||
/* CONTRACT: causal CURRENT-profile state precedes occurrence import, so a
|
||||
* legitimate direct descendant (including the explicit legacy repair)
|
||||
* cannot be rejected by the unchanged V3 occurrence validator. */
|
||||
status = mobile_export_has_companions
|
||||
? sync_receive_current_android_artifact(&device, folder_id,
|
||||
EXERCISE_PROFILE_STATE_NAME, EXERCISE_PROFILE_STATE_LOCAL, &ignored_size)
|
||||
: TRAINLOG_STATUS_NOT_FOUND;
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
profile_state_received = true;
|
||||
status = sync_run_python_tool("import_exercise_profile_state.py",
|
||||
EXERCISE_PROFILE_STATE_LOCAL, database_path, MOBILE_EXPORT_LOCAL,
|
||||
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) {
|
||||
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
||||
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||
sync_compose_diagnostic(output->error, sizeof(output->error),
|
||||
"Android→PC : profils : ", useful[0] ? useful : "import échoué");
|
||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"Android→PC : lecture profile-state échouée.");
|
||||
final_status = status;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
status =
|
||||
sync_run_python_tool(
|
||||
"import_mobile_export.py",
|
||||
|
|
@ -3005,14 +3040,25 @@ TrainlogStatus trainlog_sync_run(
|
|||
sizeof(useful)
|
||||
);
|
||||
|
||||
sync_compose_diagnostic(
|
||||
output->error,
|
||||
sizeof(output->error),
|
||||
"Android→PC : ",
|
||||
useful[0] != '\0'
|
||||
? useful
|
||||
: "import mobile échoué"
|
||||
);
|
||||
/* WHY: V3/V2 peers may predate profile-state, so absence alone stays
|
||||
* compatible. It becomes actionable only when the unchanged mobile
|
||||
* importer proves that causal profile reconciliation was required.
|
||||
* INVARIANT: never disguise unrelated import failures as capability
|
||||
* failures. */
|
||||
if (mobile_export_has_companions && !profile_state_received &&
|
||||
strstr(useful, "profil incompatible entre identités") != NULL) {
|
||||
sync_compose_diagnostic(output->error, sizeof(output->error),
|
||||
"Android→PC : PROFILE_STATE_COMPANION_MISSING : ", useful);
|
||||
} else {
|
||||
sync_compose_diagnostic(
|
||||
output->error,
|
||||
sizeof(output->error),
|
||||
"Android→PC : ",
|
||||
useful[0] != '\0'
|
||||
? useful
|
||||
: "import mobile échoué"
|
||||
);
|
||||
}
|
||||
|
||||
final_status =
|
||||
TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
|
|
@ -3024,6 +3070,24 @@ TrainlogStatus trainlog_sync_run(
|
|||
output
|
||||
);
|
||||
|
||||
/* The pre-pass may defer identities introduced by this mobile catalogue.
|
||||
* This strict pass now requires every state record to resolve. */
|
||||
if (profile_state_received) {
|
||||
status = sync_run_python_tool("import_exercise_profile_state.py",
|
||||
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
|
||||
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
strstr(tool_output, "EXERCISE_PROFILE_STATE_IMPORT=PASS") == NULL) {
|
||||
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
|
||||
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
|
||||
sync_compose_diagnostic(output->error, sizeof(output->error),
|
||||
"Android→PC : vérification profils : ",
|
||||
useful[0] ? useful : "import échoué");
|
||||
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
}
|
||||
|
||||
/* CONTRACT: body zones are one directional-neutral companion. Historic
|
||||
* V2 publishers may omit it; when present beside V3/V2 it is applied after
|
||||
* exercise definitions above established every ID or a source-V2-proven
|
||||
|
|
@ -3139,6 +3203,25 @@ TrainlogStatus trainlog_sync_run(
|
|||
}
|
||||
|
||||
outbound:
|
||||
status = sync_run_python_tool("export_exercise_profile_state.py",
|
||||
EXERCISE_PROFILE_STATE_LOCAL, database_path, NULL,
|
||||
EXERCISE_PROFILE_STATE_RESULT, tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
strstr(tool_output, "EXERCISE_PROFILE_STATE_EXPORT=PASS") == NULL) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : export profile-state échoué.");
|
||||
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
status = sync_publish_named(&device, folder_id, EXERCISE_PROFILE_STATE_LOCAL,
|
||||
EXERCISE_PROFILE_STATE_NAME);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : publication profile-state échouée.");
|
||||
final_status = status;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
status = sync_run_python_tool("export_exercise_aliases.py",
|
||||
EXERCISE_ALIASES_LOCAL, database_path, NULL, EXERCISE_ALIASES_RESULT,
|
||||
tool_output, sizeof(tool_output));
|
||||
|
|
|
|||
|
|
@ -79,23 +79,33 @@ TrainlogStatus trainlog_time_now_rfc3339(
|
|||
}
|
||||
|
||||
TrainlogStatus trainlog_feedback_relative_label(
|
||||
const char *ended_at, const char *observed_at, bool exercise_feedback,
|
||||
const char *ended_at, const char *started_at, const char *observed_at,
|
||||
bool exercise_feedback,
|
||||
char *output, size_t output_size)
|
||||
{
|
||||
TrainlogTimestampKey ended, observed;
|
||||
TrainlogTimestampKey ended, started, observed;
|
||||
int written;
|
||||
int64_t elapsed;
|
||||
const char *neutral;
|
||||
if (observed_at == NULL || output == NULL || output_size == 0U)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
neutral = exercise_feedback ? "Ressenti" : "H+?";
|
||||
if (ended_at == NULL || ended_at[0] == '\0' ||
|
||||
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended) ||
|
||||
!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
|
||||
written = snprintf(output, output_size, "%s", neutral);
|
||||
if (!trainlog_timestamp_parse(observed_at, strlen(observed_at), &observed)) {
|
||||
written = snprintf(output, output_size, "H+?");
|
||||
} else if (ended_at == NULL || ended_at[0] == '\0' ||
|
||||
!trainlog_timestamp_parse(ended_at, strlen(ended_at), &ended)) {
|
||||
if (started_at != NULL && started_at[0] != '\0' &&
|
||||
trainlog_timestamp_parse(started_at, strlen(started_at), &started) &&
|
||||
observed.utc_second >= started.utc_second) {
|
||||
elapsed = observed.utc_second - started.utc_second;
|
||||
written = snprintf(output, output_size, "≈H+%lld",
|
||||
(long long)(elapsed / INT64_C(3600)));
|
||||
} else written = snprintf(output, output_size, "H+?");
|
||||
} else if (observed.utc_second < ended.utc_second) {
|
||||
bool within_session = exercise_feedback && started_at != NULL &&
|
||||
started_at[0] != '\0' &&
|
||||
trainlog_timestamp_parse(started_at, strlen(started_at), &started) &&
|
||||
observed.utc_second >= started.utc_second;
|
||||
written = snprintf(output, output_size, "%s",
|
||||
exercise_feedback ? "Pendant la séance" : "H+?");
|
||||
within_session ? "Pendant la séance" : "H+?");
|
||||
} else {
|
||||
elapsed = observed.utc_second - ended.utc_second;
|
||||
written = snprintf(output, output_size, "H+%lld",
|
||||
|
|
|
|||
|
|
@ -2478,18 +2478,20 @@ typedef enum TrainlogExercisePhase {
|
|||
TRAINLOG_EXERCISE_MERGE_PICKER,
|
||||
TRAINLOG_EXERCISE_MERGE_CONFIRM,
|
||||
TRAINLOG_EXERCISE_MERGE_RESULT,
|
||||
TRAINLOG_EXERCISE_CONFIRM_PROFILE,
|
||||
TRAINLOG_EXERCISE_CONFIRM_DISCARD,
|
||||
TRAINLOG_EXERCISE_MESSAGE
|
||||
} TrainlogExercisePhase;
|
||||
|
||||
typedef struct TrainlogExerciseController {
|
||||
/* CONTRACT: creation/editing owns one transient value set. An edit keeps
|
||||
* exercise_id and passes the stored profile back unchanged; referenced
|
||||
* profile restrictions remain enforced by the database transaction. */
|
||||
* exercise_id; incompatible future-profile changes require confirmation
|
||||
* and never mutate occurrence-owned historical snapshots. */
|
||||
TrainlogExercisePhase phase;
|
||||
TrainlogExercisePhase return_phase;
|
||||
TrainlogFormField form;
|
||||
TrainlogExercise pending;
|
||||
TrainlogExercise original;
|
||||
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U];
|
||||
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
|
||||
size_t secondary_count;
|
||||
|
|
@ -2497,6 +2499,7 @@ typedef struct TrainlogExerciseController {
|
|||
bool editing;
|
||||
bool inline_session;
|
||||
bool dirty;
|
||||
bool profile_confirmed;
|
||||
bool leaving;
|
||||
TrainlogAppRoute pending_route;
|
||||
char message[192];
|
||||
|
|
@ -3654,22 +3657,22 @@ static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
|
|||
{
|
||||
static const char *const SQL =
|
||||
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,ps.position,"
|
||||
"CASE WHEN e.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
|
||||
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
|
||||
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
|
||||
"ps.weight_kg FROM performed_sets ps "
|
||||
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id "
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"UNION ALL "
|
||||
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,0,"
|
||||
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0,"
|
||||
"mr.max_weight_kg FROM max_results mr "
|
||||
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id "
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"UNION ALL "
|
||||
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
|
||||
"COALESCE(se.equipment_id,''),e.tracking_mode,se.load_mode,0,"
|
||||
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,"
|
||||
"ca.duration_seconds,NULL FROM continuous_activity ca "
|
||||
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id "
|
||||
|
|
@ -4593,6 +4596,7 @@ static void exercise_controller_start_edit(TrainlogAppContext *app)
|
|||
TrainlogExerciseController *controller = &app->exercise_controller;
|
||||
(void)memset(controller, 0, sizeof(*controller));
|
||||
controller->pending = app->exercise_detail;
|
||||
controller->original = app->exercise_detail;
|
||||
controller->editing = true;
|
||||
controller->phase = TRAINLOG_EXERCISE_NAME;
|
||||
controller->return_phase = TRAINLOG_EXERCISE_NAME;
|
||||
|
|
@ -4706,6 +4710,13 @@ static bool exercise_controller_save(TrainlogAppContext *app)
|
|||
TrainlogStatus status;
|
||||
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
|
||||
size_t index;
|
||||
if (controller->editing && !controller->profile_confirmed &&
|
||||
(controller->pending.tracking_mode != controller->original.tracking_mode ||
|
||||
controller->pending.recording_mode != controller->original.recording_mode ||
|
||||
controller->pending.data_fields != controller->original.data_fields)) {
|
||||
controller->phase = TRAINLOG_EXERCISE_CONFIRM_PROFILE;
|
||||
return false;
|
||||
}
|
||||
if (!controller->editing &&
|
||||
controller->pending.recording_mode == TRAINLOG_RECORDING_SETS &&
|
||||
controller->primary_zone[0] == '\0') {
|
||||
|
|
@ -4812,6 +4823,17 @@ static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app,
|
|||
controller->phase = controller->return_phase;
|
||||
return true;
|
||||
}
|
||||
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
|
||||
if (key == 'y' || key == 'Y' || key == '1') {
|
||||
controller->profile_confirmed = true;
|
||||
controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||||
(void)exercise_controller_save(app);
|
||||
} else if (key == 'n' || key == 'N' || key == '0' ||
|
||||
key == TRAINLOG_KEY_ESCAPE || key == 'q' || key == 'Q') {
|
||||
controller->phase = TRAINLOG_EXERCISE_ZONES;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (controller->phase == TRAINLOG_EXERCISE_ZONES) {
|
||||
const TrainlogBodyZone *zone = zone_count > 0U
|
||||
? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL;
|
||||
|
|
@ -5068,7 +5090,12 @@ static void app_shell_actions(TrainlogAppContext *app)
|
|||
}
|
||||
if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) {
|
||||
TrainlogExercisePhase phase = app->exercise_controller.phase;
|
||||
if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||||
if (phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
|
||||
app_shell_add_action(app, "exercise.profile.no", '0',
|
||||
"0 Non", 1U, TRAINLOG_INTENT_BACK, route);
|
||||
app_shell_add_action(app, "exercise.profile.yes", '1',
|
||||
"1 Oui, modifier", 2U, TRAINLOG_INTENT_SAVE, route);
|
||||
} else if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) {
|
||||
app_shell_add_action(app, "exercise.form.discard", '1',
|
||||
"1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route);
|
||||
app_shell_add_action(app, "exercise.form.resume", '0',
|
||||
|
|
@ -5705,6 +5732,17 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app)
|
|||
"1 abandonner · 0/Échap reprendre");
|
||||
return;
|
||||
}
|
||||
if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE) {
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Modifier le mode de suivi ?");
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Les anciennes séances resteront inchangées.");
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Les prochaines utilisations suivront le nouveau profil.");
|
||||
trainlog_surface_printf(app->content, row, 4,
|
||||
"0/Échap/q Non · 1 Oui, modifier");
|
||||
return;
|
||||
}
|
||||
if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) {
|
||||
trainlog_surface_printf(app->content, row++, 2, "%s", controller->message);
|
||||
trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir");
|
||||
|
|
@ -6990,7 +7028,8 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
|||
heading = true;
|
||||
}
|
||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||||
feedback->observed_at, true, label, sizeof(label));
|
||||
app->session_detail.started_at, feedback->observed_at,
|
||||
true, label, sizeof(label));
|
||||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||
feedback->raw_text);
|
||||
}
|
||||
|
|
@ -7045,6 +7084,7 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
|||
row < app->layout.content.height; ++followup_index) {
|
||||
char label[32];
|
||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||||
app->session_detail.started_at,
|
||||
app->session_followups[followup_index].observed_at,
|
||||
false, label, sizeof(label));
|
||||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,11 @@ static int migration_preserves_identity_and_history(void)
|
|||
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
|
||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,entry_id TEXT,equipment_id TEXT);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
||||
"session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,recording_mode TEXT NOT NULL,"
|
||||
"data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,"
|
||||
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
|
||||
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
|
||||
"'Leg press','leg press','reps','sets',0);"
|
||||
"INSERT INTO sessions VALUES(3,'se_preserved');"
|
||||
|
|
@ -195,7 +199,7 @@ static int migration_preserves_identity_and_history(void)
|
|||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 15);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 17);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 2U);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,20 @@
|
|||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_OR_CLEANUP(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf( \
|
||||
stderr, \
|
||||
"CHECK failed at %s:%d: %s\\n", \
|
||||
__FILE__, \
|
||||
__LINE__, \
|
||||
#condition \
|
||||
); \
|
||||
goto cleanup; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool test_normalization(void)
|
||||
{
|
||||
char first[512];
|
||||
|
|
@ -76,19 +90,19 @@ static bool test_profiled_creation(void)
|
|||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExercise walk;
|
||||
TrainlogExercise exercises[4];
|
||||
TrainlogExercise exercises[128];
|
||||
size_t count = 0U;
|
||||
size_t index;
|
||||
bool found = false;
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_open(
|
||||
":memory:",
|
||||
&database
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_catalog_create_exercise_profiled(
|
||||
database,
|
||||
"Marche",
|
||||
|
|
@ -99,22 +113,22 @@ static bool test_profiled_creation(void)
|
|||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
walk.recording_mode ==
|
||||
TRAINLOG_RECORDING_CONTINUOUS
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
walk.tracking_mode ==
|
||||
TRAINLOG_TRACKING_DURATION
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
walk.data_fields ==
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_catalog_create_exercise_profiled(
|
||||
database,
|
||||
"Profil invalide",
|
||||
|
|
@ -125,33 +139,31 @@ static bool test_profiled_creation(void)
|
|||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_list_exercises(
|
||||
database,
|
||||
exercises,
|
||||
4U,
|
||||
128U,
|
||||
&count
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(count == 1U);
|
||||
|
||||
for (index = 0U; index < count; ++index) {
|
||||
if (strcmp(
|
||||
exercises[index].name,
|
||||
"Marche"
|
||||
) == 0) {
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].recording_mode ==
|
||||
TRAINLOG_RECORDING_CONTINUOUS
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].tracking_mode ==
|
||||
TRAINLOG_TRACKING_DURATION
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].data_fields ==
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
||||
);
|
||||
|
|
@ -160,10 +172,14 @@ static bool test_profiled_creation(void)
|
|||
}
|
||||
}
|
||||
|
||||
CHECK(found);
|
||||
CHECK_OR_CLEANUP(found);
|
||||
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
|
||||
cleanup:
|
||||
trainlog_database_close(database);
|
||||
return false;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ int main(void)
|
|||
exercise.recording_mode =
|
||||
TRAINLOG_RECORDING_CONTINUOUS;
|
||||
|
||||
exercise.tracking_mode =
|
||||
TRAINLOG_TRACKING_DURATION;
|
||||
|
||||
exercise.data_fields =
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ int main(void)
|
|||
exercise.recording_mode =
|
||||
TRAINLOG_RECORDING_CONTINUOUS;
|
||||
|
||||
exercise.tracking_mode =
|
||||
TRAINLOG_TRACKING_DURATION;
|
||||
|
||||
exercise.data_fields =
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,24 @@
|
|||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_OR_CLEANUP(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf( \
|
||||
stderr, \
|
||||
"CHECK failed at %s:%d: %s\\n", \
|
||||
__FILE__, \
|
||||
__LINE__, \
|
||||
#condition \
|
||||
); \
|
||||
goto cleanup; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool test_profile_roundtrip(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExercise exercises[4];
|
||||
TrainlogExercise exercises[128];
|
||||
size_t count = 0U;
|
||||
size_t index;
|
||||
bool found_walk = false;
|
||||
|
|
@ -39,7 +53,7 @@ static bool test_profile_roundtrip(void)
|
|||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_insert_exercise(
|
||||
database,
|
||||
"ex_legacy",
|
||||
|
|
@ -49,7 +63,7 @@ static bool test_profile_roundtrip(void)
|
|||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_insert_exercise_profiled(
|
||||
database,
|
||||
"ex_walk",
|
||||
|
|
@ -61,7 +75,7 @@ static bool test_profile_roundtrip(void)
|
|||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_insert_exercise_profiled(
|
||||
database,
|
||||
"ex_invalid",
|
||||
|
|
@ -73,7 +87,7 @@ static bool test_profile_roundtrip(void)
|
|||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_insert_exercise_profiled(
|
||||
database,
|
||||
"ex_unknown",
|
||||
|
|
@ -85,28 +99,26 @@ static bool test_profile_roundtrip(void)
|
|||
) == TRAINLOG_STATUS_INVALID_ARGUMENT
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_list_exercises(
|
||||
database,
|
||||
exercises,
|
||||
4U,
|
||||
128U,
|
||||
&count
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(count == 2U);
|
||||
|
||||
for (index = 0U; index < count; ++index) {
|
||||
if (strcmp(exercises[index].exercise_id, "ex_walk") == 0) {
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].recording_mode ==
|
||||
TRAINLOG_RECORDING_CONTINUOUS
|
||||
);
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].tracking_mode ==
|
||||
TRAINLOG_TRACKING_DURATION
|
||||
);
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].data_fields ==
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH
|
||||
);
|
||||
|
|
@ -114,20 +126,24 @@ static bool test_profile_roundtrip(void)
|
|||
}
|
||||
|
||||
if (strcmp(exercises[index].exercise_id, "ex_legacy") == 0) {
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
exercises[index].recording_mode ==
|
||||
TRAINLOG_RECORDING_SETS
|
||||
);
|
||||
CHECK(exercises[index].data_fields == 0U);
|
||||
CHECK_OR_CLEANUP(exercises[index].data_fields == 0U);
|
||||
found_legacy = true;
|
||||
}
|
||||
}
|
||||
|
||||
CHECK(found_walk);
|
||||
CHECK(found_legacy);
|
||||
CHECK_OR_CLEANUP(found_walk);
|
||||
CHECK_OR_CLEANUP(found_legacy);
|
||||
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
|
||||
cleanup:
|
||||
trainlog_database_close(database);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool test_v2_to_current_migration(void)
|
||||
|
|
@ -179,34 +195,36 @@ static bool test_v2_to_current_migration(void)
|
|||
sqlite3 *raw = NULL;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogDatabase *database = NULL;
|
||||
int fd;
|
||||
int fd = -1;
|
||||
int version = 0;
|
||||
bool success = false;
|
||||
|
||||
fd = mkstemp(path);
|
||||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK_OR_CLEANUP(fd >= 0);
|
||||
CHECK_OR_CLEANUP(close(fd) == 0);
|
||||
fd = -1;
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(
|
||||
sqlite3_exec(raw, V2_SQL, NULL, NULL, NULL) ==
|
||||
SQLITE_OK
|
||||
);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_open(path, &database) ==
|
||||
TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
trainlog_database_schema_version(
|
||||
database,
|
||||
&version
|
||||
) == TRAINLOG_STATUS_OK
|
||||
);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
version ==
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION
|
||||
);
|
||||
|
|
@ -214,9 +232,9 @@ static bool test_v2_to_current_migration(void)
|
|||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
sqlite3_prepare_v2(
|
||||
raw,
|
||||
"SELECT recording_mode, data_fields "
|
||||
|
|
@ -227,18 +245,18 @@ static bool test_v2_to_current_migration(void)
|
|||
) == SQLITE_OK
|
||||
);
|
||||
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK_OR_CLEANUP(
|
||||
strcmp(
|
||||
(const char *)sqlite3_column_text(statement, 0),
|
||||
"sets"
|
||||
) == 0
|
||||
);
|
||||
CHECK(sqlite3_column_int64(statement, 1) == 0);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
|
||||
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
statement = NULL;
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
sqlite3_prepare_v2(
|
||||
raw,
|
||||
"SELECT recording_mode, data_fields "
|
||||
|
|
@ -249,18 +267,18 @@ static bool test_v2_to_current_migration(void)
|
|||
) == SQLITE_OK
|
||||
);
|
||||
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK_OR_CLEANUP(
|
||||
strcmp(
|
||||
(const char *)sqlite3_column_text(statement, 0),
|
||||
"sets"
|
||||
) == 0
|
||||
);
|
||||
CHECK(sqlite3_column_int64(statement, 1) == 0);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_column_int64(statement, 1) == 0);
|
||||
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
statement = NULL;
|
||||
|
||||
CHECK(
|
||||
CHECK_OR_CLEANUP(
|
||||
sqlite3_prepare_v2(
|
||||
raw,
|
||||
"SELECT name FROM sqlite_master "
|
||||
|
|
@ -271,14 +289,23 @@ static bool test_v2_to_current_migration(void)
|
|||
) == SQLITE_OK
|
||||
);
|
||||
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK_OR_CLEANUP(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
statement = NULL;
|
||||
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK_OR_CLEANUP(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
success = true;
|
||||
|
||||
cleanup:
|
||||
if (fd >= 0) (void)close(fd);
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
if (raw != NULL) (void)sqlite3_close(raw);
|
||||
if (database != NULL) trainlog_database_close(database);
|
||||
if (!success) (void)unlink(path);
|
||||
return success;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d\n",__LINE__); return 1; } } while (0)
|
||||
static int label(const char *end,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;}
|
||||
static int label(const char *end,const char *start,const char *at,bool exercise,const char *expected){char out[64];CHECK(trainlog_feedback_relative_label(end,start,at,exercise,out,sizeof(out))==TRAINLOG_STATUS_OK);CHECK(strcmp(out,expected)==0);CHECK(strstr(out,"H+-")==NULL);return 0;}
|
||||
int main(void){const char *end="2026-09-01T10:00:00+02:00";
|
||||
CHECK(label(end,"2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0);
|
||||
CHECK(label(end,"2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0);
|
||||
CHECK(label(end,end,true,"H+0")==0);CHECK(label(end,"2026-09-01T18:00:00+02:00",true,"H+8")==0);
|
||||
CHECK(label(NULL,"2026-09-01T09:20:00+02:00",true,"Ressenti")==0);
|
||||
CHECK(label(NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0);
|
||||
CHECK(label(end,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;}
|
||||
CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:20:00+02:00",true,"Pendant la séance")==0);
|
||||
CHECK(label(end,"2026-09-01T09:00:00+02:00","2026-09-01T09:59:59+02:00",true,"Pendant la séance")==0);
|
||||
CHECK(label(end,"2026-09-01T09:30:00+02:00","2026-09-01T09:20:00+02:00",true,"H+?")==0);
|
||||
CHECK(label(end,NULL,end,true,"H+0")==0);CHECK(label(end,NULL,"2026-09-01T18:00:00+02:00",true,"H+8")==0);
|
||||
CHECK(label(NULL,NULL,"2026-09-01T09:20:00+02:00",true,"H+?")==0);
|
||||
CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",false,"≈H+10")==0);
|
||||
CHECK(label(NULL,"2026-09-01T08:00:00+02:00","2026-09-01T18:00:00+02:00",true,"≈H+10")==0);
|
||||
CHECK(label(NULL,NULL,"2026-09-01T18:00:00+02:00",false,"H+?")==0);
|
||||
CHECK(label(end,NULL,"2026-09-01T09:00:00+02:00",false,"H+?")==0);return 0;}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ static void max_input(TrainlogSessionExerciseInput *input,
|
|||
(void)snprintf(input->equipment_id, sizeof(input->equipment_id), "%s",
|
||||
"rear_delt_pec_fly");
|
||||
input->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
input->tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||
input->load_mode = TRAINLOG_LOAD_NONE;
|
||||
input->has_max_weight = true;
|
||||
input->max_weight_kg = weight;
|
||||
|
|
@ -88,6 +89,7 @@ static bool test_explicit_max_round_trip_and_identity(void)
|
|||
(void)snprintf(entries[2].exercise_id, sizeof(entries[2].exercise_id), "%s",
|
||||
"ex_walk");
|
||||
entries[2].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
||||
entries[2].tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||||
entries[2].data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||
entries[2].load_mode = TRAINLOG_LOAD_NONE;
|
||||
entries[2].continuous_duration_seconds = 600;
|
||||
|
|
@ -163,16 +165,21 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
|
|||
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
|
||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
|
||||
"session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT,"
|
||||
"equipment_id TEXT,recording_mode TEXT);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,"
|
||||
"session_row_id INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL,recording_mode TEXT NOT NULL,"
|
||||
"data_fields INTEGER NOT NULL,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,"
|
||||
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
|
||||
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
|
||||
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
|
||||
"duration_seconds INTEGER,weight_kg REAL);"
|
||||
"INSERT INTO exercises VALUES(1,'ex_max_a','Max A','max a','reps','sets',0);"
|
||||
"INSERT INTO exercises VALUES(2,'ex_max_b','Max B','max b','reps','sets',0);"
|
||||
"INSERT INTO exercises VALUES(3,'ex_max_c','Max C','max c','reps','sets',0);"
|
||||
"INSERT INTO sessions VALUES(1,'max_test');"
|
||||
"INSERT INTO session_exercises VALUES(10,1,NULL,NULL,NULL,'sets');"
|
||||
"INSERT INTO session_exercises VALUES(11,1,NULL,NULL,NULL,'sets');"
|
||||
"INSERT INTO session_exercises VALUES(12,1,NULL,NULL,NULL,'sets');"
|
||||
"INSERT INTO session_exercises VALUES(10,'sxe_max_a',1,1,'sets',0,0,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
|
||||
"INSERT INTO session_exercises VALUES(11,'sxe_max_b',1,2,'sets',0,1,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
|
||||
"INSERT INTO session_exercises VALUES(12,'sxe_max_c',1,3,'sets',0,2,'none',0,NULL,NULL,NULL,NULL,NULL,NULL);"
|
||||
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
|
||||
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
|
||||
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
||||
|
|
|
|||
227
tui/tests/test_schema_v16_migration.c
Normal file
227
tui/tests/test_schema_v16_migration.c
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* @file test_schema_v16_migration.c
|
||||
* @brief Occurrence-owned tracking-mode migration and future-profile proof.
|
||||
*/
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sqlite3.h>
|
||||
#include "trainlog/database.h"
|
||||
|
||||
#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \
|
||||
"CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); return false; } } while (0)
|
||||
|
||||
static const char *const FIXTURE =
|
||||
"PRAGMA foreign_keys=ON;"
|
||||
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,"
|
||||
"normalized_name TEXT UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL,"
|
||||
"data_fields INTEGER NOT NULL);"
|
||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,"
|
||||
"ended_at TEXT,session_type TEXT,notes TEXT);"
|
||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT UNIQUE,"
|
||||
"session_row_id INTEGER REFERENCES sessions(id),exercise_row_id INTEGER REFERENCES exercises(id),"
|
||||
"recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,"
|
||||
"target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,"
|
||||
"equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));"
|
||||
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id),"
|
||||
"position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);"
|
||||
"CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id),"
|
||||
"duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);"
|
||||
"CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id),max_weight_kg REAL);"
|
||||
"CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id),zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));"
|
||||
"CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id),synced_state TEXT);"
|
||||
"CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT,raw_text TEXT);"
|
||||
"CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);"
|
||||
"CREATE TABLE session_followups(id INTEGER PRIMARY KEY,followup_id TEXT UNIQUE,session_row_id INTEGER REFERENCES sessions(id),observed_at TEXT,raw_text TEXT);"
|
||||
"CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT REFERENCES session_followups(followup_id),created_at TEXT,raw_text TEXT);"
|
||||
"INSERT INTO exercises VALUES(7,'ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','Planche face sol','planche face sol','reps','sets',0);"
|
||||
"INSERT INTO exercises VALUES(8,'ex_e32c40a6-72b4-4233-a0db-41b27e990c96','Planche gauche sol','planche gauche sol','reps','sets',0);"
|
||||
"INSERT INTO exercises VALUES(9,'ex_999fd503-ce89-49f9-979d-c19e6adbe80d','Planche droite sol','planche droite sol','reps','sets',0);"
|
||||
"INSERT INTO sessions VALUES(11,'se_old','2026-09-01T10:00:00Z',NULL,'training','note');"
|
||||
"INSERT INTO session_exercises VALUES(13,'sxe_old',11,7,'sets',0,0,'none',30,1,3,NULL,NULL,NULL,'occurrence');"
|
||||
"INSERT INTO session_exercises VALUES(14,'sxe_left_old',11,8,'sets',0,1,'none',30,1,3,NULL,NULL,NULL,'occurrence gauche');"
|
||||
"INSERT INTO performed_sets VALUES(17,13,0,3,NULL,NULL);"
|
||||
"INSERT INTO performed_sets VALUES(18,14,0,3,NULL,NULL);"
|
||||
"INSERT INTO exercise_feedback VALUES(19,'fb_old',13,'2026-09-01T10:01:00Z','stable');"
|
||||
"INSERT INTO exercise_feedback_revisions VALUES('fr_old','fb_old','2026-09-01T10:02:00Z','révisé');"
|
||||
"INSERT INTO session_followups VALUES(23,'fu_old',11,'2026-09-01T12:00:00Z','suivi');"
|
||||
"INSERT INTO session_followup_revisions VALUES('ur_old','fu_old','2026-09-01T12:01:00Z','suivi révisé');"
|
||||
"PRAGMA user_version=15;";
|
||||
|
||||
static bool scalar(sqlite3 *db, const char *sql, int expected)
|
||||
{
|
||||
sqlite3_stmt *s = NULL; bool ok = false;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &s, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(s) == SQLITE_ROW) ok = sqlite3_column_int(s, 0) == expected;
|
||||
(void)sqlite3_finalize(s); return ok;
|
||||
}
|
||||
|
||||
#undef CHECK
|
||||
#define CHECK(x) do { if (!(x)) { (void)fprintf(stderr, \
|
||||
"CHECK failed %s:%d: %s\n", __FILE__, __LINE__, #x); goto cleanup; } } while (0)
|
||||
|
||||
static bool run_test(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-v16-XXXXXX";
|
||||
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
|
||||
bool ok = false;
|
||||
TrainlogPersistedExerciseDetail old[1] = {{0}};
|
||||
TrainlogSessionSummary summary;
|
||||
size_t count = 0U; int version = 0;
|
||||
TrainlogExercise changed;
|
||||
TrainlogSetInput new_set = {.duration_seconds = 30};
|
||||
TrainlogSessionExerciseInput input = {0};
|
||||
TrainlogSessionInput session = {0};
|
||||
char diagnostic[512] = {0};
|
||||
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
if (trainlog_database_open_with_diagnostic(path, &db, diagnostic,
|
||||
sizeof(diagnostic)) != TRAINLOG_STATUS_OK) {
|
||||
(void)fprintf(stderr, "open diagnostic: %s\n", diagnostic);
|
||||
CHECK(false);
|
||||
}
|
||||
CHECK(trainlog_database_schema_version(db, &version) == TRAINLOG_STATUS_OK && version == 17);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_state s ON s.exercise_row_id=e.id WHERE s.revision_id IS NULL OR s.revision_id<>'pr_legacy_v1' OR s.legacy_seed<>1", 0));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises e LEFT JOIN exercise_profile_revisions r ON r.exercise_row_id=e.id AND r.revision_id='pr_legacy_v1' AND r.legacy_seed=1 WHERE r.revision_id IS NULL", 0));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND \"notnull\"=1", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode' AND dflt_value IS NULL", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old' AND tracking_mode='reps'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3 AND duration_seconds IS NULL", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback WHERE id=19 AND feedback_id='fb_old'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_feedback_revisions WHERE revision_id='fr_old'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followups WHERE id=23", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_followup_revisions WHERE revision_id='ur_old'", 1));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_update_exercise_profiled(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", "Planche face sol",
|
||||
"planche face sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
|
||||
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_update_exercise_profiled(db, "ex_e32c40a6-72b4-4233-a0db-41b27e990c96", "Planche gauche sol",
|
||||
"planche gauche sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
|
||||
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_update_exercise_profiled(db, "ex_999fd503-ce89-49f9-979d-c19e6adbe80d", "Planche droite sol",
|
||||
"planche droite sol", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U,
|
||||
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_get_session_details(db, "se_old", &summary, old, 1U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U && old[0].tracking_mode == TRAINLOG_TRACKING_REPS &&
|
||||
old[0].actual_set_count == 1U && old[0].actual_sets[0].reps == 3);
|
||||
trainlog_database_free_session_details(old, count);
|
||||
CHECK(trainlog_database_get_exercise_profile(db, "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac", &changed) == TRAINLOG_STATUS_OK);
|
||||
(void)snprintf(input.entry_id, sizeof(input.entry_id), "sxe_new");
|
||||
(void)snprintf(input.exercise_id, sizeof(input.exercise_id), "ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac");
|
||||
input.recording_mode = changed.recording_mode; input.tracking_mode = changed.tracking_mode;
|
||||
input.load_mode = TRAINLOG_LOAD_NONE; input.sets = &new_set; input.set_count = 1U;
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "se_new");
|
||||
(void)snprintf(session.started_at, sizeof(session.started_at), "2026-09-02T10:00:00Z");
|
||||
session.session_type = TRAINLOG_SESSION_TRAINING; session.exercises = &input; session.exercise_count = 1U;
|
||||
CHECK(trainlog_database_insert_session(db, &session) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_left_old' AND se.tracking_mode='reps' AND ps.reps=3 AND ps.duration_seconds IS NULL", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercises WHERE exercise_id IN('ex_b35fff35-9c97-4c82-9053-7d1f2a23d3ac','ex_e32c40a6-72b4-4233-a0db-41b27e990c96','ex_999fd503-ce89-49f9-979d-c19e6adbe80d') AND recording_mode='sets' AND tracking_mode='duration' AND data_fields=0", 3));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises se JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE se.entry_id='sxe_new' AND se.tracking_mode='duration' AND ps.reps IS NULL AND ps.duration_seconds=30", 1));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
ok = true;
|
||||
cleanup:
|
||||
if (raw != NULL) (void)sqlite3_close(raw);
|
||||
if (db != NULL) trainlog_database_close(db);
|
||||
if (fd >= 0) (void)close(fd);
|
||||
if (unlink(path) != 0) ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool rollback_on_invalid_backfill(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-v16-invalid-XXXXXX";
|
||||
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
|
||||
bool ok = false;
|
||||
char diagnostic[512] = {0};
|
||||
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE exercises SET tracking_mode='invalid';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(db == NULL);
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT user_version FROM pragma_user_version", 15));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('session_exercises') WHERE name='tracking_mode'", 0));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND entry_id='sxe_old'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM performed_sets WHERE id=17 AND reps=3", 1));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
ok = true;
|
||||
cleanup:
|
||||
if (raw != NULL) (void)sqlite3_close(raw);
|
||||
if (db != NULL) trainlog_database_close(db);
|
||||
if (fd >= 0) (void)close(fd);
|
||||
if (unlink(path) != 0) ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool normalize_v17_prototype_in_place(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-v17-pr1-XXXXXX";
|
||||
int fd = mkstemp(path); sqlite3 *raw = NULL; TrainlogDatabase *db = NULL;
|
||||
bool ok = false;
|
||||
char diagnostic[512] = {0};
|
||||
CHECK(fd >= 0); CHECK(close(fd) == 0); fd = -1;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, FIXTURE, NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;"
|
||||
"UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|0',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|0',NULL,'sets','reps',0,1);",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr_legacy_v1' AND parent_revision_id IS NULL AND legacy_seed=1", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr2_4444be84-5f1c-58c9-9bd2-4093a8a4cf9b' AND parent_revision_id='pr_legacy_v1' AND legacy_seed=0", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE revision_id GLOB 'pr1*'", 0));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_exercises WHERE id=13 AND tracking_mode='reps'", 1));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
/* A second open is data-idempotent. */
|
||||
CHECK(trainlog_database_open(path, &db) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(db); db = NULL;
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9", 2));
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DELETE FROM exercise_profile_revisions WHERE exercise_row_id=9;"
|
||||
"UPDATE exercise_profile_state SET revision_id='pr1|sets|reps|00',parent_revision_id=NULL,legacy_seed=1 WHERE exercise_row_id=9;"
|
||||
"INSERT INTO exercise_profile_revisions VALUES(9,'pr1|sets|reps|00',NULL,'sets','reps',0,1);",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &db, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(db == NULL);
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_state WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1));
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM exercise_profile_revisions WHERE exercise_row_id=9 AND revision_id='pr1|sets|reps|00'", 1));
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
ok = true;
|
||||
cleanup:
|
||||
if (raw != NULL) (void)sqlite3_close(raw);
|
||||
if (db != NULL) trainlog_database_close(db);
|
||||
if (fd >= 0) (void)close(fd);
|
||||
if (unlink(path) != 0) ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
int main(void) { return run_test() && rollback_on_invalid_backfill() &&
|
||||
normalize_v17_prototype_in_place() ? 0 : 1; }
|
||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v15: SQLite rc=",
|
||||
strlen("migrate database to schema v15: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v17: SQLite rc=",
|
||||
strlen("migrate database to schema v17: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
|||
|
||||
static bool test_newer_schema_has_application_diagnostic(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-schema-v16-XXXXXX";
|
||||
char path[] = "/tmp/trainlog-schema-v17-XXXXXX";
|
||||
char diagnostic[256];
|
||||
sqlite3 *raw = NULL;
|
||||
TrainlogDatabase *database = NULL;
|
||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
|||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=16;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=18;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 16 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 18 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v15") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v17") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ static void bind_reps_exercise(
|
|||
exercise->load_mode =
|
||||
TRAINLOG_LOAD_EXTERNAL;
|
||||
|
||||
exercise->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
exercise->tracking_mode = TRAINLOG_TRACKING_REPS;
|
||||
|
||||
exercise->rest_seconds = 90;
|
||||
exercise->target_sets = 3;
|
||||
exercise->target_reps = 10;
|
||||
|
|
@ -235,6 +238,7 @@ static bool test_load_replace_and_rollback(void)
|
|||
);
|
||||
CHECK(loaded_exercises[0].set_offset == 0U);
|
||||
CHECK(loaded_exercises[0].set_count == 3U);
|
||||
CHECK(loaded_exercises[0].tracking_mode == TRAINLOG_TRACKING_REPS);
|
||||
CHECK(loaded_sets[0].reps == 10);
|
||||
CHECK(loaded_sets[2].reps == 8);
|
||||
CHECK(loaded_sets[0].weight_kg > 79.99);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ static const char *const EXERCISE_NAME = "Sync wiring exercise";
|
|||
static char remote_root[4096];
|
||||
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
static unsigned int remote_mobile_version = 2U;
|
||||
static const char *remote_tracking_mode = "reps";
|
||||
static bool remote_profile_available = true;
|
||||
|
||||
static bool copy_file(const char *source, const char *target)
|
||||
{
|
||||
|
|
@ -148,7 +150,8 @@ TrainlogStatus trainlog_mtp_list_folder(
|
|||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (parent_folder_id == 2U) {
|
||||
if (capacity < 3U) {
|
||||
size_t count = remote_profile_available ? 4U : 3U;
|
||||
if (capacity < count) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
|
||||
|
|
@ -156,7 +159,11 @@ TrainlogStatus trainlog_mtp_list_folder(
|
|||
"trainlog-exercise-body-zones-v1.json", false);
|
||||
set_entry(&output[2], 12U, 2U,
|
||||
"trainlog-equipment-associations-v2.json", false);
|
||||
*output_count = 3U;
|
||||
if (remote_profile_available) {
|
||||
set_entry(&output[3], 13U, 2U,
|
||||
"trainlog-exercise-profile-state-v1.json", false);
|
||||
}
|
||||
*output_count = count;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
*output_count = 0U;
|
||||
|
|
@ -178,7 +185,8 @@ TrainlogStatus trainlog_mtp_receive_file(
|
|||
|
||||
name = item_id == 10U ? remote_mobile_name :
|
||||
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
||||
item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL;
|
||||
item_id == 12U ? "trainlog-equipment-associations-v2.json" :
|
||||
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : NULL;
|
||||
if (name == NULL || local_path == NULL) {
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
|
|
@ -274,12 +282,31 @@ static bool write_artifacts(
|
|||
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"%s\","
|
||||
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
|
||||
remote_mobile_version, mobile_id, EXERCISE_NAME) > 0);
|
||||
remote_mobile_version, mobile_id, EXERCISE_NAME,
|
||||
remote_tracking_mode) > 0);
|
||||
}
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-exercise-profile-state-v1.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-exercise-profile-state\",\"version\":1,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"exercises\":[{"
|
||||
"\"exercise_id\":\"%s\",\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
||||
"\"data_fields\":0,\"load_semantics\":null,\"machine_variant\":null,"
|
||||
"\"machine_provenance\":null,\"scientific_profile_id\":null,"
|
||||
"\"science_state\":\"unresolved\",\"legacy_equipment_id\":null,"
|
||||
"\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,\"legacy_seed\":true,"
|
||||
"\"history\":[{\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,"
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,\"legacy_seed\":true}]}]}",
|
||||
CANONICAL_ID) > 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
|
|
@ -589,6 +616,32 @@ static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool run_missing_profile_companion_case(const char *case_root)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
/* A stale local profile file is deliberately left by earlier cases. The
|
||||
* selected MTP generation omits it, so neither causal pass may consume it. */
|
||||
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
||||
remote_mobile_version = 3U;
|
||||
remote_tracking_mode = "duration";
|
||||
remote_profile_available = false;
|
||||
CHECK(prepare_database(case_root, false, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(!report.success);
|
||||
CHECK(strstr(report.error, "PROFILE_STATE_COMPANION_MISSING") != NULL);
|
||||
CHECK(strstr(report.error, "profil incompatible entre identités") != NULL);
|
||||
remote_profile_available = true;
|
||||
remote_tracking_mode = "reps";
|
||||
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
remote_mobile_version = 2U;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_all(void)
|
||||
{
|
||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||
|
|
@ -596,6 +649,7 @@ static bool run_all(void)
|
|||
char case_b[4096];
|
||||
char case_c[4096];
|
||||
char case_d[4096];
|
||||
char case_e[4096];
|
||||
char *root = mkdtemp(temporary);
|
||||
|
||||
CHECK(root != NULL);
|
||||
|
|
@ -605,11 +659,13 @@ static bool run_all(void)
|
|||
CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0);
|
||||
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
|
||||
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
|
||||
CHECK(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
|
||||
|
||||
CHECK(run_success_case(case_a, false));
|
||||
CHECK(run_success_case(case_b, true));
|
||||
CHECK(run_unknown_without_proof_case(case_c));
|
||||
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
||||
CHECK(run_missing_profile_companion_case(case_e));
|
||||
(void)puts("PASS production sync body-zone V2 proof wiring");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -620,6 +620,25 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
|||
CHECK(trainlog_database_list_exercises(database, listed, 8U,
|
||||
&count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 6U);
|
||||
|
||||
/* Incompatible axes are future defaults only, but still require the
|
||||
* global explicit confirmation contract with cancel as the safe default. */
|
||||
exercise_controller_start_edit(&app);
|
||||
app.exercise_controller.pending.recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
app.exercise_controller.pending.tracking_mode = TRAINLOG_TRACKING_DURATION;
|
||||
app.exercise_controller.phase = TRAINLOG_EXERCISE_ZONES;
|
||||
CHECK(!exercise_controller_save(&app));
|
||||
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_CONFIRM_PROFILE);
|
||||
CHECK(app_shell_dispatch_exercise_controller(&app, 'q'));
|
||||
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES);
|
||||
CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
|
||||
CHECK(!exercise_controller_save(&app));
|
||||
CHECK(app_shell_dispatch_exercise_controller(&app, '1'));
|
||||
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MESSAGE);
|
||||
CHECK(app.exercise_detail.recording_mode == TRAINLOG_RECORDING_SETS);
|
||||
CHECK(app.exercise_detail.tracking_mode == TRAINLOG_TRACKING_DURATION);
|
||||
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
||||
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue