fix(programs): reflect completed sessions in progress

This commit is contained in:
fy59 2026-09-21 21:16:38 +02:00
parent e6e5e76f68
commit 891c09179e
12 changed files with 438 additions and 13 deletions

View file

@ -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

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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],

View file

@ -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,

View file

@ -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)
})
})

View file

@ -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"],