feat(training): timestamp exercise execution
This commit is contained in:
parent
1ca354af53
commit
dd39c85f94
66 changed files with 1916 additions and 104 deletions
|
|
@ -24,6 +24,13 @@
|
|||
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||
recovery on the private 0.1.5 versionCode 40 installation.
|
||||
- Added the training execution timeline used for cardio correlation. Android
|
||||
schema v28 records explicit start/end actions for stable exercise occurrences
|
||||
while preserving the session lifecycle `started_at`/`ended_at` as canonical
|
||||
bounds. Only one exercise can be active; session finalization refuses an
|
||||
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
|
||||
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
||||
V1 companion imports idempotently into desktop schema v31.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..27
|
||||
manifest.getInt("schema") in 17..28
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..27) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..28) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import android.os.SystemClock
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.labfytools.trainlog.R
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class HeartRateSensorService : Service() {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
|
@ -36,6 +37,10 @@ class HeartRateSensorService : Service() {
|
|||
private var reconnectAttempt = 0
|
||||
private var lastMeasurementElapsed = 0L
|
||||
private var lastNotificationBpm: Int? = null
|
||||
private var repository: TrainlogRepository? = null
|
||||
|
||||
private fun repository(): TrainlogRepository =
|
||||
repository ?: TrainlogRepository(applicationContext).also { repository = it }
|
||||
|
||||
private val reconnectRunnable = Runnable { connectSelected() }
|
||||
private val connectionTimeoutRunnable =
|
||||
|
|
@ -272,6 +277,11 @@ class HeartRateSensorService : Service() {
|
|||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||
HeartRateLiveState.measurement(parsed.measurement)
|
||||
repository().recordLiveHeartRateForActiveSession(
|
||||
sensorName = selected?.name,
|
||||
observedAt = OffsetDateTime.now().toString(),
|
||||
measurement = parsed.measurement,
|
||||
)
|
||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||
lastNotificationBpm = parsed.measurement.bpm
|
||||
updateForegroundNotification()
|
||||
|
|
@ -314,6 +324,8 @@ class HeartRateSensorService : Service() {
|
|||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
main.removeCallbacks(staleRunnable)
|
||||
closeGatt()
|
||||
repository?.close()
|
||||
repository = null
|
||||
HeartRateLiveState.disconnected()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
||||
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
|
||||
"session-timeline" to
|
||||
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
|
||||
)
|
||||
private val SUPPORTED =
|
||||
(ANDROID_KINDS.values +
|
||||
|
|
@ -431,6 +433,18 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
|
||||
)
|
||||
}
|
||||
val timeline = JSONObject(checkNotNull(artifacts["session-timeline"]))
|
||||
val timelineSessions = timeline.getJSONArray("sessions")
|
||||
for (index in 0 until timelineSessions.length()) {
|
||||
db.execSQL(
|
||||
"INSERT INTO session_timeline_generation_sessions(generation_id,session_id) " +
|
||||
"VALUES(?,?)",
|
||||
arrayOf(
|
||||
generationId,
|
||||
timelineSessions.getJSONObject(index).getString("session_id"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
||||
} catch (error: Exception) {
|
||||
|
|
@ -1076,6 +1090,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
"m.capture_id=heart_rate_captures.capture_id)",
|
||||
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " +
|
||||
"acknowledged_at IS NULL AND EXISTS(" +
|
||||
"SELECT 1 FROM session_timeline_generation_sessions m " +
|
||||
"WHERE m.generation_id=? AND " +
|
||||
"m.session_id=session_timeline_sessions.session_id)",
|
||||
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||
)
|
||||
}
|
||||
target
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import android.database.sqlite.SQLiteOpenHelper
|
|||
import android.util.JsonReader
|
||||
import android.util.JsonToken
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ActiveSessionTimelineContext
|
||||
import com.labfytools.trainlog.model.AiSessionDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
||||
|
|
@ -37,6 +38,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.SessionExerciseTiming
|
||||
import com.labfytools.trainlog.model.SessionFollowUp
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
|
|
@ -505,6 +507,14 @@ class TrainlogRepository(
|
|||
data object DatabaseError : HeartRateMutationResult
|
||||
}
|
||||
|
||||
sealed interface SessionExerciseTimingResult {
|
||||
data class Started(val timing: SessionExerciseTiming) : SessionExerciseTimingResult
|
||||
data class Finished(val timing: SessionExerciseTiming) : SessionExerciseTimingResult
|
||||
data class ActiveConflict(val activeEntryId: String) : SessionExerciseTimingResult
|
||||
data class Invalid(val message: String) : SessionExerciseTimingResult
|
||||
data class DatabaseError(val message: String) : SessionExerciseTimingResult
|
||||
}
|
||||
|
||||
private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
|
||||
|
||||
private fun sleepIdentity(value: String, prefix: String): Boolean =
|
||||
|
|
@ -1158,6 +1168,296 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
private fun ensureSessionTimelineForPersistedDraft(
|
||||
db: SQLiteDatabase,
|
||||
draft: ActiveSessionDraft,
|
||||
): Pair<String, String> {
|
||||
val identity =
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at FROM active_session_draft WHERE id=1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) { "Active draft missing after persistence." }
|
||||
check(!cursor.isNull(0) && !cursor.isNull(1)) {
|
||||
"Active draft is missing lifecycle identity."
|
||||
}
|
||||
cursor.getString(0) to cursor.getString(1)
|
||||
}
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO session_timeline_sessions(" +
|
||||
"session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)",
|
||||
arrayOf(identity.first, identity.second),
|
||||
)
|
||||
val timelineStart =
|
||||
db.rawQuery(
|
||||
"SELECT started_at,ended_at FROM session_timeline_sessions WHERE session_id=?",
|
||||
arrayOf(identity.first),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst())
|
||||
check(cursor.isNull(1)) { "Active session timeline is already completed." }
|
||||
cursor.getString(0)
|
||||
}
|
||||
check(timelineStart == identity.second) {
|
||||
"Active session timeline start conflicts with draft lifecycle."
|
||||
}
|
||||
|
||||
val requested = draft.exercises.associate { it.entryId to it.exercise.exerciseId }
|
||||
db.rawQuery(
|
||||
"SELECT entry_id,exercise_id FROM session_timeline_exercises WHERE session_id=?",
|
||||
arrayOf(identity.first),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val entryId = cursor.getString(0)
|
||||
check(requested[entryId] == cursor.getString(1)) {
|
||||
"A started exercise cannot be removed or reassigned."
|
||||
}
|
||||
}
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
fun activeSessionTimelineContext(): ActiveSessionTimelineContext? {
|
||||
val db = database.readableDatabase
|
||||
return db.rawQuery(
|
||||
"SELECT d.session_id,d.started_at,d.session_type,t.entry_id " +
|
||||
"FROM active_session_draft d LEFT JOIN session_timeline_exercises t " +
|
||||
"ON t.session_id=d.session_id AND t.active_slot=1 WHERE d.id=1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst() || cursor.isNull(0) || cursor.isNull(1)) null
|
||||
else ActiveSessionTimelineContext(
|
||||
sessionId = cursor.getString(0),
|
||||
startedAt = cursor.getString(1),
|
||||
sessionType = SessionType.fromWire(cursor.getString(2)),
|
||||
activeEntryId = if (cursor.isNull(3)) null else cursor.getString(3),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun listActiveSessionExerciseTimings(): List<SessionExerciseTiming> {
|
||||
val context = activeSessionTimelineContext() ?: return emptyList()
|
||||
return buildList {
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT entry_id,exercise_id,started_at,ended_at FROM " +
|
||||
"session_timeline_exercises WHERE session_id=? ORDER BY started_at,entry_id",
|
||||
arrayOf(context.sessionId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
add(
|
||||
SessionExerciseTiming(
|
||||
sessionId = context.sessionId,
|
||||
entryId = cursor.getString(0),
|
||||
exerciseId = cursor.getString(1),
|
||||
startedAt = cursor.getString(2),
|
||||
endedAt = if (cursor.isNull(3)) null else cursor.getString(3),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startActiveSessionExercise(
|
||||
entryId: String,
|
||||
startedAt: String = OffsetDateTime.now().toString(),
|
||||
): SessionExerciseTimingResult {
|
||||
if (!SESSION_ENTRY_ID_V4_PATTERN.matches(entryId) || TrainlogTimestamp.parse(startedAt) == null)
|
||||
return SessionExerciseTimingResult.Invalid("Invalid exercise timing.")
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
return try {
|
||||
val active =
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at FROM active_session_draft " +
|
||||
"WHERE id=1 AND session_id IS NOT NULL AND started_at IS NOT NULL",
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1)
|
||||
} ?: return SessionExerciseTimingResult.Invalid("No active session.")
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO session_timeline_sessions(" +
|
||||
"session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)",
|
||||
arrayOf(active.first, active.second),
|
||||
)
|
||||
val exerciseId =
|
||||
db.rawQuery(
|
||||
"SELECT e.exercise_id FROM draft_session_exercises de " +
|
||||
"JOIN exercises e ON e.id=de.exercise_row_id " +
|
||||
"WHERE de.draft_id=1 AND de.entry_id=?",
|
||||
arrayOf(entryId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0)
|
||||
} ?: return SessionExerciseTimingResult.Invalid("Exercise occurrence is not active.")
|
||||
val existing =
|
||||
db.rawQuery(
|
||||
"SELECT exercise_id,started_at,ended_at FROM session_timeline_exercises " +
|
||||
"WHERE session_id=? AND entry_id=?",
|
||||
arrayOf(active.first, entryId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else Triple(
|
||||
cursor.getString(0),
|
||||
cursor.getString(1),
|
||||
if (cursor.isNull(2)) null else cursor.getString(2),
|
||||
)
|
||||
}
|
||||
if (existing != null) {
|
||||
if (existing.first != exerciseId) {
|
||||
return SessionExerciseTimingResult.Invalid(
|
||||
"Exercise identity conflicts with existing timing.",
|
||||
)
|
||||
}
|
||||
val timing =
|
||||
SessionExerciseTiming(
|
||||
active.first,
|
||||
entryId,
|
||||
exerciseId,
|
||||
existing.second,
|
||||
existing.third,
|
||||
)
|
||||
return if (existing.third == null) {
|
||||
SessionExerciseTimingResult.Started(timing)
|
||||
} else {
|
||||
SessionExerciseTimingResult.Invalid("Exercise has already been completed.")
|
||||
}
|
||||
}
|
||||
val conflict =
|
||||
db.rawQuery(
|
||||
"SELECT entry_id FROM session_timeline_exercises WHERE active_slot=1 LIMIT 1",
|
||||
null,
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (conflict != null) return SessionExerciseTimingResult.ActiveConflict(conflict)
|
||||
check(TrainlogTimestamp.parse(startedAt)!! >= TrainlogTimestamp.parse(active.second)!!) {
|
||||
"Exercise cannot start before session."
|
||||
}
|
||||
db.execSQL(
|
||||
"INSERT INTO session_timeline_exercises(" +
|
||||
"session_id,entry_id,exercise_id,started_at,ended_at,active_slot) " +
|
||||
"VALUES(?,?,?,?,NULL,1)",
|
||||
arrayOf(active.first, entryId, exerciseId, startedAt),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET active_exercise_entry_id=? " +
|
||||
"WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(entryId, active.first),
|
||||
)
|
||||
val timing =
|
||||
SessionExerciseTiming(active.first, entryId, exerciseId, startedAt, null)
|
||||
db.setTransactionSuccessful()
|
||||
SessionExerciseTimingResult.Started(timing)
|
||||
} catch (error: IllegalStateException) {
|
||||
SessionExerciseTimingResult.Invalid(error.message ?: "Invalid exercise timing.")
|
||||
} catch (error: Exception) {
|
||||
SessionExerciseTimingResult.DatabaseError(error.message ?: "Exercise start failed.")
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun finishActiveSessionExercise(
|
||||
entryId: String,
|
||||
endedAt: String = OffsetDateTime.now().toString(),
|
||||
): SessionExerciseTimingResult {
|
||||
if (!SESSION_ENTRY_ID_V4_PATTERN.matches(entryId) || TrainlogTimestamp.parse(endedAt) == null)
|
||||
return SessionExerciseTimingResult.Invalid("Invalid exercise timing.")
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
return try {
|
||||
val row =
|
||||
db.rawQuery(
|
||||
"SELECT session_id,exercise_id,started_at FROM session_timeline_exercises " +
|
||||
"WHERE entry_id=? AND active_slot=1",
|
||||
arrayOf(entryId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else Triple(cursor.getString(0), cursor.getString(1), cursor.getString(2))
|
||||
} ?: return SessionExerciseTimingResult.Invalid("Exercise is not active.")
|
||||
check(TrainlogTimestamp.parse(endedAt)!! >= TrainlogTimestamp.parse(row.third)!!) {
|
||||
"Exercise cannot end before it starts."
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put("ended_at", endedAt)
|
||||
putNull("active_slot")
|
||||
}
|
||||
check(
|
||||
db.update(
|
||||
"session_timeline_exercises",
|
||||
values,
|
||||
"session_id=? AND entry_id=? AND active_slot=1",
|
||||
arrayOf(row.first, entryId),
|
||||
) == 1,
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET active_exercise_entry_id=NULL " +
|
||||
"WHERE active_slot=1 AND context_id=? AND active_exercise_entry_id=?",
|
||||
arrayOf(row.first, entryId),
|
||||
)
|
||||
val timing =
|
||||
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
|
||||
db.setTransactionSuccessful()
|
||||
SessionExerciseTimingResult.Finished(timing)
|
||||
} catch (error: IllegalStateException) {
|
||||
SessionExerciseTimingResult.Invalid(error.message ?: "Invalid exercise timing.")
|
||||
} catch (error: Exception) {
|
||||
SessionExerciseTimingResult.DatabaseError(error.message ?: "Exercise end failed.")
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun recordLiveHeartRateForActiveSession(
|
||||
sensorName: String?,
|
||||
observedAt: String,
|
||||
measurement: ParsedHeartRateMeasurement,
|
||||
): HeartRateMutationResult? {
|
||||
val context = activeSessionTimelineContext() ?: return null
|
||||
val contextKind =
|
||||
if (context.sessionType.wireValue == "cardio") {
|
||||
HeartRateContextKind.CARDIO
|
||||
} else {
|
||||
HeartRateContextKind.SESSION
|
||||
}
|
||||
|
||||
var capture = activeHeartRateCapture()
|
||||
if (capture == null) {
|
||||
when (
|
||||
startHeartRateCapture(
|
||||
contextKind,
|
||||
context.sessionId,
|
||||
context.startedAt,
|
||||
sensorName,
|
||||
)
|
||||
) {
|
||||
is StartHeartRateCaptureResult.Started -> capture = activeHeartRateCapture()
|
||||
StartHeartRateCaptureResult.Conflict -> capture = activeHeartRateCapture()
|
||||
else -> return HeartRateMutationResult.DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
val current = capture ?: return HeartRateMutationResult.NotFound
|
||||
if (current.contextId != context.sessionId || current.contextKind != contextKind) return null
|
||||
|
||||
if (current.activeExerciseEntryId != context.activeEntryId) {
|
||||
when (setHeartRateActiveExercise(current.captureId, context.activeEntryId)) {
|
||||
is HeartRateMutationResult.Applied -> Unit
|
||||
else -> return HeartRateMutationResult.DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
return appendHeartRateSample(
|
||||
captureId = current.captureId,
|
||||
observedAt = observedAt,
|
||||
bpm = measurement.bpm,
|
||||
sensorContactDetected = measurement.sensorContactDetected,
|
||||
energyExpended = measurement.energyExpended,
|
||||
rrIntervals =
|
||||
measurement.rrIntervals1024.mapIndexed { index, value ->
|
||||
HeartRateRrInterval(index, value)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean =
|
||||
when (kind) {
|
||||
HeartRateContextKind.SESSION,
|
||||
|
|
@ -1543,6 +1843,67 @@ class TrainlogRepository(
|
|||
.toString()
|
||||
}
|
||||
|
||||
internal fun buildSessionTimelineV1Json(): String {
|
||||
val db = database.readableDatabase
|
||||
val sessions = JSONArray()
|
||||
db.rawQuery(
|
||||
"SELECT t.session_id,t.started_at,t.ended_at FROM session_timeline_sessions t " +
|
||||
"JOIN sessions s ON s.session_id=t.session_id " +
|
||||
"WHERE t.ended_at IS NOT NULL AND s.started_at=t.started_at " +
|
||||
"AND s.ended_at=t.ended_at AND t.acknowledged_at IS NULL " +
|
||||
"AND EXISTS(SELECT 1 FROM session_timeline_exercises x " +
|
||||
"WHERE x.session_id=t.session_id) AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM session_timeline_generation_sessions m " +
|
||||
"JOIN sync_generations g ON g.generation_id=m.generation_id " +
|
||||
"WHERE m.session_id=t.session_id AND " +
|
||||
"g.status IN('captured','published','waiting_acknowledgement')) " +
|
||||
"ORDER BY t.ended_at,t.session_id LIMIT ?",
|
||||
arrayOf(MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION.toString()),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val sessionId = cursor.getString(0)
|
||||
val exerciseRows = JSONArray()
|
||||
db.rawQuery(
|
||||
"SELECT t.entry_id,t.exercise_id,t.started_at,t.ended_at " +
|
||||
"FROM session_timeline_exercises t " +
|
||||
"JOIN session_exercises se ON se.entry_id=t.entry_id " +
|
||||
"JOIN sessions s ON s.id=se.session_row_id AND s.session_id=t.session_id " +
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id AND e.exercise_id=t.exercise_id " +
|
||||
"WHERE t.session_id=? ORDER BY t.started_at,t.entry_id",
|
||||
arrayOf(sessionId),
|
||||
).use { exerciseCursor ->
|
||||
var count = 0
|
||||
while (exerciseCursor.moveToNext()) {
|
||||
check(++count <= MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION)
|
||||
check(!exerciseCursor.isNull(3)) {
|
||||
"Completed session timeline contains an active exercise."
|
||||
}
|
||||
exerciseRows.put(
|
||||
JSONObject()
|
||||
.put("entry_id", exerciseCursor.getString(0))
|
||||
.put("exercise_id", exerciseCursor.getString(1))
|
||||
.put("started_at", exerciseCursor.getString(2))
|
||||
.put("ended_at", exerciseCursor.getString(3)),
|
||||
)
|
||||
}
|
||||
}
|
||||
sessions.put(
|
||||
JSONObject()
|
||||
.put("session_id", sessionId)
|
||||
.put("started_at", cursor.getString(1))
|
||||
.put("ended_at", cursor.getString(2))
|
||||
.put("exercises", exerciseRows),
|
||||
)
|
||||
}
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-session-timeline")
|
||||
.put("version", 1)
|
||||
.put("generated_at", OffsetDateTime.now().toString())
|
||||
.put("sessions", sessions)
|
||||
.toString()
|
||||
}
|
||||
|
||||
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
|
||||
internal fun backupDatabaseTo(destination: File) {
|
||||
require(!destination.exists()) { "Backup snapshot destination already exists." }
|
||||
|
|
@ -1590,6 +1951,7 @@ class TrainlogRepository(
|
|||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
||||
artifacts["heart-rate"] = buildHeartRateV1Json()
|
||||
artifacts["session-timeline"] = buildSessionTimelineV1Json()
|
||||
artifacts
|
||||
}
|
||||
|
||||
|
|
@ -3606,18 +3968,42 @@ class TrainlogRepository(
|
|||
|
||||
fun discardActiveSessionDraft():
|
||||
ActiveDraftMutationResult {
|
||||
val db = database.writableDatabase
|
||||
db.beginTransaction()
|
||||
return try {
|
||||
database.writableDatabase.delete(
|
||||
val sessionId =
|
||||
db.rawQuery(
|
||||
"SELECT session_id FROM active_session_draft WHERE id=?",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getString(0)
|
||||
}
|
||||
db.delete(
|
||||
"active_session_draft",
|
||||
"id = ?",
|
||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||
)
|
||||
if (sessionId != null) {
|
||||
db.delete(
|
||||
"heart_rate_captures",
|
||||
"active_slot=1 AND context_id=? AND context_kind IN('session','cardio')",
|
||||
arrayOf(sessionId),
|
||||
)
|
||||
db.delete(
|
||||
"session_timeline_sessions",
|
||||
"session_id=? AND ended_at IS NULL",
|
||||
arrayOf(sessionId),
|
||||
)
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
ActiveDraftMutationResult.Saved
|
||||
} catch (error: Exception) {
|
||||
ActiveDraftMutationResult.Error(
|
||||
error.message
|
||||
?: "Suppression du brouillon impossible."
|
||||
)
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3664,6 +4050,20 @@ class TrainlogRepository(
|
|||
transactionOpen = false
|
||||
return FinalizeActiveDraftResult.Invalid("The requested execution draft is not active.")
|
||||
}
|
||||
val activeEntryId =
|
||||
db.rawQuery(
|
||||
"SELECT entry_id FROM session_timeline_exercises " +
|
||||
"WHERE session_id=? AND active_slot=1 LIMIT 1",
|
||||
arrayOf(lifecycleSessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeEntryId != null) {
|
||||
db.endTransaction()
|
||||
transactionOpen = false
|
||||
return FinalizeActiveDraftResult.Invalid(
|
||||
"ACTIVE_EXERCISE:$activeEntryId",
|
||||
)
|
||||
}
|
||||
val finalizedAt = OffsetDateTime.now().toString()
|
||||
|
||||
val completed =
|
||||
SessionDraft(
|
||||
|
|
@ -3693,8 +4093,21 @@ class TrainlogRepository(
|
|||
active.draft.sourceSessionId,
|
||||
forcedSessionId = lifecycleSessionId,
|
||||
forcedStartedAt = active.draft.startedAt,
|
||||
forcedEndedAt = finalizedAt,
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE session_timeline_sessions SET ended_at=? " +
|
||||
"WHERE session_id=? AND ended_at IS NULL",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||
"active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
|
||||
/* INVARIANT: completion moves the exact Program provenance from
|
||||
* the singleton draft to history in the same transaction. */
|
||||
db.execSQL(
|
||||
|
|
@ -3726,7 +4139,7 @@ class TrainlogRepository(
|
|||
ContentValues().apply {
|
||||
put("session_id", lifecycleSessionId)
|
||||
put("final_revision_id", active.draft.revisionId)
|
||||
put("finalized_at", OffsetDateTime.now().toString())
|
||||
put("finalized_at", finalizedAt)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -8664,6 +9077,8 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
ensureSessionTimelineForPersistedDraft(db, draft)
|
||||
|
||||
/* 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
|
||||
|
|
@ -8835,6 +9250,7 @@ class TrainlogRepository(
|
|||
generalCorrection: Boolean = false,
|
||||
forcedSessionId: String? = null,
|
||||
forcedStartedAt: String? = null,
|
||||
forcedEndedAt: String? = null,
|
||||
): String {
|
||||
val sessionId: String
|
||||
val sessionRowId: Long
|
||||
|
|
@ -8849,7 +9265,7 @@ class TrainlogRepository(
|
|||
put("started_at", forcedStartedAt ?: 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("ended_at", forcedEndedAt ?: OffsetDateTime.now().toString())
|
||||
put("session_type", draft.sessionType.wireValue)
|
||||
}
|
||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||
|
|
@ -8892,7 +9308,7 @@ class TrainlogRepository(
|
|||
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
||||
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
||||
if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
|
||||
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
|
||||
arrayOf<Any>(forcedEndedAt ?: OffsetDateTime.now().toString(), sessionRowId))
|
||||
/* INVARIANT: child replacement and draft deletion are in the
|
||||
* caller's transaction; failure restores the completed baseline. */
|
||||
db.delete(
|
||||
|
|
@ -9963,6 +10379,8 @@ private const val MAX_HEART_RATE_CAPTURES_PER_GENERATION = 256
|
|||
private const val MAX_HEART_RATE_SAMPLES_PER_CAPTURE = 200000
|
||||
private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64
|
||||
private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160
|
||||
private const val MAX_SESSION_TIMELINE_SESSIONS_PER_GENERATION = 256
|
||||
private const val MAX_SESSION_TIMELINE_EXERCISES_PER_SESSION = 128
|
||||
private const val MAX_AI_DRAFT_ENTRIES = 64
|
||||
private const val MAX_AI_TARGET_SETS = 99
|
||||
private const val MAX_AI_TARGET_REPS = 999
|
||||
|
|
@ -9981,7 +10399,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
27,
|
||||
28,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -10036,6 +10454,7 @@ private class TrainlogDatabaseHelper(
|
|||
createProgramExecutionProvenance(db)
|
||||
createSleepDiaryTables(db)
|
||||
createHeartRateTables(db)
|
||||
createSessionTimelineTables(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -10257,6 +10676,16 @@ private class TrainlogDatabaseHelper(
|
|||
createHeartRateTables(db)
|
||||
version = 27
|
||||
}
|
||||
if (version < 28 && newVersion >= 28) {
|
||||
/* WHY: exercise/cardio correlation requires genuine user-action
|
||||
* boundaries that survive draft-row rebuilds. CONTRACT: v28 adds
|
||||
* only stable session/occurrence timeline facts. INVARIANT: an
|
||||
* existing active draft contributes only its already-authoritative
|
||||
* session_id/started_at; no exercise timing is fabricated. */
|
||||
createSessionTimelineTables(db)
|
||||
seedActiveSessionTimeline(db)
|
||||
version = 28
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
|
|
@ -10460,6 +10889,61 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createSessionTimelineTables(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS session_timeline_sessions(
|
||||
session_id TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
acknowledged_at TEXT,
|
||||
CHECK(session_id GLOB 'se_*')
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS session_timeline_exercises(
|
||||
session_id TEXT NOT NULL
|
||||
REFERENCES session_timeline_sessions(session_id) ON DELETE CASCADE,
|
||||
entry_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1),
|
||||
PRIMARY KEY(session_id,entry_id),
|
||||
CHECK(entry_id GLOB 'sxe_*'),
|
||||
CHECK(exercise_id GLOB 'ex_*')
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""CREATE TABLE IF NOT EXISTS session_timeline_generation_sessions(
|
||||
generation_id TEXT NOT NULL
|
||||
REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
|
||||
session_id TEXT NOT NULL
|
||||
REFERENCES session_timeline_sessions(session_id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY(generation_id,session_id)
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS session_timeline_exercises_time " +
|
||||
"ON session_timeline_exercises(started_at,session_id,entry_id)",
|
||||
)
|
||||
}
|
||||
|
||||
private fun seedActiveSessionTimeline(db: SQLiteDatabase) {
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at FROM active_session_draft " +
|
||||
"WHERE id=1 AND session_id IS NOT NULL AND started_at IS NOT NULL",
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO session_timeline_sessions(" +
|
||||
"session_id,started_at,ended_at,acknowledged_at) VALUES(?,?,NULL,NULL)",
|
||||
arrayOf(cursor.getString(0), cursor.getString(1)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
|
||||
fun addColumn(table: String, name: String, declaration: String) {
|
||||
if (!tableHasColumn(db, table, name)) {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,24 @@ data class SessionDraft(
|
|||
val sessionType: SessionType = SessionType.TRAINING,
|
||||
)
|
||||
|
||||
data class SessionExerciseTiming(
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val exerciseId: String,
|
||||
val startedAt: String,
|
||||
val endedAt: String?,
|
||||
) {
|
||||
val active: Boolean get() = endedAt == null
|
||||
}
|
||||
|
||||
data class ActiveSessionTimelineContext(
|
||||
val sessionId: String,
|
||||
val startedAt: String,
|
||||
val sessionType: SessionType,
|
||||
val activeEntryId: String?,
|
||||
)
|
||||
|
||||
|
||||
data class SessionDraftForm(
|
||||
val selectedExercise: ExerciseProfile? = null,
|
||||
/** Null means creation; otherwise replace this durable draft entry. */
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -56,6 +59,7 @@ 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.data.TrainlogRepository.SessionExerciseTimingResult
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
|
|
@ -152,6 +156,9 @@ fun SessionScreen(
|
|||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||
var feedbackRevision by remember { mutableStateOf(0) }
|
||||
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
||||
var timelineRevision by remember { mutableStateOf(0) }
|
||||
var pendingExerciseSwitch by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var pendingFinalizeActiveEntryId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val persistDraft:
|
||||
(ActiveSessionDraft, String?) -> Unit =
|
||||
|
|
@ -191,6 +198,12 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
val currentDraft = activeDraft!!
|
||||
val exerciseTimings =
|
||||
remember(timelineRevision, currentDraft.exercises.map { it.entryId }) {
|
||||
repository.listActiveSessionExerciseTimings()
|
||||
}
|
||||
val timingByEntry = exerciseTimings.associateBy { it.entryId }
|
||||
val activeTiming = exerciseTimings.firstOrNull { it.active }
|
||||
|
||||
val lastWriteFailed =
|
||||
message?.startsWith(
|
||||
|
|
@ -357,6 +370,88 @@ fun SessionScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val timing = timingByEntry[draft.entryId]
|
||||
when {
|
||||
timing == null -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_start),
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.startActiveSessionExercise(draft.entryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Started -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
draft.exercise.name,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.ActiveConflict -> {
|
||||
pendingExerciseSwitch =
|
||||
result.activeEntryId to draft.entryId
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.Finished -> Unit
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("start-exercise-${draft.entryId}"),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
}
|
||||
timing.active -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_finish),
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.finishActiveSessionExercise(draft.entryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finished,
|
||||
draft.exercise.name,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("finish-exercise-${draft.entryId}"),
|
||||
containerColor = colors.warning,
|
||||
style = TrainlogButtonStyle.SECONDARY,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_completed),
|
||||
onClick = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("completed-exercise-${draft.entryId}"),
|
||||
enabled = false,
|
||||
style = TrainlogButtonStyle.SECONDARY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||
description = strings.getString(R.string.edit_exercise_description),
|
||||
|
|
@ -518,22 +613,30 @@ fun SessionScreen(
|
|||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
||||
if (activeEntryId != null) {
|
||||
pendingFinalizeActiveEntryId = activeEntryId
|
||||
} else {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.finalize_failed,
|
||||
localizedRepositoryMessage(strings, result.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||
|
|
@ -589,7 +692,19 @@ fun SessionScreen(
|
|||
strings.getString(R.string.exercise_added) ||
|
||||
message ==
|
||||
strings.getString(R.string.exercise_removed) ||
|
||||
message == strings.getString(R.string.exercise_order_saved)
|
||||
message == strings.getString(R.string.exercise_order_saved) ||
|
||||
currentDraft.exercises.any { exercise ->
|
||||
message ==
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
exercise.exercise.name,
|
||||
) ||
|
||||
message ==
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finished,
|
||||
exercise.exercise.name,
|
||||
)
|
||||
}
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
|
|
@ -640,6 +755,138 @@ fun SessionScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) ->
|
||||
val activeName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
val targetName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == targetEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingExerciseSwitch = null },
|
||||
title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) },
|
||||
text = {
|
||||
Text(
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_switch_detail,
|
||||
activeName,
|
||||
targetName,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
when (
|
||||
val finished =
|
||||
repository.finishActiveSessionExercise(activeEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
when (
|
||||
val started =
|
||||
repository.startActiveSessionExercise(targetEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Started -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
targetName,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = started.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = started.message
|
||||
is SessionExerciseTimingResult.ActiveConflict,
|
||||
is SessionExerciseTimingResult.Finished -> Unit
|
||||
}
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
pendingExerciseSwitch = null
|
||||
},
|
||||
) {
|
||||
Text(strings.getString(R.string.exercise_timeline_switch_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingExerciseSwitch = null }) {
|
||||
Text(strings.getString(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingFinalizeActiveEntryId?.let { activeEntryId ->
|
||||
val activeName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingFinalizeActiveEntryId = null },
|
||||
title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) },
|
||||
text = {
|
||||
Text(
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finalize_detail,
|
||||
activeName,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
when (
|
||||
val finished =
|
||||
repository.finishActiveSessionExercise(activeEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
when (val result = repository.finalizeActiveSessionDraft()) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
pendingFinalizeActiveEntryId = null
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
is FinalizeActiveDraftResult.Invalid ->
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
is FinalizeActiveDraftResult.DatabaseError ->
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.finalize_failed,
|
||||
localizedRepositoryMessage(strings, result.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
pendingFinalizeActiveEntryId = null
|
||||
},
|
||||
) {
|
||||
Text(strings.getString(R.string.exercise_timeline_finalize_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingFinalizeActiveEntryId = null }) {
|
||||
Text(strings.getString(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -525,4 +525,15 @@
|
|||
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
|
||||
<string name="settings_cardio_forget">Forget sensor</string>
|
||||
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
|
||||
<string name="exercise_timeline_start">Start exercise</string>
|
||||
<string name="exercise_timeline_finish">Finish exercise</string>
|
||||
<string name="exercise_timeline_completed">Exercise completed</string>
|
||||
<string name="exercise_timeline_started">%1$s · started</string>
|
||||
<string name="exercise_timeline_finished">%1$s · finished</string>
|
||||
<string name="exercise_timeline_switch_title">Switch exercise?</string>
|
||||
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
|
||||
<string name="exercise_timeline_switch_confirm">Finish and start</string>
|
||||
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -535,4 +535,15 @@
|
|||
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
||||
<string name="settings_cardio_forget">Oublier le capteur</string>
|
||||
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
|
||||
<string name="exercise_timeline_start">Démarrer l’exercice</string>
|
||||
<string name="exercise_timeline_finish">Terminer l’exercice</string>
|
||||
<string name="exercise_timeline_completed">Exercice terminé</string>
|
||||
<string name="exercise_timeline_started">%1$s · démarré</string>
|
||||
<string name="exercise_timeline_finished">%1$s · terminé</string>
|
||||
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
|
||||
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
|
||||
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
|
||||
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(28, 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"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(28, 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 !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(28, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(27, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(28, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(27, it.version) }
|
||||
).use { assertEquals(28, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(27, it.version) }
|
||||
).use { assertEquals(28, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
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.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 java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
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 AndroidV28SessionTimelineMigrationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
|
||||
val name = "timeline-v27-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.startActiveSessionDraft(),
|
||||
)
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Timeline migration exercise",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val loaded =
|
||||
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val sessionId = checkNotNull(loaded.draft.sessionId)
|
||||
val startedAt = checkNotNull(loaded.draft.startedAt)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
loaded.draft.copy(
|
||||
exercises =
|
||||
listOf(
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
repository.close()
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE,
|
||||
).use { db ->
|
||||
db.execSQL("DROP TABLE session_timeline_generation_sessions")
|
||||
db.execSQL("DROP TABLE session_timeline_exercises")
|
||||
db.execSQL("DROP TABLE session_timeline_sessions")
|
||||
db.version = 27
|
||||
}
|
||||
|
||||
repository = TrainlogRepository(context, name)
|
||||
assertNotNull(repository.activeSessionTimelineContext())
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
assertEquals(28, db.version)
|
||||
db.rawQuery(
|
||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(sessionId, cursor.getString(0))
|
||||
assertEquals(startedAt, cursor.getString(1))
|
||||
assertTrue(cursor.isNull(2))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM session_timeline_exercises",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
assertNotNull(repository.activeSessionTimelineContext())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +328,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(27, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(28, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(27, it.version) }
|
||||
.use { assertEquals(28, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("27", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("28", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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 java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
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 SessionTimelineRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun startedOccurrenceCannotBeRemovedOrSilentlyClosedBySessionFinalization() {
|
||||
val name = "timeline-guard-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Timeline guarded exercise",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.startActiveSessionDraft(),
|
||||
)
|
||||
val opened =
|
||||
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val occurrence =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 5)),
|
||||
)
|
||||
val activeDraft = opened.draft.copy(exercises = listOf(occurrence))
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(activeDraft),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(occurrence.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
|
||||
val removed = repository.saveActiveSessionDraft(activeDraft.copy(exercises = emptyList()))
|
||||
assertTrue(removed is ActiveDraftMutationResult.Error)
|
||||
val finalize = repository.finalizeActiveSessionDraft()
|
||||
assertTrue(finalize is FinalizeActiveDraftResult.Invalid)
|
||||
assertTrue(
|
||||
(finalize as FinalizeActiveDraftResult.Invalid)
|
||||
.message
|
||||
.startsWith("ACTIVE_EXERCISE:"),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(occurrence.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finalizeActiveSessionDraft() is
|
||||
FinalizeActiveDraftResult.Saved,
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trainingTimelineCorrelatesMeasuredSamplesWithoutInventingRestOwner() {
|
||||
val name = "timeline-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
fun exercise(label: String) =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
|
||||
val first = exercise("Timeline A")
|
||||
val second = exercise("Timeline B")
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val firstEntry = SessionExerciseDraft(
|
||||
exercise = first,
|
||||
sets = listOf(SessionSetDraft(reps = 10)),
|
||||
)
|
||||
val secondEntry = SessionExerciseDraft(
|
||||
exercise = second,
|
||||
sets = listOf(SessionSetDraft(reps = 10)),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
opened.draft.copy(exercises = listOf(firstEntry, secondEntry)),
|
||||
),
|
||||
)
|
||||
|
||||
val startA = OffsetDateTime.now().toString()
|
||||
val startedA = repository.startActiveSessionExercise(firstEntry.entryId, startA)
|
||||
assertTrue(startedA is TrainlogRepository.SessionExerciseTimingResult.Started)
|
||||
val conflict = repository.startActiveSessionExercise(secondEntry.entryId)
|
||||
assertEquals(
|
||||
firstEntry.entryId,
|
||||
(conflict as TrainlogRepository.SessionExerciseTimingResult.ActiveConflict).activeEntryId,
|
||||
)
|
||||
|
||||
val measurement = ParsedHeartRateMeasurement(
|
||||
bpm = 101,
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(620),
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR", OffsetDateTime.now().toString(), measurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(firstEntry.entryId)
|
||||
is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 88),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(secondEntry.entryId)
|
||||
is TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 109),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(secondEntry.entryId)
|
||||
is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
val finalized = repository.finalizeActiveSessionDraft()
|
||||
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||
|
||||
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
|
||||
val session = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||
assertEquals(2, session.getJSONArray("exercises").length())
|
||||
assertEquals(firstEntry.entryId, session.getJSONArray("exercises").getJSONObject(0).getString("entry_id"))
|
||||
assertEquals(secondEntry.entryId, session.getJSONArray("exercises").getJSONObject(1).getString("entry_id"))
|
||||
|
||||
val heart = JSONObject(repository.buildHeartRateV1Json())
|
||||
val samples = heart.getJSONArray("captures").getJSONObject(0).getJSONArray("samples")
|
||||
assertEquals(3, samples.length())
|
||||
assertEquals(firstEntry.entryId, samples.getJSONObject(0).getString("exercise_entry_id"))
|
||||
assertTrue(samples.getJSONObject(1).isNull("exercise_entry_id"))
|
||||
assertEquals(secondEntry.entryId, samples.getJSONObject(2).getString("exercise_entry_id"))
|
||||
assertEquals(101, samples.getJSONObject(0).getInt("bpm"))
|
||||
assertEquals(88, samples.getJSONObject(1).getInt("bpm"))
|
||||
assertEquals(109, samples.getJSONObject(2).getInt("bpm"))
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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 java.nio.file.Files
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
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 SessionTimelineSyncGenerationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
|
||||
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
|
||||
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
try {
|
||||
fun exercise(label: String) =
|
||||
(source.createExercise(
|
||||
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val a = SessionExerciseDraft(
|
||||
exercise = exercise("Sync timeline A"),
|
||||
sets = listOf(SessionSetDraft(reps = 8)),
|
||||
)
|
||||
val b = SessionExerciseDraft(
|
||||
exercise = exercise("Sync timeline B"),
|
||||
sets = listOf(SessionSetDraft(reps = 9)),
|
||||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
|
||||
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
|
||||
)
|
||||
assertTrue(
|
||||
source.startActiveSessionExercise(a.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.finishActiveSessionExercise(a.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base.copy(bpm = 84),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.startActiveSessionExercise(b.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base.copy(bpm = 105),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.finishActiveSessionExercise(b.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: " + command.joinToString(" ") + "\n" + output,
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
),
|
||||
)!!
|
||||
.groupValues[1]
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val timelineJson =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
captured.stagingDirectory,
|
||||
"session-timeline-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals(1, timelineJson.getJSONArray("sessions").length())
|
||||
assertEquals(
|
||||
2,
|
||||
timelineJson
|
||||
.getJSONArray("sessions")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("exercises")
|
||||
.length(),
|
||||
)
|
||||
val heartJson =
|
||||
JSONObject(
|
||||
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
heartJson
|
||||
.getJSONArray("captures")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("samples")
|
||||
.length(),
|
||||
)
|
||||
|
||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||
val ackFile = java.io.File(root, "ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ackFile.absolutePath,
|
||||
)
|
||||
val ack = JSONObject(ackFile.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
||||
"consumed",
|
||||
ack.getString("result"),
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
|
||||
|
||||
assertEquals(
|
||||
"2|3|3",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
||||
).trim(),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildSessionTimelineV1Json())
|
||||
.getJSONArray("sessions")
|
||||
.length(),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildHeartRateV1Json())
|
||||
.getJSONArray("captures")
|
||||
.length(),
|
||||
)
|
||||
} finally {
|
||||
source.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -509,7 +509,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(27, it.getInt(0))
|
||||
assertEquals(28, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(27, cursor.getInt(0))
|
||||
assertEquals(28, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(27, cursor.getInt(0))
|
||||
assertEquals(28, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 |
|
||||
| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 |
|
||||
| Desktop SQLite | schema v31; session exercise timeline import is additive in v31 |
|
||||
| Android SQLite | schema v28; active training timeline/outbox is additive in v28 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` |
|
||||
|
|
@ -150,7 +150,8 @@ parallel implementation of its rules.
|
|||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1`: persist real session/exercise start/end boundaries and correlate live heart-rate samples with the active stable `entry_id` |
|
||||
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
||||
| Current operational cursor | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1`: add the Android Sleep entry point with one-tap Couché / medication / Réveil / Levé, immediate undo, later correction, and sleep-linked cardio capture |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
# Desktop database
|
||||
|
||||
## Schema v31: synchronized session exercise timeline
|
||||
|
||||
Schema v31 additively owns Android-recorded execution timing for completed
|
||||
session occurrences in `session_exercise_timeline`. Each row is keyed by the
|
||||
stable `session_id + entry_id` pair and stores the matching catalogue
|
||||
`exercise_id`, the real user-action `started_at` and `ended_at`, and import
|
||||
time. The importer requires the existing canonical session and occurrence to
|
||||
match; it never creates workout history or infers exercise timing from order,
|
||||
sets, plans, or heart-rate samples.
|
||||
|
||||
The session bounds remain the existing canonical `sessions.started_at` and
|
||||
`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion
|
||||
must match those exact values before occurrence timing can be imported.
|
||||
|
||||
## Schema v30: synchronized Heart Rate V1
|
||||
|
||||
Schema v30 additively owns Android-recorded heart-rate captures after
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
|||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_TRAINING_TIMELINE_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1089,7 +1089,23 @@ MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
|
|||
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
|
||||
implementation layer.
|
||||
|
||||
## 20. Legacy active-protocol limitations
|
||||
## 20. Session timeline companion V1
|
||||
|
||||
`trainlog-session-timeline` V1 is an optional Android→desktop full-generation
|
||||
participant. Android records the real execution interval of each stable
|
||||
`entry_id`; session start/end reuse the same lifecycle identity and timestamps
|
||||
that become canonical completed history.
|
||||
|
||||
Only completed timelines are published. The desktop v31 consumer first proves
|
||||
that `session_id`, `started_at`, `ended_at`, `entry_id` and `exercise_id`
|
||||
already match canonical history, then inserts the occurrence timing facts.
|
||||
Exact replay is a no-op and conflicting reuse of an identity is rejected.
|
||||
|
||||
Heart-rate samples use the same active `entry_id` at acquisition time. Samples
|
||||
between exercise end and the next exercise start retain no fabricated exercise
|
||||
owner and therefore remain factual rest/transition data.
|
||||
|
||||
## 21. Legacy active-protocol limitations
|
||||
|
||||
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
||||
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
|
||||
|
|
|
|||
|
|
@ -1727,3 +1727,20 @@ reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
|
|||
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
|
||||
reenabling Bluetooth restored the same foreground service automatically and
|
||||
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
|
||||
|
||||
## Training timeline V1 validation
|
||||
|
||||
`TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS` covers Android schema v28 and
|
||||
desktop schema v31. Repository tests prove explicit exercise start/end
|
||||
timestamps, one-active-exercise enforcement, restart-safe active ownership,
|
||||
finalization refusal while an exercise remains active, and heart-rate samples
|
||||
tagged with the active stable `entry_id` while rest samples remain unowned.
|
||||
|
||||
The generation regression publishes a completed timeline through the production
|
||||
full-generation path, imports it through `trainlog-session-timeline` V1,
|
||||
accepts the correlated ACK, and verifies idempotent replay/conflict rejection.
|
||||
|
||||
The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
|
||||
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||
also succeeds. The five skips remain optional historical/external fixture gates.
|
||||
|
|
|
|||
138
tests/test_session_timeline_exchange.py
Normal file
138
tests/test_session_timeline_exchange.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import session_timeline_exchange as timeline
|
||||
|
||||
SESSION = "se_11111111-1111-4111-8111-111111111111"
|
||||
ENTRY = "sxe_22222222-2222-4222-8222-222222222222"
|
||||
EXERCISE = "ex_33333333-3333-4333-8333-333333333333"
|
||||
|
||||
|
||||
def schema(db: sqlite3.Connection) -> None:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE sessions(
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT UNIQUE,
|
||||
started_at TEXT,
|
||||
ended_at TEXT
|
||||
);
|
||||
CREATE TABLE exercises(
|
||||
id INTEGER PRIMARY KEY,
|
||||
exercise_id TEXT UNIQUE
|
||||
);
|
||||
CREATE TABLE session_exercises(
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_row_id INTEGER,
|
||||
exercise_row_id INTEGER,
|
||||
entry_id TEXT UNIQUE
|
||||
);
|
||||
CREATE TABLE session_exercise_timeline(
|
||||
session_id TEXT,
|
||||
entry_id TEXT,
|
||||
exercise_id TEXT,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
imported_at TEXT,
|
||||
PRIMARY KEY(session_id,entry_id)
|
||||
);
|
||||
PRAGMA user_version=31;
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO sessions(id,session_id,started_at,ended_at) VALUES(1,?,?,?)",
|
||||
(SESSION, "2026-09-23T08:00:00+02:00", "2026-09-23T09:00:00+02:00"),
|
||||
)
|
||||
db.execute("INSERT INTO exercises(id,exercise_id) VALUES(1,?)", (EXERCISE,))
|
||||
db.execute(
|
||||
"INSERT INTO session_exercises(id,session_row_id,exercise_row_id,entry_id) "
|
||||
"VALUES(1,1,1,?)",
|
||||
(ENTRY,),
|
||||
)
|
||||
|
||||
|
||||
def document() -> dict:
|
||||
return {
|
||||
"format": "trainlog-session-timeline",
|
||||
"version": 1,
|
||||
"generated_at": "2026-09-23T09:01:00+02:00",
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": SESSION,
|
||||
"started_at": "2026-09-23T08:00:00+02:00",
|
||||
"ended_at": "2026-09-23T09:00:00+02:00",
|
||||
"exercises": [
|
||||
{
|
||||
"entry_id": ENTRY,
|
||||
"exercise_id": EXERCISE,
|
||||
"started_at": "2026-09-23T08:10:00+02:00",
|
||||
"ended_at": "2026-09-23T08:20:00+02:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class SessionTimelineExchangeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = sqlite3.connect(Path(self.temp.name) / "timeline.sqlite")
|
||||
schema(self.db)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_import_and_exact_replay(self):
|
||||
payload = timeline.validate(document())
|
||||
self.assertEqual((1, 0), timeline.apply(self.db, payload))
|
||||
self.assertEqual((0, 1), timeline.apply(self.db, payload))
|
||||
self.assertEqual(
|
||||
(
|
||||
SESSION,
|
||||
ENTRY,
|
||||
EXERCISE,
|
||||
"2026-09-23T08:10:00+02:00",
|
||||
"2026-09-23T08:20:00+02:00",
|
||||
),
|
||||
self.db.execute(
|
||||
"SELECT session_id,entry_id,exercise_id,started_at,ended_at "
|
||||
"FROM session_exercise_timeline"
|
||||
).fetchone(),
|
||||
)
|
||||
|
||||
def test_rejects_identity_reuse_with_different_content(self):
|
||||
payload = timeline.validate(document())
|
||||
timeline.apply(self.db, payload)
|
||||
changed = deepcopy(document())
|
||||
changed["sessions"][0]["exercises"][0]["ended_at"] = "2026-09-23T08:21:00+02:00"
|
||||
with self.assertRaisesRegex(ValueError, "different content"):
|
||||
timeline.apply(self.db, timeline.validate(changed))
|
||||
|
||||
def test_rejects_occurrence_that_does_not_match_history(self):
|
||||
payload = document()
|
||||
payload["sessions"][0]["exercises"][0]["exercise_id"] = (
|
||||
"ex_44444444-4444-4444-8444-444444444444"
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not match session history"):
|
||||
timeline.apply(self.db, timeline.validate(payload))
|
||||
|
||||
def test_rejects_exercise_outside_session_bounds(self):
|
||||
payload = document()
|
||||
payload["sessions"][0]["exercises"][0]["started_at"] = "2026-09-23T07:59:59+02:00"
|
||||
with self.assertRaisesRegex(ValueError, "outside session"):
|
||||
timeline.validate(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -66,13 +66,14 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.5")
|
||||
self.assertEqual(inventory["desktop_schema"], 30)
|
||||
self.assertEqual(inventory["android_schema"], 27)
|
||||
self.assertEqual(inventory["desktop_schema"], 31)
|
||||
self.assertEqual(inventory["android_schema"], 28)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
||||
self.assertIn("heart-rate-v1", inventory["protocols"])
|
||||
self.assertIn("session-timeline-v1", inventory["protocols"])
|
||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||
self.assertTrue((output / "bin/trainlog").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
raise CausalError(f"desktop schema v20-v30 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise CausalError(f"desktop schema v20-v31 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
raise LifecycleError(f"desktop schema v19-v30 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise LifecycleError(f"desktop schema v19-v31 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
|||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v30 requis")
|
||||
raise ValueError("schema desktop v15-v31 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v30 requis")
|
||||
raise ValueError("schema desktop v18-v31 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 31))
|
||||
supported_versions = tuple(range(8, 32))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v30 requis")
|
||||
raise ValueError("schema desktop v8 à v31 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 31))
|
||||
supported_versions = tuple(range(8, 32))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v30 requis")
|
||||
raise ValueError("schema desktop v8 à v31 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
raise ValueError("schema desktop v12-v30 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise ValueError("schema desktop v12-v31 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 31))
|
||||
supported_versions = tuple(range(11, 32))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v30 requis")
|
||||
raise ValueError("schema desktop v11-v31 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
exercises = []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,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 range(8, 31):
|
||||
if version not in range(8, 32):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 31)
|
||||
supported_versions = range(11, 32)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-v30 requis (v10 accepté pour export V2 explicite)")
|
||||
raise ValueError("schema desktop v11-v31 requis (v10 accepté pour export V2 explicite)")
|
||||
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31):
|
||||
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 31)
|
||||
supported_versions = range(15, 32)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v30 requis")
|
||||
raise ValueError("schema desktop v15-v31 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -236,8 +236,8 @@ def main() -> None:
|
|||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
|
||||
fail("desktop schema v30 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
|
||||
fail("desktop schema v30-v31 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
supported_versions = range(18, 31)
|
||||
supported_versions = range(18, 32)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v30 requis")
|
||||
raise ImportFailure("schema desktop v18-v31 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,9 +206,9 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 31)
|
||||
supported_versions = range(8, 32)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v30 requis")
|
||||
fail("schema desktop v8 à v31 requis")
|
||||
proof_path = args.mobile_export
|
||||
if proof_path is None:
|
||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 31)
|
||||
supported_versions = range(8, 32)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v30 requis")
|
||||
fail("schema desktop v8 à v31 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
fail("causal equipment protection requires the staged artifact")
|
||||
imported = skipped = 0
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 31)
|
||||
supported_versions = range(12, 32)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v30 requis")
|
||||
fail("schema desktop v12-v31 requis")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 31)
|
||||
supported_versions = range(11, 32)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v30 requis")
|
||||
raise ImportFailure("schema desktop v11-v31 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
fail("schema desktop v17-v30 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
fail("schema desktop v17-v31 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,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 range(5, 31):
|
||||
if version not in range(5, 32):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v30 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v31 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v31")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 31)
|
||||
supported_versions = range(15, 32)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v30 requis")
|
||||
fail("schema desktop v15-v31 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ RUNTIME_TOOLS = [
|
|||
"sync_generation_exchange.py",
|
||||
"sleep_diary_exchange.py",
|
||||
"heart_rate_exchange.py",
|
||||
"session_timeline_exchange.py",
|
||||
"trainlog_sqlite.py",
|
||||
"validate_json.py",
|
||||
"validate_training_knowledge.py",
|
||||
|
|
@ -115,8 +116,8 @@ def main() -> int:
|
|||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.5",
|
||||
"desktop_schema": 30,
|
||||
"android_schema": 27,
|
||||
"desktop_schema": 31,
|
||||
"android_schema": 28,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -132,6 +133,7 @@ def main() -> int:
|
|||
"program-executions-v1",
|
||||
"sleep-diary-v1",
|
||||
"heart-rate-v1",
|
||||
"session-timeline-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
"bin/trainlog",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
|||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30):
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31):
|
||||
fail("desktop schema v27, v28 or v29 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
|
|
|
|||
195
tools/session_timeline_exchange.py
Normal file
195
tools/session_timeline_exchange.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict Android-owned Trainlog session timeline V1 companion."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
FORMAT = "trainlog-session-timeline"
|
||||
VERSION = 1
|
||||
MAX_BYTES = 4 * 1024 * 1024
|
||||
MAX_SESSIONS = 256
|
||||
MAX_EXERCISES = 128
|
||||
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
SESSION_ID = re.compile(rf"^se_{UUID4}$")
|
||||
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
|
||||
EXERCISE_ID = re.compile(rf"^ex_{UUID4}$")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
fail("duplicate JSON field: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def timestamp(value: object) -> tuple[str, datetime]:
|
||||
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
|
||||
fail("invalid session-timeline timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("invalid session-timeline timestamp") from error
|
||||
if parsed.utcoffset() is None:
|
||||
fail("session-timeline timestamp lacks offset")
|
||||
return value, parsed
|
||||
|
||||
|
||||
def validate(root: object) -> dict:
|
||||
if not isinstance(root, dict) or set(root) != {
|
||||
"format", "version", "generated_at", "sessions"
|
||||
}:
|
||||
fail("invalid session-timeline envelope")
|
||||
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||
fail("unsupported session-timeline format")
|
||||
timestamp(root["generated_at"])
|
||||
sessions = root["sessions"]
|
||||
if not isinstance(sessions, list) or len(sessions) > MAX_SESSIONS:
|
||||
fail("session-timeline session bound exceeded")
|
||||
seen_sessions = set()
|
||||
for session in sessions:
|
||||
if not isinstance(session, dict) or set(session) != {
|
||||
"session_id", "started_at", "ended_at", "exercises"
|
||||
}:
|
||||
fail("invalid session-timeline session")
|
||||
session_id = session["session_id"]
|
||||
if (
|
||||
not isinstance(session_id, str)
|
||||
or not SESSION_ID.fullmatch(session_id)
|
||||
or session_id in seen_sessions
|
||||
):
|
||||
fail("invalid or duplicate session-timeline session identity")
|
||||
seen_sessions.add(session_id)
|
||||
_, session_start = timestamp(session["started_at"])
|
||||
_, session_end = timestamp(session["ended_at"])
|
||||
if session_end < session_start:
|
||||
fail("invalid session-timeline session interval")
|
||||
exercises = session["exercises"]
|
||||
if not isinstance(exercises, list) or len(exercises) > MAX_EXERCISES:
|
||||
fail("session-timeline exercise bound exceeded")
|
||||
seen_entries = set()
|
||||
previous_start = None
|
||||
for exercise in exercises:
|
||||
if not isinstance(exercise, dict) or set(exercise) != {
|
||||
"entry_id", "exercise_id", "started_at", "ended_at"
|
||||
}:
|
||||
fail("invalid session-timeline exercise")
|
||||
entry_id = exercise["entry_id"]
|
||||
exercise_id = exercise["exercise_id"]
|
||||
if (
|
||||
not isinstance(entry_id, str)
|
||||
or not ENTRY_ID.fullmatch(entry_id)
|
||||
or entry_id in seen_entries
|
||||
or not isinstance(exercise_id, str)
|
||||
or not EXERCISE_ID.fullmatch(exercise_id)
|
||||
):
|
||||
fail("invalid or duplicate session-timeline exercise identity")
|
||||
seen_entries.add(entry_id)
|
||||
_, start = timestamp(exercise["started_at"])
|
||||
_, end = timestamp(exercise["ended_at"])
|
||||
if start < session_start or end < start or end > session_end:
|
||||
fail("session-timeline exercise outside session")
|
||||
if previous_start is not None and start < previous_start:
|
||||
fail("session-timeline exercises are not ordered")
|
||||
previous_start = start
|
||||
return root
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
if path.stat().st_size > MAX_BYTES:
|
||||
fail("session-timeline artifact exceeds 4 MiB")
|
||||
return validate(
|
||||
json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
||||
)
|
||||
|
||||
|
||||
def _existing_session(db: sqlite3.Connection, session: dict) -> int:
|
||||
row = db.execute(
|
||||
"SELECT id,started_at,ended_at FROM sessions WHERE session_id=?",
|
||||
(session["session_id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
fail("session-timeline references missing session")
|
||||
if row[1] != session["started_at"] or row[2] != session["ended_at"]:
|
||||
fail("session-timeline session bounds conflict with history")
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def _same_timing(db: sqlite3.Connection, session_id: str, exercise: dict) -> bool:
|
||||
row = db.execute(
|
||||
"SELECT exercise_id,started_at,ended_at FROM session_exercise_timeline "
|
||||
"WHERE session_id=? AND entry_id=?",
|
||||
(session_id, exercise["entry_id"]),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if tuple(row) != (
|
||||
exercise["exercise_id"],
|
||||
exercise["started_at"],
|
||||
exercise["ended_at"],
|
||||
):
|
||||
fail("session-timeline identity reused with different content")
|
||||
return True
|
||||
|
||||
|
||||
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||
applied = 0
|
||||
unchanged = 0
|
||||
for session in root["sessions"]:
|
||||
session_row_id = _existing_session(db, session)
|
||||
for exercise in session["exercises"]:
|
||||
owner = db.execute(
|
||||
"SELECT e.exercise_id FROM session_exercises se "
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"WHERE se.session_row_id=? AND se.entry_id=?",
|
||||
(session_row_id, exercise["entry_id"]),
|
||||
).fetchone()
|
||||
if owner is None or owner[0] != exercise["exercise_id"]:
|
||||
fail("session-timeline occurrence does not match session history")
|
||||
if _same_timing(db, session["session_id"], exercise):
|
||||
unchanged += 1
|
||||
continue
|
||||
db.execute(
|
||||
"INSERT INTO session_exercise_timeline("
|
||||
"session_id,entry_id,exercise_id,started_at,ended_at,imported_at"
|
||||
") VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
session["session_id"],
|
||||
exercise["entry_id"],
|
||||
exercise["exercise_id"],
|
||||
exercise["started_at"],
|
||||
exercise["ended_at"],
|
||||
root["generated_at"],
|
||||
),
|
||||
)
|
||||
applied += 1
|
||||
return applied, unchanged
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
root = load(args.input)
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 31:
|
||||
fail("desktop schema v31 required")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
apply(db, root)
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -422,8 +422,8 @@ def main() -> None:
|
|||
import_command.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
|
||||
fail("desktop schema v30 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
|
||||
fail("desktop schema v30-v31 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from trainlog_sqlite import connect_database
|
|||
import causal_delete_exchange
|
||||
import execution_draft_exchange
|
||||
import heart_rate_exchange
|
||||
import session_timeline_exchange
|
||||
import import_equipment_associations
|
||||
import import_equipment_definitions
|
||||
import import_exercise_aliases
|
||||
|
|
@ -83,6 +84,7 @@ ARTIFACTS = (
|
|||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||
("trainlog-program-executions", 1),
|
||||
("trainlog-heart-rate", 1),
|
||||
("trainlog-session-timeline", 1),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -211,7 +213,7 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
|
||||
|
|
@ -671,6 +673,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
if "sleep-diary" in listed else None)
|
||||
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
|
||||
if "heart-rate" in listed else None)
|
||||
session_timeline = (session_timeline_exchange.load(listed["session-timeline"])
|
||||
if "session-timeline" in listed else None)
|
||||
program_executions = (
|
||||
program_execution_exchange.load(listed["program-executions"])
|
||||
if "program-executions" in listed else None
|
||||
|
|
@ -716,6 +720,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
sleep_diary_exchange.apply(db, sleep_diary)
|
||||
if heart_rate is not None:
|
||||
heart_rate_exchange.apply(db, heart_rate)
|
||||
if session_timeline is not None:
|
||||
session_timeline_exchange.apply(db, session_timeline)
|
||||
if program_executions is not None:
|
||||
program_execution_exchange.apply_executions(db, program_executions)
|
||||
ack = record_consumed(db, manifest, manifest_digest)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 30
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 31
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -831,6 +831,14 @@ test_schema_v30_migration = executable(
|
|||
)
|
||||
test('schema_v30_migration', test_schema_v30_migration)
|
||||
|
||||
test_schema_v31_migration = executable(
|
||||
'test_schema_v31_migration',
|
||||
'tests/test_schema_v31_migration.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('schema_v31_migration', test_schema_v31_migration)
|
||||
|
||||
test_schema_v7_migration = executable(
|
||||
'test_schema_v7_migration',
|
||||
'tests/test_schema_v7_migration.c',
|
||||
|
|
@ -915,6 +923,11 @@ test(
|
|||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
|
||||
)
|
||||
test(
|
||||
'session_timeline_exchange',
|
||||
find_program('python3'),
|
||||
args: [meson.project_source_root() / 'tests/test_session_timeline_exchange.py'],
|
||||
)
|
||||
test(
|
||||
'session_preparation_export',
|
||||
find_program('python3'),
|
||||
|
|
|
|||
|
|
@ -1377,6 +1377,22 @@ static const char *const MIGRATE_V29_TO_V30_SQL =
|
|||
"heart_rate_captures(context_kind,context_id,started_at);"
|
||||
"PRAGMA user_version=30;COMMIT;";
|
||||
|
||||
/* WHY: exercise/cardio correlation requires exact user-action boundaries on
|
||||
* the canonical desktop history. CONTRACT: v31 imports only Android-recorded
|
||||
* session occurrence timing facts. INVARIANT: no session or measurement time
|
||||
* is inferred from order, reps, plans or heart-rate samples. */
|
||||
static const char *const MIGRATE_V30_TO_V31_SQL =
|
||||
"BEGIN IMMEDIATE;"
|
||||
"CREATE TABLE IF NOT EXISTS session_exercise_timeline("
|
||||
"session_id TEXT NOT NULL,entry_id TEXT NOT NULL,exercise_id TEXT NOT NULL,"
|
||||
"started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL,"
|
||||
"PRIMARY KEY(session_id,entry_id),"
|
||||
"CHECK(session_id GLOB 'se_*'),CHECK(entry_id GLOB 'sxe_*'),"
|
||||
"CHECK(exercise_id GLOB 'ex_*'));"
|
||||
"CREATE INDEX IF NOT EXISTS session_exercise_timeline_time ON "
|
||||
"session_exercise_timeline(started_at,session_id,entry_id);"
|
||||
"PRAGMA user_version=31;COMMIT;";
|
||||
|
||||
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||
|
|
@ -2210,7 +2226,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30) {
|
||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
||||
version == 31) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2361,6 +2378,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status == TRAINLOG_STATUS_OK && version < 30) {
|
||||
status = execute_sql(database, MIGRATE_V29_TO_V30_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK && version < 31) {
|
||||
status = execute_sql(database, MIGRATE_V30_TO_V31_SQL);
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK) {
|
||||
status = ensure_v18_ai_draft_publication_state(database);
|
||||
}
|
||||
|
|
@ -2380,7 +2400,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
|||
if (status != TRAINLOG_STATUS_OK) {
|
||||
set_open_diagnostic(output_diagnostic,
|
||||
output_diagnostic_capacity,
|
||||
version == 0 ? "create schema v30" : "migrate database to schema v30",
|
||||
version == 0 ? "create schema v31" : "migrate database to schema v31",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ int main(void) {
|
|||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DROP TABLE session_exercise_timeline;"
|
||||
"DROP TABLE heart_rate_rr_intervals;"
|
||||
"DROP TABLE heart_rate_samples;"
|
||||
"DROP TABLE heart_rate_captures;"
|
||||
"DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;"
|
||||
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
|
||||
NULL,
|
||||
|
|
@ -49,7 +53,7 @@ int main(void) {
|
|||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 30);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 31);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
||||
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
||||
|
|
@ -74,9 +78,14 @@ int main(void) {
|
|||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DROP TABLE session_exercise_timeline;"
|
||||
"DROP TABLE heart_rate_rr_intervals;"
|
||||
"DROP TABLE heart_rate_samples;"
|
||||
"DROP TABLE heart_rate_captures;"
|
||||
"DROP TABLE sleep_medication_intakes;"
|
||||
"DROP TABLE sleep_medication_revisions;"
|
||||
"DROP TABLE sleep_medications;",
|
||||
"DROP TABLE sleep_medications;"
|
||||
"PRAGMA user_version=29;",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
|
|
@ -88,7 +97,7 @@ int main(void) {
|
|||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 30);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 31);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
||||
"'sleep_medication%'") == 3);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ int main(void) {
|
|||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DROP TABLE session_exercise_timeline;"
|
||||
"DROP TABLE heart_rate_rr_intervals;"
|
||||
"DROP TABLE heart_rate_samples;"
|
||||
"DROP TABLE heart_rate_captures;"
|
||||
|
|
@ -51,7 +52,7 @@ int main(void) {
|
|||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 30);
|
||||
CHECK(version == 31);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
72
tui/tests/test_schema_v31_migration.c
Normal file
72
tui/tests/test_schema_v31_migration.c
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
|
||||
#define CHECK(value) \
|
||||
do { \
|
||||
if (!(value)) { \
|
||||
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
|
||||
goto cleanup; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int scalar(sqlite3 *database, const char *sql) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int value = -1;
|
||||
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
value = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return value;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char path[] = "/tmp/trainlog-schema-v31-XXXXXX";
|
||||
TrainlogDatabase *production = NULL;
|
||||
sqlite3 *raw = NULL;
|
||||
int descriptor = mkstemp(path);
|
||||
int version = 0;
|
||||
int result = EXIT_FAILURE;
|
||||
|
||||
CHECK(descriptor >= 0 && close(descriptor) == 0);
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw,
|
||||
"DROP TABLE session_exercise_timeline;"
|
||||
"PRAGMA user_version=30;",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
raw = NULL;
|
||||
|
||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||
CHECK(version == 31);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' "
|
||||
"AND name='session_exercise_timeline'") == 1);
|
||||
CHECK(scalar(raw,
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
|
||||
"AND name='session_exercise_timeline_time'") == 1);
|
||||
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
|
||||
result = EXIT_SUCCESS;
|
||||
|
||||
cleanup:
|
||||
if (production != NULL) trainlog_database_close(production);
|
||||
if (raw != NULL) (void)sqlite3_close(raw);
|
||||
(void)unlink(path);
|
||||
if (result == EXIT_SUCCESS) puts("PASS schema v30 to v31 session timeline migration");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
|
|||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strncmp(diagnostic,
|
||||
"migrate database to schema v30: SQLite rc=",
|
||||
strlen("migrate database to schema v30: SQLite rc=")) == 0);
|
||||
"migrate database to schema v31: SQLite rc=",
|
||||
strlen("migrate database to schema v31: SQLite rc=")) == 0);
|
||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||
|
|
@ -197,12 +197,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=31;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=32;", 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 31 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 32 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,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 v30") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v31") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
Loading…
Reference in a new issue