feat(trainlog): add synchronized training feedback
This commit is contained in:
parent
bb3790588f
commit
2dec03e7b2
66 changed files with 2811 additions and 152 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,5 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased — Training feedback UX and safety
|
||||
|
||||
- Added additive schema v15 immutable feedback revisions and Training Feedback
|
||||
V2 with V1 import compatibility.
|
||||
- Made saved exercise feedback and session follow-ups correctable on Android
|
||||
while preserving root identity, observation time, H+ placement and history.
|
||||
- Preserved occurrence `entry_id` and feedback through draft edit,
|
||||
reconstruction, finalization and completed-session correction.
|
||||
- Added explicit destructive confirmation to the reachable Android and TUI
|
||||
session removal/abandon paths.
|
||||
|
||||
## Unreleased — MACHINE_EXERCISE_MODEL_V1
|
||||
|
||||
- Add desktop and Android schema v13 for machine-specific exercise metadata.
|
||||
|
|
@ -353,3 +364,8 @@ direct measurements. Android remains capture-only for this feature.
|
|||
- Hid `SESSION_GENERATOR_V1` from normal UI pending V2; manual `%MAX` entry is unchanged.
|
||||
- Added versioned canonical exercise display names, stale-peer-name convergence,
|
||||
and the STATS_V1 query-contract preparation (no analytics implementation).
|
||||
# Unreleased
|
||||
|
||||
- Add `TRAINING_FEEDBACK_V1`: schema v14 append-only exercise observations and
|
||||
session follow-ups, Android French voice/manual capture with no audio storage,
|
||||
bidirectional strict companion sync, H+ timelines, and TUI read-only display.
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export or desktop synchronization as completed sessions.
|
|||
Schema migrations are additive and preserve existing capture data. See
|
||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||
|
||||
The current Android schema is v13. Its additive v4 -> v13 chain adds the shared
|
||||
The current Android schema is v14. Its additive v4 -> v14 chain adds the shared
|
||||
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||
identities, custom-equipment definition support, explicit MAX results and
|
||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ dependencies {
|
|||
)
|
||||
|
||||
implementation("androidx.compose.material3:material3")
|
||||
|
||||
implementation(
|
||||
"androidx.compose.ui:ui"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.ui.test.assertIsDisplayed
|
|||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
|
|
@ -93,19 +94,19 @@ class SessionDraftUiInstrumentedTest {
|
|||
.performClick()
|
||||
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
|
||||
compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
|
||||
compose.onNodeWithText(
|
||||
"Retirer Test UI"
|
||||
compose.onNodeWithContentDescription(
|
||||
"Supprimer cet exercice"
|
||||
).performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedDiscardRemovesResumeWithoutHistory() {
|
||||
compose.onNodeWithText(
|
||||
compose.onNodeWithContentDescription(
|
||||
"Supprimer la séance en cours"
|
||||
).performClick()
|
||||
compose.onNodeWithText(
|
||||
"Confirmer la suppression"
|
||||
"Abandonner la séance en cours ?"
|
||||
).assertIsDisplayed()
|
||||
compose.onNodeWithText(
|
||||
"Annuler"
|
||||
|
|
@ -114,13 +115,13 @@ class SessionDraftUiInstrumentedTest {
|
|||
"Reprendre la séance en cours"
|
||||
).assertIsDisplayed()
|
||||
|
||||
compose.onNodeWithText(
|
||||
compose.onNodeWithContentDescription(
|
||||
"Supprimer la séance en cours"
|
||||
).performClick()
|
||||
compose.onNodeWithText(
|
||||
"Confirmer la suppression"
|
||||
"Abandonner la séance en cours ?"
|
||||
).assertIsDisplayed()
|
||||
.performClick()
|
||||
compose.onNodeWithText("Abandonner").performClick()
|
||||
compose.onNodeWithText(
|
||||
"Reprendre la séance en cours"
|
||||
).assertDoesNotExist()
|
||||
|
|
@ -229,7 +230,10 @@ class SessionDraftUiInstrumentedTest {
|
|||
compose.onNodeWithTag("session-set-1-reps").performTextInput("9")
|
||||
compose.onNodeWithTag("session-set-1-weight")
|
||||
.performScrollTo().performTextInput("32,5")
|
||||
compose.onNodeWithText("Supprimer la série 1").performScrollTo().performClick()
|
||||
compose.onNodeWithTag("delete-set-1").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Supprimer la série 1 ?").assertIsDisplayed()
|
||||
compose.onNodeWithTag("session-set-0-reps").assertTextEquals("4")
|
||||
compose.onNodeWithText("Supprimer").performClick()
|
||||
compose.onNodeWithText("Ajouter une série").performScrollTo().performClick()
|
||||
|
||||
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("9")
|
||||
|
|
@ -243,6 +247,16 @@ class SessionDraftUiInstrumentedTest {
|
|||
compose.onNodeWithTag("session-set-3-reps").performScrollTo().assertTextEquals("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modifierOpensPrefilledEditorAndKeepsOccurrenceIdentity() {
|
||||
compose.onNodeWithText("Reprendre la séance en cours").performClick()
|
||||
compose.onNodeWithText("Modifier Test UI").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Modification en cours — faites défiler jusqu’à SAISIE — Test UI.")
|
||||
.performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithTag("session-set-0-reps").performScrollTo().assertTextEquals("4")
|
||||
compose.onNodeWithTag("session-set-2-reps").performScrollTo().assertTextEquals("6")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingAnExerciseUsesItsIdentityAndAllowsASecondPassage() {
|
||||
resumeAndOpenExerciseSearch()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Voice feedback stores recognition text only; no audio file is created. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
|
|||
|
|
@ -145,11 +145,14 @@ class SyncCatalogInbox(
|
|||
when (val sessions = importPcSessions(directory)) {
|
||||
null -> when (val equipment = importPcEquipmentAssociations(directory)) {
|
||||
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
||||
null -> CatalogInboxResult.Imported(
|
||||
imported = result.imported,
|
||||
reconciled = result.reconciled,
|
||||
skipped = result.skipped,
|
||||
)
|
||||
null -> when (val feedback = importTrainingFeedback(directory)) {
|
||||
null -> CatalogInboxResult.Imported(
|
||||
imported = result.imported,
|
||||
reconciled = result.reconciled,
|
||||
skipped = result.skipped,
|
||||
)
|
||||
else -> CatalogInboxResult.Error(feedback)
|
||||
}
|
||||
else -> CatalogInboxResult.Error(bodyZones)
|
||||
}
|
||||
else -> CatalogInboxResult.Error(equipment)
|
||||
|
|
@ -196,6 +199,23 @@ class SyncCatalogInbox(
|
|||
} catch (error: Exception) { error.message ?: "Import séances impossible." }
|
||||
}
|
||||
|
||||
private fun importTrainingFeedback(directory: DocumentFile): String? {
|
||||
/* V2 is authoritative for revision history. V1 remains a fallback for
|
||||
* an older peer and can only add deterministic initial revisions. */
|
||||
val file = directory.findFile("trainlog-training-feedback-v2.json")
|
||||
?: directory.findFile("trainlog-training-feedback-v1.json") ?: return null
|
||||
return try {
|
||||
val json = appContext.contentResolver.openInputStream(file.uri)?.bufferedReader(Charsets.UTF_8)
|
||||
?.use { it.readText() } ?: return "Lecture des ressentis impossible."
|
||||
when (val result = repository.applyTrainingFeedbackJson(json)) {
|
||||
is TrainingFeedbackImportResult.Applied -> null
|
||||
is TrainingFeedbackImportResult.Invalid -> result.message
|
||||
is TrainingFeedbackImportResult.Conflict -> "Conflit de ressenti : ${result.stableId}"
|
||||
TrainingFeedbackImportResult.DatabaseError -> "Erreur base locale ressentis."
|
||||
}
|
||||
} catch (error: Exception) { error.message ?: "Import des ressentis impossible." }
|
||||
}
|
||||
|
||||
/** Test seam for the real filename-priority boundary; production uses the same method. */
|
||||
internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? =
|
||||
importPcSessions(directory)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class SyncExporter(
|
|||
val associationsJson: String
|
||||
val bodyZonesJson: String
|
||||
val aliasesJson: String
|
||||
val feedbackJson: String
|
||||
try {
|
||||
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
||||
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
||||
|
|
@ -56,6 +57,7 @@ class SyncExporter(
|
|||
associationsJson = repository.buildEquipmentAssociationsJson()
|
||||
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
||||
aliasesJson = repository.buildExerciseAliasesJson()
|
||||
feedbackJson = repository.buildTrainingFeedbackJson()
|
||||
} catch (error: Exception) {
|
||||
return SyncExportResult.Error(
|
||||
error.message ?: "Préparation de l'export impossible.",
|
||||
|
|
@ -200,6 +202,9 @@ class SyncExporter(
|
|||
"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. */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import com.labfytools.trainlog.model.BodyObservationSummary
|
|||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
import com.labfytools.trainlog.model.ExerciseFeedback
|
||||
import com.labfytools.trainlog.model.DraftExerciseFeedback
|
||||
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
|
|
@ -24,6 +26,7 @@ import com.labfytools.trainlog.model.SessionSummary
|
|||
import com.labfytools.trainlog.model.SessionDetail
|
||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.SessionFollowUp
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
|
|
@ -180,6 +183,20 @@ sealed interface SaveSessionResult {
|
|||
SaveSessionResult
|
||||
}
|
||||
|
||||
sealed interface SaveFeedbackResult {
|
||||
data class Saved(val stableId: String) : SaveFeedbackResult
|
||||
data class Invalid(val message: String) : SaveFeedbackResult
|
||||
data class DatabaseError(val message: String) : SaveFeedbackResult
|
||||
}
|
||||
|
||||
sealed interface TrainingFeedbackImportResult {
|
||||
data class Applied(val exerciseAdded: Int, val exerciseSkipped: Int,
|
||||
val followupsAdded: Int, val followupsSkipped: Int) : TrainingFeedbackImportResult
|
||||
data class Invalid(val message: String) : TrainingFeedbackImportResult
|
||||
data class Conflict(val stableId: String) : TrainingFeedbackImportResult
|
||||
data object DatabaseError : TrainingFeedbackImportResult
|
||||
}
|
||||
|
||||
sealed interface ActiveDraftLoadResult {
|
||||
data class Loaded(
|
||||
val draft: ActiveSessionDraft,
|
||||
|
|
@ -1541,6 +1558,7 @@ class TrainlogRepository(
|
|||
val startedAt =
|
||||
OffsetDateTime.now()
|
||||
.toString()
|
||||
val endedAt = OffsetDateTime.now().toString()
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
|
|
@ -1562,6 +1580,7 @@ class TrainlogRepository(
|
|||
draft.sessionType
|
||||
.wireValue
|
||||
)
|
||||
put("ended_at", endedAt)
|
||||
}
|
||||
|
||||
val sessionRowId =
|
||||
|
|
@ -3815,7 +3834,8 @@ class TrainlogRepository(
|
|||
s.session_id,
|
||||
s.started_at,
|
||||
s.session_type,
|
||||
COUNT(se.id)
|
||||
COUNT(se.id),
|
||||
s.ended_at
|
||||
FROM sessions AS s
|
||||
LEFT JOIN session_exercises AS se
|
||||
ON se.session_row_id = s.id
|
||||
|
|
@ -3835,9 +3855,8 @@ class TrainlogRepository(
|
|||
exerciseCount =
|
||||
cursor.getInt(3),
|
||||
sessionType =
|
||||
SessionType.fromWire(
|
||||
cursor.getString(2)
|
||||
),
|
||||
SessionType.fromWire(cursor.getString(2)),
|
||||
endedAt = cursor.optionalText(4),
|
||||
)
|
||||
}
|
||||
} ?: return null
|
||||
|
|
@ -4076,12 +4095,328 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
val feedbackByEntry = listExerciseFeedback(sessionId).groupBy { it.entryId }
|
||||
return SessionDetail(
|
||||
summary = summary,
|
||||
exercises = exercises,
|
||||
exercises = exercises.map { it.copy(feedback = feedbackByEntry[it.entryId].orEmpty()) },
|
||||
followUps = listSessionFollowUps(sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
/** Android-only root creation API. Revisions are immutable; raw_text on
|
||||
* the root is only the transactionally maintained current-text cache. */
|
||||
fun saveExerciseFeedback(sessionId: String, entryId: String, rawText: String,
|
||||
observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult {
|
||||
val text = rawText.trim()
|
||||
if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null)
|
||||
return SaveFeedbackResult.Invalid("Le ressenti doit contenir au plus 8192 octets UTF-8.")
|
||||
val stableId = "fb_" + UUID.randomUUID().toString()
|
||||
return try {
|
||||
val db = database.writableDatabase
|
||||
val row = db.rawQuery(
|
||||
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;",
|
||||
arrayOf(sessionId, entryId),
|
||||
).use { if (it.moveToFirst()) it.getLong(0) else null }
|
||||
?: return SaveFeedbackResult.Invalid("Occurrence de séance introuvable.")
|
||||
db.beginTransaction()
|
||||
db.insertOrThrow("exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id", stableId); put("session_exercise_row_id", row)
|
||||
put("observed_at", observedAt); put("raw_text", text)
|
||||
})
|
||||
insertInitialRevision(db, "exercise_feedback_revisions", "feedback_id", stableId, observedAt, text)
|
||||
db.setTransactionSuccessful(); db.endTransaction()
|
||||
SaveFeedbackResult.Saved(stableId)
|
||||
} catch (error: Exception) { if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction(); SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.") }
|
||||
}
|
||||
|
||||
/** Persist feedback immediately against the durable active-draft
|
||||
* occurrence. The stable ID is retained when the session is finalized. */
|
||||
fun saveDraftExerciseFeedback(entryId: String, rawText: String,
|
||||
observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult {
|
||||
val text = rawText.trim()
|
||||
if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null)
|
||||
return SaveFeedbackResult.Invalid("Le ressenti doit contenir au plus 8192 octets UTF-8.")
|
||||
val stableId = "fb_" + UUID.randomUUID().toString()
|
||||
return try {
|
||||
val db = database.writableDatabase
|
||||
val row = db.rawQuery(
|
||||
"SELECT de.id FROM draft_session_exercises de JOIN active_session_draft d ON d.id=de.draft_id " +
|
||||
"WHERE d.id=? AND de.entry_id=?;",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString(), entryId),
|
||||
).use { if (it.moveToFirst()) it.getLong(0) else null }
|
||||
?: return SaveFeedbackResult.Invalid("Occurrence du brouillon introuvable.")
|
||||
db.beginTransaction()
|
||||
db.insertOrThrow("draft_exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id", stableId); put("draft_session_exercise_row_id", row)
|
||||
put("observed_at", observedAt); put("raw_text", text)
|
||||
})
|
||||
insertInitialRevision(db, "draft_exercise_feedback_revisions", "feedback_id", stableId, observedAt, text)
|
||||
db.setTransactionSuccessful(); db.endTransaction()
|
||||
SaveFeedbackResult.Saved(stableId)
|
||||
} catch (error: Exception) {
|
||||
if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction()
|
||||
SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.")
|
||||
}
|
||||
}
|
||||
|
||||
fun listDraftExerciseFeedback(entryId: String): List<DraftExerciseFeedback> = buildList {
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT f.feedback_id,de.entry_id,e.exercise_id,f.observed_at,f.raw_text," +
|
||||
"(SELECT COUNT(*) FROM draft_exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " +
|
||||
"FROM draft_exercise_feedback f JOIN draft_session_exercises de " +
|
||||
"ON de.id=f.draft_session_exercise_row_id JOIN exercises e ON e.id=de.exercise_row_id " +
|
||||
"WHERE de.draft_id=? AND de.entry_id=?;",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString(), entryId),
|
||||
).use { c -> while (c.moveToNext()) add(DraftExerciseFeedback(
|
||||
c.getString(0), c.getString(1), c.getString(2), c.getString(3), c.getString(4), c.getInt(5) > 1)) }
|
||||
}.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!)
|
||||
.takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) }
|
||||
|
||||
fun saveSessionFollowUp(sessionId: String, rawText: String,
|
||||
observedAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult {
|
||||
val text = rawText.trim()
|
||||
if (!validFeedbackText(text) || TrainlogTimestamp.parse(observedAt) == null)
|
||||
return SaveFeedbackResult.Invalid("Le suivi doit contenir au plus 8192 octets UTF-8.")
|
||||
val stableId = "fu_" + UUID.randomUUID().toString()
|
||||
return try {
|
||||
val db = database.writableDatabase
|
||||
val row = db.rawQuery("SELECT id FROM sessions WHERE session_id=?;", arrayOf(sessionId))
|
||||
.use { if (it.moveToFirst()) it.getLong(0) else null }
|
||||
?: return SaveFeedbackResult.Invalid("Séance introuvable.")
|
||||
db.beginTransaction()
|
||||
db.insertOrThrow("session_followups", null, ContentValues().apply {
|
||||
put("followup_id", stableId); put("session_row_id", row)
|
||||
put("observed_at", observedAt); put("raw_text", text)
|
||||
})
|
||||
insertInitialRevision(db, "session_followup_revisions", "followup_id", stableId, observedAt, text)
|
||||
db.setTransactionSuccessful(); db.endTransaction()
|
||||
SaveFeedbackResult.Saved(stableId)
|
||||
} catch (error: Exception) { if (database.writableDatabase.inTransaction()) database.writableDatabase.endTransaction(); SaveFeedbackResult.DatabaseError(error.message ?: "Enregistrement impossible.") }
|
||||
}
|
||||
|
||||
private fun insertInitialRevision(db: SQLiteDatabase, table: String, parentColumn: String,
|
||||
rootId: String, createdAt: String, rawText: String) {
|
||||
db.insertOrThrow(table, null, ContentValues().apply {
|
||||
put("revision_id", "fr0_$rootId"); put(parentColumn, rootId)
|
||||
put("created_at", createdAt); put("raw_text", rawText)
|
||||
})
|
||||
}
|
||||
|
||||
private fun saveFeedbackRevision(table: String, parentColumn: String, rootTable: String,
|
||||
rootIdColumn: String, rootId: String, rawText: String,
|
||||
createdAt: String = OffsetDateTime.now().toString()): SaveFeedbackResult {
|
||||
val text = rawText.trim()
|
||||
if (!validFeedbackText(text) || TrainlogTimestamp.parse(createdAt) == null)
|
||||
return SaveFeedbackResult.Invalid("Le texte doit contenir au plus 8192 octets UTF-8.")
|
||||
val revisionId = "fr_" + UUID.randomUUID().toString()
|
||||
val db = database.writableDatabase
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
val changed = db.update(rootTable, ContentValues().apply { put("raw_text", text) },
|
||||
"$rootIdColumn=?", arrayOf(rootId))
|
||||
if (changed != 1) throw IllegalArgumentException("Ressenti introuvable.")
|
||||
db.insertOrThrow(table, null, ContentValues().apply {
|
||||
put("revision_id", revisionId); put(parentColumn, rootId)
|
||||
put("created_at", createdAt); put("raw_text", text)
|
||||
})
|
||||
db.setTransactionSuccessful(); SaveFeedbackResult.Saved(rootId)
|
||||
} catch (error: Exception) {
|
||||
SaveFeedbackResult.DatabaseError(error.message ?: "Correction impossible.")
|
||||
} finally { if (db.inTransaction()) db.endTransaction() }
|
||||
}
|
||||
|
||||
fun reviseExerciseFeedback(feedbackId: String, rawText: String): SaveFeedbackResult =
|
||||
saveFeedbackRevision("exercise_feedback_revisions", "feedback_id", "exercise_feedback", "feedback_id", feedbackId, rawText)
|
||||
|
||||
fun reviseDraftExerciseFeedback(feedbackId: String, rawText: String): SaveFeedbackResult =
|
||||
saveFeedbackRevision("draft_exercise_feedback_revisions", "feedback_id", "draft_exercise_feedback", "feedback_id", feedbackId, rawText)
|
||||
|
||||
fun reviseSessionFollowUp(followupId: String, rawText: String): SaveFeedbackResult =
|
||||
saveFeedbackRevision("session_followup_revisions", "followup_id", "session_followups", "followup_id", followupId, rawText)
|
||||
|
||||
fun listExerciseFeedback(sessionId: String): List<ExerciseFeedback> = buildList {
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at,f.raw_text," +
|
||||
"(SELECT COUNT(*) FROM exercise_feedback_revisions r WHERE r.feedback_id=f.feedback_id) " +
|
||||
"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 " +
|
||||
"WHERE s.session_id=?;", arrayOf(sessionId),
|
||||
).use { c -> while (c.moveToNext()) add(ExerciseFeedback(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getString(4),c.getString(5),c.getInt(6)>1)) }
|
||||
}.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!)
|
||||
.takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.feedbackId,b.feedbackId) }
|
||||
|
||||
fun listSessionFollowUps(sessionId: String): List<SessionFollowUp> = buildList {
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT f.followup_id,s.session_id,f.observed_at,f.raw_text," +
|
||||
"(SELECT COUNT(*) FROM session_followup_revisions r WHERE r.followup_id=f.followup_id) FROM session_followups f " +
|
||||
"JOIN sessions s ON s.id=f.session_row_id WHERE s.session_id=?;", arrayOf(sessionId),
|
||||
).use { c -> while (c.moveToNext()) add(SessionFollowUp(c.getString(0),c.getString(1),c.getString(2),c.getString(3),c.getInt(4)>1)) }
|
||||
}.sortedWith { a,b -> TrainlogTimestamp.parse(a.observedAt)!!.compareTo(TrainlogTimestamp.parse(b.observedAt)!!)
|
||||
.takeIf { it != 0 } ?: TrainlogTimestamp.compareIds(a.followupId,b.followupId) }
|
||||
|
||||
private fun validFeedbackText(text: String): Boolean = text.isNotBlank() &&
|
||||
text.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES
|
||||
|
||||
/** Direction-neutral V2 companion: roots and every immutable revision. */
|
||||
fun buildTrainingFeedbackJson(): String {
|
||||
val root = JSONObject().put("format", "trainlog-training-feedback").put("version", 2)
|
||||
.put("generated_at", OffsetDateTime.now().toString())
|
||||
val exercise = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"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 " +
|
||||
"ORDER BY f.observed_at,f.feedback_id;", null,
|
||||
).use { c -> while (c.moveToNext()) {
|
||||
val revisions = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=?;",
|
||||
arrayOf(c.getString(0)),
|
||||
).use { r -> while (r.moveToNext()) revisions.put(JSONObject().put("revision_id",r.getString(0))
|
||||
.put("created_at",r.getString(1)).put("raw_text",r.getString(2))) }
|
||||
exercise.put(JSONObject().put("feedback_id",c.getString(0)).put("session_id",c.getString(1))
|
||||
.put("entry_id",c.getString(2)).put("exercise_id",c.getString(3)).put("observed_at",c.getString(4))
|
||||
.put("revisions",revisions))
|
||||
} }
|
||||
val followups = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f " +
|
||||
"JOIN sessions s ON s.id=f.session_row_id ORDER BY f.observed_at,f.followup_id;", null,
|
||||
).use { c -> while (c.moveToNext()) {
|
||||
val revisions = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT revision_id,created_at,raw_text FROM session_followup_revisions WHERE followup_id=?;",
|
||||
arrayOf(c.getString(0)),
|
||||
).use { r -> while (r.moveToNext()) revisions.put(JSONObject().put("revision_id",r.getString(0))
|
||||
.put("created_at",r.getString(1)).put("raw_text",r.getString(2))) }
|
||||
followups.put(JSONObject().put("followup_id",c.getString(0)).put("session_id",c.getString(1))
|
||||
.put("observed_at",c.getString(2)).put("revisions",revisions))
|
||||
} }
|
||||
return root.put("exercise_feedback",exercise).put("session_followups",followups).toString()
|
||||
}
|
||||
|
||||
fun applyTrainingFeedbackJson(json: String): TrainingFeedbackImportResult {
|
||||
if (json.toByteArray(Charsets.UTF_8).size > 40 * 1024 * 1024)
|
||||
return TrainingFeedbackImportResult.Invalid("Compagnon de ressentis trop volumineux.")
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return TrainingFeedbackImportResult.Invalid("JSON de ressentis invalide.")
|
||||
}
|
||||
if (root.keys().asSequence().toSet() != setOf("format","version","generated_at","exercise_feedback","session_followups") ||
|
||||
root.optString("format") != "trainlog-training-feedback" || root.optInt("version",-1) !in 1..2 ||
|
||||
TrainlogTimestamp.parse(root.optString("generated_at")) == null)
|
||||
return TrainingFeedbackImportResult.Invalid("Contrat trainlog-training-feedback v1/v2 invalide.")
|
||||
if (root.optInt("version") == 2) return applyTrainingFeedbackV2(root)
|
||||
val exercise = root.optJSONArray("exercise_feedback")
|
||||
?: return TrainingFeedbackImportResult.Invalid("exercise_feedback absent.")
|
||||
val followups = root.optJSONArray("session_followups")
|
||||
?: return TrainingFeedbackImportResult.Invalid("session_followups absent.")
|
||||
if (exercise.length() > MAX_FEEDBACK_RECORDS || followups.length() > MAX_FEEDBACK_RECORDS)
|
||||
return TrainingFeedbackImportResult.Invalid("Trop d’observations dans le compagnon.")
|
||||
val db = database.writableDatabase
|
||||
var ea=0; var es=0; var fa=0; var fs=0
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (index in 0 until exercise.length()) {
|
||||
val item = exercise.optJSONObject(index) ?: throw IllegalArgumentException("Observation exercice invalide.")
|
||||
val keys = setOf("feedback_id","session_id","entry_id","exercise_id","observed_at","raw_text")
|
||||
if (item.keys().asSequence().toSet()!=keys) throw IllegalArgumentException("Champs observation exercice invalides.")
|
||||
val id=item.optString("feedback_id"); val sid=item.optString("session_id"); val entry=item.optString("entry_id")
|
||||
val claimed=item.optString("exercise_id"); val at=item.optString("observed_at"); val text=item.optString("raw_text")
|
||||
if (!stableFeedbackId(id,"fb_") || TrainlogTimestamp.parse(at)==null || !validFeedbackText(text))
|
||||
throw IllegalArgumentException("Observation exercice mal formée.")
|
||||
val occurrence = db.rawQuery("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?;",arrayOf(sid,entry))
|
||||
.use { c -> if(c.moveToFirst()) c.getLong(0) to c.getString(1) else null }
|
||||
?: throw IllegalArgumentException("Séance/entrée référencée introuvable.")
|
||||
val canonicalClaim = canonicalExerciseId(db, claimed)
|
||||
?: throw IllegalArgumentException("Exercice référencé introuvable.")
|
||||
if (canonicalClaim != occurrence.second) throw IllegalArgumentException("Exercice incompatible avec l’occurrence.")
|
||||
val existing = db.rawQuery("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?;",arrayOf(id))
|
||||
.use { c -> if(c.moveToFirst()) c.getLong(0) to c.getString(1) else null }
|
||||
if(existing==null){ db.insertOrThrow("exercise_feedback",null,ContentValues().apply{put("feedback_id",id);put("session_exercise_row_id",occurrence.first);put("observed_at",at);put("raw_text",text)});insertInitialRevision(db,"exercise_feedback_revisions","feedback_id",id,at,text);ea++ }
|
||||
else if(existing==occurrence.first to at) es++ else { db.endTransaction(); return TrainingFeedbackImportResult.Conflict(id) }
|
||||
}
|
||||
for(index in 0 until followups.length()) {
|
||||
val item=followups.optJSONObject(index)?:throw IllegalArgumentException("Suivi invalide.")
|
||||
if(item.keys().asSequence().toSet()!=setOf("followup_id","session_id","observed_at","raw_text")) throw IllegalArgumentException("Champs suivi invalides.")
|
||||
val id=item.optString("followup_id");val sid=item.optString("session_id");val at=item.optString("observed_at");val text=item.optString("raw_text")
|
||||
if(!stableFeedbackId(id,"fu_")||TrainlogTimestamp.parse(at)==null||!validFeedbackText(text)) throw IllegalArgumentException("Suivi mal formé.")
|
||||
val row=db.rawQuery("SELECT id FROM sessions WHERE session_id=?;",arrayOf(sid)).use{if(it.moveToFirst())it.getLong(0) else null}
|
||||
?:throw IllegalArgumentException("Séance référencée introuvable.")
|
||||
val old=db.rawQuery("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?;",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null}
|
||||
if(old==null){db.insertOrThrow("session_followups",null,ContentValues().apply{put("followup_id",id);put("session_row_id",row);put("observed_at",at);put("raw_text",text)});insertInitialRevision(db,"session_followup_revisions","followup_id",id,at,text);fa++}
|
||||
else if(old==row to at)fs++ else {db.endTransaction();return TrainingFeedbackImportResult.Conflict(id)}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
return TrainingFeedbackImportResult.Applied(ea,es,fa,fs)
|
||||
} catch(error: IllegalArgumentException) { return TrainingFeedbackImportResult.Invalid(error.message?:"Compagnon invalide.") }
|
||||
catch(_: Exception){return TrainingFeedbackImportResult.DatabaseError}
|
||||
finally { if(db.inTransaction()) db.endTransaction() }
|
||||
}
|
||||
|
||||
private fun applyTrainingFeedbackV2(root: JSONObject): TrainingFeedbackImportResult {
|
||||
val exercise = root.optJSONArray("exercise_feedback") ?: return TrainingFeedbackImportResult.Invalid("exercise_feedback absent.")
|
||||
val followups = root.optJSONArray("session_followups") ?: return TrainingFeedbackImportResult.Invalid("session_followups absent.")
|
||||
if (exercise.length() > MAX_FEEDBACK_RECORDS || followups.length() > MAX_FEEDBACK_RECORDS)
|
||||
return TrainingFeedbackImportResult.Invalid("Trop d’observations dans le compagnon.")
|
||||
val db=database.writableDatabase;var ea=0;var es=0;var fa=0;var fs=0
|
||||
fun mergeRevisions(item: JSONObject, rootId: String, table: String, parentColumn: String,
|
||||
rootTable: String, rootColumn: String) {
|
||||
val revisions=item.optJSONArray("revisions") ?: throw IllegalArgumentException("Révisions absentes.")
|
||||
if(revisions.length()==0 || revisions.length()>MAX_FEEDBACK_RECORDS) throw IllegalArgumentException("Révisions hors borne.")
|
||||
val seen=mutableSetOf<String>()
|
||||
for(i in 0 until revisions.length()) {
|
||||
val revision=revisions.optJSONObject(i)?:throw IllegalArgumentException("Révision invalide.")
|
||||
if(revision.keys().asSequence().toSet()!=setOf("revision_id","created_at","raw_text")) throw IllegalArgumentException("Champs révision invalides.")
|
||||
val id=revision.optString("revision_id");val at=revision.optString("created_at");val text=revision.optString("raw_text")
|
||||
if((!stableFeedbackId(id,"fr_") && id!="fr0_$rootId") || !seen.add(id) || TrainlogTimestamp.parse(at)==null || !validFeedbackText(text)) throw IllegalArgumentException("Révision mal formée.")
|
||||
val old=db.rawQuery("SELECT $parentColumn,created_at,raw_text FROM $table WHERE revision_id=?",arrayOf(id)).use{c->if(c.moveToFirst())Triple(c.getString(0),c.getString(1),c.getString(2))else null}
|
||||
val expected=Triple(rootId,at,text)
|
||||
if(old==null) db.insertOrThrow(table,null,ContentValues().apply{put("revision_id",id);put(parentColumn,rootId);put("created_at",at);put("raw_text",text)})
|
||||
else if(old!=expected) throw SQLiteConstraintException("HARD CONFLICT $id")
|
||||
}
|
||||
val current=db.rawQuery("SELECT revision_id,created_at,raw_text FROM $table WHERE $parentColumn=?",arrayOf(rootId)).use{c->buildList{while(c.moveToNext())add(Triple(c.getString(0),c.getString(1),c.getString(2)))}}
|
||||
.maxWithOrNull{a,b->TrainlogTimestamp.parse(a.second)!!.compareTo(TrainlogTimestamp.parse(b.second)!!).takeIf{it!=0}?:TrainlogTimestamp.compareIds(a.first,b.first)}!!
|
||||
db.update(rootTable,ContentValues().apply{put("raw_text",current.third)},"$rootColumn=?",arrayOf(rootId))
|
||||
}
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for(i in 0 until exercise.length()) {
|
||||
val item=exercise.optJSONObject(i)?:throw IllegalArgumentException("Observation exercice invalide.")
|
||||
if(item.keys().asSequence().toSet()!=setOf("feedback_id","session_id","entry_id","exercise_id","observed_at","revisions"))throw IllegalArgumentException("Champs observation invalides.")
|
||||
val id=item.optString("feedback_id");val at=item.optString("observed_at")
|
||||
if(!stableFeedbackId(id,"fb_")||TrainlogTimestamp.parse(at)==null)throw IllegalArgumentException("Observation mal formée.")
|
||||
val occurrence=db.rawQuery("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?",arrayOf(item.optString("session_id"),item.optString("entry_id"))).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null}?:throw IllegalArgumentException("Occurrence introuvable.")
|
||||
if(canonicalExerciseId(db,item.optString("exercise_id"))!=occurrence.second)throw IllegalArgumentException("Exercice incompatible.")
|
||||
val old=db.rawQuery("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null}
|
||||
val revisions=item.getJSONArray("revisions");val seed=revisions.optJSONObject(0)?.optString("raw_text")?:throw IllegalArgumentException("Révision absente.")
|
||||
if(old==null){db.insertOrThrow("exercise_feedback",null,ContentValues().apply{put("feedback_id",id);put("session_exercise_row_id",occurrence.first);put("observed_at",at);put("raw_text",seed)});ea++}
|
||||
else if(old==occurrence.first to at)es++ else throw SQLiteConstraintException("HARD CONFLICT $id")
|
||||
mergeRevisions(item,id,"exercise_feedback_revisions","feedback_id","exercise_feedback","feedback_id")
|
||||
}
|
||||
for(i in 0 until followups.length()) {
|
||||
val item=followups.optJSONObject(i)?:throw IllegalArgumentException("Suivi invalide.")
|
||||
if(item.keys().asSequence().toSet()!=setOf("followup_id","session_id","observed_at","revisions"))throw IllegalArgumentException("Champs suivi invalides.")
|
||||
val id=item.optString("followup_id");val at=item.optString("observed_at")
|
||||
if(!stableFeedbackId(id,"fu_")||TrainlogTimestamp.parse(at)==null)throw IllegalArgumentException("Suivi mal formé.")
|
||||
val row=db.rawQuery("SELECT id FROM sessions WHERE session_id=?",arrayOf(item.optString("session_id"))).use{if(it.moveToFirst())it.getLong(0)else null}?:throw IllegalArgumentException("Séance introuvable.")
|
||||
val old=db.rawQuery("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",arrayOf(id)).use{c->if(c.moveToFirst())c.getLong(0) to c.getString(1)else null};val revisions=item.getJSONArray("revisions");val seed=revisions.optJSONObject(0)?.optString("raw_text")?:throw IllegalArgumentException("Révision absente.")
|
||||
if(old==null){db.insertOrThrow("session_followups",null,ContentValues().apply{put("followup_id",id);put("session_row_id",row);put("observed_at",at);put("raw_text",seed)});fa++}
|
||||
else if(old==row to at)fs++ else throw SQLiteConstraintException("HARD CONFLICT $id")
|
||||
mergeRevisions(item,id,"session_followup_revisions","followup_id","session_followups","followup_id")
|
||||
}
|
||||
db.setTransactionSuccessful();return TrainingFeedbackImportResult.Applied(ea,es,fa,fs)
|
||||
} catch(error:SQLiteConstraintException){return TrainingFeedbackImportResult.Conflict(error.message?.substringAfterLast(' ')?:"revision")}
|
||||
catch(error:IllegalArgumentException){return TrainingFeedbackImportResult.Invalid(error.message?:"Compagnon invalide.")}
|
||||
catch(_:Exception){return TrainingFeedbackImportResult.DatabaseError}
|
||||
finally{if(db.inTransaction())db.endTransaction()}
|
||||
}
|
||||
|
||||
private fun stableFeedbackId(value:String,prefix:String):Boolean = value.startsWith(prefix) &&
|
||||
runCatching { UUID.fromString(value.removePrefix(prefix)) }.isSuccess
|
||||
private fun canonicalExerciseId(db:SQLiteDatabase,id:String):String? = db.rawQuery(
|
||||
"SELECT exercise_id FROM exercises WHERE exercise_id=? UNION ALL SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=? LIMIT 1;",arrayOf(id,id))
|
||||
.use{if(it.moveToFirst())it.getString(0)else null}
|
||||
|
||||
/**
|
||||
* Build one transient suggestion from current runtime identities and the
|
||||
* complete actual history. No draft/history row is written by this path.
|
||||
|
|
@ -5127,6 +5462,28 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
/* CONTRACT: draft persistence currently rebuilds occurrence rows.
|
||||
* Preserve immutable feedback by stable entry_id across that rebuild;
|
||||
* feedback for an explicitly removed occurrence remains parent-owned
|
||||
* and is deliberately not restored. */
|
||||
val retainedFeedback = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
val retainedDraftRevisions = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
db.rawQuery(
|
||||
"SELECT de.entry_id,f.feedback_id,f.observed_at,f.raw_text FROM draft_exercise_feedback f " +
|
||||
"JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id " +
|
||||
"WHERE de.draft_id=?;", arrayOf(ACTIVE_DRAFT_ID.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 draft_exercise_feedback_revisions r " +
|
||||
"JOIN draft_exercise_feedback f ON f.feedback_id=r.feedback_id " +
|
||||
"JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id WHERE de.draft_id=?;",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||
).use { cursor -> while (cursor.moveToNext()) retainedDraftRevisions
|
||||
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
||||
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
||||
|
||||
db.delete(
|
||||
"draft_session_exercises",
|
||||
"draft_id = ?",
|
||||
|
|
@ -5178,6 +5535,21 @@ class TrainlogRepository(
|
|||
exerciseValues,
|
||||
)
|
||||
|
||||
retainedFeedback[exerciseDraft.entryId].orEmpty().forEach { feedback ->
|
||||
db.insertOrThrow("draft_exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id", feedback.first)
|
||||
put("draft_session_exercise_row_id", draftExerciseRowId)
|
||||
put("observed_at", feedback.second)
|
||||
put("raw_text", feedback.third)
|
||||
})
|
||||
retainedDraftRevisions[feedback.first].orEmpty().forEach { revision ->
|
||||
db.insertOrThrow("draft_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 (exerciseDraft.maxWeightKg != null) {
|
||||
db.insertOrThrow(
|
||||
"draft_max_results",
|
||||
|
|
@ -5254,6 +5626,8 @@ class TrainlogRepository(
|
|||
): String {
|
||||
val sessionId: String
|
||||
val sessionRowId: Long
|
||||
val retainedCompletedFeedback = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
val retainedCompletedRevisions = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
if (sourceSessionId == null) {
|
||||
sessionId = "se_" + UUID.randomUUID().toString()
|
||||
/* Preserve the existing Android meaning: started_at is assigned
|
||||
|
|
@ -5261,6 +5635,9 @@ class TrainlogRepository(
|
|||
val sessionValues = ContentValues().apply {
|
||||
put("session_id", sessionId)
|
||||
put("started_at", OffsetDateTime.now().toString())
|
||||
/* CONTRACT: this exact explicit finalization instant anchors
|
||||
* H+; old NULL values remain unknown and are never backfilled. */
|
||||
put("ended_at", OffsetDateTime.now().toString())
|
||||
put("session_type", draft.sessionType.wireValue)
|
||||
}
|
||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||
|
|
@ -5279,6 +5656,27 @@ class TrainlogRepository(
|
|||
check(resumedDraftIdentityIsSafe(db, sessionRowId, draft)) {
|
||||
"Une séance reprise ne peut supprimer, réordonner ou réaffecter ses entrées existantes."
|
||||
}
|
||||
/* Android has one bounded completed-session correction path:
|
||||
* resumed MAX_TEST finalization. Preserve feedback by the stable
|
||||
* occurrence entry_id before cascading reconstruction; the
|
||||
* surrounding finalization transaction owns rollback. */
|
||||
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(sessionRowId.toString()),
|
||||
).use { cursor -> while (cursor.moveToNext()) retainedCompletedFeedback
|
||||
.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(sessionRowId.toString()),
|
||||
).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=?;",
|
||||
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
|
||||
/* INVARIANT: child replacement and draft deletion are in the
|
||||
* caller's transaction; failure restores the completed baseline. */
|
||||
db.delete(
|
||||
|
|
@ -5328,6 +5726,44 @@ class TrainlogRepository(
|
|||
exerciseValues,
|
||||
)
|
||||
|
||||
retainedCompletedFeedback[exerciseDraft.entryId].orEmpty().forEach { feedback ->
|
||||
db.insertOrThrow("exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id", feedback.first)
|
||||
put("session_exercise_row_id", sessionExerciseRowId)
|
||||
put("observed_at", feedback.second)
|
||||
put("raw_text", feedback.third)
|
||||
})
|
||||
retainedCompletedRevisions[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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* INVARIANT: transfer occurs inside the caller's finalization
|
||||
* transaction and matches the durable occurrence only by entry_id.
|
||||
* Stable feedback identity, timestamp and raw wording are copied
|
||||
* exactly; deleting the draft later removes only the draft parent. */
|
||||
db.rawQuery(
|
||||
"SELECT f.feedback_id,f.observed_at,f.raw_text FROM draft_exercise_feedback f " +
|
||||
"JOIN draft_session_exercises de ON de.id=f.draft_session_exercise_row_id " +
|
||||
"WHERE de.draft_id=? AND de.entry_id=? ORDER BY f.observed_at,f.feedback_id;",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString(), exerciseDraft.entryId),
|
||||
).use { cursor -> while (cursor.moveToNext()) {
|
||||
db.insertOrThrow("exercise_feedback", null, ContentValues().apply {
|
||||
put("feedback_id", cursor.getString(0))
|
||||
put("session_exercise_row_id", sessionExerciseRowId)
|
||||
put("observed_at", cursor.getString(1))
|
||||
put("raw_text", cursor.getString(2))
|
||||
})
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " +
|
||||
"SELECT revision_id,feedback_id,created_at,raw_text FROM draft_exercise_feedback_revisions WHERE feedback_id=?;",
|
||||
arrayOf(cursor.getString(0)),
|
||||
)
|
||||
} }
|
||||
|
||||
if (exerciseDraft.maxWeightKg != null) {
|
||||
db.insertOrThrow(
|
||||
"max_results",
|
||||
|
|
@ -5798,6 +6234,9 @@ private const val MAX_PLAN_DURATION_SECONDS = 86400
|
|||
private const val MAX_PLAN_REST_SECONDS = 86400
|
||||
private const val MAX_EXERCISE_ALIAS_BYTES = 1024 * 1024
|
||||
private const val MAX_EXERCISE_ALIASES = 4096
|
||||
/** UTF-8 byte bound, not UTF-16 code units; oversized dictation is rejected. */
|
||||
const val MAX_FEEDBACK_UTF8_BYTES = 8192
|
||||
private const val MAX_FEEDBACK_RECORDS = 4096
|
||||
private const val ANDROID_LEG_PRESS_LEGACY_ID =
|
||||
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
||||
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
||||
|
|
@ -5813,7 +6252,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
13,
|
||||
15,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -5842,6 +6281,8 @@ private class TrainlogDatabaseHelper(
|
|||
createEquipmentTables(db)
|
||||
createBodyZoneTables(db)
|
||||
createExerciseAliasTable(db)
|
||||
createTrainingFeedbackTables(db)
|
||||
createTrainingFeedbackRevisionTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -5946,6 +6387,20 @@ private class TrainlogDatabaseHelper(
|
|||
version = 13
|
||||
}
|
||||
|
||||
if (version < 14 && newVersion >= 14) {
|
||||
/* CONTRACT: feedback migration is additive and deliberately does
|
||||
* not manufacture ended_at for any historical session. */
|
||||
if (!tableHasColumn(db, "sessions", "ended_at"))
|
||||
db.execSQL("ALTER TABLE sessions ADD COLUMN ended_at TEXT;")
|
||||
createTrainingFeedbackTables(db)
|
||||
version = 14
|
||||
}
|
||||
|
||||
if (version < 15 && newVersion >= 15) {
|
||||
migrateTrainingFeedbackToVersionFifteen(db)
|
||||
version = 15
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -5985,6 +6440,65 @@ private class TrainlogDatabaseHelper(
|
|||
db.execSQL("CREATE INDEX IF NOT EXISTS exercise_aliases_canonical ON exercise_aliases(canonical_exercise_id);")
|
||||
}
|
||||
|
||||
private fun createTrainingFeedbackTables(db: SQLiteDatabase) {
|
||||
/* WHY: immutable subjective wording is distinct from actual training
|
||||
* facts. INVARIANT: only Android APIs create rows; peer imports merely
|
||||
* merge identical stable IDs and never update or delete them. */
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS 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 CHECK(length(trim(raw_text))>0));",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS exercise_feedback_timeline ON exercise_feedback(session_exercise_row_id,observed_at,feedback_id);")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS draft_exercise_feedback(" +
|
||||
"id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE," +
|
||||
"draft_session_exercise_row_id INTEGER NOT NULL REFERENCES draft_session_exercises(id) ON DELETE CASCADE," +
|
||||
"observed_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS draft_exercise_feedback_timeline ON draft_exercise_feedback(draft_session_exercise_row_id,observed_at,feedback_id);")
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS 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 CHECK(length(trim(raw_text))>0));",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS session_followups_timeline ON session_followups(session_row_id,observed_at,followup_id);")
|
||||
}
|
||||
|
||||
private fun createTrainingFeedbackRevisionTables(db: SQLiteDatabase) {
|
||||
/* WHY: a wording correction is history about one observation, not a
|
||||
* replacement observation. CONTRACT: roots retain raw_text only as a
|
||||
* transactionally maintained current-text cache. INVARIANT: revision
|
||||
* rows are append-only and are ordered by parsed instant then stable ID. */
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS exercise_feedback_revisions(" +
|
||||
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE," +
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS exercise_feedback_revisions_current ON exercise_feedback_revisions(feedback_id,created_at,revision_id);")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS draft_exercise_feedback_revisions(" +
|
||||
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES draft_exercise_feedback(feedback_id) ON DELETE CASCADE," +
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS draft_exercise_feedback_revisions_current ON draft_exercise_feedback_revisions(feedback_id,created_at,revision_id);")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS session_followup_revisions(" +
|
||||
"revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE," +
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS session_followup_revisions_current ON session_followup_revisions(followup_id,created_at,revision_id);")
|
||||
}
|
||||
|
||||
private fun migrateTrainingFeedbackToVersionFifteen(db: SQLiteDatabase) {
|
||||
createTrainingFeedbackRevisionTables(db)
|
||||
/* CONTRACT: fr0_<root-id> is the cross-peer deterministic revision-one
|
||||
* identity. INSERT OR IGNORE makes v14 migration and later V1 import
|
||||
* converge without manufacturing duplicate initial revisions. */
|
||||
db.execSQL("INSERT OR IGNORE INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " +
|
||||
"SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM exercise_feedback;")
|
||||
db.execSQL("INSERT OR IGNORE INTO draft_exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) " +
|
||||
"SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM draft_exercise_feedback;")
|
||||
db.execSQL("INSERT OR IGNORE INTO session_followup_revisions(revision_id,followup_id,created_at,raw_text) " +
|
||||
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;")
|
||||
}
|
||||
|
||||
private fun seedInitialBodyZones(db: SQLiteDatabase) {
|
||||
val catalog = BodyZoneCatalog.load(appContext)
|
||||
catalog.initialMappings.forEach { mapping ->
|
||||
|
|
@ -6381,6 +6895,7 @@ private class TrainlogDatabaseHelper(
|
|||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
session_type TEXT NOT NULL
|
||||
CHECK(
|
||||
session_type IN (
|
||||
|
|
|
|||
|
|
@ -121,3 +121,21 @@ internal object TrainlogTimestamp {
|
|||
return days + day - 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 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+?"
|
||||
val observed = TrainlogTimestamp.parse(observedAt) ?: return "H+?"
|
||||
val seconds = observed.utcSecond - end.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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,44 @@ data class SessionSummary(
|
|||
val startedAt: String,
|
||||
val exerciseCount: Int,
|
||||
val sessionType: SessionType = SessionType.TRAINING,
|
||||
/** Null is preserved for historical sessions without a trustworthy anchor. */
|
||||
val endedAt: String? = null,
|
||||
)
|
||||
|
||||
data class ExerciseFeedback(
|
||||
val feedbackId: String,
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val exerciseId: String,
|
||||
val observedAt: String,
|
||||
val rawText: String,
|
||||
val modified: Boolean = false,
|
||||
)
|
||||
|
||||
/** Android-local feedback owned by an unfinished occurrence. */
|
||||
data class DraftExerciseFeedback(
|
||||
val feedbackId: String,
|
||||
val entryId: String,
|
||||
val exerciseId: String,
|
||||
val observedAt: String,
|
||||
val rawText: String,
|
||||
val modified: Boolean = false,
|
||||
)
|
||||
|
||||
data class SessionFollowUp(
|
||||
val followupId: String,
|
||||
val sessionId: String,
|
||||
val observedAt: String,
|
||||
val rawText: String,
|
||||
val modified: Boolean = false,
|
||||
)
|
||||
|
||||
/** Immutable wording revision. The parent observation timestamp is deliberately
|
||||
* absent: correcting wording never creates a new physiological observation. */
|
||||
data class FeedbackRevision(
|
||||
val revisionId: String,
|
||||
val createdAt: String,
|
||||
val rawText: String,
|
||||
)
|
||||
|
||||
data class SessionExerciseDetail(
|
||||
|
|
@ -128,11 +166,13 @@ data class SessionExerciseDetail(
|
|||
val continuousDurationSeconds: Int = 0,
|
||||
val speedKmh: Double? = null,
|
||||
val distanceKm: Double? = null,
|
||||
val feedback: List<ExerciseFeedback> = emptyList(),
|
||||
)
|
||||
|
||||
data class SessionDetail(
|
||||
val summary: SessionSummary,
|
||||
val exercises: List<SessionExerciseDetail>,
|
||||
val followUps: List<SessionFollowUp> = emptyList(),
|
||||
)
|
||||
|
||||
data class LatestExerciseMax(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
|
||||
class AndroidSpeechRecognitionAdapter(context: Context) : SpeechRecognitionAdapter {
|
||||
private val appContext = context.applicationContext
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
override val available: Boolean get() = SpeechRecognizer.isRecognitionAvailable(appContext)
|
||||
|
||||
override fun start(languageTag: String, listener: SpeechRecognitionAdapter.Listener) {
|
||||
val instance = recognizer ?: SpeechRecognizer.createSpeechRecognizer(appContext).also { recognizer = it }
|
||||
instance.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onPartialResults(results: Bundle) = listener.onPartial(best(results))
|
||||
override fun onResults(results: Bundle) = listener.onFinal(best(results))
|
||||
override fun onError(error: Int) = listener.onError("Reconnaissance interrompue (code $error).")
|
||||
override fun onReadyForSpeech(params: Bundle?) = Unit
|
||||
override fun onBeginningOfSpeech() = Unit
|
||||
override fun onRmsChanged(rmsdB: Float) = Unit
|
||||
override fun onBufferReceived(buffer: ByteArray?) = Unit
|
||||
override fun onEndOfSpeech() = Unit
|
||||
override fun onEvent(eventType: Int, params: Bundle?) = Unit
|
||||
})
|
||||
val intent = buildTrainlogRecognitionIntent(languageTag,
|
||||
android.os.Build.VERSION.SDK_INT >= 31 && SpeechRecognizer.isOnDeviceRecognitionAvailable(appContext))
|
||||
instance.startListening(intent)
|
||||
}
|
||||
override fun stop() { recognizer?.stopListening() }
|
||||
override fun destroy() { recognizer?.destroy(); recognizer = null }
|
||||
private fun best(bundle: Bundle): String =
|
||||
bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty()
|
||||
}
|
||||
|
||||
internal const val COMPLETE_SILENCE_MILLIS = 1500L
|
||||
internal const val POSSIBLY_COMPLETE_SILENCE_MILLIS = 1100L
|
||||
|
||||
/** Best-effort recognizer hints only: engines may ignore silence extras.
|
||||
* CONTRACT: Trainlog does not synthesize results or automatically restart a
|
||||
* naturally completed recognizer session. The listener owns real engine text. */
|
||||
internal fun buildTrainlogRecognitionIntent(languageTag: String, preferOffline: Boolean): Intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageTag)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS,
|
||||
COMPLETE_SILENCE_MILLIS)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
|
||||
POSSIBLY_COMPLETE_SILENCE_MILLIS)
|
||||
if (preferOffline) putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
/** Explicit, platform-independent dictation state used by Compose and tests. */
|
||||
enum class DictationPhase { Idle, Listening, Editing, Saving, Error }
|
||||
|
||||
data class DictationState(
|
||||
val phase: DictationPhase = DictationPhase.Idle,
|
||||
val committedText: String = "",
|
||||
val partialText: String = "",
|
||||
val message: String? = null,
|
||||
) {
|
||||
val visibleText: String get() = appendSegment(committedText, partialText)
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: recognition events transport text only. Implementations must not
|
||||
* record, cache, or persist microphone audio. destroy() releases callbacks and
|
||||
* the platform recognizer; it is safe to call more than once.
|
||||
*/
|
||||
interface SpeechRecognitionAdapter {
|
||||
val available: Boolean
|
||||
fun start(languageTag: String, listener: Listener)
|
||||
fun stop()
|
||||
fun destroy()
|
||||
interface Listener {
|
||||
fun onPartial(text: String)
|
||||
fun onFinal(text: String)
|
||||
fun onError(message: String)
|
||||
}
|
||||
}
|
||||
|
||||
class FeedbackDictationController(
|
||||
private val recognizer: SpeechRecognitionAdapter,
|
||||
private val onChanged: (DictationState) -> Unit = {},
|
||||
) : SpeechRecognitionAdapter.Listener {
|
||||
var state = DictationState()
|
||||
private set
|
||||
|
||||
private fun update(value: DictationState) { state = value; onChanged(value) }
|
||||
fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy(
|
||||
phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) }
|
||||
|
||||
fun start(permissionGranted: Boolean) {
|
||||
if (!permissionGranted) {
|
||||
update(state.copy(phase = DictationPhase.Error, partialText = "",
|
||||
message = "Microphone refusé : la saisie manuelle reste disponible."))
|
||||
} else if (!recognizer.available) {
|
||||
update(state.copy(phase = DictationPhase.Error, partialText = "",
|
||||
message = "Reconnaissance vocale indisponible : utilisez la saisie manuelle."))
|
||||
} else {
|
||||
update(state.copy(phase = DictationPhase.Listening, partialText = "", message = null))
|
||||
recognizer.start("fr-FR", this)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (state.phase == DictationPhase.Listening) {
|
||||
recognizer.stop()
|
||||
update(state.copy(phase = DictationPhase.Editing, committedText = state.visibleText,
|
||||
partialText = ""))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartial(text: String) { if (state.phase == DictationPhase.Listening)
|
||||
update(state.copy(partialText = text)) }
|
||||
override fun onFinal(text: String) { if (state.phase == DictationPhase.Listening)
|
||||
update(state.copy(phase = DictationPhase.Editing,
|
||||
committedText = appendSegment(state.committedText, text), partialText = "")) }
|
||||
override fun onError(message: String) {
|
||||
update(state.copy(phase = DictationPhase.Error, committedText = state.visibleText,
|
||||
partialText = "", message = message))
|
||||
}
|
||||
fun beginSaving(): String? {
|
||||
val text = state.visibleText.trim()
|
||||
if (text.isBlank()) { update(state.copy(phase = DictationPhase.Error,
|
||||
message = "Un ressenti vide ne peut pas être enregistré.")); return null }
|
||||
update(state.copy(phase = DictationPhase.Saving, committedText = text, partialText = ""))
|
||||
return text
|
||||
}
|
||||
fun saveFailed(message: String) { update(state.copy(phase = DictationPhase.Error, message = message)) }
|
||||
fun cancel() { recognizer.stop(); update(DictationState()) }
|
||||
fun destroy() { recognizer.destroy() }
|
||||
}
|
||||
|
||||
internal fun appendSegment(base: String, segment: String): String = when {
|
||||
base.isBlank() -> segment
|
||||
segment.isBlank() -> base
|
||||
else -> base.trimEnd() + " " + segment.trimStart()
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun FeedbackEditor(
|
||||
onSave: (String) -> String?,
|
||||
onCancel: () -> Unit,
|
||||
initialText: String = "",
|
||||
editing: Boolean = false,
|
||||
recognizerFactory: ((Context) -> SpeechRecognitionAdapter)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colors = LocalTrainlogColors.current
|
||||
var state by remember(initialText) { mutableStateOf(DictationState(committedText = initialText)) }
|
||||
val controller = remember(initialText) { FeedbackDictationController(
|
||||
recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context),
|
||||
onChanged = { state = it }).also {
|
||||
if (initialText.isNotEmpty()) it.edit(initialText)
|
||||
} }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
controller.start(granted)
|
||||
}
|
||||
DisposableEffect(controller) { onDispose { controller.destroy() } }
|
||||
val transcriptScroll = rememberScrollState()
|
||||
/* Scroll only when layout overflow grows. Partial tokens that still fit do
|
||||
* not move the viewport, so manual reading is not overridden continuously. */
|
||||
LaunchedEffect(transcriptScroll.maxValue, state.phase) {
|
||||
if (state.phase == DictationPhase.Listening &&
|
||||
transcriptScroll.maxValue > transcriptScroll.value) {
|
||||
transcriptScroll.scrollTo(transcriptScroll.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "Dictée du ressenti") {
|
||||
if (state.phase == DictationPhase.Listening) {
|
||||
TrainlogInfo("● Enregistrement en cours", color = colors.error)
|
||||
TrainlogIconAction(TrainlogIcons.Stop, "Arrêter la dictée",
|
||||
onClick = { controller.stop() }, accent = colors.warning,
|
||||
modifier = Modifier.testTag("feedback-stop"))
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 192.dp)
|
||||
.background(colors.surface).verticalScroll(transcriptScroll)
|
||||
.padding(12.dp).testTag("feedback-live-transcript")) {
|
||||
BasicText(state.visibleText, style = TrainlogTypography.normal.copy(color = colors.text))
|
||||
}
|
||||
} else {
|
||||
TrainlogInputField("Texte", state.visibleText, onValueChange = {
|
||||
if (it.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) controller.edit(it)
|
||||
}, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8)
|
||||
val resume = state.committedText.isNotBlank()
|
||||
TrainlogIconAction(TrainlogIcons.Mic,
|
||||
if (resume) "Reprendre la dictée" else "Démarrer la dictée", onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
|
||||
controller.start(true) else permission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}, accent = colors.accent, modifier = Modifier.testTag("feedback-mic"))
|
||||
TrainlogAction(if (editing) "Enregistrer les modifications" else "Enregistrer",
|
||||
if (editing) "Créer une nouvelle révision immutable." else "Créer un enregistrement historique immutable.", onClick = {
|
||||
controller.beginSaving()?.let { text -> onSave(text)?.let(controller::saveFailed) }
|
||||
}, accent = colors.success)
|
||||
TrainlogAction("Annuler", "Ne rien enregistrer.",
|
||||
onClick = { controller.cancel(); onCancel() }, accent = colors.muted)
|
||||
}
|
||||
state.message?.let { TrainlogInfo(it, color = colors.error) }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,12 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.exerciseFeedbackElapsedLabel
|
||||
import com.labfytools.trainlog.data.sessionFollowUpElapsedLabel
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
|
|
@ -27,8 +32,13 @@ fun SessionDetailScreen(
|
|||
|
||||
var revision by remember(sessionId) { mutableStateOf(0) }
|
||||
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var pendingEquipmentChange by remember(sessionId) { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
var equipmentQuery by remember(sessionId) { mutableStateOf("") }
|
||||
var resumeMessage by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var feedbackEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var editingFeedbackId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var editingFollowUpId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
var addingFollowUp by remember(sessionId) { mutableStateOf(false) }
|
||||
|
||||
val detail =
|
||||
remember(sessionId, revision) {
|
||||
|
|
@ -136,14 +146,13 @@ fun SessionDetailScreen(
|
|||
value = equipmentQuery,
|
||||
onValueChange = { equipmentQuery = it },
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Retirer l'équipement",
|
||||
description = "Conserver l'exercice sans machine associée.",
|
||||
TrainlogIconAction(
|
||||
icon = TrainlogIcons.DeleteOutline,
|
||||
contentDescription = "Retirer la machine de cet exercice",
|
||||
accent = colors.warning,
|
||||
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
|
||||
onClick = {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, null)) {
|
||||
revision++; editingEntryId = null; equipmentQuery = ""
|
||||
}
|
||||
pendingEquipmentChange = exercise.entryId to null
|
||||
},
|
||||
)
|
||||
repository.searchEquipment(equipmentQuery).take(8).forEach { equipment ->
|
||||
|
|
@ -152,9 +161,11 @@ fun SessionDetailScreen(
|
|||
description = equipment.labelName.ifBlank { equipment.type },
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) {
|
||||
revision++; editingEntryId = null; equipmentQuery = ""
|
||||
}
|
||||
if (exercise.equipmentId == null) {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) {
|
||||
revision++; editingEntryId = null; equipmentQuery = ""
|
||||
}
|
||||
} else pendingEquipmentChange = exercise.entryId to equipment.equipmentId
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -188,8 +199,76 @@ fun SessionDetailScreen(
|
|||
?.loadSemantics,
|
||||
)
|
||||
}
|
||||
exercise.feedback.forEach { item ->
|
||||
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, 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(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
} }, onCancel = { editingFeedbackId = null })
|
||||
}
|
||||
if (feedbackEntryId == exercise.entryId) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
}
|
||||
}, onCancel = { feedbackEntryId = null })
|
||||
} else {
|
||||
TrainlogAction(
|
||||
label = if (exercise.feedback.isEmpty()) "🎙 Ressenti" else "+ Ajouter un ressenti",
|
||||
description = "Ajouter une observation subjective à cette occurrence.",
|
||||
accent = colors.accent,
|
||||
onClick = { feedbackEntryId = exercise.entryId },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "Suivi après séance") {
|
||||
detail.followUps.forEach { item ->
|
||||
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, 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)
|
||||
if (editingFollowUpId == item.followupId) FeedbackEditor(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
} }, onCancel = { editingFollowUpId = null })
|
||||
}
|
||||
if (addingFollowUp) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
}
|
||||
}, onCancel = { addingFollowUp = false })
|
||||
} else TrainlogAction("+ Ajouter un enregistrement",
|
||||
"Ajouter une observation chronologique sans l’attribuer à un exercice.",
|
||||
onClick = { addingFollowUp = true }, accent = colors.success)
|
||||
}
|
||||
pendingEquipmentChange?.let { (entryId, equipmentId) ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?",
|
||||
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.",
|
||||
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
|
||||
onCancel = { pendingEquipmentChange = null },
|
||||
onConfirm = {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
|
||||
revision++;editingEntryId=null;equipmentQuery="";pendingEquipmentChange=null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import com.labfytools.trainlog.data.CreateEquipmentResult
|
|||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
|
|
@ -103,6 +104,12 @@ fun SessionScreen(
|
|||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var feedbackEntryId by remember { mutableStateOf<String?>(null) }
|
||||
var editingFeedbackId by remember { mutableStateOf<String?>(null) }
|
||||
var pendingRemoval by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||
var feedbackRevision by remember { mutableStateOf(0) }
|
||||
|
||||
val persistDraft:
|
||||
(ActiveSessionDraft, String?) -> Unit =
|
||||
{ updated, successMessage ->
|
||||
|
|
@ -316,30 +323,68 @@ fun SessionScreen(
|
|||
),
|
||||
null,
|
||||
)
|
||||
message = "Éditeur ouvert pour ${draft.exercise.name} — les champs préremplis apparaissent ci-dessous."
|
||||
},
|
||||
)
|
||||
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||
TrainlogInfo("Modification en cours — faites défiler jusqu’à SAISIE — ${draft.exercise.name}.", color = colors.accent)
|
||||
}
|
||||
|
||||
val draftFeedback = remember(feedbackRevision, draft.entryId) {
|
||||
repository.listDraftExerciseFeedback(draft.entryId)
|
||||
}
|
||||
draftFeedback.forEach { feedback ->
|
||||
TrainlogInfo(
|
||||
text = "Ressenti · ${feedback.rawText}${if (feedback.modified) " · Modifié" else ""}",
|
||||
color = colors.muted,
|
||||
)
|
||||
TrainlogAction("Modifier", "Corriger le texte sans changer l’observation.",
|
||||
onClick = { editingFeedbackId = feedback.feedbackId }, accent = colors.accent)
|
||||
if (editingFeedbackId == feedback.feedbackId) FeedbackEditor(
|
||||
initialText = feedback.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseDraftExerciseFeedback(feedback.feedbackId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { feedbackRevision++; editingFeedbackId = null; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
} }, onCancel = { editingFeedbackId = null })
|
||||
}
|
||||
TrainlogAction(
|
||||
label = if (draftFeedback.isEmpty()) {
|
||||
"🎙 Ressenti"
|
||||
} else {
|
||||
"Ressenti (${draftFeedback.size}) · + Ajouter un ressenti"
|
||||
},
|
||||
description = "Dicter ou saisir un ressenti maintenant, avant la fin de la séance.",
|
||||
accent = colors.success,
|
||||
modifier = Modifier.testTag("draft-feedback-${draft.entryId}"),
|
||||
onClick = { feedbackEntryId = draft.entryId },
|
||||
)
|
||||
if (feedbackEntryId == draft.entryId) {
|
||||
FeedbackEditor(
|
||||
onSave = { text ->
|
||||
when (val result = repository.saveDraftExerciseFeedback(draft.entryId, text)) {
|
||||
is SaveFeedbackResult.Saved -> {
|
||||
feedbackRevision += 1
|
||||
feedbackEntryId = null
|
||||
message = "Ressenti sauvegardé dans la séance en cours."
|
||||
null
|
||||
}
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
}
|
||||
},
|
||||
onCancel = { feedbackEntryId = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (currentDraft.sourceSessionId == null) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Retirer ${draft.exercise.name}",
|
||||
description =
|
||||
"Supprimer cet exercice de la séance en cours.",
|
||||
accent =
|
||||
colors.error,
|
||||
TrainlogIconAction(
|
||||
icon = TrainlogIcons.DeleteOutline,
|
||||
contentDescription = "Supprimer cet exercice",
|
||||
accent = colors.error,
|
||||
modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"),
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
exercises =
|
||||
currentDraft.exercises
|
||||
.filterIndexed {
|
||||
itemIndex,
|
||||
_ ->
|
||||
itemIndex != index
|
||||
}
|
||||
),
|
||||
"Exercice retiré de la séance.",
|
||||
)
|
||||
pendingRemoval = index to draft
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -388,7 +433,14 @@ fun SessionScreen(
|
|||
onAdd = {
|
||||
draft ->
|
||||
val editIndex = currentDraft.form.editingExerciseIndex
|
||||
persistDraft(
|
||||
val previous = editIndex?.let(currentDraft.exercises::getOrNull)
|
||||
if (editIndex != null && previous != null &&
|
||||
(previous.exercise.recordingMode != draft.exercise.recordingMode ||
|
||||
previous.exercise.trackingMode != draft.exercise.trackingMode) &&
|
||||
(previous.sets.isNotEmpty() || previous.continuousDurationSeconds > 0 ||
|
||||
previous.maxWeightKg != null || previous.plan != null)) {
|
||||
pendingDestructiveEdit = editIndex to draft
|
||||
} else persistDraft(
|
||||
currentDraft.copy(
|
||||
exercises = editIndex?.let { replacingIndex ->
|
||||
currentDraft.exercises.mapIndexed { index, existing ->
|
||||
|
|
@ -456,28 +508,23 @@ fun SessionScreen(
|
|||
},
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label = "Supprimer la séance en cours",
|
||||
description =
|
||||
"Supprimer uniquement ce brouillon local.",
|
||||
TrainlogIconAction(
|
||||
icon = TrainlogIcons.DeleteOutline,
|
||||
contentDescription = "Supprimer la séance en cours",
|
||||
accent = colors.error,
|
||||
modifier = Modifier.testTag("delete-active-draft"),
|
||||
onClick = {
|
||||
confirmingDiscard = true
|
||||
},
|
||||
)
|
||||
|
||||
if (confirmingDiscard) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Cette suppression n'ajoutera rien à l'historique.",
|
||||
color = colors.error,
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Confirmer la suppression",
|
||||
description =
|
||||
"Supprimer définitivement la séance en cours.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
DestructiveConfirmationDialog(
|
||||
title = "Abandonner la séance en cours ?",
|
||||
detail = "Cette action supprimera le brouillon, ses ${currentDraft.exercises.size} exercice(s) et toutes leurs données enregistrées.",
|
||||
confirmLabel = "Abandonner",
|
||||
onCancel = { confirmingDiscard = false },
|
||||
onConfirm = {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
|
|
@ -494,15 +541,6 @@ fun SessionScreen(
|
|||
}
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description =
|
||||
"Conserver la séance en cours.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -525,6 +563,47 @@ fun SessionScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingRemoval?.let { (index, draft) ->
|
||||
val feedbackCount = repository.listDraftExerciseFeedback(draft.entryId).size
|
||||
val facts = buildList {
|
||||
if (draft.sets.isNotEmpty()) add("${draft.sets.size} série(s)")
|
||||
if (draft.continuousDurationSeconds > 0) add("l’activité continue")
|
||||
if (draft.maxWeightKg != null) add("la donnée MAX")
|
||||
if (feedbackCount > 0) add("$feedbackCount ressenti(s)")
|
||||
}
|
||||
DestructiveConfirmationDialog(
|
||||
title = "Supprimer \"${draft.exercise.name}\" de la séance ?",
|
||||
detail = if (facts.isEmpty()) "Cette occurrence sera retirée." else
|
||||
"Cette action supprimera :\n- " + facts.joinToString("\n- "),
|
||||
confirmLabel = "Supprimer",
|
||||
onCancel = { pendingRemoval = null },
|
||||
onConfirm = {
|
||||
persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }),
|
||||
"Exercice retiré de la séance.")
|
||||
pendingRemoval = null
|
||||
},
|
||||
)
|
||||
}
|
||||
pendingDestructiveEdit?.let { (index, replacement) ->
|
||||
val previous=currentDraft.exercises[index]
|
||||
val losses=buildList {
|
||||
if(previous.sets.isNotEmpty())add("${previous.sets.size} série(s)")
|
||||
if(previous.continuousDurationSeconds>0)add("l’activité continue")
|
||||
if(previous.maxWeightKg!=null)add("la donnée MAX")
|
||||
if(previous.plan!=null)add("les objectifs et le repos")
|
||||
}
|
||||
DestructiveConfirmationDialog(
|
||||
title="Changer le profil de ${previous.exercise.name} ?",
|
||||
detail="Ce changement incompatible remplacera :\n- "+losses.joinToString("\n- ")+"\nLes ressentis restent attachés au même entry_id.",
|
||||
confirmLabel="Remplacer",
|
||||
onCancel={pendingDestructiveEdit=null},
|
||||
onConfirm={
|
||||
persistDraft(currentDraft.copy(exercises=currentDraft.exercises.mapIndexed{i,item->if(i==index)replacement else item},form=SessionDraftForm()),"Exercice modifié.")
|
||||
pendingDestructiveEdit=null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -812,6 +891,7 @@ private fun SessionExerciseForm(
|
|||
initialSetRows
|
||||
)
|
||||
}
|
||||
var pendingSetRemoval by remember(key) { mutableStateOf<Int?>(null) }
|
||||
|
||||
var maxWeightText by
|
||||
remember(key) {
|
||||
|
|
@ -1059,22 +1139,13 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Supprimer la série ${index + 1}",
|
||||
description = "Retirer uniquement cette série.",
|
||||
TrainlogIconAction(
|
||||
icon = TrainlogIcons.DeleteOutline,
|
||||
contentDescription = "Supprimer cette série",
|
||||
accent = colors.error,
|
||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||
onClick = {
|
||||
val updated = setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
|
||||
setRows = updated
|
||||
repsText = encodeRawReps(updated)
|
||||
weightText = encodeRawWeights(updated)
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise, setCountText, repsText, durationText,
|
||||
speedText, distanceText, selectedEquipmentId, weightText,
|
||||
)
|
||||
)
|
||||
pendingSetRemoval = index
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1310,6 +1381,20 @@ private fun SessionExerciseForm(
|
|||
color = colors.error,
|
||||
)
|
||||
}
|
||||
pendingSetRemoval?.let { index ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = "Supprimer la série ${index + 1} ?",
|
||||
detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.",
|
||||
confirmLabel = "Supprimer",
|
||||
onCancel = { pendingSetRemoval = null },
|
||||
onConfirm = {
|
||||
val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index }
|
||||
setRows=updated;repsText=encodeRawReps(updated);weightText=encodeRawWeights(updated);error=null
|
||||
onFormChanged(currentForm(exercise,setCountText,repsText,durationText,speedText,distanceText,selectedEquipmentId,weightText,maxWeightText))
|
||||
pendingSetRemoval=null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ import androidx.compose.foundation.text.BasicTextField
|
|||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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
|
||||
|
|
@ -35,10 +39,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
|
||||
|
|
@ -181,6 +187,22 @@ fun TrainlogAction(
|
|||
}
|
||||
}
|
||||
|
||||
/** Compact pictogram action with a mandatory accessible name. */
|
||||
@Composable
|
||||
fun TrainlogIconAction(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
accent: Color? = null,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
IconButton(onClick = onClick, modifier = modifier.heightIn(min = 48.dp)) {
|
||||
Icon(icon, contentDescription = contentDescription,
|
||||
tint = accent ?: colors.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -228,6 +250,9 @@ fun TrainlogInputField(
|
|||
keyboardOptions: KeyboardOptions =
|
||||
KeyboardOptions.Default,
|
||||
testTag: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -265,7 +290,9 @@ fun TrainlogInputField(
|
|||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
keyboardOptions = keyboardOptions,
|
||||
cursorBrush =
|
||||
SolidColor(colors.accent),
|
||||
|
|
@ -340,3 +367,24 @@ fun TrainlogInfo(
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared destructive commit gate. CONTRACT: dismissal and Back are safe,
|
||||
* outside taps cannot confirm, and only the explicit labelled button invokes
|
||||
* the destructive callback. */
|
||||
@Composable
|
||||
fun DestructiveConfirmationDialog(
|
||||
title: String,
|
||||
detail: String,
|
||||
confirmLabel: String,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
title = { Text(title) },
|
||||
text = { Text(detail) },
|
||||
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** The two voice glyphs are local to avoid shipping the large extended icon catalog. */
|
||||
internal object TrainlogIcons {
|
||||
val Mic: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "Mic",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
moveTo(12f, 14f)
|
||||
curveTo(13.66f, 14f, 14.99f, 12.66f, 14.99f, 11f)
|
||||
lineTo(15f, 5f)
|
||||
curveTo(15f, 3.34f, 13.66f, 2f, 12f, 2f)
|
||||
reflectiveCurveTo(9f, 3.34f, 9f, 5f)
|
||||
verticalLineToRelative(6f)
|
||||
curveTo(9f, 12.66f, 10.34f, 14f, 12f, 14f)
|
||||
close()
|
||||
moveTo(17.3f, 11f)
|
||||
curveTo(17.3f, 14f, 14.76f, 16.1f, 12f, 16.1f)
|
||||
reflectiveCurveTo(6.7f, 14f, 6.7f, 11f)
|
||||
horizontalLineTo(5f)
|
||||
curveTo(5f, 14.41f, 7.72f, 17.23f, 11f, 17.72f)
|
||||
verticalLineTo(21f)
|
||||
horizontalLineTo(8f)
|
||||
verticalLineTo(23f)
|
||||
horizontalLineTo(16f)
|
||||
verticalLineTo(21f)
|
||||
horizontalLineTo(13f)
|
||||
verticalLineToRelative(-3.28f)
|
||||
curveTo(16.28f, 17.24f, 19f, 14.42f, 19f, 11f)
|
||||
horizontalLineToRelative(-1.7f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
val Stop: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "Stop",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
moveTo(6f, 6f)
|
||||
horizontalLineTo(18f)
|
||||
verticalLineTo(18f)
|
||||
horizontalLineTo(6f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
val DeleteOutline: ImageVector by lazy {
|
||||
ImageVector.Builder(
|
||||
name = "DeleteOutline",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
moveTo(6f, 19f)
|
||||
curveTo(6f, 20.1f, 6.9f, 21f, 8f, 21f)
|
||||
horizontalLineTo(16f)
|
||||
curveTo(17.1f, 21f, 18f, 20.1f, 18f, 19f)
|
||||
verticalLineTo(7f)
|
||||
horizontalLineTo(6f)
|
||||
verticalLineTo(19f)
|
||||
close()
|
||||
moveTo(8f, 9f)
|
||||
horizontalLineTo(16f)
|
||||
verticalLineTo(19f)
|
||||
horizontalLineTo(8f)
|
||||
verticalLineTo(9f)
|
||||
close()
|
||||
moveTo(15.5f, 4f)
|
||||
lineTo(14.5f, 3f)
|
||||
horizontalLineTo(9.5f)
|
||||
lineTo(8.5f, 4f)
|
||||
horizontalLineTo(5f)
|
||||
verticalLineTo(6f)
|
||||
horizontalLineTo(19f)
|
||||
verticalLineTo(4f)
|
||||
horizontalLineTo(15.5f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(13, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(15, 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,7 +64,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(13, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(15, 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" }
|
||||
|
|
|
|||
|
|
@ -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(13, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(15, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.labfytools.trainlog.data
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
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))}
|
||||
}
|
||||
@Test fun `unknown followup anchor is explicit`()=assertEquals("H+?",sessionFollowUpElapsedLabel(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"))
|
||||
}
|
||||
@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"))
|
||||
assertTrue(labels.none{it.startsWith("H+-")})
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,46 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
|||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realVersionThirteenCopyUsesProductionMigrationPreservesFactsAndIsIdempotent() {
|
||||
val source = File(System.getenv("TRAINLOG_ANDROID_V13_FIXTURE") ?: "")
|
||||
val output = File(System.getenv("TRAINLOG_ANDROID_V14_OUTPUT") ?: "")
|
||||
assumeTrue("TRAINLOG_ANDROID_V13_FIXTURE is not available", source.isFile)
|
||||
assumeTrue("TRAINLOG_ANDROID_V14_OUTPUT is not available", output.parentFile?.isDirectory == true)
|
||||
val databasePath = context.getDatabasePath(databaseName)
|
||||
databasePath.parentFile?.mkdirs()
|
||||
Files.copy(source.toPath(), databasePath.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
val before = historicalDump(databasePath)
|
||||
val beforeDraftCounts = SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
scalarInt(database, "SELECT COUNT(*) FROM active_session_draft;") to
|
||||
scalarInt(database, "SELECT COUNT(*) FROM draft_session_exercises;")
|
||||
}
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
assertEquals(13, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||
}
|
||||
TrainlogRepository(context, databaseName).let { repository ->
|
||||
try { assertTrue(repository.listExercises().isNotEmpty()) } finally { repository.close() }
|
||||
}
|
||||
assertValidVersionThirteen(databasePath)
|
||||
assertEquals(before, historicalDump(databasePath, before.keys))
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM exercise_feedback;"))
|
||||
assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM draft_exercise_feedback;"))
|
||||
assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM session_followups;"))
|
||||
assertEquals(beforeDraftCounts.first, scalarInt(database, "SELECT COUNT(*) FROM active_session_draft;"))
|
||||
assertEquals(beforeDraftCounts.second, scalarInt(database, "SELECT COUNT(*) FROM draft_session_exercises;"))
|
||||
/* No historical value is synthesized by the nullable new column. */
|
||||
assertEquals(0, scalarInt(database, "SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL;"))
|
||||
}
|
||||
val first = logicalDump(databasePath)
|
||||
TrainlogRepository(context, databaseName).let { repository ->
|
||||
try { assertTrue(repository.listExercises().isNotEmpty()) } finally { repository.close() }
|
||||
}
|
||||
assertEquals(first, logicalDump(databasePath))
|
||||
Files.copy(databasePath.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realVersionTwelveCopyUsesProductionMigrationAndIsIdempotent() {
|
||||
val source = File(System.getenv("TRAINLOG_ANDROID_V12_FIXTURE") ?: "")
|
||||
|
|
@ -85,7 +125,7 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
|||
|
||||
private fun assertValidVersionThirteen(databasePath: File) {
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
assertEquals(13, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals(15, scalarInt(database, "PRAGMA user_version;"))
|
||||
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||
}
|
||||
|
|
@ -115,6 +155,26 @@ class RealAndroidV12MachineExerciseMigrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
/** Snapshot every original column so additive columns/tables are ignored
|
||||
* while every pre-migration user fact remains byte-for-byte comparable. */
|
||||
private fun historicalDump(databasePath: File, shape: Set<String>? = null): Map<String, List<String>> =
|
||||
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||
val tables = database.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;", null)
|
||||
.use { c -> buildList { while(c.moveToNext()) add(c.getString(0)) } }
|
||||
.filter { it != "android_metadata" }
|
||||
val specifications = shape ?: tables.map { table ->
|
||||
val columns = database.rawQuery("PRAGMA table_info(`$table`);", null)
|
||||
.use { c -> buildList { while(c.moveToNext()) add(c.getString(1)) } }
|
||||
"$table|${columns.joinToString(",")}"
|
||||
}.toSet()
|
||||
specifications.associateWith { specification ->
|
||||
val table = specification.substringBefore('|')
|
||||
val columns = specification.substringAfter('|').split(',')
|
||||
database.rawQuery("SELECT ${columns.joinToString(",") { "`$it`" }} FROM `$table` ORDER BY rowid;",null)
|
||||
.use { c -> buildList { while(c.moveToNext()) add(buildString { repeat(c.columnCount){i->if(i>0)append('|');append(if(c.isNull(i))"<NULL>" else c.getString(i))} }) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun scalarInt(database: SQLiteDatabase, sql: String): Int =
|
||||
database.rawQuery(sql, null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/** Optional copy-only gate for the current daily v14 database. The fixture is
|
||||
* never opened by Trainlog: only a second test-owned copy is migrated. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class RealAndroidV14FeedbackRevisionMigrationTest {
|
||||
@Test fun freshRealCopyMigratesLosslesslyToVersionFifteen() {
|
||||
val source=File(System.getenv("TRAINLOG_ANDROID_V14_FIXTURE")?:"")
|
||||
val output=File(System.getenv("TRAINLOG_ANDROID_V15_OUTPUT")?:"")
|
||||
assumeTrue(source.isFile);assumeTrue(output.parentFile?.isDirectory==true)
|
||||
val context=ApplicationProvider.getApplicationContext<Context>();val name="real-v14-feedback-copy.db"
|
||||
val target=context.getDatabasePath(name);target.parentFile?.mkdirs();Files.copy(source.toPath(),target.toPath(),StandardCopyOption.REPLACE_EXISTING)
|
||||
val before=SQLiteDatabase.openDatabase(target.path,null,SQLiteDatabase.OPEN_READONLY).use{db->
|
||||
assertEquals(14,scalar(db,"PRAGMA user_version"));assertEquals(1,scalar(db,"SELECT COUNT(*) FROM active_session_draft"));assertEquals(6,scalar(db,"SELECT COUNT(*) FROM draft_session_exercises"))
|
||||
val ids=values(db,"SELECT entry_id FROM draft_session_exercises ORDER BY entry_id")
|
||||
val roots=values(db,"SELECT feedback_id||'|'||observed_at||'|'||raw_text FROM draft_exercise_feedback ORDER BY feedback_id")
|
||||
ids to roots
|
||||
}
|
||||
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(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"))
|
||||
assertEquals(1,scalar(db,"SELECT COUNT(*) FROM draft_session_exercises de JOIN exercises e ON e.id=de.exercise_row_id WHERE de.entry_id='sxe_6603ec2a-fc48-493f-94e1-58e4ede43b41' AND e.name='Converting chest press' AND de.position=3 AND de.target_sets=3 AND de.target_reps=10 AND abs(de.target_weight_kg-31.2)<0.0001"))
|
||||
assertEquals(3,scalar(db,"SELECT COUNT(*) FROM draft_performed_sets ps JOIN draft_session_exercises de ON de.id=ps.draft_exercise_row_id WHERE de.entry_id='sxe_6603ec2a-fc48-493f-94e1-58e4ede43b41' AND ps.reps=10 AND abs(ps.weight_kg-32.0)<0.0001"))
|
||||
}
|
||||
Files.copy(target.toPath(),output.toPath(),StandardCopyOption.REPLACE_EXISTING);context.deleteDatabase(name)
|
||||
}
|
||||
private fun scalar(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{it.moveToFirst();it.getInt(0)}
|
||||
private fun string(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{it.moveToFirst();it.getString(0)}
|
||||
private fun values(db:SQLiteDatabase,sql:String)=db.rawQuery(sql,null).use{c->buildList{while(c.moveToNext())add(c.getString(0))}}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
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.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
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 TrainingFeedbackRepositoryTest {
|
||||
private lateinit var context:Context;private lateinit var aName:String;private lateinit var bName:String
|
||||
private lateinit var android:TrainlogRepository;private lateinit var peer:TrainlogRepository
|
||||
@Before fun setUp(){context=ApplicationProvider.getApplicationContext();aName="feedback-a-${UUID.randomUUID()}.db";bName="feedback-b-${UUID.randomUUID()}.db";android=TrainlogRepository(context,aName);peer=TrainlogRepository(context,bName)}
|
||||
@After fun tearDown(){android.close();peer.close();context.deleteDatabase(aName);context.deleteDatabase(bName)}
|
||||
@Test fun `post sync addition unions and return converges without duplicates`() {
|
||||
val created=android.createExercise(NewExerciseProfile("Fixture feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8)))
|
||||
val saved=android.saveSession(SessionDraft(listOf(entry))) as SaveSessionResult.Saved
|
||||
assertTrue(android.saveExerciseFeedback(saved.sessionId,entry.entryId,"ressenti A","2026-09-01T10:01:00+02:00") is SaveFeedbackResult.Saved)
|
||||
assertTrue(android.saveSessionFollowUp(saved.sessionId,"suivi A","2026-09-01T18:00:00+02:00") is SaveFeedbackResult.Saved)
|
||||
assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied)
|
||||
assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied)
|
||||
assertEquals(1,android.listExerciseFeedback(saved.sessionId).size);assertEquals(1,android.listSessionFollowUps(saved.sessionId).size)
|
||||
assertTrue(android.saveSessionFollowUp(saved.sessionId,"suivi B","2026-09-02T09:00:00+02:00") is SaveFeedbackResult.Saved)
|
||||
assertTrue(android.applyTrainingFeedbackJson(android.buildTrainingFeedbackJson()) is TrainingFeedbackImportResult.Applied)
|
||||
assertEquals(listOf("suivi A","suivi B"),android.listSessionFollowUps(saved.sessionId).map{it.rawText})
|
||||
assertEquals(2,android.listSessionFollowUps(saved.sessionId).size)
|
||||
}
|
||||
|
||||
@Test fun `draft feedback is durable ordered excluded from sync and transfers unchanged`() {
|
||||
val created=android.createExercise(NewExerciseProfile("Fixture draft feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
val entry=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000d1",exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8)))
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry))))
|
||||
val later=android.saveDraftExerciseFeedback(entry.entryId,"deuxième observation","2026-09-01T10:10:00+02:00") as SaveFeedbackResult.Saved
|
||||
val first=android.saveDraftExerciseFeedback(entry.entryId,"première observation","2026-09-01T10:00:00+02:00") as SaveFeedbackResult.Saved
|
||||
assertTrue(android.reviseDraftExerciseFeedback(first.stableId,"première observation corrigée") is SaveFeedbackResult.Saved)
|
||||
assertEquals(listOf(first.stableId,later.stableId),android.listDraftExerciseFeedback(entry.entryId).map{it.feedbackId})
|
||||
/* A normal form/draft write rebuilds occurrence rows; feedback must
|
||||
* remain attached by entry_id instead of depending on a transient rowid. */
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry))))
|
||||
assertEquals(2,android.listDraftExerciseFeedback(entry.entryId).size)
|
||||
assertEquals(0,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").length())
|
||||
|
||||
android.close()
|
||||
android=TrainlogRepository(context,aName)
|
||||
val reopened=android.listDraftExerciseFeedback(entry.entryId)
|
||||
assertEquals(listOf("première observation corrigée","deuxième observation"),reopened.map{it.rawText})
|
||||
assertTrue(reopened.first().modified)
|
||||
val saved=android.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
assertTrue(android.listDraftExerciseFeedback(entry.entryId).isEmpty())
|
||||
val completed=android.getSessionDetail(saved.sessionId)!!.exercises.single().feedback
|
||||
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))
|
||||
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())
|
||||
val revised=(0 until exported.length()).map{exported.getJSONObject(it)}.single{it.getString("feedback_id")==first.stableId}
|
||||
assertEquals(2,revised.getJSONArray("revisions").length())
|
||||
assertTrue(android.applyTrainingFeedbackJson(JSONObject(android.buildTrainingFeedbackJson()).toString()) is TrainingFeedbackImportResult.Applied)
|
||||
assertEquals(2,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").let{items->(0 until items.length()).map{items.getJSONObject(it)}.single{it.getString("feedback_id")==first.stableId}.getJSONArray("revisions").length()})
|
||||
}
|
||||
|
||||
@Test fun `draft discard cascades feedback without orphan`() {
|
||||
val created=android.createExercise(NewExerciseProfile("Fixture discard feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8)))
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry))))
|
||||
assertTrue(android.saveDraftExerciseFeedback(entry.entryId,"à abandonner") is SaveFeedbackResult.Saved)
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.discardActiveSessionDraft())
|
||||
assertTrue(android.listDraftExerciseFeedback(entry.entryId).isEmpty())
|
||||
assertEquals(0,JSONObject(android.buildTrainingFeedbackJson()).getJSONArray("exercise_feedback").length())
|
||||
}
|
||||
|
||||
@Test fun `feedback transfer failure rolls back completed session and preserves draft`() {
|
||||
val created=android.createExercise(NewExerciseProfile("Fixture rollback feedback",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
val entry=SessionExerciseDraft(exercise=created.exercise,sets=listOf(SessionSetDraft(reps=8)))
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(ActiveSessionDraft(exercises=listOf(entry))))
|
||||
val feedback=android.saveDraftExerciseFeedback(entry.entryId,"doit survivre") as SaveFeedbackResult.Saved
|
||||
android.close()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("CREATE TRIGGER fail_feedback_transfer BEFORE INSERT ON exercise_feedback BEGIN SELECT RAISE(ABORT,'synthetic transfer failure'); END;")
|
||||
}
|
||||
android=TrainlogRepository(context,aName)
|
||||
assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.DatabaseError)
|
||||
assertTrue(android.listSessions().isEmpty())
|
||||
assertEquals(listOf(feedback.stableId),android.listDraftExerciseFeedback(entry.entryId).map{it.feedbackId})
|
||||
android.close()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path,null,SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("DROP TRIGGER fail_feedback_transfer;")
|
||||
}
|
||||
android=TrainlogRepository(context,aName)
|
||||
assertTrue(android.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(1,android.listSessions().size)
|
||||
assertEquals(listOf(feedback.stableId),android.listExerciseFeedback(android.listSessions().single().sessionId).map{it.feedbackId})
|
||||
}
|
||||
|
||||
@Test fun `resumed max correction preserves completed feedback and adds draft feedback`() {
|
||||
val created=android.createExercise(NewExerciseProfile("Fixture max correction",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created
|
||||
val entry=SessionExerciseDraft(entryId="sxe_00000000-0000-4000-8000-0000000000f1",exercise=created.exercise,maxWeightKg=100.0)
|
||||
val saved=android.saveSession(SessionDraft(listOf(entry),SessionType.MAX_TEST)) as SaveSessionResult.Saved
|
||||
val original=android.saveExerciseFeedback(saved.sessionId,entry.entryId,"avant reprise","2026-09-01T10:00:00+02:00") as SaveFeedbackResult.Saved
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.resumeMaxTestSession(saved.sessionId))
|
||||
val added=android.saveDraftExerciseFeedback(entry.entryId,"pendant reprise","2026-09-01T11:00:00+02:00") as SaveFeedbackResult.Saved
|
||||
val resumed=(android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(ActiveDraftMutationResult.Saved,android.saveActiveSessionDraft(
|
||||
resumed.copy(exercises=resumed.exercises.map{it.copy(maxWeightKg=101.0)})))
|
||||
val finalized=android.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
assertEquals(saved.sessionId,finalized.sessionId)
|
||||
val feedback=android.listExerciseFeedback(saved.sessionId)
|
||||
assertEquals(listOf(original.stableId,added.stableId),feedback.map{it.feedbackId})
|
||||
assertEquals(listOf("avant reprise","pendant reprise"),feedback.map{it.rawText})
|
||||
assertEquals(101.0,android.getSessionDetail(saved.sessionId)!!.exercises.single().maxWeightKg!!,0.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1651,7 +1651,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(13, cursor.getInt(0))
|
||||
assertEquals(15, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -1778,7 +1778,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(13, cursor.getInt(0))
|
||||
assertEquals(15, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.speech.RecognizerIntent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class AndroidSpeechRecognitionIntentTest {
|
||||
@Test fun `intent carries bounded best effort silence hints`() {
|
||||
val intent=buildTrainlogRecognitionIntent("fr-FR",false)
|
||||
assertEquals(RecognizerIntent.ACTION_RECOGNIZE_SPEECH,intent.action)
|
||||
assertEquals(1500L,intent.getLongExtra(
|
||||
RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS,-1L))
|
||||
assertEquals(1100L,intent.getLongExtra(
|
||||
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,-1L))
|
||||
assertFalse(intent.hasExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE))
|
||||
assertTrue(intent.getBooleanExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS,false))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import java.util.UUID
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class DraftFeedbackUiReadbackTest {
|
||||
@Suppress("DEPRECATION")
|
||||
@get:Rule val compose = createComposeRule()
|
||||
private lateinit var context: Context
|
||||
private lateinit var databaseName: String
|
||||
private lateinit var repository: TrainlogRepository
|
||||
|
||||
@Before fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
databaseName = "draft-feedback-ui-${UUID.randomUUID()}.db"
|
||||
repository = TrainlogRepository(context, databaseName)
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
}
|
||||
|
||||
@Test fun activeAndCompletedScreensReadBackTheSameFeedback() {
|
||||
val exercise = (repository.createExercise(NewExerciseProfile(
|
||||
"Tirage synthétique", RecordingMode.SETS, TrackingMode.REPS, 0,
|
||||
)) as CreateExerciseResult.Created).exercise
|
||||
val entry = SessionExerciseDraft(exercise = exercise, sets = listOf(SessionSetDraft(reps = 8)))
|
||||
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)
|
||||
|
||||
val completedSessionId = mutableStateOf<String?>(null)
|
||||
compose.setContent { TrainlogTheme {
|
||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||
?: SessionScreen(repository, 0, {}, {}, {})
|
||||
} }
|
||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||
|
||||
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
compose.runOnIdle { completedSessionId.value = saved.sessionId }
|
||||
compose.onNodeWithText("Pendant la séance · texte UI durable").fetchSemanticsNode()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FeedbackDictationControllerTest {
|
||||
private class Fake(override var available:Boolean=true):SpeechRecognitionAdapter{
|
||||
var listener:SpeechRecognitionAdapter.Listener?=null;var stopped=false;var destroyed=false
|
||||
override fun start(languageTag:String,listener:SpeechRecognitionAdapter.Listener){assertEquals("fr-FR",languageTag);this.listener=listener}
|
||||
override fun stop(){stopped=true};override fun destroy(){destroyed=true}
|
||||
}
|
||||
@Test fun `partial final correction resume append stop and save`() {
|
||||
val fake=Fake();val c=FeedbackDictationController(fake)
|
||||
c.start(true);assertEquals(DictationPhase.Listening,c.state.phase)
|
||||
fake.listener!!.onPartial("premier");assertEquals("premier",c.state.visibleText)
|
||||
fake.listener!!.onPartial("premier segment");assertEquals("premier segment",c.state.visibleText)
|
||||
fake.listener!!.onFinal("premier segment");assertEquals(DictationPhase.Editing,c.state.phase)
|
||||
c.edit("segment corrigé");c.start(true);fake.listener!!.onPartial("suite");c.stop()
|
||||
assertEquals("segment corrigé suite",c.state.visibleText)
|
||||
assertEquals("segment corrigé suite",c.beginSaving())
|
||||
assertEquals(DictationPhase.Saving,c.state.phase)
|
||||
}
|
||||
@Test fun `long multiline partial survives immediate stop and resume appends`() {
|
||||
val fake=Fake();val c=FeedbackDictationController(fake)
|
||||
c.edit("ligne une\nligne deux corrigée")
|
||||
c.start(true)
|
||||
val partial="ligne trois très longue qui doit rester intégralement visible après l'arrêt"
|
||||
fake.listener!!.onPartial(partial)
|
||||
c.stop()
|
||||
assertTrue(fake.stopped)
|
||||
assertEquals("ligne une\nligne deux corrigée $partial",c.state.visibleText)
|
||||
assertEquals(DictationPhase.Editing,c.state.phase)
|
||||
c.start(true);fake.listener!!.onFinal("ligne quatre")
|
||||
assertEquals("ligne une\nligne deux corrigée $partial ligne quatre",c.state.visibleText)
|
||||
}
|
||||
@Test fun `errors preserve text and manual entry remains usable`() {
|
||||
val fake=Fake();val c=FeedbackDictationController(fake);c.edit("conservé")
|
||||
c.start(false);assertEquals("conservé",c.state.visibleText);c.edit("manuel")
|
||||
fake.available=false;c.start(true);assertEquals("manuel",c.state.visibleText)
|
||||
fake.available=true;c.start(true);fake.listener!!.onPartial("partiel");fake.listener!!.onError("erreur")
|
||||
assertEquals("manuel partiel",c.state.visibleText);c.cancel();assertEquals("",c.state.visibleText)
|
||||
}
|
||||
@Test fun `blank save forbidden and lifecycle destroys recognizer`() {
|
||||
val fake=Fake();val c=FeedbackDictationController(fake)
|
||||
assertNull(c.beginSaving());c.destroy();assertTrue(fake.destroyed)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk=[35])
|
||||
class FeedbackEditorVoiceUiTest {
|
||||
@Suppress("DEPRECATION") @get:Rule val compose=createComposeRule()
|
||||
private class Fake:SpeechRecognitionAdapter {
|
||||
override val available=true
|
||||
lateinit var listener:SpeechRecognitionAdapter.Listener
|
||||
var stopped=false
|
||||
override fun start(languageTag:String,listener:SpeechRecognitionAdapter.Listener){this.listener=listener}
|
||||
override fun stop(){stopped=true}
|
||||
override fun destroy()=Unit
|
||||
}
|
||||
@Test fun micStopTranscriptAndResumeExposeAccessibleState() {
|
||||
Shadows.shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO)
|
||||
val fake=Fake()
|
||||
compose.setContent { TrainlogTheme { FeedbackEditor({null},{},recognizerFactory={fake}) } }
|
||||
compose.onNodeWithContentDescription("Démarrer la dictée").assertIsDisplayed()
|
||||
assertTrue(compose.onAllNodesWithContentDescription("Arrêter la dictée")
|
||||
.fetchSemanticsNodes().isEmpty())
|
||||
compose.onNodeWithContentDescription("Démarrer la dictée").performClick()
|
||||
compose.onNodeWithContentDescription("Arrêter la dictée").assertIsDisplayed()
|
||||
val longText="première ligne dictée avec assez de mots pour envelopper\ndeuxième ligne conservée"
|
||||
compose.runOnIdle{fake.listener.onPartial(longText)}
|
||||
compose.onNodeWithText(longText).assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Arrêter la dictée").performClick()
|
||||
compose.runOnIdle{assertTrue(fake.stopped)}
|
||||
compose.onNodeWithTag("feedback-edit-transcript").assertTextContains(longText)
|
||||
compose.onNodeWithContentDescription("Reprendre la dictée").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,10 @@ The desktop remains the canonical long-term history and analytics store.
|
|||
## Session exchange V3
|
||||
|
||||
Completed session occurrences persist an `entry_id`; it is never regenerated
|
||||
for exchange. Android publishes `trainlog-mobile-export-v3.json` as the active
|
||||
for exchange. Android schema v14 added feedback roots and completion `ended_at`
|
||||
anchors; v15 adds immutable correction history and the separately versioned
|
||||
`trainlog-training-feedback-v2.json` (while still reading V1). No audio is stored. Android publishes
|
||||
`trainlog-mobile-export-v3.json` as the active
|
||||
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.
|
||||
|
|
@ -135,6 +138,12 @@ additive v4 -> v11 chain preserves catalog, completed sessions/actuals, body
|
|||
observations and the draft while adding the shared equipment catalogue,
|
||||
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
||||
Exactly one active draft is supported; it is separate from completed history.
|
||||
Schema v14 also gives its occurrences durable Android-local
|
||||
`draft_exercise_feedback`. The active-session **🎙 Ressenti** action saves there
|
||||
immediately and reads all observations back in timestamp/ID order. Multiple
|
||||
records are allowed. They survive process restart and transfer by stable
|
||||
`entry_id`, without regenerated feedback IDs, in the atomic finalization
|
||||
transaction. Explicit draft discard cascades this draft-owned data.
|
||||
Schema v9 adds explicit completed/draft MAX results. Schema v10 additively
|
||||
stores direct primary/secondary body-zone relations and their private sync
|
||||
baseline; the taxonomy itself remains the shared manifest asset. Schema v11
|
||||
|
|
@ -464,6 +473,8 @@ The user does not need a separate manual export step before synchronization.
|
|||
An active draft is never included in completed history, session detail or this
|
||||
snapshot. Synchronization continues to exchange completed data while the draft
|
||||
stays local; no draft fields were added to the frozen mobile artifact.
|
||||
Training Feedback V1 likewise excludes `draft_exercise_feedback`; only its
|
||||
exact transferred completed rows are exported after finalization.
|
||||
|
||||
## 10. PC catalog access
|
||||
|
||||
|
|
@ -725,3 +736,19 @@ Equipment Detail reads the shared V2 relation asset, shows verified possible
|
|||
exercises with French confidence/evidence status, and opens Exercise Detail.
|
||||
Exercise Detail shows exercise-owned muscles, movements, BODY ZONES, and
|
||||
compatible physical equipment. Custom equipment receives no inferred anatomy.
|
||||
Saved exercise feedback and session follow-ups expose **Modifier**. The latest
|
||||
text is prefilled, resumed dictation appends to it, and save creates an immutable
|
||||
v15 revision without changing `observed_at` or the H+ label.
|
||||
The feedback editor presents microphone and stop actions with accessible labels
|
||||
and keeps the live transcript in a wrapped, multi-line area. Its speech intent
|
||||
requests 1.5 seconds of complete silence (and 1.1 seconds of possibly complete
|
||||
silence) before completion. These durations are best-effort hints: Android
|
||||
recognition engines may ignore them. Trainlog neither fabricates results with a
|
||||
local timer nor enters an automatic restart loop; natural completion preserves
|
||||
the captured text, while an explicit Stop remains immediate.
|
||||
|
||||
Every user-initiated destructive commit uses an action-specific confirmation.
|
||||
Cancel and Back are safe, outside taps cannot confirm, and the destructive
|
||||
button is explicit. Draft occurrence warnings name nonzero performed-set,
|
||||
continuous, MAX and feedback children; series deletion and whole-draft abandon
|
||||
use the same gate.
|
||||
|
|
|
|||
|
|
@ -168,7 +168,9 @@ silently rendered as unclassified.
|
|||
|
||||
### Desktop
|
||||
|
||||
Desktop SQLite schema v13 is canonical long-term history. Its v9 -> v10
|
||||
Desktop SQLite schema v15 is canonical long-term history. Its 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
|
||||
their strictly-positive contracts. `session_exercises`
|
||||
|
|
@ -203,7 +205,7 @@ exercise_body_zone_sync
|
|||
|
||||
### Android
|
||||
|
||||
Android has an independent local SQLite schema, currently v13. Completed and
|
||||
Android has an independent local SQLite schema, currently v14. Completed and
|
||||
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
||||
resuming a completed Test max records its stable source session in the one
|
||||
durable draft.
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ 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 v13, with stable ordered `session_exercises.entry_id`,
|
||||
- SQLite schema v15, with stable ordered `session_exercises.entry_id`,
|
||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
||||
|
|
@ -133,6 +133,8 @@ Implemented:
|
|||
- `BODY_FOCUS_HOME_V1`: a compact accessible Home front/back BODY ZONES map,
|
||||
textual top-three exposure ranking and Catalogue/Statistics drill-through;
|
||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||
- schema v15 immutable feedback wording revisions, Android correction UX,
|
||||
read-only TUI display, and Training Feedback V2 synchronization;
|
||||
- exercise creation;
|
||||
- stable-ID exercise rename/editing with referenced-profile protection;
|
||||
- primary/secondary body-zone selection, display, hierarchy filtering and
|
||||
|
|
@ -185,7 +187,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 v13. Schema v13 is additive: exercise
|
||||
desktop and Android databases are schema v15. 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.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ is a later, separately versioned migration.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=12
|
||||
TRAINLOG_DATABASE_SCHEMA_VERSION=15
|
||||
DATABASE_SCHEMA_V12=PASS
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
|
@ -90,6 +90,32 @@ direct zones, and rejects profile or primary-zone conflicts before mutation.
|
|||
A schema fixture must represent the real historical structure. Rewriting only
|
||||
`user_version` is not an acceptable migration test.
|
||||
|
||||
Schema v14 adds `exercise_feedback` and `session_followups` without rewriting
|
||||
v13 facts. Both use stable immutable IDs, exact timestamps, non-empty bounded
|
||||
text at API boundaries, and parent-owned cascading foreign keys to
|
||||
occurrence/session owners. There is no direct feedback delete operation;
|
||||
parent cascade preserves existing correction/deletion semantics. Android
|
||||
additionally gains nullable `sessions.ended_at`; migration does
|
||||
not populate it for old rows.
|
||||
Desktop occurrence replacement preserves feedback for logical occurrences
|
||||
whose `entry_id` remains in the same session: it snapshots and remaps the rows
|
||||
inside the replacement transaction. Removed entries cascade, new entries do
|
||||
not inherit observations, and the stable `sessions` row—and therefore every
|
||||
session follow-up—remains unchanged.
|
||||
On Android, v14 additionally creates `draft_exercise_feedback`, owned by
|
||||
`draft_session_exercises` with `ON DELETE CASCADE`. Its stable `feedback_id`,
|
||||
timestamp and raw text transfer to the completed table by occurrence `entry_id`
|
||||
inside the existing finalization transaction. It is never synchronized while
|
||||
the parent session remains a draft.
|
||||
|
||||
Schema v15 is additive. `exercise_feedback_revisions`, Android-only
|
||||
`draft_exercise_feedback_revisions`, and `session_followup_revisions` retain
|
||||
immutable wording revisions. Every v14 root receives the deterministic initial
|
||||
identity `fr0_<root-id>` with `created_at=observed_at`; corrections use
|
||||
`fr_<uuid-v4>`. Root `raw_text` remains only a transactionally updated
|
||||
current-text cache. The authoritative current revision is selected by parsed
|
||||
`created_at` instant, then bytewise `revision_id` on ties.
|
||||
|
||||
## 3. Connection invariants
|
||||
|
||||
Every connection enables:
|
||||
|
|
|
|||
39
docs/reviews/training_feedback_v1_ux_safety_audit.md
Normal file
39
docs/reviews/training_feedback_v1_ux_safety_audit.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Training Feedback V1 — UX and destructive-action audit
|
||||
|
||||
Repository audit performed for the schema-v15 closeout. “No action” means the
|
||||
current product exposes no such user operation; no functionality was invented.
|
||||
|
||||
| Action | Client | Persisted data affected | Confirmation before this tranche | Required correction / resulting gate |
|
||||
|---|---|---|---|---|
|
||||
| Remove active-draft occurrence | Android | occurrence, performed sets/continuous/MAX, plan and feedback roots/revisions | No | Shared action-specific modal with exact nonzero child counts |
|
||||
| Remove one performed set | Android | one durable draft performed-set fact | No | Shared series-specific modal |
|
||||
| Discard active draft/session | Android | complete durable draft graph | Yes, inline second action | Replaced by non-dismissible-outside shared modal; Back/Cancel safe |
|
||||
| Change occurrence to incompatible profile | Android | existing actuals/MAX/plan may be replaced | No | Loss-specific modal; same `entry_id` and feedback retained |
|
||||
| Detach/replace completed occurrence equipment | Android | persisted equipment association | No | Association-loss modal before detach/replacement |
|
||||
| Delete feedback directly | Android | feedback root/revisions | No action | None added; correction appends a revision |
|
||||
| Delete custom exercise | Android | catalog/history | No reachable delete action | No correction |
|
||||
| Delete custom legacy equipment | Android | catalog association | No catalog-delete action; completed association editor can detach | Detach/replace gate above |
|
||||
| Delete body observation | Android | body observation | No reachable delete action | No correction |
|
||||
| Merge/retire exercise | Android | catalog identity graph | No reachable action | No correction |
|
||||
| Settings reset/clear | Android | database/settings | No reachable destructive reset | No correction |
|
||||
| Remove session occurrence | TUI | transient/corrected occurrence graph and children | Yes, numeric confirmation | Modal state normalized to default Non; arrows/Tab focus; Enter explicit; Escape/q cancel |
|
||||
| Remove performed set | TUI | one occurrence performed-set fact | No | Existing event loop now enters the same safe confirmation state |
|
||||
| Abandon in-process session draft | TUI | complete process-owned draft | Yes, numeric confirmation | Default Non modal semantics; no `q` confirmation |
|
||||
| Merge/retire exercise | TUI | source identity, occurrence ownership, equipment/zones | Yes | Existing impact preview and default-safe two-choice overlay retained |
|
||||
| Completed-session correction removes occurrence | TUI | occurrence plus its child facts and feedback revisions | Indirectly through explicit correction/save workflow | Replacement stays transactional; revisions retained only for same `entry_id`; removal follows confirmed occurrence choice |
|
||||
| Delete custom exercise/equipment | TUI | catalog rows | No reachable direct delete action | No correction |
|
||||
| Delete body observation | TUI | body row | No reachable delete action | No correction |
|
||||
| Settings reset/clear | TUI | database/settings | No reachable action | No correction |
|
||||
|
||||
Generator proposal removal/discard and dirty-form abandon concern transient,
|
||||
not-yet-persisted editor state. Existing keep/discard guards remain explicit;
|
||||
they do not delete durable training facts. Synchronization cleanup of temporary
|
||||
documents is system-owned publication rollback/housekeeping, not a user data
|
||||
deletion action.
|
||||
|
||||
Undo was rejected for occurrence deletion. Current persistence reconstructs
|
||||
draft occurrence rows; a byte-for-byte logical restore would require capturing
|
||||
and reinstating every root, revision, timestamp, child row and position through
|
||||
a second durable transaction. A cosmetic Snackbar could not prove that
|
||||
identity contract. The confirmation modal is therefore the truthful V1 safety
|
||||
mechanism.
|
||||
|
|
@ -494,3 +494,9 @@ Future equipment identification may collect manufacturer, model, name, photo,
|
|||
pictogram, and notes to form a proposal which the user must verify before
|
||||
persistence. OCR/image recognition and a custom-equipment relation schema are
|
||||
not part of EQUIPMENT_KNOWLEDGE_V2.
|
||||
## TRAINING_FEEDBACK_V1
|
||||
|
||||
Implemented for human review: schema v14, Android voice/manual append-only
|
||||
capture and readback, desktop read-only consultation, and bidirectional strict
|
||||
companion convergence. Automatic interpretation and recommendations remain
|
||||
future scope.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Machine-exercise Phase 1 compatibility
|
||||
|
||||
Desktop and Android schema v13 retain mobile export V3, readable V1/V2 imports,
|
||||
Desktop and Android schema v15 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
|
||||
|
|
@ -675,3 +675,7 @@ existing equipment.
|
|||
Mapped canonical exercise names are metadata owned by `exercise_id`: PC exports
|
||||
them and both import paths prevent stale mapped peer labels from restoring an
|
||||
old display name. This does not change TRAINLOG_FORMAT_V1.
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -699,3 +699,20 @@ EQUIPMENT_KNOWLEDGE_V2 validation covers relation envelopes, canonical UUID and
|
|||
equipment references, enum/source integrity, uniqueness and stable ordering,
|
||||
the unresolved Seated Leg invariant, generic/custom non-inference, and generated
|
||||
C/Android query parity for leg press, combo equipment, and chest-zone derivation.
|
||||
`training_feedback_sync` covers first import, identical replay, post-sync
|
||||
follow-up addition, PC export convergence, and immutable-ID conflict. Android
|
||||
unit tests cover partial/final recognition, correction, resume/append, stop,
|
||||
save/cancel, failure preservation, permission/unavailable fallback, lifecycle
|
||||
destroy, and H+ flooring/unknown anchors. Draft-feedback coverage saves before
|
||||
completion, reopens the repository, preserves multiple stable IDs and ordering
|
||||
across ordinary draft rewrites, verifies active/completed Compose readback,
|
||||
excludes the draft rows from sync, transfers them atomically on finalization,
|
||||
rolls back on an injected insert failure, and checks discard cascading. The
|
||||
real-v13-copy production migration test also requires the existing active draft
|
||||
and all its occurrences to remain identical while the new draft-feedback table
|
||||
starts empty and a second production open is logically idempotent.
|
||||
`feedback_correction` reconstructs completed occurrence rowids while retaining,
|
||||
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.
|
||||
|
|
|
|||
108
docs/training_feedback.md
Normal file
108
docs/training_feedback.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Training feedback V1/V2
|
||||
|
||||
`TRAINING_FEEDBACK_V1` collects subjective raw wording for later human review.
|
||||
It never changes a session, load, MAX, plan, BODY ZONE, scientific profile, or
|
||||
generator decision, and it performs no medical, sentiment, recovery, pain, or
|
||||
anatomical inference.
|
||||
|
||||
## Ownership and data model
|
||||
|
||||
Android is the only creator. The desktop database stores synchronized rows
|
||||
and the TUI displays them read-only; neither desktop surface offers add, edit,
|
||||
delete, or dictation actions. Schema v14 adds only:
|
||||
|
||||
- `exercise_feedback(feedback_id, session_exercise_row_id, observed_at, raw_text)`;
|
||||
- Android-only `draft_exercise_feedback(feedback_id, draft_session_exercise_row_id,
|
||||
observed_at, raw_text)`;
|
||||
- `session_followups(followup_id, session_row_id, observed_at, raw_text)`.
|
||||
|
||||
The foreign keys point at real occurrence/session rows and cascade only when
|
||||
an existing parent-deletion workflow removes that owner; there is no direct
|
||||
feedback deletion action. Public exercise
|
||||
feedback derives `session_id`, `entry_id`, and the current canonical
|
||||
`exercise_id` from the occurrence. IDs are UUIDv4-backed `fb_…` and `fu_…`.
|
||||
Schema v15 keeps each root and adds immutable wording revisions. A correction
|
||||
keeps the root ID, parent and original `observed_at`; it is not a new
|
||||
physiological observation. Android exposes **Modifier** with current text
|
||||
prefilled and resumed dictation appending to it. Cancel creates nothing.
|
||||
Blank text and text exceeding 8192 UTF-8 bytes are rejected, never truncated.
|
||||
Only synthetic wording belongs in source fixtures; real feedback is private
|
||||
runtime data and must not be committed.
|
||||
|
||||
`trainlog-training-feedback-v2.json` transports roots plus complete
|
||||
`revisions[]`. Merge is an append-only union by revision ID; omission never
|
||||
deletes and immutable disagreement is a hard conflict. V1 remains readable:
|
||||
its `raw_text` becomes `fr0_<root-id>` only if absent and can never erase a
|
||||
later V2 revision. Draft revisions survive restart/reconstruction and transfer
|
||||
unchanged on finalization. Retained `session_id` + `entry_id` corrections keep
|
||||
all revisions; confirmed parent removal may cascade them. No direct feedback
|
||||
delete action exists, and feedback never drives automatic training adaptation.
|
||||
|
||||
## Dictation
|
||||
|
||||
The Android editor exposes **Démarrer**, live partial text while listening,
|
||||
**Stop**, editable text, **Reprendre**, **Enregistrer**, and **Annuler**.
|
||||
Stopping never saves. Resume appends recognition to the manually corrected
|
||||
committed text. Errors retain captured text. French `fr-FR` is the V1 default;
|
||||
on-device recognition is preferred when supported, otherwise Android's normal
|
||||
service is used. Permission denial or unavailable recognition leaves manual
|
||||
entry fully usable. The adapter and persistence transport text only: Trainlog
|
||||
does not create or retain PCM, WAV, compressed audio, service audio, or a
|
||||
microphone temporary file. Destroying the UI releases recognizer callbacks and
|
||||
resources.
|
||||
|
||||
## Time presentation
|
||||
|
||||
`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
|
||||
parses instants, then compares the stable ID bytewise; it never
|
||||
uses lexical timestamp order or machine-local timezone.
|
||||
|
||||
## Synchronization
|
||||
|
||||
`trainlog-training-feedback-v1.json` has format
|
||||
`trainlog-training-feedback`, version 1, `generated_at`, and bounded
|
||||
`exercise_feedback`/`session_followups` arrays. It is a separate,
|
||||
direction-neutral companion and does not alter mobile export V3,
|
||||
`TRAINLOG_FORMAT_V1`, BODY ZONES, equipment, or alias companions.
|
||||
|
||||
Import occurs after exercise definitions, aliases, machine identities, sessions,
|
||||
entries, and equipment reconciliation. Missing parents fail; no phantom object
|
||||
is created. Exercise claims resolve aliases and must equal the occurrence's
|
||||
canonical identity. A missing stable record is inserted, an identical stable
|
||||
record is an idempotent success, and any differing immutable field is a hard
|
||||
conflict. Omission never deletes local rows. Consequently an Android follow-up
|
||||
added after an earlier sync is unioned on the next run and the complete PC set
|
||||
can converge back to Android without duplication.
|
||||
|
||||
Exercise feedback entered from the active-session occurrence UI is committed
|
||||
immediately to `draft_exercise_feedback`; it does not wait for session
|
||||
completion. It survives navigation, activity/process recreation and repository
|
||||
reopen. Ordinary draft rewrites preserve it by stable `entry_id`. Explicitly
|
||||
discarding the occurrence or whole draft removes this parent-owned local data
|
||||
through `ON DELETE CASCADE`; there is no independent delete UI.
|
||||
|
||||
Finalization copies every draft observation to `exercise_feedback` in the same
|
||||
transaction as the completed session, matching the completed occurrence by
|
||||
`entry_id` and preserving `feedback_id`, `observed_at`, and `raw_text` exactly.
|
||||
The subsequent draft deletion removes only draft storage. Any transfer failure
|
||||
rolls the entire finalization back.
|
||||
|
||||
Draft feedback is Android-local and absent from all V1 companions because it
|
||||
does not yet own a completed `session_id`. After finalization, the same stable
|
||||
records enter the normal bidirectional Training Feedback companion.
|
||||
|
||||
Completed-session correction may reconstruct internal occurrence rows. Desktop
|
||||
therefore snapshots feedback inside the correction transaction and reattaches
|
||||
it only when the same `entry_id` remains in the same `session_id`; IDs,
|
||||
timestamps and raw text are unchanged. Removed occurrences cascade their owned
|
||||
feedback and new occurrences inherit nothing. The stable session row is not
|
||||
recreated, so follow-ups remain untouched. Android has no general completed
|
||||
training-session editor, but its bounded resumed-MAX correction path applies
|
||||
the same rule before reconstructing occurrences. Any reattachment failure
|
||||
rolls the complete correction back.
|
||||
10
docs/tui.md
10
docs/tui.md
|
|
@ -272,6 +272,12 @@ Inside editable session exercise lists:
|
|||
|
||||
```text
|
||||
d delete selected exercise from the session
|
||||
|
||||
These delete keys now open the existing event-loop-owned confirmation state;
|
||||
they do not commit directly. Selection defaults to **Non**, Escape and `q`
|
||||
cancel, and Enter commits only when the explicit destructive choice is focused.
|
||||
Draft abandon follows the same rule. Exercise merge keeps its impact preview
|
||||
and confirmation. Feedback wording remains read-only in the TUI.
|
||||
```
|
||||
|
||||
A failed replacement rolls back completely.
|
||||
|
|
@ -640,3 +646,7 @@ dispatch the one registered list-search action on supported lists.
|
|||
Equipment Detail lists V2 verified exercise options and confidence within the
|
||||
72x20 layout. The generated core also exposes bounded reverse and BODY ZONE
|
||||
queries without changing Body Focus geometry or session generation.
|
||||
Completed-session detail also consults synchronized exercise feedback and the
|
||||
session follow-up timeline read-only. Exercise labels use `Pendant la séance`,
|
||||
`H+xx`, or neutral `Ressenti`; follow-ups use `H+xx` or `H+?`. The TUI never
|
||||
renders a negative H+ and has no V1 feedback mutation.
|
||||
|
|
|
|||
33
tests/test_training_feedback_sync.py
Normal file
33
tests/test_training_feedback_sync.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Real desktop migration and append-only feedback companion regression."""
|
||||
import json,sqlite3,subprocess,tempfile
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]; BUILD=ROOT/"build/tui/trainlog"
|
||||
def run(*a):return subprocess.run(a,cwd=ROOT,text=True,capture_output=True,check=True)
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
d=Path(td);db=d/"feedback.db"
|
||||
con=sqlite3.connect(db);con.executescript("""PRAGMA foreign_keys=ON;
|
||||
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,science_state TEXT);
|
||||
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT REFERENCES exercises(exercise_id));
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type 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);
|
||||
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT UNIQUE,session_exercise_row_id INTEGER REFERENCES session_exercises(id),observed_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 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_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id TEXT REFERENCES session_followups(followup_id) ON DELETE CASCADE,created_at TEXT,raw_text TEXT);
|
||||
PRAGMA user_version=15;""");assert con.execute("pragma user_version").fetchone()[0]==15
|
||||
con.execute("insert into exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,science_state) values('ex_11111111-1111-4111-8111-111111111111','Plate Loaded Leg Press','fixture press','reps','sets',0,'unresolved')")
|
||||
con.execute("insert into sessions(session_id,started_at,ended_at,session_type) values('se_11111111-1111-4111-8111-111111111111','2026-09-01T09:00:00+02:00','2026-09-01T10:00:00+02:00','training')")
|
||||
con.execute("insert into session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds) values('sxe_fixture',(select id from sessions),(select id from exercises),'sets',0,0,'none',0)");con.commit();con.close()
|
||||
p={"format":"trainlog-training-feedback","version":1,"generated_at":"2026-09-01T18:00:00+02:00","exercise_feedback":[{"feedback_id":"fb_22222222-2222-4222-8222-222222222222","session_id":"se_11111111-1111-4111-8111-111111111111","entry_id":"sxe_fixture","exercise_id":"ex_11111111-1111-4111-8111-111111111111","observed_at":"2026-09-01T10:01:00+02:00","raw_text":"ressenti synthétique"}],"session_followups":[{"followup_id":"fu_33333333-3333-4333-8333-333333333333","session_id":"se_11111111-1111-4111-8111-111111111111","observed_at":"2026-09-01T18:00:00+02:00","raw_text":"suivi A"}]};artifact=d/"in.json";artifact.write_text(json.dumps(p),encoding="utf8")
|
||||
assert "PASS" in run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout
|
||||
assert "roots_skipped=2" in run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout
|
||||
p["session_followups"].append({"followup_id":"fu_44444444-4444-4444-8444-444444444444","session_id":p["session_followups"][0]["session_id"],"observed_at":"2026-09-02T09:00:00+02:00","raw_text":"suivi B"});artifact.write_text(json.dumps(p),encoding="utf8");run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db)
|
||||
out=d/"out.json";run("python",ROOT/"tools/export_training_feedback.py",out,"--database",db);x=json.loads(out.read_text());assert x["version"]==2 and len(x["exercise_feedback"])==1 and len(x["session_followups"])==2
|
||||
revision={"revision_id":"fr_55555555-5555-4555-8555-555555555555","created_at":"2026-09-03T10:00:00+02:00","raw_text":"ressenti corrigé"};x["exercise_feedback"][0]["revisions"].append(revision);artifact.write_text(json.dumps(x),encoding="utf8")
|
||||
run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db);again=run("python",ROOT/"tools/import_training_feedback.py",artifact,"--database",db).stdout;assert "revisions_added=0" in again
|
||||
con=sqlite3.connect(db);assert con.execute("select count(*) from exercise_feedback_revisions").fetchone()[0]==2;assert con.execute("select raw_text from exercise_feedback").fetchone()[0]=="ressenti corrigé";con.close()
|
||||
x["exercise_feedback"][0]["revisions"][-1]["raw_text"]="conflit";artifact.write_text(json.dumps(x),encoding="utf8");bad=subprocess.run(["python",str(ROOT/"tools/import_training_feedback.py"),str(artifact),"--database",str(db)],capture_output=True);assert bad.returncode!=0
|
||||
print("TRAINING_FEEDBACK_SYNC_TEST=PASS")
|
||||
if __name__=="__main__":main()
|
||||
|
|
@ -27,7 +27,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||
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")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ def main():
|
|||
connection = sqlite3.connect(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||
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")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def main():
|
|||
args = parser.parse_args()
|
||||
con = sqlite3.connect(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v12/v13 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def main():
|
|||
connection = sqlite3.connect(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
||||
raise ValueError("schema desktop v11/v12/v13 requis")
|
||||
exercises = []
|
||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||
|
|
|
|||
|
|
@ -67,7 +67,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):
|
||||
if version not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in (11, 12, 13) and not (args.version == 2 and schema_version == 10):
|
||||
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)")
|
||||
known_equipment = supplied_equipment_ids()
|
||||
canonical_names = load_exercise_names()
|
||||
|
|
|
|||
31
tools/export_training_feedback.py
Normal file
31
tools/export_training_feedback.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the bounded, direction-neutral TRAINING_FEEDBACK_V2 companion."""
|
||||
import argparse, datetime, json, sqlite3
|
||||
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)
|
||||
try:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 15: raise ValueError("schema desktop v15 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")
|
||||
exercise=[]
|
||||
for row in roots:
|
||||
revisions=db.execute("SELECT revision_id,created_at,raw_text FROM exercise_feedback_revisions WHERE feedback_id=?",(row[0],)).fetchall()
|
||||
revisions.sort(key=lambda r:(instant(r[1]),r[0].encode()))
|
||||
exercise.append(dict(zip(("feedback_id","session_id","entry_id","exercise_id","observed_at"),row)) | {"revisions":[dict(zip(("revision_id","created_at","raw_text"),r)) for r in revisions]})
|
||||
result_followups=[]
|
||||
for row in followups:
|
||||
revisions=db.execute("SELECT revision_id,created_at,raw_text FROM session_followup_revisions WHERE followup_id=?",(row[0],)).fetchall()
|
||||
revisions.sort(key=lambda r:(instant(r[1]),r[0].encode()))
|
||||
result_followups.append(dict(zip(("followup_id","session_id","observed_at"),row)) | {"revisions":[dict(zip(("revision_id","created_at","raw_text"),r)) for r in revisions]})
|
||||
exercise.sort(key=lambda v:(instant(v["observed_at"]),v["feedback_id"].encode())); result_followups.sort(key=lambda v:(instant(v["observed_at"]),v["followup_id"].encode()))
|
||||
payload={"format":"trainlog-training-feedback","version":2,"generated_at":datetime.datetime.now().astimezone().isoformat(),"exercise_feedback":exercise,"session_followups":result_followups}
|
||||
args.output.write_text(json.dumps(payload,ensure_ascii=False,separators=(",",":")),encoding="utf-8")
|
||||
finally: db.close()
|
||||
print(f"TRAINING_FEEDBACK_EXPORT=PASS version=2 exercise={len(exercise)} followups={len(result_followups)}")
|
||||
if __name__=="__main__": main()
|
||||
|
|
@ -172,7 +172,7 @@ def main():
|
|||
fail("clés extension équipement invalides")
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
fail("schema desktop v8 à v13 requis")
|
||||
known = load_catalog(args.catalog)
|
||||
known.update(row[0] for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ def main():
|
|||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13, 14, 15):
|
||||
fail("schema desktop v8 à v13 requis")
|
||||
imported = skipped = 0
|
||||
# Validate every same-ID row before inserting any definition.
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ def main():
|
|||
con = sqlite3.connect(args.database)
|
||||
try:
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
|
||||
fail("schema desktop v12/v13 requis")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13, 14, 15):
|
||||
fail("schema desktop v12/v13/v14/v15 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()
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ def main():
|
|||
connection = sqlite3.connect(args.database)
|
||||
updated = skipped = kept_local = 0
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13, 14, 15):
|
||||
raise ImportFailure("schema desktop v11/v12/v13 requis")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
|
|
|
|||
|
|
@ -820,9 +820,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):
|
||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v13 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v15 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
86
tools/import_training_feedback.py
Normal file
86
tools/import_training_feedback.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict V1-compatible, append-only TRAINING_FEEDBACK_V2 desktop merger."""
|
||||
import argparse,json,re,sqlite3
|
||||
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}$")
|
||||
FB,FU,FR=ID("fb_"),ID("fu_"),ID("fr_")
|
||||
def fail(message): raise ValueError(message)
|
||||
def instant(value):
|
||||
if not isinstance(value,str) or "T" not in value or not re.search(r"(?:Z|[+-]\d\d:\d\d)$",value): fail("timestamp offset-aware invalide")
|
||||
try:return datetime.fromisoformat(value.replace("Z","+00:00"))
|
||||
except ValueError:fail("timestamp invalide")
|
||||
def valid_text(value):
|
||||
if not isinstance(value,str) or not value.strip() or len(value.encode())>8192: fail("texte vide ou supérieur à 8192 octets UTF-8")
|
||||
def load(path):
|
||||
if path.stat().st_size>40*1024*1024:fail("artifact trop volumineux")
|
||||
def unique(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
if key in result:fail("champ JSON dupliqué: "+key)
|
||||
result[key]=value
|
||||
return result
|
||||
root=json.loads(path.read_text(encoding="utf-8"),object_pairs_hook=unique)
|
||||
if set(root)!={"format","version","generated_at","exercise_feedback","session_followups"} or root["format"]!="trainlog-training-feedback" or type(root["version"]) is not int or root["version"] not in (1,2):fail("artifact feedback v1/v2 invalide")
|
||||
instant(root["generated_at"])
|
||||
if not isinstance(root["exercise_feedback"],list) or not isinstance(root["session_followups"],list) or len(root["exercise_feedback"])>4096 or len(root["session_followups"])>4096:fail("tableau feedback hors borne")
|
||||
return root
|
||||
def revision_list(item,root_id,version):
|
||||
if version==1: valid_text(item["raw_text"]); return [("fr0_"+root_id,item["observed_at"],item["raw_text"])]
|
||||
values=item["revisions"]
|
||||
if not isinstance(values,list) or not values or len(values)>4096:fail("révisions absentes/hors borne")
|
||||
result=[];seen=set()
|
||||
for revision in values:
|
||||
if not isinstance(revision,dict) or set(revision)!={"revision_id","created_at","raw_text"}:fail("révision invalide")
|
||||
rid=revision["revision_id"]
|
||||
if not isinstance(rid,str) or not (FR.fullmatch(rid) or rid=="fr0_"+root_id) or rid in seen:fail("revision_id invalide/dupliqué")
|
||||
seen.add(rid);instant(revision["created_at"]);valid_text(revision["raw_text"]);result.append((rid,revision["created_at"],revision["raw_text"]))
|
||||
return result
|
||||
def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||
added=skipped=0
|
||||
for revision in revisions:
|
||||
old=db.execute(f"SELECT {parent_column},created_at,raw_text FROM {table} WHERE revision_id=?",(revision[0],)).fetchone(); expected=(parent_id,revision[1],revision[2])
|
||||
if old is None:db.execute(f"INSERT INTO {table}(revision_id,{parent_column},created_at,raw_text) VALUES(?,?,?,?)",(revision[0],*expected));added+=1
|
||||
elif old==expected:skipped+=1
|
||||
else:fail("HARD CONFLICT "+revision[0])
|
||||
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)
|
||||
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")
|
||||
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"}
|
||||
if not isinstance(item,dict) or set(item)!=keys:fail(f"exercise_feedback[{index}] invalide")
|
||||
fid=item["feedback_id"]
|
||||
if not isinstance(fid,str) or not FB.fullmatch(fid) or fid in seen:fail("feedback_id invalide/dupliqué")
|
||||
seen.add(fid);instant(item["observed_at"]);occurrence=db.execute("SELECT se.id,e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?",(item["session_id"],item["entry_id"])).fetchone()
|
||||
if not occurrence:fail("session/entry introuvable")
|
||||
canonical=db.execute("SELECT exercise_id FROM exercises WHERE exercise_id=? UNION ALL SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=? LIMIT 1",(item["exercise_id"],item["exercise_id"])).fetchone()
|
||||
if not canonical or canonical[0]!=occurrence[1]:fail("exercise_id incompatible avec l'occurrence")
|
||||
old=db.execute("SELECT session_exercise_row_id,observed_at FROM exercise_feedback WHERE feedback_id=?",(fid,)).fetchone();expected=(occurrence[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
||||
if old is None:db.execute("INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
||||
elif old==expected:roots_skipped+=1
|
||||
else:fail("HARD CONFLICT "+fid)
|
||||
added,skipped,current=merge_revisions(db,"exercise_feedback_revisions","feedback_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE exercise_feedback SET raw_text=? WHERE feedback_id=?",(current,fid))
|
||||
seen=set()
|
||||
for index,item in enumerate(root["session_followups"]):
|
||||
keys={"followup_id","session_id","observed_at","raw_text"} if root["version"]==1 else {"followup_id","session_id","observed_at","revisions"}
|
||||
if not isinstance(item,dict) or set(item)!=keys:fail(f"session_followups[{index}] invalide")
|
||||
fid=item["followup_id"]
|
||||
if not isinstance(fid,str) or not FU.fullmatch(fid) or fid in seen:fail("followup_id invalide/dupliqué")
|
||||
seen.add(fid);instant(item["observed_at"]);session=db.execute("SELECT id FROM sessions WHERE session_id=?",(item["session_id"],)).fetchone()
|
||||
if not session:fail("session introuvable")
|
||||
old=db.execute("SELECT session_row_id,observed_at FROM session_followups WHERE followup_id=?",(fid,)).fetchone();expected=(session[0],item["observed_at"]);revisions=revision_list(item,fid,root["version"])
|
||||
if old is None:db.execute("INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) VALUES(?,?,?,?)",(fid,*expected,revisions[0][2]));roots_added+=1
|
||||
elif old==expected:roots_skipped+=1
|
||||
else:fail("HARD CONFLICT "+fid)
|
||||
added,skipped,current=merge_revisions(db,"session_followup_revisions","followup_id",fid,revisions);revisions_added+=added;revisions_skipped+=skipped;db.execute("UPDATE session_followups SET raw_text=? WHERE followup_id=?",(current,fid))
|
||||
db.commit()
|
||||
except Exception:db.rollback();raise
|
||||
finally:db.close()
|
||||
print(f"TRAINING_FEEDBACK_IMPORT=PASS version={root['version']} roots_added={roots_added} roots_skipped={roots_skipped} revisions_added={revisions_added} revisions_skipped={revisions_skipped}")
|
||||
if __name__=="__main__":main()
|
||||
|
|
@ -11,10 +11,27 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 13
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 15
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
#define TRAINLOG_FEEDBACK_TEXT_MAX 8192U
|
||||
#define TRAINLOG_FEEDBACK_VIEW_MAX 4096U
|
||||
typedef struct TrainlogFeedbackView {
|
||||
char stable_id[TRAINLOG_ID_MAX + 1U];
|
||||
char entry_id[TRAINLOG_ID_MAX + 1U]; /* empty for session follow-up */
|
||||
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
char raw_text[TRAINLOG_FEEDBACK_TEXT_MAX + 1U];
|
||||
} TrainlogFeedbackView;
|
||||
|
||||
/* CONTRACT: read-only chronological consultation; Android remains the sole
|
||||
* V1 creator. Ties use the stable ID byte order. No SQLite resources escape. */
|
||||
TrainlogStatus trainlog_database_list_training_feedback(
|
||||
TrainlogDatabase *database, const char *session_id,
|
||||
TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity,
|
||||
size_t *exercise_count, TrainlogFeedbackView *followups,
|
||||
size_t followup_capacity, size_t *followup_count);
|
||||
|
||||
/* Nestable read savepoints let composition services observe one database state
|
||||
* without exposing SQLite or issuing writes to application data. */
|
||||
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database);
|
||||
|
|
@ -633,9 +650,11 @@ TrainlogStatus trainlog_database_load_session_editable(
|
|||
* @brief Replace only the exercise/set contents of an existing session.
|
||||
*
|
||||
* The session row itself is preserved, so session_id, timestamps, session type,
|
||||
* session notes and body_observations.session_row_id remain attached to the
|
||||
* same row. The replacement is atomic: any error rolls the whole operation
|
||||
* back.
|
||||
* session notes, session follow-ups and body_observations.session_row_id remain
|
||||
* attached to the same row. Exercise feedback is remapped only by retained
|
||||
* entry_id within that session; removed entries cascade and new entries never
|
||||
* inherit feedback. The replacement is atomic: any error rolls the whole
|
||||
* operation back.
|
||||
*/
|
||||
TrainlogStatus trainlog_database_replace_session_exercises(
|
||||
TrainlogDatabase *database,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "trainlog/status.h"
|
||||
|
||||
|
|
@ -20,4 +21,11 @@ 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. */
|
||||
TrainlogStatus trainlog_feedback_relative_label(
|
||||
const char *ended_at, const char *observed_at, bool exercise_feedback,
|
||||
char *output, size_t output_size);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -558,6 +558,12 @@ test(
|
|||
args: [meson.project_source_root() / 'tests/test_exercise_alias_import.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'training_feedback_sync',
|
||||
python3_trainlog_tests,
|
||||
args: [meson.project_source_root() / 'tests/test_training_feedback_sync.py'],
|
||||
)
|
||||
|
||||
test_sync_direction = executable(
|
||||
'test_sync_direction',
|
||||
'tests/test_sync_direction.c',
|
||||
|
|
@ -652,6 +658,22 @@ test_tui_workflows = executable(
|
|||
|
||||
test('tui_workflows', test_tui_workflows)
|
||||
|
||||
test_feedback_labels = executable(
|
||||
'test_feedback_labels',
|
||||
'tests/test_feedback_labels.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('feedback_labels', test_feedback_labels)
|
||||
|
||||
test_feedback_correction = executable(
|
||||
'test_feedback_correction',
|
||||
'tests/test_feedback_correction.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('feedback_correction', test_feedback_correction)
|
||||
|
||||
test_app_shell = executable(
|
||||
'test_app_shell',
|
||||
'tests/test_app_shell.c',
|
||||
|
|
|
|||
|
|
@ -566,6 +566,47 @@ static const char *const MIGRATE_V12_TO_V13_SQL_C =
|
|||
"UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';"
|
||||
"PRAGMA user_version=13;COMMIT;";
|
||||
|
||||
/* WHY: subjective observations have their own immutable chronology and must
|
||||
* never be encoded as performed sets, notes, or inferred exercise metadata.
|
||||
* CONTRACT: v14 is additive; exercise feedback belongs to one durable
|
||||
* occurrence and follow-up belongs only to its session. Text byte bounds are
|
||||
* enforced by import/application APIs so SQLite never silently truncates.
|
||||
* INVARIANT: every v13 row and identity remains byte-for-byte untouched. */
|
||||
static const char *const MIGRATE_V13_TO_V14_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"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 CHECK(length(trim(raw_text))>0));"
|
||||
"CREATE INDEX exercise_feedback_timeline ON exercise_feedback(session_exercise_row_id,observed_at,feedback_id);"
|
||||
"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 CHECK(length(trim(raw_text))>0));"
|
||||
"CREATE INDEX session_followups_timeline ON session_followups(session_row_id,observed_at,followup_id);"
|
||||
"PRAGMA user_version=14;COMMIT;";
|
||||
|
||||
/* WHY: wording corrections must not overwrite the original wording.
|
||||
* CONTRACT: v14 root raw_text remains a transactionally maintained current
|
||||
* cache; immutable revisions are the history source of truth. INVARIANT:
|
||||
* fr0_<root-id> is deterministic across peers and migration/V1 import is
|
||||
* idempotent. */
|
||||
static const char *const MIGRATE_V14_TO_V15_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE exercise_feedback_revisions("
|
||||
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,"
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
||||
"CREATE INDEX exercise_feedback_revisions_current ON exercise_feedback_revisions(feedback_id,created_at,revision_id);"
|
||||
"CREATE TABLE session_followup_revisions("
|
||||
"revision_id TEXT PRIMARY KEY,followup_id TEXT NOT NULL REFERENCES session_followups(followup_id) ON DELETE CASCADE,"
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL CHECK(length(trim(raw_text))>0));"
|
||||
"CREATE INDEX session_followup_revisions_current ON session_followup_revisions(followup_id,created_at,revision_id);"
|
||||
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) "
|
||||
"SELECT 'fr0_'||feedback_id,feedback_id,observed_at,raw_text FROM exercise_feedback;"
|
||||
"INSERT INTO session_followup_revisions(revision_id,followup_id,created_at,raw_text) "
|
||||
"SELECT 'fr0_'||followup_id,followup_id,observed_at,raw_text FROM session_followups;"
|
||||
"PRAGMA user_version=15;COMMIT;";
|
||||
|
||||
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"ALTER TABLE sessions "
|
||||
|
|
@ -986,6 +1027,80 @@ static TrainlogStatus read_single_int_pragma(
|
|||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus list_feedback_query(
|
||||
TrainlogDatabase *database, const char *sql, const char *session_id,
|
||||
TrainlogFeedbackView *output, size_t capacity, size_t *output_count,
|
||||
bool has_entry)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
int rc;
|
||||
if (database == NULL || sql == NULL || session_id == NULL || output == NULL ||
|
||||
output_count == NULL || capacity == 0U) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
|
||||
if (rc != SQLITE_OK || sqlite3_bind_text(statement, 1, session_id, -1,
|
||||
SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
while (count < capacity && (rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
const unsigned char *stable = sqlite3_column_text(statement, 0);
|
||||
const unsigned char *entry = has_entry ? sqlite3_column_text(statement, 1) : NULL;
|
||||
const unsigned char *at = sqlite3_column_text(statement, has_entry ? 2 : 1);
|
||||
const unsigned char *text = sqlite3_column_text(statement, has_entry ? 3 : 2);
|
||||
if (stable == NULL || at == NULL || text == NULL ||
|
||||
strlen((const char *)text) > TRAINLOG_FEEDBACK_TEXT_MAX) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
(void)snprintf(output[count].stable_id, sizeof(output[count].stable_id), "%s", stable);
|
||||
(void)snprintf(output[count].entry_id, sizeof(output[count].entry_id), "%s",
|
||||
entry != NULL ? (const char *)entry : "");
|
||||
(void)snprintf(output[count].observed_at, sizeof(output[count].observed_at), "%s", at);
|
||||
(void)snprintf(output[count].raw_text, sizeof(output[count].raw_text), "%s", text);
|
||||
count += 1U;
|
||||
}
|
||||
if (rc != SQLITE_DONE && count < capacity) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
rc = sqlite3_finalize(statement); *output_count = count;
|
||||
return rc == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static int compare_feedback_view(const void *left_value, const void *right_value)
|
||||
{
|
||||
const TrainlogFeedbackView *left = left_value;
|
||||
const TrainlogFeedbackView *right = right_value;
|
||||
TrainlogTimestampKey left_key, right_key;
|
||||
int compared;
|
||||
if (!trainlog_timestamp_parse(left->observed_at, strlen(left->observed_at), &left_key) ||
|
||||
!trainlog_timestamp_parse(right->observed_at, strlen(right->observed_at), &right_key))
|
||||
return strcmp(left->stable_id, right->stable_id);
|
||||
compared = trainlog_timestamp_compare(&left_key, &right_key);
|
||||
return compared != 0 ? compared : strcmp(left->stable_id, right->stable_id);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_list_training_feedback(
|
||||
TrainlogDatabase *database, const char *session_id,
|
||||
TrainlogFeedbackView *exercise_feedback, size_t exercise_capacity,
|
||||
size_t *exercise_count, TrainlogFeedbackView *followups,
|
||||
size_t followup_capacity, size_t *followup_count)
|
||||
{
|
||||
TrainlogStatus status = list_feedback_query(database,
|
||||
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text 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 "
|
||||
"WHERE s.session_id=?1 ORDER BY f.observed_at COLLATE BINARY,f.feedback_id COLLATE BINARY",
|
||||
session_id, exercise_feedback, exercise_capacity, exercise_count, true);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
qsort(exercise_feedback, *exercise_count, sizeof(*exercise_feedback), compare_feedback_view);
|
||||
status = list_feedback_query(database,
|
||||
"SELECT f.followup_id,f.observed_at,f.raw_text FROM session_followups f "
|
||||
"JOIN sessions s ON s.id=f.session_row_id WHERE s.session_id=?1 "
|
||||
"ORDER BY f.observed_at COLLATE BINARY,f.followup_id COLLATE BINARY",
|
||||
session_id, followups, followup_capacity, followup_count, false);
|
||||
if (status == TRAINLOG_STATUS_OK)
|
||||
qsort(followups, *followup_count, sizeof(*followups), compare_feedback_view);
|
||||
return status;
|
||||
}
|
||||
|
||||
static TrainlogStatus initialize_or_validate_schema(
|
||||
TrainlogDatabase *database,
|
||||
char *output_diagnostic,
|
||||
|
|
@ -1082,7 +1197,7 @@ 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) {
|
||||
} else if (version == 11 || version == 12 || version == 13 || version == 14) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -1231,6 +1346,12 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_C);
|
||||
}
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 14) {
|
||||
status = execute_sql(database, MIGRATE_V13_TO_V14_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 15) {
|
||||
status = execute_sql(database, MIGRATE_V14_TO_V15_SQL);
|
||||
}
|
||||
|
||||
if (
|
||||
status !=
|
||||
|
|
@ -1239,7 +1360,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
|||
set_open_diagnostic(
|
||||
output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v13" : "migrate database to schema v13",
|
||||
version == 0 ? "create schema v15" : "migrate database to schema v15",
|
||||
database->connection,
|
||||
SQLITE_ERROR
|
||||
);
|
||||
|
|
@ -3723,6 +3844,63 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
/* WHY: replacement reconstructs internal occurrence rows, while feedback
|
||||
* belongs to the stable logical occurrence. CONTRACT: retain immutable
|
||||
* feedback only when the same entry_id remains inside this same session;
|
||||
* removed entries lose their parent-owned rows and new entries inherit
|
||||
* nothing. INVARIANT: the temporary snapshot and reattachment are inside
|
||||
* this transaction, so every failure restores the original graph. */
|
||||
rc = sqlite3_exec(
|
||||
database->connection,
|
||||
"CREATE TEMP TABLE trainlog_correction_feedback("
|
||||
"feedback_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL,"
|
||||
"observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);",
|
||||
NULL, NULL, NULL
|
||||
);
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"INSERT INTO trainlog_correction_feedback"
|
||||
"(feedback_id,entry_id,observed_at,raw_text) "
|
||||
"SELECT f.feedback_id,se.entry_id,f.observed_at,f.raw_text "
|
||||
"FROM exercise_feedback AS f "
|
||||
"JOIN session_exercises AS se "
|
||||
"ON se.id=f.session_exercise_row_id "
|
||||
"WHERE se.session_row_id=?1;",
|
||||
-1, &statement, NULL
|
||||
);
|
||||
}
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
||||
}
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
rc == SQLITE_DONE) {
|
||||
rc = SQLITE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
if (rc != SQLITE_DONE) {
|
||||
(void)trainlog_database_rollback(database);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
rc = sqlite3_exec(
|
||||
database->connection,
|
||||
"CREATE TEMP TABLE trainlog_correction_feedback_revisions("
|
||||
"revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL,"
|
||||
"created_at TEXT NOT NULL,raw_text TEXT NOT NULL);"
|
||||
"INSERT INTO trainlog_correction_feedback_revisions "
|
||||
"SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text "
|
||||
"FROM exercise_feedback_revisions r JOIN trainlog_correction_feedback f "
|
||||
"ON f.feedback_id=r.feedback_id;",
|
||||
NULL, NULL, NULL
|
||||
);
|
||||
if (rc != SQLITE_OK) {
|
||||
(void)trainlog_database_rollback(database);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
rc = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"DELETE FROM session_exercises "
|
||||
|
|
@ -3770,6 +3948,47 @@ TrainlogStatus trainlog_database_replace_session_exercises(
|
|||
return status;
|
||||
}
|
||||
|
||||
rc = sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"INSERT INTO exercise_feedback"
|
||||
"(feedback_id,session_exercise_row_id,observed_at,raw_text) "
|
||||
"SELECT saved.feedback_id,se.id,saved.observed_at,saved.raw_text "
|
||||
"FROM trainlog_correction_feedback AS saved "
|
||||
"JOIN session_exercises AS se ON se.entry_id=saved.entry_id "
|
||||
"WHERE se.session_row_id=?1;",
|
||||
-1, &statement, NULL
|
||||
);
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_bind_int64(statement, 1, session_row_id);
|
||||
}
|
||||
if (rc == SQLITE_OK) {
|
||||
rc = sqlite3_step(statement);
|
||||
}
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
|
||||
rc == SQLITE_DONE) {
|
||||
rc = SQLITE_ERROR;
|
||||
}
|
||||
statement = NULL;
|
||||
if (rc == SQLITE_DONE) {
|
||||
rc = sqlite3_exec(
|
||||
database->connection,
|
||||
"INSERT INTO exercise_feedback_revisions(revision_id,feedback_id,created_at,raw_text) "
|
||||
"SELECT r.revision_id,r.feedback_id,r.created_at,r.raw_text "
|
||||
"FROM trainlog_correction_feedback_revisions r "
|
||||
"JOIN exercise_feedback f ON f.feedback_id=r.feedback_id;",
|
||||
NULL, NULL, NULL
|
||||
);
|
||||
}
|
||||
if (rc != SQLITE_OK || sqlite3_exec(
|
||||
database->connection,
|
||||
"DROP TABLE trainlog_correction_feedback_revisions;"
|
||||
"DROP TABLE trainlog_correction_feedback;",
|
||||
NULL, NULL, NULL
|
||||
) != SQLITE_OK) {
|
||||
(void)trainlog_database_rollback(database);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
status = trainlog_database_commit(database);
|
||||
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ static const char *const EXERCISE_BODY_ZONES_NAME =
|
|||
|
||||
static const char *const EXERCISE_ALIASES_NAME =
|
||||
"trainlog-exercise-aliases-v1.json";
|
||||
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 SYNC_REQUEST_NAME =
|
||||
"trainlog-sync-request-v1.json";
|
||||
|
|
@ -90,12 +94,16 @@ static const char *const EXERCISE_BODY_ZONES_LOCAL =
|
|||
|
||||
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_BODY_ZONES_RESULT =
|
||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
||||
|
||||
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 SYNC_REQUEST_LOCAL =
|
||||
"/tmp/trainlog-sync-request-v1.json";
|
||||
|
|
@ -3094,6 +3102,36 @@ TrainlogStatus trainlog_sync_run(
|
|||
goto finalize;
|
||||
}
|
||||
|
||||
/* CONTRACT: feedback references are resolved only after aliases,
|
||||
* catalogue/session snapshots, and occurrence equipment reconciliation. */
|
||||
status = sync_receive_current_android_artifact(&device, folder_id,
|
||||
TRAINING_FEEDBACK_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size);
|
||||
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||
/* V2 is authoritative, but a deployed V1-only peer remains readable.
|
||||
* The importer turns its immutable raw_text into fr0_<root-id> and
|
||||
* cannot erase locally known revisions. */
|
||||
status = sync_receive_current_android_artifact(&device, folder_id,
|
||||
TRAINING_FEEDBACK_V1_NAME, TRAINING_FEEDBACK_LOCAL, &ignored_size);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = sync_run_python_tool("import_training_feedback.py",
|
||||
TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT,
|
||||
tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_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 : ressentis : ", 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 ressentis échouée.");
|
||||
final_status = status;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
||||
output->success = true;
|
||||
final_status = TRAINLOG_STATUS_OK;
|
||||
|
|
@ -3120,6 +3158,24 @@ outbound:
|
|||
goto finalize;
|
||||
}
|
||||
|
||||
status = sync_run_python_tool("export_training_feedback.py",
|
||||
TRAINING_FEEDBACK_LOCAL, database_path, NULL, TRAINING_FEEDBACK_RESULT,
|
||||
tool_output, sizeof(tool_output));
|
||||
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "TRAINING_FEEDBACK_EXPORT=PASS") == NULL) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : export ressentis échoué.");
|
||||
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
goto finalize;
|
||||
}
|
||||
status = sync_publish_named(&device, folder_id, TRAINING_FEEDBACK_LOCAL,
|
||||
TRAINING_FEEDBACK_NAME);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
(void)snprintf(output->error, sizeof(output->error),
|
||||
"PC→Android : publication ressentis échouée.");
|
||||
final_status = status;
|
||||
goto finalize;
|
||||
}
|
||||
|
||||
status = sync_run_python_tool("export_equipment_definitions.py",
|
||||
PC_EQUIPMENT_DEFINITIONS_LOCAL,
|
||||
database_path,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "timestamp.h"
|
||||
|
||||
TrainlogStatus trainlog_time_now_rfc3339(
|
||||
char *output,
|
||||
|
|
@ -74,3 +77,30 @@ TrainlogStatus trainlog_time_now_rfc3339(
|
|||
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_feedback_relative_label(
|
||||
const char *ended_at, const char *observed_at, bool exercise_feedback,
|
||||
char *output, size_t output_size)
|
||||
{
|
||||
TrainlogTimestampKey ended, 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);
|
||||
} else if (observed.utc_second < ended.utc_second) {
|
||||
written = snprintf(output, output_size, "%s",
|
||||
exercise_feedback ? "Pendant la séance" : "H+?");
|
||||
} else {
|
||||
elapsed = observed.utc_second - ended.utc_second;
|
||||
written = snprintf(output, output_size, "H+%lld",
|
||||
(long long)(elapsed / INT64_C(3600)));
|
||||
}
|
||||
return written >= 0 && (size_t)written < output_size
|
||||
? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
|
|
|
|||
153
tui/src/tui.c
153
tui/src/tui.c
|
|
@ -2356,6 +2356,7 @@ typedef enum TrainlogSessionPhase {
|
|||
TRAINLOG_SESSION_GENERATOR_WARNING,
|
||||
TRAINLOG_SESSION_GENERATOR_PREVIEW,
|
||||
TRAINLOG_SESSION_CONFIRM_REMOVE,
|
||||
TRAINLOG_SESSION_CONFIRM_SET_REMOVE,
|
||||
TRAINLOG_SESSION_CONFIRM_ABANDON,
|
||||
TRAINLOG_SESSION_CONFIRM_LEAVE,
|
||||
TRAINLOG_SESSION_MESSAGE
|
||||
|
|
@ -2393,6 +2394,7 @@ typedef struct TrainlogSessionController {
|
|||
bool generation_warning_acknowledged;
|
||||
bool correcting;
|
||||
bool replacing_occurrence;
|
||||
bool destructive_confirm_selected;
|
||||
TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES];
|
||||
size_t draft_count;
|
||||
size_t selected;
|
||||
|
|
@ -2428,6 +2430,19 @@ typedef struct TrainlogSessionController {
|
|||
TrainlogDurabilityState durability;
|
||||
} TrainlogSessionController;
|
||||
|
||||
/* WHY: compiler-visible source buffers are wider than the shell route field.
|
||||
* CONTRACT: stable identities are copied whole or rejected, never truncated.
|
||||
* INVARIANT: a failed copy leaves an empty, terminated destination. */
|
||||
static bool copy_ui_stable_id(char *output, size_t capacity, const char *value)
|
||||
{
|
||||
size_t length;
|
||||
if (output == NULL || capacity == 0U || value == NULL) return false;
|
||||
length = strlen(value);
|
||||
if (length >= capacity) { output[0] = '\0'; return false; }
|
||||
(void)memcpy(output, value, length + 1U);
|
||||
return true;
|
||||
}
|
||||
|
||||
typedef enum TrainlogEquipmentPhase {
|
||||
TRAINLOG_EQUIPMENT_IDLE = 0,
|
||||
TRAINLOG_EQUIPMENT_NAME,
|
||||
|
|
@ -2700,6 +2715,10 @@ typedef struct TrainlogAppContext {
|
|||
size_t session_entry_selected;
|
||||
size_t session_set_scroll;
|
||||
bool session_detail_error;
|
||||
TrainlogFeedbackView *exercise_feedback;
|
||||
TrainlogFeedbackView *session_followups;
|
||||
size_t exercise_feedback_count;
|
||||
size_t session_followup_count;
|
||||
TrainlogBodyObservationRecord body_records[MAX_BODY_OBSERVATIONS];
|
||||
TrainlogBodyObservationRecord body_detail;
|
||||
TrainlogBodyMetricPoint body_metric_points[MAX_BODY_METRIC_POINTS];
|
||||
|
|
@ -4445,8 +4464,8 @@ static bool equipment_controller_save(TrainlogAppContext *app)
|
|||
(void)snprintf(controller->message, sizeof(controller->message),
|
||||
"Équipement personnel créé : %.120s", controller->pending.display_name);
|
||||
app_shell_refresh_list(app);
|
||||
(void)snprintf(app->list.selected_id, sizeof(app->list.selected_id), "%s",
|
||||
controller->pending.equipment_id);
|
||||
if (!copy_ui_stable_id(app->list.selected_id, sizeof(app->list.selected_id),
|
||||
controller->pending.equipment_id)) return false;
|
||||
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
|
||||
app->list.visible_rows, app->list.total_known, app->list.more_available);
|
||||
return true;
|
||||
|
|
@ -4750,8 +4769,8 @@ static bool exercise_controller_save(TrainlogAppContext *app)
|
|||
}
|
||||
else {
|
||||
app_shell_refresh_list(app);
|
||||
(void)snprintf(app->list.selected_id, sizeof(app->list.selected_id),
|
||||
"%s", created.exercise_id);
|
||||
if (!copy_ui_stable_id(app->list.selected_id,
|
||||
sizeof(app->list.selected_id), created.exercise_id)) return false;
|
||||
trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count,
|
||||
app->list.visible_rows, app->list.total_known,
|
||||
app->list.more_available);
|
||||
|
|
@ -4952,6 +4971,12 @@ static void app_shell_release_session_detail(TrainlogAppContext *app)
|
|||
app->session_entry_count);
|
||||
(void)memset(app->session_entries, 0, sizeof(app->session_entries));
|
||||
app->session_entry_count = 0U;
|
||||
free(app->exercise_feedback);
|
||||
free(app->session_followups);
|
||||
app->exercise_feedback = NULL;
|
||||
app->session_followups = NULL;
|
||||
app->exercise_feedback_count = 0U;
|
||||
app->session_followup_count = 0U;
|
||||
}
|
||||
|
||||
static void app_shell_open_session_detail(TrainlogAppContext *app,
|
||||
|
|
@ -4961,6 +4986,23 @@ static void app_shell_open_session_detail(TrainlogAppContext *app,
|
|||
app->session_detail_error = trainlog_database_get_session_details(app->database,
|
||||
session_id, &app->session_detail, app->session_entries,
|
||||
MAX_SESSION_EXERCISES, &app->session_entry_count) != TRAINLOG_STATUS_OK;
|
||||
if (!app->session_detail_error) {
|
||||
/* Resource bound matches the strict companion cardinality. The cache
|
||||
* is detail-owned and released on every navigation replacement. */
|
||||
app->exercise_feedback = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
|
||||
sizeof(*app->exercise_feedback));
|
||||
app->session_followups = calloc(TRAINLOG_FEEDBACK_VIEW_MAX,
|
||||
sizeof(*app->session_followups));
|
||||
if (app->exercise_feedback == NULL || app->session_followups == NULL)
|
||||
app->session_detail_error = true;
|
||||
}
|
||||
if (!app->session_detail_error) {
|
||||
app->session_detail_error = trainlog_database_list_training_feedback(
|
||||
app->database, session_id, app->exercise_feedback,
|
||||
TRAINLOG_FEEDBACK_VIEW_MAX, &app->exercise_feedback_count,
|
||||
app->session_followups, TRAINLOG_FEEDBACK_VIEW_MAX,
|
||||
&app->session_followup_count) != TRAINLOG_STATUS_OK;
|
||||
}
|
||||
app->session_entry_selected = 0U;
|
||||
app->session_set_scroll = 0U;
|
||||
(void)trainlog_navigation_open(&app->navigation, TRAINLOG_ROUTE_SESSION_DETAIL,
|
||||
|
|
@ -5559,13 +5601,20 @@ static void app_shell_render_session_controller(TrainlogAppContext *app)
|
|||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Retirer cette occurrence de la séance ?");
|
||||
trainlog_surface_printf(app->content, row, 4,
|
||||
"1 confirmer · 0/Échap annuler — le catalogue reste intact");
|
||||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
|
||||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
|
||||
trainlog_surface_printf(app->content, row++, 2, "Supprimer cette série ?");
|
||||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, supprimer",
|
||||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Abandonner la séance en cours ?");
|
||||
trainlog_surface_printf(app->content, row, 4,
|
||||
"1 confirmer · 0/Échap annuler");
|
||||
trainlog_surface_printf(app->content, row, 4, "%s Non %s Oui, abandonner",
|
||||
session->destructive_confirm_selected ? "[ ]" : "[>]",
|
||||
session->destructive_confirm_selected ? "[>]" : "[ ]");
|
||||
} else if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) {
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Proposition non acceptée conservée en mémoire.");
|
||||
|
|
@ -6927,6 +6976,25 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
|||
app->session_entry_count, entry->name);
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Occurrence : %s", entry->entry_id);
|
||||
{
|
||||
size_t feedback_index;
|
||||
bool heading = false;
|
||||
for (feedback_index = 0U;
|
||||
feedback_index < app->exercise_feedback_count &&
|
||||
row < app->layout.content.height; ++feedback_index) {
|
||||
TrainlogFeedbackView *feedback = &app->exercise_feedback[feedback_index];
|
||||
char label[32];
|
||||
if (strcmp(feedback->entry_id, entry->entry_id) != 0) continue;
|
||||
if (!heading) {
|
||||
trainlog_surface_printf(app->content, row++, 2, "RESSENTIS EXERCICES");
|
||||
heading = true;
|
||||
}
|
||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||||
feedback->observed_at, true, label, sizeof(label));
|
||||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||
feedback->raw_text);
|
||||
}
|
||||
}
|
||||
if (entry->equipment_id[0] != '\0')
|
||||
trainlog_surface_printf(app->content, row++, 2,
|
||||
"Équipement : %s (i pour la fiche)", entry->equipment_id);
|
||||
|
|
@ -6970,6 +7038,19 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
|||
}
|
||||
}
|
||||
}
|
||||
if (app->session_followup_count > 0U && row < app->layout.content.height) {
|
||||
size_t followup_index;
|
||||
trainlog_surface_printf(app->content, row++, 2, "SUIVI APRÈS SÉANCE");
|
||||
for (followup_index = 0U; followup_index < app->session_followup_count &&
|
||||
row < app->layout.content.height; ++followup_index) {
|
||||
char label[32];
|
||||
(void)trainlog_feedback_relative_label(app->session_detail.ended_at,
|
||||
app->session_followups[followup_index].observed_at,
|
||||
false, label, sizeof(label));
|
||||
trainlog_surface_printf(app->content, row++, 4, "%s %s", label,
|
||||
app->session_followups[followup_index].raw_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
|
||||
const TrainlogResolvedEquipment *item = &app->equipment_detail;
|
||||
|
|
@ -7785,9 +7866,15 @@ static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key)
|
|||
controller->merge_target.exercise_id);
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
app->exercise_detail = controller->merge_target;
|
||||
(void)snprintf(app->navigation.current.stable_id,
|
||||
sizeof(app->navigation.current.stable_id), "%s",
|
||||
controller->merge_target.exercise_id);
|
||||
if (!copy_ui_stable_id(app->navigation.current.stable_id,
|
||||
sizeof(app->navigation.current.stable_id),
|
||||
controller->merge_target.exercise_id)) {
|
||||
controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT;
|
||||
(void)snprintf(controller->message,
|
||||
sizeof(controller->message),
|
||||
"Fusion terminée; identifiant UI hors borne.");
|
||||
return;
|
||||
}
|
||||
app_shell_load_exercise_detail_metadata(app);
|
||||
(void)snprintf(controller->message, sizeof(controller->message),
|
||||
"Fusion terminée. Cible sélectionnée : %.112s",
|
||||
|
|
@ -8327,22 +8414,41 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
|
|||
return true;
|
||||
}
|
||||
if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) {
|
||||
if (key == '1' && session->selected < session->draft_count) {
|
||||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && session->selected < session->draft_count) {
|
||||
draft_delete_exercise(session->drafts, &session->draft_count,
|
||||
session->selected);
|
||||
if (session->selected >= session->draft_count && session->selected > 0U)
|
||||
--session->selected;
|
||||
session->dirty = true;
|
||||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||||
return true;
|
||||
}
|
||||
if (session->phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE) {
|
||||
TrainlogSessionDraftExercise *draft = &session->drafts[session->selected];
|
||||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected && draft->input.set_count > 0U) {
|
||||
for (size_t index = session->set_selected; index + 1U < draft->input.set_count; ++index)
|
||||
draft->sets[index] = draft->sets[index + 1U];
|
||||
--draft->input.set_count;
|
||||
if (session->set_selected >= draft->input.set_count && session->set_selected > 0U) --session->set_selected;
|
||||
draft_bind_input(draft); session->dirty = true; session->phase = TRAINLOG_SESSION_ACTUALS;
|
||||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
session->phase = TRAINLOG_SESSION_ACTUALS;
|
||||
session_controller_sync_durability(session);
|
||||
return true;
|
||||
}
|
||||
if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) {
|
||||
if (key == '1') {
|
||||
if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB)
|
||||
session->destructive_confirm_selected = !session->destructive_confirm_selected;
|
||||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && session->destructive_confirm_selected) {
|
||||
session_controller_clear_draft(session);
|
||||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||||
} else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
} else if ((key == TRAINLOG_KEY_ENTER || key == '\n') || key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
session->phase = TRAINLOG_SESSION_DRAFT;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -8530,13 +8636,8 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
|
|||
session->set_field = session->set_field == 0U ? 1U : 0U;
|
||||
else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
|
||||
draft->input.set_count > 0U) {
|
||||
for (size_t index = session->set_selected;
|
||||
index + 1U < draft->input.set_count; ++index)
|
||||
draft->sets[index] = draft->sets[index + 1U];
|
||||
--draft->input.set_count;
|
||||
if (session->set_selected >= draft->input.set_count &&
|
||||
session->set_selected > 0U) --session->set_selected;
|
||||
draft_bind_input(draft); session->dirty = true;
|
||||
session->destructive_confirm_selected = false;
|
||||
session->phase = TRAINLOG_SESSION_CONFIRM_SET_REMOVE;
|
||||
} else if ((key == 'a' || key == 'A') &&
|
||||
draft->input.set_count < MAX_SETS_PER_EXERCISE) {
|
||||
(void)memset(&session->pending_set, 0, sizeof(session->pending_set));
|
||||
|
|
@ -8576,8 +8677,10 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
|
|||
session->planning_field = 0U;
|
||||
session->phase = TRAINLOG_SESSION_PLANNING;
|
||||
} else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) &&
|
||||
session->draft_count > 0U)
|
||||
session->draft_count > 0U) {
|
||||
session->destructive_confirm_selected = false;
|
||||
session->phase = TRAINLOG_SESSION_CONFIRM_REMOVE;
|
||||
}
|
||||
else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') &&
|
||||
session->draft_count > 0U) {
|
||||
session->set_selected = 0U;
|
||||
|
|
@ -8592,8 +8695,10 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key)
|
|||
if (status != TRAINLOG_STATUS_INVALID_ARGUMENT)
|
||||
(void)snprintf(session->message, sizeof(session->message),
|
||||
"Échec de l’enregistrement; le brouillon est conservé.");
|
||||
} else if (key == 'q' || key == 'Q')
|
||||
} else if (key == 'q' || key == 'Q') {
|
||||
session->destructive_confirm_selected = false;
|
||||
session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON;
|
||||
}
|
||||
else if (key == TRAINLOG_KEY_ESCAPE || key == 27)
|
||||
app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS);
|
||||
session_controller_sync_durability(session);
|
||||
|
|
|
|||
|
|
@ -195,7 +195,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 == 13);
|
||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 15);
|
||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 2U);
|
||||
|
|
|
|||
137
tui/tests/test_feedback_correction.c
Normal file
137
tui/tests/test_feedback_correction.c
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/** @file test_feedback_correction.c
|
||||
* @brief Stable-entry feedback preservation across completed-session edits. */
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sqlite3.h>
|
||||
#include "trainlog/database.h"
|
||||
#include "../src/database_internal.h"
|
||||
|
||||
#define CHECK(c) do { if (!(c)) { (void)fprintf(stderr, \
|
||||
"CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #c); return false; \
|
||||
} } while (0)
|
||||
|
||||
static bool exec_sql(TrainlogDatabase *db, const char *sql)
|
||||
{ return sqlite3_exec(db->connection, sql, NULL, NULL, NULL) == SQLITE_OK; }
|
||||
|
||||
static sqlite3_int64 scalar(TrainlogDatabase *db, const char *sql)
|
||||
{
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
sqlite3_int64 value = -1;
|
||||
if (sqlite3_prepare_v2(db->connection, sql, -1, &stmt, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(stmt) == SQLITE_ROW) value = sqlite3_column_int64(stmt, 0);
|
||||
(void)sqlite3_finalize(stmt);
|
||||
return value;
|
||||
}
|
||||
|
||||
static void bind_entry(TrainlogSessionExerciseInput *entry, const char *entry_id,
|
||||
const char *exercise_id, TrainlogSetInput *set, int reps)
|
||||
{
|
||||
(void)memset(entry, 0, sizeof(*entry));
|
||||
(void)memset(set, 0, sizeof(*set));
|
||||
(void)snprintf(entry->entry_id, sizeof(entry->entry_id), "%s", entry_id);
|
||||
(void)snprintf(entry->exercise_id, sizeof(entry->exercise_id), "%s", exercise_id);
|
||||
entry->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
entry->sets = set;
|
||||
entry->set_count = 1U;
|
||||
set->reps = reps;
|
||||
}
|
||||
|
||||
static bool matches(const TrainlogFeedbackView *item, const char *id,
|
||||
const char *entry, const char *at, const char *text)
|
||||
{
|
||||
return strcmp(item->stable_id, id) == 0 && strcmp(item->entry_id, entry) == 0 &&
|
||||
strcmp(item->observed_at, at) == 0 && strcmp(item->raw_text, text) == 0;
|
||||
}
|
||||
|
||||
static bool test_correction(void)
|
||||
{
|
||||
TrainlogDatabase *db = NULL;
|
||||
TrainlogSessionInput session;
|
||||
TrainlogSessionExerciseInput original[2], replacement[2];
|
||||
TrainlogSetInput original_sets[2], replacement_sets[2];
|
||||
TrainlogFeedbackView feedback[8], followups[4];
|
||||
size_t feedback_count = 0U, followup_count = 0U;
|
||||
sqlite3_int64 old_a, old_b, new_a, new_b;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &db) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(db, "ex_press", "Presse", "presse",
|
||||
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise(db, "ex_plank", "Gainage", "gainage",
|
||||
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
||||
bind_entry(&original[0], "sxe_a", "ex_press", &original_sets[0], 10);
|
||||
bind_entry(&original[1], "sxe_b", "ex_plank", &original_sets[1], 20);
|
||||
(void)memset(&session, 0, sizeof(session));
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_feedback_edit");
|
||||
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
|
||||
"2026-09-01T10:00:00+02:00");
|
||||
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
|
||||
"2026-09-01T11:00:00+02:00");
|
||||
session.session_type = TRAINLOG_SESSION_TRAINING;
|
||||
session.exercises = original;
|
||||
session.exercise_count = 2U;
|
||||
CHECK(trainlog_database_insert_session(db, &session) == TRAINLOG_STATUS_OK);
|
||||
CHECK(exec_sql(db,
|
||||
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_a1',id,'2026-09-01T10:20:00+02:00','A première' FROM session_exercises WHERE entry_id='sxe_a';"
|
||||
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_a2',id,'2026-09-01T10:30:00+02:00','A deuxième' FROM session_exercises WHERE entry_id='sxe_a';"
|
||||
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_b1',id,'2026-09-01T10:40:00+02:00','B unique' FROM session_exercises WHERE entry_id='sxe_b';"
|
||||
"INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) SELECT 'fu_1',id,'2026-09-01T19:00:00+02:00','suivi inchangé' FROM sessions WHERE session_id='se_feedback_edit';"));
|
||||
/* Reproduce the pre-fix primitive: deleting reconstructed parents really
|
||||
* does cascade all feedback. Roll back, then exercise the guarded API. */
|
||||
CHECK(exec_sql(db, "BEGIN; DELETE FROM session_exercises WHERE session_row_id=(SELECT id FROM sessions WHERE session_id='se_feedback_edit');"));
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 0);
|
||||
CHECK(exec_sql(db, "ROLLBACK;"));
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 3);
|
||||
old_a = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_a';");
|
||||
old_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';");
|
||||
|
||||
/* Reorder and change A's exercise while retaining both logical IDs. */
|
||||
bind_entry(&replacement[0], "sxe_b", "ex_plank", &replacement_sets[0], 21);
|
||||
bind_entry(&replacement[1], "sxe_a", "ex_plank", &replacement_sets[1], 11);
|
||||
CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit",
|
||||
replacement, 2U) == TRAINLOG_STATUS_OK);
|
||||
new_a = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_a';");
|
||||
new_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';");
|
||||
CHECK(old_a != new_a && old_b != new_b);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_a' AND se.exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_plank');") == 2);
|
||||
CHECK(trainlog_database_list_training_feedback(db, "se_feedback_edit", feedback,
|
||||
8U, &feedback_count, followups, 4U, &followup_count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(feedback_count == 3U && followup_count == 1U);
|
||||
CHECK(matches(&feedback[0], "fb_a1", "sxe_a", "2026-09-01T10:20:00+02:00", "A première"));
|
||||
CHECK(matches(&feedback[1], "fb_a2", "sxe_a", "2026-09-01T10:30:00+02:00", "A deuxième"));
|
||||
CHECK(matches(&feedback[2], "fb_b1", "sxe_b", "2026-09-01T10:40:00+02:00", "B unique"));
|
||||
CHECK(strcmp(followups[0].stable_id, "fu_1") == 0 &&
|
||||
strcmp(followups[0].raw_text, "suivi inchangé") == 0);
|
||||
|
||||
/* Remove A and add C: B survives, A cascades, C inherits nothing. */
|
||||
bind_entry(&replacement[0], "sxe_b", "ex_plank", &replacement_sets[0], 22);
|
||||
bind_entry(&replacement[1], "sxe_c", "ex_press", &replacement_sets[1], 12);
|
||||
CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit",
|
||||
replacement, 2U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback;") == 1);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_b';") == 1);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.entry_id='sxe_c';") == 0);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM session_followups;") == 1);
|
||||
|
||||
/* Inject reattachment failure; rollback restores parent row, work and histories. */
|
||||
new_b = scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';");
|
||||
CHECK(exec_sql(db, "CREATE TRIGGER fail_correction_feedback BEFORE INSERT ON exercise_feedback BEGIN SELECT RAISE(ABORT,'synthetic correction failure'); END;"));
|
||||
bind_entry(&replacement[0], "sxe_b", "ex_press", &replacement_sets[0], 99);
|
||||
CHECK(trainlog_database_replace_session_exercises(db, "se_feedback_edit",
|
||||
replacement, 1U) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(scalar(db, "SELECT id FROM session_exercises WHERE entry_id='sxe_b';") == new_b);
|
||||
CHECK(scalar(db, "SELECT reps FROM performed_sets WHERE session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='sxe_b');") == 22);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback WHERE feedback_id='fb_b1';") == 1);
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM session_followups WHERE followup_id='fu_1';") == 1);
|
||||
CHECK(exec_sql(db, "DROP TRIGGER fail_correction_feedback;"));
|
||||
CHECK(scalar(db, "SELECT COUNT(*) FROM exercise_feedback f LEFT JOIN session_exercises se ON se.id=f.session_exercise_row_id WHERE se.id IS NULL;") == 0);
|
||||
trainlog_database_close(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
if (!test_correction()) return 1;
|
||||
(void)puts("Completed-session feedback correction tests passed.");
|
||||
return 0;
|
||||
}
|
||||
12
tui/tests/test_feedback_labels.c
Normal file
12
tui/tests/test_feedback_labels.c
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#include "trainlog/timeutil.h"
|
||||
#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;}
|
||||
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;}
|
||||
|
|
@ -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 v13: SQLite rc=",
|
||||
strlen("migrate database to schema v13: SQLite rc=")) == 0);
|
||||
CHECK(strncmp(diagnostic, "migrate database to schema v15: SQLite rc=",
|
||||
strlen("migrate database to schema v15: 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-v14-XXXXXX";
|
||||
char path[] = "/tmp/trainlog-schema-v16-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=14;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=16;", 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 14 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 16 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 v13") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v15") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
|
|
@ -370,6 +370,35 @@ static bool test_shell_generator_keep_and_accept_has_no_actuals(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool test_destructive_session_choices_default_to_cancel(void)
|
||||
{
|
||||
TrainlogAppContext app;
|
||||
(void)memset(&app, 0, sizeof(app));
|
||||
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
||||
app.session.has_draft = true;
|
||||
app.session.draft_count = 1U;
|
||||
app.session.phase = TRAINLOG_SESSION_ACTUALS;
|
||||
app.session.drafts[0].input.set_count = 2U;
|
||||
app.session.drafts[0].sets[0].reps = 8;
|
||||
app.session.drafts[0].sets[1].reps = 7;
|
||||
draft_bind_input(&app.session.drafts[0]);
|
||||
CHECK(app_shell_dispatch_session(&app, 'd'));
|
||||
CHECK(app.session.phase == TRAINLOG_SESSION_CONFIRM_SET_REMOVE);
|
||||
CHECK(!app.session.destructive_confirm_selected);
|
||||
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
||||
CHECK(app.session.phase == TRAINLOG_SESSION_ACTUALS);
|
||||
CHECK(app.session.drafts[0].input.set_count == 2U);
|
||||
CHECK(app_shell_dispatch_session(&app, 'd'));
|
||||
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_RIGHT));
|
||||
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
||||
CHECK(app.session.drafts[0].input.set_count == 1U);
|
||||
app.session.phase = TRAINLOG_SESSION_DRAFT;
|
||||
CHECK(app_shell_dispatch_session(&app, 'd'));
|
||||
CHECK(app_shell_dispatch_session(&app, 'q'));
|
||||
CHECK(app.session.draft_count == 1U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_shell_session_forms_keep_plan_and_actuals_separate(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
|
|
@ -2206,6 +2235,7 @@ int main(void)
|
|||
if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() ||
|
||||
!test_shell_session_navigation_preserves_run_draft() ||
|
||||
!test_shell_generator_keep_and_accept_has_no_actuals() ||
|
||||
!test_destructive_session_choices_default_to_cancel() ||
|
||||
!test_shell_session_forms_keep_plan_and_actuals_separate() ||
|
||||
!test_shell_continuous_supplemental_fields_have_no_fake_set() ||
|
||||
!test_shell_generator_dose_edit_drops_stale_load_provenance() ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue