fix(programs): reflect completed sessions in progress
This commit is contained in:
parent
e6e5e76f68
commit
891c09179e
12 changed files with 438 additions and 13 deletions
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
## Unreleased — 0.1.4
|
||||
|
||||
- Fixed Program progress for sessions started from a delivered manual
|
||||
preparation. The preparation companion now carries its stable Program and
|
||||
Program-session provenance through Android start, finalization, restart and
|
||||
the existing execution companion; legacy stored deliveries are safely
|
||||
enriched only when every pre-existing field and execution identity matches.
|
||||
Android and Web consequently present the completed session and progress from
|
||||
the same persisted identity without fabricating history.
|
||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||
the previous entry immediately, adopts only an exact-date result and rejects
|
||||
|
|
|
|||
|
|
@ -2543,10 +2543,14 @@ class TrainlogRepository(
|
|||
return try {
|
||||
for (deliveryIndex in 0 until values.length()) {
|
||||
val delivery = values.getJSONObject(deliveryIndex)
|
||||
val required = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
||||
val legacyKeys = setOf("delivery_id", "preparation_id", "revision_id", "execution_session_id",
|
||||
"state", "title", "session_type", "planned_for", "notes", "source_proposal_id",
|
||||
"source_payload_sha256", "occurrences")
|
||||
require(delivery.hasExactKeys(required)) { "Forme de préparation invalide." }
|
||||
val provenanceKeys = setOf("source_program_id", "source_program_session_id")
|
||||
require(delivery.hasExactKeys(legacyKeys) ||
|
||||
delivery.hasExactKeys(legacyKeys + provenanceKeys)) {
|
||||
"Forme de préparation invalide."
|
||||
}
|
||||
val deliveryId = delivery.getString("delivery_id")
|
||||
val preparationId = delivery.getString("preparation_id")
|
||||
val revisionId = delivery.getString("revision_id")
|
||||
|
|
@ -2555,6 +2559,17 @@ class TrainlogRepository(
|
|||
stableUuidV4Id(preparationId, "sp_") &&
|
||||
stableUuidV4Id(revisionId, "spr_") &&
|
||||
stableUuidV4Id(executionId, "se_")) { "Identité de préparation invalide." }
|
||||
if (delivery.has("source_program_id")) {
|
||||
val programId = if (delivery.isNull("source_program_id")) null else
|
||||
delivery.getString("source_program_id")
|
||||
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||
delivery.getString("source_program_session_id")
|
||||
require((programId == null) == (programSessionId == null) &&
|
||||
(programId == null || (stableUuidV4Id(programId, "pg_") &&
|
||||
stableUuidV4Id(programSessionId!!, "pgs_")))) {
|
||||
"Provenance de programme invalide."
|
||||
}
|
||||
}
|
||||
require(delivery.getString("state") in setOf("pending", "remote_unknown")) { "État de livraison invalide." }
|
||||
val occurrences = delivery.getJSONArray("occurrences")
|
||||
require(occurrences.length() in 1..64) { "Occurrences de préparation invalides." }
|
||||
|
|
@ -2581,9 +2596,83 @@ class TrainlogRepository(
|
|||
})
|
||||
imported++
|
||||
} else {
|
||||
require(existing.first == revisionId && JSONObject(existing.second).toString() == canonical) {
|
||||
val existingPayload = JSONObject(existing.second)
|
||||
/* WHY: the PC delivery lifecycle can advance from pending
|
||||
* to acknowledged before a legacy Android row receives
|
||||
* newly exported Program provenance. CONTRACT: recovery
|
||||
* ignores only that transport state and the two missing
|
||||
* provenance fields; every preparation fact and identity
|
||||
* must remain byte-for-byte equivalent. */
|
||||
val legacyEnrichment =
|
||||
existing.first == revisionId &&
|
||||
!existingPayload.has("source_program_id") &&
|
||||
delivery.has("source_program_id") &&
|
||||
JSONObject(delivery.toString()).apply {
|
||||
remove("source_program_id")
|
||||
remove("source_program_session_id")
|
||||
remove("state")
|
||||
}.toString() == JSONObject(existingPayload.toString()).apply {
|
||||
remove("state")
|
||||
}.toString()
|
||||
require(existing.first == revisionId &&
|
||||
(existingPayload.toString() == canonical || legacyEnrichment)) {
|
||||
"Identité de livraison réutilisée avec un contenu différent."
|
||||
}
|
||||
if (legacyEnrichment) {
|
||||
val programId = if (delivery.isNull("source_program_id")) null else
|
||||
delivery.getString("source_program_id")
|
||||
val programSessionId = if (delivery.isNull("source_program_session_id")) null else
|
||||
delivery.getString("source_program_session_id")
|
||||
if (programId != null && programSessionId != null) {
|
||||
val currentProvenance = db.rawQuery(
|
||||
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
||||
"WHERE session_id=? UNION ALL SELECT source_program_id," +
|
||||
"source_program_session_id FROM active_session_draft WHERE session_id=?",
|
||||
arrayOf(executionId, executionId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) {
|
||||
null
|
||||
} else {
|
||||
Pair(
|
||||
if (cursor.isNull(0)) null else cursor.getString(0),
|
||||
if (cursor.isNull(1)) null else cursor.getString(1),
|
||||
).also {
|
||||
require(!cursor.moveToNext()) {
|
||||
"Identité d'exécution dupliquée."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(currentProvenance == null ||
|
||||
(currentProvenance.first == null &&
|
||||
currentProvenance.second == null) ||
|
||||
currentProvenance == Pair(programId, programSessionId)) {
|
||||
"Conflit de provenance du programme."
|
||||
}
|
||||
/* CONTRACT: old deliveries omitted Program
|
||||
* provenance. A later canonical delivery may fill
|
||||
* only those missing identities on the exact
|
||||
* reserved execution session; performed facts and
|
||||
* lifecycle state remain untouched. */
|
||||
db.execSQL(
|
||||
"UPDATE sessions SET source_program_id=?," +
|
||||
"source_program_session_id=? WHERE session_id=? AND " +
|
||||
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||
arrayOf(programId, programSessionId, executionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE active_session_draft SET source_program_id=?," +
|
||||
"source_program_session_id=? WHERE session_id=? AND " +
|
||||
"source_program_id IS NULL AND source_program_session_id IS NULL",
|
||||
arrayOf(programId, programSessionId, executionId),
|
||||
)
|
||||
}
|
||||
db.execSQL(
|
||||
"UPDATE session_preparation_deliveries SET payload_json=? " +
|
||||
"WHERE delivery_id=? AND revision_id=?",
|
||||
arrayOf(canonical, deliveryId, revisionId),
|
||||
)
|
||||
}
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
|
@ -2754,9 +2843,24 @@ class TrainlogRepository(
|
|||
val payload = JSONObject(row.second)
|
||||
val entries = parsePreparedOccurrences(db, payload.getJSONArray("occurrences"))
|
||||
val type = if (payload.getString("session_type") == "max_test") SessionType.MAX_TEST else SessionType.TRAINING
|
||||
/* INVARIANT: the desktop-reserved execution identity survives
|
||||
* start and replay; no performed value is manufactured. */
|
||||
persistActiveSessionDraft(db, ActiveSessionDraft(sessionId = row.first, exercises = entries, sessionType = type))
|
||||
val sourceProgramId = if (!payload.has("source_program_id") ||
|
||||
payload.isNull("source_program_id")) null else payload.getString("source_program_id")
|
||||
val sourceProgramSessionId = if (!payload.has("source_program_session_id") ||
|
||||
payload.isNull("source_program_session_id")) null else
|
||||
payload.getString("source_program_session_id")
|
||||
/* INVARIANT: the desktop-reserved execution and Program
|
||||
* identities survive start, edits, finalization and replay;
|
||||
* no performed value is manufactured by this transition. */
|
||||
persistActiveSessionDraft(
|
||||
db,
|
||||
ActiveSessionDraft(
|
||||
sessionId = row.first,
|
||||
exercises = entries,
|
||||
sessionType = type,
|
||||
sourceProgramId = sourceProgramId,
|
||||
sourceProgramSessionId = sourceProgramSessionId,
|
||||
),
|
||||
)
|
||||
db.execSQL("UPDATE session_preparation_deliveries SET state='started',started_at=? WHERE delivery_id=? AND state='pending'",
|
||||
arrayOf(OffsetDateTime.now(java.time.ZoneOffset.UTC).toString(), deliveryId))
|
||||
db.setTransactionSuccessful()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
): String {
|
||||
val occurrence =
|
||||
JSONObject()
|
||||
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
|
||||
.put("position", 0)
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 90)
|
||||
.put("target_sets", 3)
|
||||
.put("target_reps", 8)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 40.0)
|
||||
.put("notes", JSONObject.NULL)
|
||||
val delivery =
|
||||
JSONObject()
|
||||
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
|
||||
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
|
||||
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
|
||||
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
|
||||
.put("state", deliveryState)
|
||||
.put("title", "Séance A")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", JSONObject.NULL)
|
||||
.put("notes", JSONObject.NULL)
|
||||
.put("source_proposal_id", JSONObject.NULL)
|
||||
.put("source_payload_sha256", JSONObject.NULL)
|
||||
.put("occurrences", JSONArray().put(occurrence))
|
||||
if (includeProgramProvenance) {
|
||||
delivery
|
||||
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.put(
|
||||
"source_program_session_id",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-session-preparations")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:05:00Z")
|
||||
.put("deliveries", JSONArray().put(delivery))
|
||||
.put("withdrawals", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun deletion(): JSONObject =
|
||||
JSONObject()
|
||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||
|
|
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
|
||||
val name = "program-delivery-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("name", "Exercise fixture")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(1, 0),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(includeProgramProvenance = false),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
StartAiSessionDraftResult.Started,
|
||||
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
|
||||
)
|
||||
val active =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(null, active.sourceProgramId)
|
||||
assertEquals(null, active.sourceProgramSessionId)
|
||||
val performed =
|
||||
active.copy(
|
||||
exercises =
|
||||
active.exercises.map { occurrence ->
|
||||
occurrence.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||
)
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(performed),
|
||||
)
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.TODO,
|
||||
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
.sessions.single().executionState,
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(0, 1),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(deliveryState = "remote_unknown"),
|
||||
),
|
||||
)
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
|
||||
val execution =
|
||||
JSONObject(repository.buildProgramExecutionsV1Json())
|
||||
.getJSONArray("executions")
|
||||
.getJSONObject(0)
|
||||
assertEquals("completed", execution.getString("state"))
|
||||
assertEquals(
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
execution.getString("program_session_id"),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(1, detail.sessions.count {
|
||||
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
})
|
||||
assertEquals(
|
||||
StartProgramSessionResult.AlreadyCompleted,
|
||||
repository.startSyncedProgramSession(
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ Android schema v22 adds a pending manual-preparation store which is separate
|
|||
from AI proposals and the exactly-one active-session singleton. A received
|
||||
preparation is inert until explicit start; an occupied singleton is never
|
||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||
copies targets only and creates no performed facts.
|
||||
copies targets only and creates no performed facts. A Program-owned preparation
|
||||
also preserves `source_program_id` and `source_program_session_id` through the
|
||||
active draft and completed session. A legacy stored delivery may receive those
|
||||
two omitted fields later only when its revision, complete prior payload and
|
||||
reserved execution identity match exactly; this repairs provenance without
|
||||
altering performed workout facts.
|
||||
|
||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||
|
|
|
|||
|
|
@ -430,6 +430,13 @@ ACK without duplicating the execution or resurrecting the Program. A final
|
|||
correlated exchange remained completed with no missing capability. The real
|
||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
||||
|
||||
Program-owned manual preparations now carry their stable Program provenance to
|
||||
Android. Starting and finalizing such a delivery retains both identities, and a
|
||||
strict same-delivery enrichment repairs completed sessions produced from the
|
||||
earlier omission without changing workout facts. Android and Web therefore
|
||||
derive the completed count and per-session state from the same execution
|
||||
identity after synchronization and restart.
|
||||
|
||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||
schema v23 to v24 after verified backups, then installed the signed
|
||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ full run, generation, peer, hash and ACK envelope and never starts a workout.
|
|||
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||
generation artifact. It carries immutable ready revision snapshots and stable
|
||||
delivery, preparation, occurrence and reserved execution identities, plus
|
||||
immutable preparation-withdrawal operations. It never
|
||||
immutable preparation-withdrawal operations. Program-owned deliveries also
|
||||
carry the paired stable Program and Program-session identities. Android keeps
|
||||
reading legacy deliveries that omitted this pair and accepts a later enrichment
|
||||
only when all legacy fields and the reserved execution identity are identical.
|
||||
It never
|
||||
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||
the complete artifact in the generation transaction and stores it as pending;
|
||||
start remains an explicit foreground action. Desktop records the exact
|
||||
|
|
@ -246,6 +250,11 @@ is idempotent; identity reuse or regression is a hard conflict. Because the
|
|||
descriptor is optional, peers predating this companion keep exchanging all
|
||||
existing required domains without a capability failure.
|
||||
|
||||
For Program-owned manual preparations, an acknowledged delivery is republished
|
||||
while its matching canonical execution evidence is absent on the PC. This
|
||||
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
||||
and stops automatically when the execution returns through this companion.
|
||||
|
||||
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||
removed through causal history deletion. A retained older
|
||||
`program-executions-v1` fact is skipped only when the matching session has a
|
||||
|
|
|
|||
|
|
@ -130,6 +130,13 @@ through Program deletion, restart and old-generation replay without duplication
|
|||
or resurrection. The desktop populated migration test now continues through
|
||||
schema v27 and proves the new execution ledger starts empty.
|
||||
|
||||
The delivered-Program regression covers the previously omitted provenance
|
||||
pair end to end: legacy preparation receipt, start, performed capture,
|
||||
finalization, exact-payload enrichment, completed Android projection, execution
|
||||
companion output and restart. Export tests assert both stable IDs, the C Web API
|
||||
asserts `completed`, and the Web component verifies `1/24`, `Effectuée` and the
|
||||
absence of a preparation action on the completed session.
|
||||
|
||||
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
||||
session and execution identities byte-for-byte, admits the terminal deleted
|
||||
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
current_revision_id TEXT NOT NULL,
|
||||
source_proposal_id TEXT,
|
||||
source_payload_sha256 TEXT,
|
||||
source_program_id TEXT,
|
||||
source_program_session_id TEXT,
|
||||
withdrawn_at TEXT
|
||||
);
|
||||
CREATE TABLE session_preparation_revisions(
|
||||
|
|
@ -56,6 +58,13 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE program_session_executions(
|
||||
program_session_id TEXT PRIMARY KEY,
|
||||
program_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE session_preparation_withdrawals(
|
||||
withdrawal_id TEXT PRIMARY KEY,
|
||||
preparation_id TEXT NOT NULL,
|
||||
|
|
@ -77,12 +86,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
delivery = f"spd_{suffix}"
|
||||
execution = f"se_{suffix}"
|
||||
self.database.execute(
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
preparation,
|
||||
revision,
|
||||
"aid_11111111-1111-4111-8111-111111111111",
|
||||
"a" * 64,
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"2026-09-18T12:00:00Z" if withdrawn else None,
|
||||
),
|
||||
)
|
||||
|
|
@ -120,6 +131,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual("trainlog-session-preparations", payload["format"])
|
||||
self.assertEqual(2, payload["version"])
|
||||
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual(
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
payload["deliveries"][0]["source_program_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
payload["deliveries"][0]["source_program_session_id"],
|
||||
)
|
||||
self.assertEqual(1, len(payload["withdrawals"]))
|
||||
removal = payload["withdrawals"][0]
|
||||
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
||||
|
|
@ -147,6 +166,35 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual([], payload["deliveries"])
|
||||
self.assertEqual([], payload["withdrawals"])
|
||||
|
||||
def test_acknowledged_program_delivery_is_republished_until_execution_returns(self):
|
||||
suffix = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||
self.add_preparation(suffix, withdrawn=False)
|
||||
self.database.execute(
|
||||
"UPDATE session_preparation_deliveries SET state='acknowledged'"
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([f"spd_{suffix}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual("remote_unknown", payload["deliveries"][0]["state"])
|
||||
|
||||
self.database.execute(
|
||||
"INSERT INTO program_session_executions VALUES(?,?,?,?,?)",
|
||||
(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
f"se_{suffix}",
|
||||
"completed",
|
||||
"2026-09-18T12:10:00Z",
|
||||
),
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([], payload["deliveries"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -22,12 +22,21 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
|||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,"
|
||||
"CASE WHEN d.state='acknowledged' THEN 'remote_unknown' ELSE d.state END,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256,"
|
||||
"p.source_program_id,p.source_program_session_id "
|
||||
"FROM session_preparation_deliveries d "
|
||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
||||
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
||||
"WHERE p.withdrawn_at IS NULL AND ("
|
||||
"d.state IN('pending','remote_unknown') OR ("
|
||||
"d.state='acknowledged' AND p.source_program_id IS NOT NULL AND "
|
||||
"p.source_program_session_id IS NOT NULL AND NOT EXISTS("
|
||||
"SELECT 1 FROM program_session_executions x "
|
||||
"WHERE x.program_session_id=p.source_program_session_id "
|
||||
"AND x.program_id=p.source_program_id AND x.session_id=d.execution_session_id"
|
||||
"))) "
|
||||
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||
(MAX_DELIVERIES + 1,),
|
||||
).fetchall()
|
||||
|
|
@ -48,6 +57,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
|||
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
||||
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
||||
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
||||
"source_program_id": row[11], "source_program_session_id": row[12],
|
||||
"occurrences": [{
|
||||
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||
"equipment_id": entry[3], "recording_mode": entry[4],
|
||||
|
|
|
|||
|
|
@ -317,6 +317,26 @@ static bool import_archive_and_prepare(void) {
|
|||
free(response);
|
||||
response = NULL;
|
||||
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO program_session_executions("
|
||||
"program_session_id,program_id,session_id,state,observed_at) VALUES("
|
||||
"'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',"
|
||||
"'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',"
|
||||
"'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee','completed',"
|
||||
"'2026-09-21T12:00:00Z')",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_programs_detail_json(
|
||||
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(strstr(response, "\"execution_state\":\"completed\"") != NULL);
|
||||
CHECK(strstr(response,
|
||||
"\"execution_session_id\":"
|
||||
"\"se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee\"") != NULL);
|
||||
free(response);
|
||||
response = NULL;
|
||||
|
||||
CHECK(trainlog_web_programs_archive_json(database,
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
revision,
|
||||
|
|
|
|||
|
|
@ -206,4 +206,56 @@ describe('Programs tab', () => {
|
|||
expect(fetchMock.mock.calls.filter(([, options]) =>
|
||||
(options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('counts a completed program session and no longer offers to prepare it', async () => {
|
||||
const sessions = Array.from({ length: 24 }, (_, index) => ({
|
||||
program_session_id: `pgs_${index}`,
|
||||
position: index,
|
||||
title: index === 0 ? 'S1 A — Ceinture abdominale + jambes' : `Séance ${index + 1}`,
|
||||
session_type: 'training',
|
||||
planned_for: null,
|
||||
note: null,
|
||||
execution_state: index === 0 ? 'completed' : 'todo',
|
||||
execution_session_id: index === 0 ? 'se_completed' : null,
|
||||
occurrences: [],
|
||||
}))
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/v1/sessions/program/pg_test') {
|
||||
return jsonResponse({
|
||||
api_version: 1,
|
||||
program_id: 'pg_test',
|
||||
title: 'Programme 24 séances',
|
||||
note: null,
|
||||
state: 'active',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
created_at: '2026-09-18T12:00:00Z',
|
||||
updated_at: '2026-09-21T12:00:00Z',
|
||||
revision_id: 'revision',
|
||||
source_format: 'trainlog-program',
|
||||
source_version: 1,
|
||||
source_payload_sha256: 'a'.repeat(64),
|
||||
sessions,
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected request ${url}`)
|
||||
}))
|
||||
|
||||
render(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('1/24')).toBeInTheDocument()
|
||||
expect(screen.getByText('4% effectué')).toBeInTheDocument()
|
||||
const completed = screen
|
||||
.getByText(/S1 A — Ceinture abdominale \+ jambes/)
|
||||
.closest('li')
|
||||
expect(completed).not.toBeNull()
|
||||
expect(within(completed as HTMLElement).getByText('Effectuée')).toBeInTheDocument()
|
||||
expect(
|
||||
within(completed as HTMLElement).queryByRole('button', {
|
||||
name: 'Créer une préparation',
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: 'Créer une préparation' })).toHaveLength(23)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
SleepEntry,
|
||||
SleepEntryInput,
|
||||
|
|
@ -170,6 +170,8 @@ const dayAEntry = (): SleepEntry => ({
|
|||
|
||||
describe("SleepDiaryWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-09-21T16:00:00+02:00"));
|
||||
vi.clearAllMocks();
|
||||
api.nextId = 0;
|
||||
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
|
||||
|
|
@ -181,6 +183,10 @@ describe("SleepDiaryWorkspace", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bed_time", "Mise au lit", "22:30", null],
|
||||
["sleep", "Sommeil", "23:00", "06:30"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue