diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c7d01..95b1c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 67df5da..554b7f4 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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() diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt index 308ff26..77f477f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramsProjectionRepositoryTest.kt @@ -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) + } + } } diff --git a/docs/android.md b/docs/android.md index 8e1b8ad..cad170d 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index f5233aa..273629b 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 37dd120..1ffa4ab 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index 18a8e53..b9f5ee9 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/tests/test_session_preparation_export.py b/tests/test_session_preparation_export.py index bf20799..e22ae2b 100644 --- a/tests/test_session_preparation_export.py +++ b/tests/test_session_preparation_export.py @@ -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() diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index fe847d3..60d54f1 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -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], diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index b863fcb..a69d045 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -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, diff --git a/web/src/routes/ProgramsTab.test.tsx b/web/src/routes/ProgramsTab.test.tsx index 12b06ed..3976f48 100644 --- a/web/src/routes/ProgramsTab.test.tsx +++ b/web/src/routes/ProgramsTab.test.tsx @@ -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() + + 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) + }) }) diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index fd754a1..5b7bb6e 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -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"],