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
|
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||||
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||||
recovery on the private 0.1.5 versionCode 40 installation.
|
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.
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..27
|
manifest.getInt("schema") in 17..28
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
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
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.labfytools.trainlog.R
|
import com.labfytools.trainlog.R
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
class HeartRateSensorService : Service() {
|
class HeartRateSensorService : Service() {
|
||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
@ -36,6 +37,10 @@ class HeartRateSensorService : Service() {
|
||||||
private var reconnectAttempt = 0
|
private var reconnectAttempt = 0
|
||||||
private var lastMeasurementElapsed = 0L
|
private var lastMeasurementElapsed = 0L
|
||||||
private var lastNotificationBpm: Int? = null
|
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 reconnectRunnable = Runnable { connectSelected() }
|
||||||
private val connectionTimeoutRunnable =
|
private val connectionTimeoutRunnable =
|
||||||
|
|
@ -272,6 +277,11 @@ class HeartRateSensorService : Service() {
|
||||||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||||
HeartRateLiveState.measurement(parsed.measurement)
|
HeartRateLiveState.measurement(parsed.measurement)
|
||||||
|
repository().recordLiveHeartRateForActiveSession(
|
||||||
|
sensorName = selected?.name,
|
||||||
|
observedAt = OffsetDateTime.now().toString(),
|
||||||
|
measurement = parsed.measurement,
|
||||||
|
)
|
||||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||||
lastNotificationBpm = parsed.measurement.bpm
|
lastNotificationBpm = parsed.measurement.bpm
|
||||||
updateForegroundNotification()
|
updateForegroundNotification()
|
||||||
|
|
@ -314,6 +324,8 @@ class HeartRateSensorService : Service() {
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
main.removeCallbacks(staleRunnable)
|
main.removeCallbacks(staleRunnable)
|
||||||
closeGatt()
|
closeGatt()
|
||||||
|
repository?.close()
|
||||||
|
repository = null
|
||||||
HeartRateLiveState.disconnected()
|
HeartRateLiveState.disconnected()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-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),
|
"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 =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
|
|
@ -431,6 +433,18 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
|
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)
|
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
|
@ -1076,6 +1090,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
"m.capture_id=heart_rate_captures.capture_id)",
|
"m.capture_id=heart_rate_captures.capture_id)",
|
||||||
arrayOf(ack.getString("consumed_at"), ack.getString("generation_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
|
target
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import android.database.sqlite.SQLiteOpenHelper
|
||||||
import android.util.JsonReader
|
import android.util.JsonReader
|
||||||
import android.util.JsonToken
|
import android.util.JsonToken
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionTimelineContext
|
||||||
import com.labfytools.trainlog.model.AiSessionDraft
|
import com.labfytools.trainlog.model.AiSessionDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
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.SessionDetail
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
import com.labfytools.trainlog.model.SessionExerciseDetail
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseTiming
|
||||||
import com.labfytools.trainlog.model.SessionFollowUp
|
import com.labfytools.trainlog.model.SessionFollowUp
|
||||||
import com.labfytools.trainlog.model.SessionLoadMode
|
import com.labfytools.trainlog.model.SessionLoadMode
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
|
@ -505,6 +507,14 @@ class TrainlogRepository(
|
||||||
data object DatabaseError : HeartRateMutationResult
|
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 sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
|
||||||
|
|
||||||
private fun sleepIdentity(value: String, prefix: String): Boolean =
|
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 =
|
private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean =
|
||||||
when (kind) {
|
when (kind) {
|
||||||
HeartRateContextKind.SESSION,
|
HeartRateContextKind.SESSION,
|
||||||
|
|
@ -1543,6 +1843,67 @@ class TrainlogRepository(
|
||||||
.toString()
|
.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. */
|
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
|
||||||
internal fun backupDatabaseTo(destination: File) {
|
internal fun backupDatabaseTo(destination: File) {
|
||||||
require(!destination.exists()) { "Backup snapshot destination already exists." }
|
require(!destination.exists()) { "Backup snapshot destination already exists." }
|
||||||
|
|
@ -1590,6 +1951,7 @@ class TrainlogRepository(
|
||||||
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
artifacts["program-executions"] = buildProgramExecutionsV1Json()
|
||||||
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
|
||||||
artifacts["heart-rate"] = buildHeartRateV1Json()
|
artifacts["heart-rate"] = buildHeartRateV1Json()
|
||||||
|
artifacts["session-timeline"] = buildSessionTimelineV1Json()
|
||||||
artifacts
|
artifacts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3606,18 +3968,42 @@ class TrainlogRepository(
|
||||||
|
|
||||||
fun discardActiveSessionDraft():
|
fun discardActiveSessionDraft():
|
||||||
ActiveDraftMutationResult {
|
ActiveDraftMutationResult {
|
||||||
|
val db = database.writableDatabase
|
||||||
|
db.beginTransaction()
|
||||||
return try {
|
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",
|
"active_session_draft",
|
||||||
"id = ?",
|
"id = ?",
|
||||||
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
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
|
ActiveDraftMutationResult.Saved
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
ActiveDraftMutationResult.Error(
|
ActiveDraftMutationResult.Error(
|
||||||
error.message
|
error.message
|
||||||
?: "Suppression du brouillon impossible."
|
?: "Suppression du brouillon impossible."
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
db.endTransaction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3664,6 +4050,20 @@ class TrainlogRepository(
|
||||||
transactionOpen = false
|
transactionOpen = false
|
||||||
return FinalizeActiveDraftResult.Invalid("The requested execution draft is not active.")
|
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 =
|
val completed =
|
||||||
SessionDraft(
|
SessionDraft(
|
||||||
|
|
@ -3693,6 +4093,19 @@ class TrainlogRepository(
|
||||||
active.draft.sourceSessionId,
|
active.draft.sourceSessionId,
|
||||||
forcedSessionId = lifecycleSessionId,
|
forcedSessionId = lifecycleSessionId,
|
||||||
forcedStartedAt = active.draft.startedAt,
|
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
|
/* INVARIANT: completion moves the exact Program provenance from
|
||||||
|
|
@ -3726,7 +4139,7 @@ class TrainlogRepository(
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("session_id", lifecycleSessionId)
|
put("session_id", lifecycleSessionId)
|
||||||
put("final_revision_id", active.draft.revisionId)
|
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.
|
/* CONTRACT: draft persistence currently rebuilds occurrence rows.
|
||||||
* Preserve immutable feedback by stable entry_id across that rebuild;
|
* Preserve immutable feedback by stable entry_id across that rebuild;
|
||||||
* feedback for an explicitly removed occurrence remains parent-owned
|
* feedback for an explicitly removed occurrence remains parent-owned
|
||||||
|
|
@ -8835,6 +9250,7 @@ class TrainlogRepository(
|
||||||
generalCorrection: Boolean = false,
|
generalCorrection: Boolean = false,
|
||||||
forcedSessionId: String? = null,
|
forcedSessionId: String? = null,
|
||||||
forcedStartedAt: String? = null,
|
forcedStartedAt: String? = null,
|
||||||
|
forcedEndedAt: String? = null,
|
||||||
): String {
|
): String {
|
||||||
val sessionId: String
|
val sessionId: String
|
||||||
val sessionRowId: Long
|
val sessionRowId: Long
|
||||||
|
|
@ -8849,7 +9265,7 @@ class TrainlogRepository(
|
||||||
put("started_at", forcedStartedAt ?: OffsetDateTime.now().toString())
|
put("started_at", forcedStartedAt ?: OffsetDateTime.now().toString())
|
||||||
/* CONTRACT: this exact explicit finalization instant anchors
|
/* CONTRACT: this exact explicit finalization instant anchors
|
||||||
* H+; old NULL values remain unknown and are never backfilled. */
|
* 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)
|
put("session_type", draft.sessionType.wireValue)
|
||||||
}
|
}
|
||||||
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
|
||||||
|
|
@ -8892,7 +9308,7 @@ class TrainlogRepository(
|
||||||
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
.getOrPut(cursor.getString(0)) { mutableListOf() }
|
||||||
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
.add(Triple(cursor.getString(1), cursor.getString(2), cursor.getString(3))) }
|
||||||
if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
|
if (!generalCorrection) db.execSQL("UPDATE sessions SET ended_at=? WHERE id=?;",
|
||||||
arrayOf<Any>(OffsetDateTime.now().toString(), sessionRowId))
|
arrayOf<Any>(forcedEndedAt ?: OffsetDateTime.now().toString(), sessionRowId))
|
||||||
/* INVARIANT: child replacement and draft deletion are in the
|
/* INVARIANT: child replacement and draft deletion are in the
|
||||||
* caller's transaction; failure restores the completed baseline. */
|
* caller's transaction; failure restores the completed baseline. */
|
||||||
db.delete(
|
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_SAMPLES_PER_CAPTURE = 200000
|
||||||
private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64
|
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_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_DRAFT_ENTRIES = 64
|
||||||
private const val MAX_AI_TARGET_SETS = 99
|
private const val MAX_AI_TARGET_SETS = 99
|
||||||
private const val MAX_AI_TARGET_REPS = 999
|
private const val MAX_AI_TARGET_REPS = 999
|
||||||
|
|
@ -9981,7 +10399,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
27,
|
28,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -10036,6 +10454,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createProgramExecutionProvenance(db)
|
createProgramExecutionProvenance(db)
|
||||||
createSleepDiaryTables(db)
|
createSleepDiaryTables(db)
|
||||||
createHeartRateTables(db)
|
createHeartRateTables(db)
|
||||||
|
createSessionTimelineTables(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10257,6 +10676,16 @@ private class TrainlogDatabaseHelper(
|
||||||
createHeartRateTables(db)
|
createHeartRateTables(db)
|
||||||
version = 27
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
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) {
|
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
|
||||||
fun addColumn(table: String, name: String, declaration: String) {
|
fun addColumn(table: String, name: String, declaration: String) {
|
||||||
if (!tableHasColumn(db, table, name)) {
|
if (!tableHasColumn(db, table, name)) {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,24 @@ data class SessionDraft(
|
||||||
val sessionType: SessionType = SessionType.TRAINING,
|
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(
|
data class SessionDraftForm(
|
||||||
val selectedExercise: ExerciseProfile? = null,
|
val selectedExercise: ExerciseProfile? = null,
|
||||||
/** Null means creation; otherwise replace this durable draft entry. */
|
/** 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.verticalScroll
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.ManualPercentMaxResult
|
||||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.ExerciseProfile
|
import com.labfytools.trainlog.model.ExerciseProfile
|
||||||
|
|
@ -152,6 +156,9 @@ fun SessionScreen(
|
||||||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||||
var feedbackRevision by remember { mutableStateOf(0) }
|
var feedbackRevision by remember { mutableStateOf(0) }
|
||||||
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
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:
|
val persistDraft:
|
||||||
(ActiveSessionDraft, String?) -> Unit =
|
(ActiveSessionDraft, String?) -> Unit =
|
||||||
|
|
@ -191,6 +198,12 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDraft = activeDraft!!
|
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 =
|
val lastWriteFailed =
|
||||||
message?.startsWith(
|
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(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||||
description = strings.getString(R.string.edit_exercise_description),
|
description = strings.getString(R.string.edit_exercise_description),
|
||||||
|
|
@ -518,6 +613,10 @@ fun SessionScreen(
|
||||||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||||
|
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
||||||
|
if (activeEntryId != null) {
|
||||||
|
pendingFinalizeActiveEntryId = activeEntryId
|
||||||
|
} else {
|
||||||
when (
|
when (
|
||||||
val result =
|
val result =
|
||||||
repository.finalizeActiveSessionDraft()
|
repository.finalizeActiveSessionDraft()
|
||||||
|
|
@ -533,7 +632,11 @@ fun SessionScreen(
|
||||||
|
|
||||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||||
message =
|
message =
|
||||||
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
strings.getString(
|
||||||
|
R.string.finalize_failed,
|
||||||
|
localizedRepositoryMessage(strings, result.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||||
|
|
@ -589,7 +692,19 @@ fun SessionScreen(
|
||||||
strings.getString(R.string.exercise_added) ||
|
strings.getString(R.string.exercise_added) ||
|
||||||
message ==
|
message ==
|
||||||
strings.getString(R.string.exercise_removed) ||
|
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
|
colors.success
|
||||||
} else {
|
} 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_configured">Heart-rate sensor configured.</string>
|
||||||
<string name="settings_cardio_forget">Forget sensor</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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -535,4 +535,15 @@
|
||||||
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
||||||
<string name="settings_cardio_forget">Oublier le capteur</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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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")) {
|
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(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"))
|
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()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* 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") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(27, it.version) }
|
).use { assertEquals(28, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(27, it.version) }
|
).use { assertEquals(28, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
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.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(27, it.version) }
|
.use { assertEquals(28, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("27", scalarText(name, "PRAGMA user_version"))
|
assertEquals("28", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
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 ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(27, it.getInt(0))
|
assertEquals(28, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(27, cursor.getInt(0))
|
assertEquals(28, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,8 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 |
|
| Desktop SQLite | schema v31; session exercise timeline import is additive in v31 |
|
||||||
| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 |
|
| 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 |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` |
|
| 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 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 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 |
|
| 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 |
|
| 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` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
# Desktop database
|
# 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: synchronized Heart Rate V1
|
||||||
|
|
||||||
Schema v30 additively owns Android-recorded heart-rate captures after
|
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.
|
Bluetooth synchronization transport contract.
|
||||||
|
|
||||||
```text
|
```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
|
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
|
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
|
||||||
implementation layer.
|
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.
|
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
|
`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;
|
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
|
||||||
reenabling Bluetooth restored the same foreground service automatically and
|
reenabling Bluetooth restored the same foreground service automatically and
|
||||||
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
|
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)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.5")
|
self.assertEqual(inventory["product_version"], "0.1.5")
|
||||||
self.assertEqual(inventory["desktop_schema"], 30)
|
self.assertEqual(inventory["desktop_schema"], 31)
|
||||||
self.assertEqual(inventory["android_schema"], 27)
|
self.assertEqual(inventory["android_schema"], 28)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertIn("programs-v1", inventory["protocols"])
|
self.assertIn("programs-v1", inventory["protocols"])
|
||||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||||
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
||||||
self.assertIn("heart-rate-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.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||||
self.assertTrue((output / "bin/trainlog").is_file())
|
self.assertTrue((output / "bin/trainlog").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-sync-once").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:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||||
raise CausalError(f"desktop schema v20-v30 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v31 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||||
raise LifecycleError(f"desktop schema v19-v30 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v31 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
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 = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_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."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v30 requis")
|
raise ValueError("schema desktop v15-v31 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
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:
|
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(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
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:
|
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 = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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:
|
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():
|
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")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
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):
|
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-v30 requis")
|
raise ValueError("schema desktop v12-v31 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
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:
|
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():
|
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")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
exercises = []
|
exercises = []
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
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}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
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
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# 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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
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")
|
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
|
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
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(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
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:
|
def export_programs(database: Path) -> dict:
|
||||||
with closing(connect_database(database)) as db:
|
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")
|
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||||
programs = []
|
programs = []
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
||||||
|
|
||||||
|
|
||||||
def build_export(connection: sqlite3.Connection) -> dict:
|
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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||||
deliveries = []
|
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()
|
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)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(15, 31)
|
supported_versions = range(15, 32)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
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")
|
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()
|
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()
|
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()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
|
||||||
fail("desktop schema v30 required")
|
fail("desktop schema v30-v31 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
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:
|
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)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
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"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(8, 31)
|
supported_versions = range(8, 32)
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
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
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
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):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""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:
|
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():
|
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")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""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:
|
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:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
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}")
|
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):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""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:
|
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")
|
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={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# 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")
|
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
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
|
||||||
fail("schema desktop v17-v30 requis")
|
fail("schema desktop v17-v31 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
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
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# 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(
|
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)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
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):
|
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 à v30")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v31")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"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):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
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:
|
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")
|
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()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ RUNTIME_TOOLS = [
|
||||||
"sync_generation_exchange.py",
|
"sync_generation_exchange.py",
|
||||||
"sleep_diary_exchange.py",
|
"sleep_diary_exchange.py",
|
||||||
"heart_rate_exchange.py",
|
"heart_rate_exchange.py",
|
||||||
|
"session_timeline_exchange.py",
|
||||||
"trainlog_sqlite.py",
|
"trainlog_sqlite.py",
|
||||||
"validate_json.py",
|
"validate_json.py",
|
||||||
"validate_training_knowledge.py",
|
"validate_training_knowledge.py",
|
||||||
|
|
@ -115,8 +116,8 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.5",
|
"product_version": "0.1.5",
|
||||||
"desktop_schema": 30,
|
"desktop_schema": 31,
|
||||||
"android_schema": 27,
|
"android_schema": 28,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
|
|
@ -132,6 +133,7 @@ def main() -> int:
|
||||||
"program-executions-v1",
|
"program-executions-v1",
|
||||||
"sleep-diary-v1",
|
"sleep-diary-v1",
|
||||||
"heart-rate-v1",
|
"heart-rate-v1",
|
||||||
|
"session-timeline-v1",
|
||||||
],
|
],
|
||||||
"entry_points": [
|
"entry_points": [
|
||||||
"bin/trainlog",
|
"bin/trainlog",
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
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")
|
fail("desktop schema v27, v28 or v29 required")
|
||||||
added = 0
|
added = 0
|
||||||
advanced = 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)
|
import_command.add_argument("--database", required=True, type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
|
||||||
fail("desktop schema v30 required")
|
fail("desktop schema v30-v31 required")
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||||
separators=(",", ":")), encoding="utf-8")
|
separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from trainlog_sqlite import connect_database
|
||||||
import causal_delete_exchange
|
import causal_delete_exchange
|
||||||
import execution_draft_exchange
|
import execution_draft_exchange
|
||||||
import heart_rate_exchange
|
import heart_rate_exchange
|
||||||
|
import session_timeline_exchange
|
||||||
import import_equipment_associations
|
import import_equipment_associations
|
||||||
import import_equipment_definitions
|
import import_equipment_definitions
|
||||||
import import_exercise_aliases
|
import import_exercise_aliases
|
||||||
|
|
@ -83,6 +84,7 @@ ARTIFACTS = (
|
||||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||||
("trainlog-program-executions", 1),
|
("trainlog-program-executions", 1),
|
||||||
("trainlog-heart-rate", 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:
|
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:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
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)
|
if "sleep-diary" in listed else None)
|
||||||
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
|
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
|
||||||
if "heart-rate" in listed else None)
|
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_executions = (
|
||||||
program_execution_exchange.load(listed["program-executions"])
|
program_execution_exchange.load(listed["program-executions"])
|
||||||
if "program-executions" in listed else None
|
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)
|
sleep_diary_exchange.apply(db, sleep_diary)
|
||||||
if heart_rate is not None:
|
if heart_rate is not None:
|
||||||
heart_rate_exchange.apply(db, heart_rate)
|
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:
|
if program_executions is not None:
|
||||||
program_execution_exchange.apply_executions(db, program_executions)
|
program_execution_exchange.apply_executions(db, program_executions)
|
||||||
ack = record_consumed(db, manifest, manifest_digest)
|
ack = record_consumed(db, manifest, manifest_digest)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 30
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 31
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -831,6 +831,14 @@ test_schema_v30_migration = executable(
|
||||||
)
|
)
|
||||||
test('schema_v30_migration', test_schema_v30_migration)
|
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 = executable(
|
||||||
'test_schema_v7_migration',
|
'test_schema_v7_migration',
|
||||||
'tests/test_schema_v7_migration.c',
|
'tests/test_schema_v7_migration.c',
|
||||||
|
|
@ -915,6 +923,11 @@ test(
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
|
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(
|
test(
|
||||||
'session_preparation_export',
|
'session_preparation_export',
|
||||||
find_program('python3'),
|
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);"
|
"heart_rate_captures(context_kind,context_id,started_at);"
|
||||||
"PRAGMA user_version=30;COMMIT;";
|
"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
|
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
* 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 ||
|
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
|
||||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -2361,6 +2378,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 30) {
|
if (status == TRAINLOG_STATUS_OK && version < 30) {
|
||||||
status = execute_sql(database, MIGRATE_V29_TO_V30_SQL);
|
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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
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) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
set_open_diagnostic(output_diagnostic,
|
set_open_diagnostic(output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
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,
|
database->connection,
|
||||||
SQLITE_ERROR);
|
SQLITE_ERROR);
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ int main(void) {
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw,
|
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_events;DROP TABLE sleep_diary_revisions;"
|
||||||
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
|
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
|
||||||
NULL,
|
NULL,
|
||||||
|
|
@ -49,7 +53,7 @@ int main(void) {
|
||||||
trainlog_database_close(production);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "PRAGMA user_version") == 30);
|
CHECK(scalar(raw, "PRAGMA user_version") == 31);
|
||||||
CHECK(scalar(raw,
|
CHECK(scalar(raw,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
||||||
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
||||||
|
|
@ -74,9 +78,14 @@ int main(void) {
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw,
|
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_intakes;"
|
||||||
"DROP TABLE sleep_medication_revisions;"
|
"DROP TABLE sleep_medication_revisions;"
|
||||||
"DROP TABLE sleep_medications;",
|
"DROP TABLE sleep_medications;"
|
||||||
|
"PRAGMA user_version=29;",
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
NULL) == SQLITE_OK);
|
NULL) == SQLITE_OK);
|
||||||
|
|
@ -88,7 +97,7 @@ int main(void) {
|
||||||
trainlog_database_close(production);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "PRAGMA user_version") == 30);
|
CHECK(scalar(raw, "PRAGMA user_version") == 31);
|
||||||
CHECK(scalar(raw,
|
CHECK(scalar(raw,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
||||||
"'sleep_medication%'") == 3);
|
"'sleep_medication%'") == 3);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw,
|
CHECK(sqlite3_exec(raw,
|
||||||
|
"DROP TABLE session_exercise_timeline;"
|
||||||
"DROP TABLE heart_rate_rr_intervals;"
|
"DROP TABLE heart_rate_rr_intervals;"
|
||||||
"DROP TABLE heart_rate_samples;"
|
"DROP TABLE heart_rate_samples;"
|
||||||
"DROP TABLE heart_rate_captures;"
|
"DROP TABLE heart_rate_captures;"
|
||||||
|
|
@ -51,7 +52,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(version == 30);
|
CHECK(version == 31);
|
||||||
trainlog_database_close(production);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
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);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic,
|
CHECK(strncmp(diagnostic,
|
||||||
"migrate database to schema v30: SQLite rc=",
|
"migrate database to schema v31: SQLite rc=",
|
||||||
strlen("migrate database to schema v30: SQLite rc=")) == 0);
|
strlen("migrate database to schema v31: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
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)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue