Compare commits
10 commits
a856451875
...
4b76848c54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b76848c54 | ||
|
|
823184d99e | ||
|
|
1fc15d6214 | ||
|
|
1a373cb3bb | ||
|
|
c4074f4732 | ||
|
|
1be4ab0397 | ||
|
|
189307b548 | ||
|
|
ed3301217f | ||
|
|
9cdd9c2313 | ||
|
|
f57682790d |
15 changed files with 284 additions and 30 deletions
|
|
@ -7,6 +7,12 @@
|
|||
explicit proposal derivation, desktop schema v23, Android schema v22 and the
|
||||
separate `trainlog-session-preparations` V1 generation participant.
|
||||
|
||||
- Completed the controlled Sessions V1 deployment and delivered a six-entry
|
||||
validation preparation to Android without starting it or replacing an active
|
||||
draft. Foreground generation retries now emit a fresh daemon request and
|
||||
resume the exact interrupted generation until both peer halves are durable,
|
||||
preventing false transport timeouts and duplicate recapture.
|
||||
|
||||
- Added compatible non-destructive generation archival on desktop schema v22
|
||||
and Android schema v21. Only exactly acknowledged generations older than the
|
||||
two active recovery tips leave admission after a verified durable payload
|
||||
|
|
|
|||
|
|
@ -17,6 +17,34 @@ internal sealed interface ForegroundGenerationResult {
|
|||
internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
||||
"AND c.result IN('consumed','rejected')))) " +
|
||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
||||
"LIMIT 2",
|
||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else {
|
||||
val captured =
|
||||
CapturedSyncGeneration(
|
||||
cursor.getString(0),
|
||||
runId,
|
||||
cursor.getString(1),
|
||||
File(cursor.getString(2)),
|
||||
)
|
||||
if (cursor.moveToNext())
|
||||
throw SyncGenerationException("ambiguous resumable generation")
|
||||
captured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireExactKeys(value: JSONObject, expected: Set<String>, label: String) {
|
||||
val actual = value.keys().asSequence().toSet()
|
||||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||
|
|
@ -112,6 +140,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
fun run(
|
||||
directory: File,
|
||||
timeout: Duration = Duration.ofMinutes(5),
|
||||
afterPeerPublication: (() -> Unit)? = null,
|
||||
afterPublication: (() -> Unit)? = null,
|
||||
): ForegroundGenerationResult =
|
||||
try {
|
||||
|
|
@ -139,17 +168,25 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
)
|
||||
.toString(),
|
||||
)
|
||||
/* WHY: trainlog-syncd is intentionally driven by the established
|
||||
* Android request artifact, not by polling generation internals.
|
||||
* CONTRACT: each explicit foreground attempt publishes the peer
|
||||
* identity before its caller emits one fresh request signal.
|
||||
* INVARIANT: a failed attempt's request, run, generations, and
|
||||
* ACK evidence remain immutable; retry creates new correlation. */
|
||||
afterPeerPublication?.invoke()
|
||||
val deadline = System.nanoTime() + timeout.toNanos()
|
||||
val requestRaw =
|
||||
awaitCorrelated(File(directory, "request-v1.json"), deadline) { candidate ->
|
||||
val runId = candidate.optString("run_id")
|
||||
runId.startsWith("sy_") &&
|
||||
!repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
}
|
||||
(resumableGeneration(runId) != null ||
|
||||
!repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
})
|
||||
}
|
||||
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
|
||||
if (request.getString("android_peer_id") != peer)
|
||||
|
|
@ -168,8 +205,18 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
|
|||
peer,
|
||||
request.getString("desktop_peer_id"),
|
||||
)
|
||||
/* WHY: transport can fail after immutable generation publication.
|
||||
* CONTRACT: an explicit retry for the same run republishes that
|
||||
* exact generation and resumes ACK handling; it never recaptures
|
||||
* mutable application state under an existing identity.
|
||||
* INVARIANT: a completed inbound ledger makes the run ineligible;
|
||||
* an acknowledged outbound generation remains resumable only to
|
||||
* finish its missing inbound half. */
|
||||
val captured =
|
||||
service.capture(
|
||||
resumableGeneration(
|
||||
request.getString("run_id"),
|
||||
request.getString("desktop_peer_id"),
|
||||
) ?: service.capture(
|
||||
directory,
|
||||
request.getString("desktop_peer_id"),
|
||||
request.getString("run_id"),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,26 @@ internal suspend fun publishBundleAndRequest(
|
|||
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
|
||||
} catch (_: Exception) { false }
|
||||
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
|
||||
return@withContext when (val result = SyncGenerationForegroundCoordinator(repository).run(canonicalExchangeDirectory())) {
|
||||
val opened = exporter.openStorageSnapshot()
|
||||
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
||||
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
|
||||
}
|
||||
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
|
||||
return@withContext when (
|
||||
val result = SyncGenerationForegroundCoordinator(repository).run(
|
||||
canonicalExchangeDirectory(),
|
||||
afterPeerPublication = {
|
||||
when (val request = requestOutbox.requestSync(snapshot)) {
|
||||
is SyncRequestResult.Requested -> Unit
|
||||
SyncRequestResult.Unsupported ->
|
||||
throw IllegalStateException("Generation synchronization is unsupported.")
|
||||
is SyncRequestResult.Error -> throw IllegalStateException(request.message)
|
||||
is SyncRequestResult.Completed ->
|
||||
throw IllegalStateException("Unexpected completed request signal.")
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,11 +103,15 @@ class SyncDeploymentConversationTest {
|
|||
repeat(conversations) {
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
var requestSignals = 0
|
||||
val result =
|
||||
executor.submit<ForegroundGenerationResult> {
|
||||
coordinator.run(transport, Duration.ofSeconds(30)) {
|
||||
release?.let { awaitFile(File(it)) }
|
||||
}
|
||||
coordinator.run(
|
||||
transport,
|
||||
Duration.ofSeconds(30),
|
||||
afterPeerPublication = { requestSignals += 1 },
|
||||
afterPublication = { release?.let { awaitFile(File(it)) } },
|
||||
)
|
||||
}
|
||||
val peer = JSONObject(awaitFile(File(transport, "android-peer-v1.json")).readText())
|
||||
if (suppliedTransport == null) {
|
||||
|
|
@ -138,6 +142,7 @@ class SyncDeploymentConversationTest {
|
|||
"conversation ${it + 1}: $completed",
|
||||
completed is ForegroundGenerationResult.Completed,
|
||||
)
|
||||
assertEquals("one fresh request signal per conversation", 1, requestSignals)
|
||||
var workerOutput = ""
|
||||
localWorker?.let { worker ->
|
||||
workerOutput = worker.inputStream.bufferedReader().readText()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,54 @@ import org.robolectric.annotation.Config
|
|||
class SyncGenerationServiceTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
|
||||
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
|
||||
val destinationName = "generation-resume-destination-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-resume-").toFile()
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
val destination = TrainlogRepository(context, destinationName)
|
||||
try {
|
||||
val sourceService = SyncGenerationService(source)
|
||||
val destinationService = SyncGenerationService(destination)
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
val outbound =
|
||||
sourceService.capture(
|
||||
java.io.File(root, "source-owned"),
|
||||
destinationService.peerId(),
|
||||
runId,
|
||||
)
|
||||
val outboundDirectory =
|
||||
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
||||
sourceService.acceptAcknowledgement(
|
||||
destinationService.consume(outboundDirectory).toByteArray()
|
||||
)
|
||||
|
||||
val coordinator = SyncGenerationForegroundCoordinator(source)
|
||||
assertEquals(
|
||||
outbound.generationId,
|
||||
coordinator.resumableGeneration(runId, destinationService.peerId())?.generationId,
|
||||
)
|
||||
|
||||
val inbound =
|
||||
destinationService.capture(
|
||||
java.io.File(root, "destination-owned"),
|
||||
sourceService.peerId(),
|
||||
runId,
|
||||
)
|
||||
sourceService.consume(
|
||||
destinationService.publish(inbound, java.io.File(root, "destination-objects"))
|
||||
)
|
||||
assertEquals(null, coordinator.resumableGeneration(runId, destinationService.peerId()))
|
||||
} finally {
|
||||
source.close()
|
||||
destination.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
context.deleteDatabase(destinationName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v19MigrationPreservesDataAndInventsNoGenerationState() {
|
||||
val name = "generation-migration-${UUID.randomUUID()}.db"
|
||||
|
|
|
|||
|
|
@ -972,3 +972,11 @@ ledger without changing any domain identity. Its full-generation foreground
|
|||
coordinator waits for correlated objects, tolerates stale objects left by
|
||||
interrupted runs, strictly revalidates desktop-retained historical ACKs, and
|
||||
shows completion only after both durable peer acknowledgements.
|
||||
|
||||
Each explicit foreground generation attempt publishes the Android peer before
|
||||
emitting one fresh legacy request signal for `trainlog-syncd`. If transport is
|
||||
interrupted after generation publication, a later attempt resumes the exact
|
||||
stored generation instead of recapturing mutable data. An acknowledged Android
|
||||
generation remains resumable only until the desktop generation for that run is
|
||||
durably consumed; a fully completed run is then excluded from foreground
|
||||
selection. This preserves late-ACK recovery without replaying completed runs.
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ parallel implementation of its rules.
|
|||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (isolated software validation; controlled deployment pending) |
|
||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
|
||||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
||||
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
||||
|
|
@ -228,6 +228,13 @@ Android schema v22 stores received preparations separately from AI proposals
|
|||
and the active singleton. An exact correlated ACK is required before delivery
|
||||
is shown as acknowledged.
|
||||
|
||||
The controlled deployment preserved the installed Android signing identity and
|
||||
advanced its private `versionCode` without uninstalling or clearing data. The
|
||||
validation preparation derived from the six-occurrence proposal remained a
|
||||
separate pending Android preparation across application and service restart.
|
||||
Two correlated generation conversations completed without a duplicate,
|
||||
performed fact, or active-draft replacement.
|
||||
|
||||
The Next Session tile no longer treats the frozen Dashboard
|
||||
`next_session.available` placeholder as a durable inventory. A separate bounded
|
||||
`GET /api/v1/prepared-items` Core projection distinguishes imported AI
|
||||
|
|
|
|||
|
|
@ -817,6 +817,14 @@ for its ACK). Objects retained from an interrupted conversation are ignored
|
|||
until atomically replaced by matching objects. After both peer ACKs, Android
|
||||
reports completion directly; it does not enter the legacy receipt wait state.
|
||||
|
||||
The peer publication precedes one fresh `trainlog-sync-request-v1` signal on
|
||||
every explicit Android attempt. This wakes the existing user-session daemon;
|
||||
it is not an ACK and does not alter generation identity. A retry may resume an
|
||||
exact `captured`, `published`, or `waiting_acknowledgement` generation. It may
|
||||
also finish the inbound half of an acknowledged outbound generation when no
|
||||
terminal consumed/rejected inbound ledger row exists for the run. It never
|
||||
recaptures state under that run identity.
|
||||
|
||||
The 2026-09-17 private rollout exercised this path with the production libmtp
|
||||
adapter on one paired Samsung phone. Three complete bidirectional conversations
|
||||
passed, including service/application restart and an idempotent replay whose
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ built into the canonical Meson binary.
|
|||
singleton refusal, explicit start, reserved execution identity and absence of
|
||||
performed facts. `SyncGenerationServiceTest` and
|
||||
`SyncDeploymentConversationTest` exercise the real optional artifact through
|
||||
generation validation and 24 bidirectional conversations.
|
||||
generation validation and 24 bidirectional conversations. It also asserts that
|
||||
every foreground conversation emits exactly one fresh daemon request signal;
|
||||
the coordinator's production resume predicate preserves an interrupted
|
||||
generation identity until the counterpart generation has a durable terminal
|
||||
consumption row.
|
||||
|
||||
`SyncDeploymentConversationTest` executes both production generation services
|
||||
and reopens Android storage. `test_web_sync_browser.py` adds a real Firefox click
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ typedef struct TrainlogWebSessionsPageQuery {
|
|||
const char *search;
|
||||
} TrainlogWebSessionsPageQuery;
|
||||
|
||||
/*
|
||||
* CONTRACT: every successful JSON-producing function below transfers one
|
||||
* heap allocation through output_json; the caller owns it and releases it
|
||||
* with free(). Borrowed input strings need only remain valid for the call.
|
||||
*/
|
||||
|
||||
/*
|
||||
* CONTRACT: database and query strings are borrowed for the call. On success,
|
||||
* output_json is heap-owned by the caller and must be released with free().
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ if web_assets_enabled
|
|||
'../web/src/dashboard/dashboardLayout.ts',
|
||||
'../web/src/routes/DashboardPage.tsx',
|
||||
'../web/src/routes/PlaceholderPage.tsx',
|
||||
'../web/src/routes/SessionsPage.tsx',
|
||||
'../web/src/api/sessions.ts',
|
||||
'../web/src/theme/app.css',
|
||||
'../web/src/theme/tokens.css',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -792,10 +792,16 @@ static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database,
|
|||
value = yyjson_obj_get(entry, "target_weight_kg");
|
||||
if (value == NULL || yyjson_is_null(value)) {
|
||||
(void)sqlite3_bind_null(statement, 11);
|
||||
} else if (!yyjson_is_num(value) || !isfinite(yyjson_get_real(value)) ||
|
||||
yyjson_get_real(value) <= 0.0 || yyjson_get_real(value) > 2000.0 ||
|
||||
sqlite3_bind_double(statement, 11, yyjson_get_real(value)) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
} else {
|
||||
double target_weight;
|
||||
if (!yyjson_is_num(value)) {
|
||||
goto invalid;
|
||||
}
|
||||
target_weight = yyjson_get_num(value);
|
||||
if (!isfinite(target_weight) || target_weight <= 0.0 || target_weight > 2000.0 ||
|
||||
sqlite3_bind_double(statement, 11, target_weight) != SQLITE_OK) {
|
||||
goto invalid;
|
||||
}
|
||||
}
|
||||
if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) {
|
||||
goto invalid;
|
||||
|
|
@ -923,18 +929,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
|||
if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) {
|
||||
if (sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"UPDATE session_preparations SET source_proposal_id=?1,source_payload_sha256=?2 "
|
||||
"WHERE preparation_id=?3 AND EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE "
|
||||
"i.draft_id=?1 AND i.payload_sha256=?2)",
|
||||
"SELECT 1 FROM ai_session_draft_imports WHERE draft_id=?1 AND payload_sha256=?2",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) !=
|
||||
SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, actual_preparation, -1, SQLITE_TRANSIENT) !=
|
||||
SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
|
||||
sqlite3_step(statement) != SQLITE_ROW) {
|
||||
status = TRAINLOG_STATUS_CONFLICT;
|
||||
goto rollback;
|
||||
}
|
||||
|
|
@ -964,7 +966,7 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
|||
statement = NULL;
|
||||
} else if (sqlite3_prepare_v2(
|
||||
database->connection,
|
||||
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',NULL,NULL)",
|
||||
"INSERT INTO session_preparations VALUES(?1,?2,?3,?3,?4,'local',?5,?6)",
|
||||
-1,
|
||||
&statement,
|
||||
NULL) != SQLITE_OK ||
|
||||
|
|
@ -973,6 +975,14 @@ TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database,
|
|||
sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 3, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
(source_proposal == NULL
|
||||
? sqlite3_bind_null(statement, 5)
|
||||
: sqlite3_bind_text(statement, 5, source_proposal, -1, SQLITE_TRANSIENT)) !=
|
||||
SQLITE_OK ||
|
||||
(source_fingerprint == NULL
|
||||
? sqlite3_bind_null(statement, 6)
|
||||
: sqlite3_bind_text(statement, 6, source_fingerprint, -1, SQLITE_TRANSIENT)) !=
|
||||
SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_DONE) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
goto rollback;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ static bool creation_replay_revision_and_pagination(void) {
|
|||
"{\"title\":\"Réordonnée\",\"session_type\":\"training\","
|
||||
"\"planned_for\":null,\"notes\":null,\"editing_state\":\"draft\","
|
||||
"\"occurrences\":[]}";
|
||||
static const char DERIVED_BODY[] =
|
||||
"{\"title\":\"Derived\",\"session_type\":\"training\","
|
||||
"\"planned_for\":\"2026-09-17\",\"notes\":null,\"editing_state\":\"ready\","
|
||||
"\"occurrences\":[{\"exercise_id\":\"ex_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
|
||||
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
|
||||
"\"target_sets\":4,\"target_reps\":10,\"target_duration_seconds\":null,"
|
||||
"\"target_weight_kg\":39,\"notes\":null}],\"source_proposal_id\":"
|
||||
"\"aid_11111111-1111-4111-8111-111111111111\","
|
||||
"\"source_payload_sha256\":"
|
||||
"\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}";
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogWebSessionsPageQuery query = {TRAINLOG_WEB_SESSION_PREPARATIONS, 0U, 1U, "échappée"};
|
||||
char *created = NULL;
|
||||
|
|
@ -70,6 +80,17 @@ static bool creation_replay_revision_and_pagination(void) {
|
|||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(sqlite3_exec(database->connection,
|
||||
"INSERT INTO ai_session_drafts(draft_id,created_at,session_type,title) "
|
||||
"VALUES('aid_11111111-1111-4111-8111-111111111111','2026-09-18T00:00:00Z',"
|
||||
"'training','Source');"
|
||||
"INSERT INTO ai_session_draft_imports VALUES("
|
||||
"last_insert_rowid(),'aid_11111111-1111-4111-8111-111111111111',"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
||||
"'2026-09-18T00:00:00Z');",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
NULL,
|
||||
"",
|
||||
|
|
@ -125,6 +146,20 @@ static bool creation_replay_revision_and_pagination(void) {
|
|||
free(detail);
|
||||
detail = NULL;
|
||||
detail_size = 0U;
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
NULL,
|
||||
"",
|
||||
"request-derived-1",
|
||||
DERIVED_BODY,
|
||||
strlen(DERIVED_BODY),
|
||||
&detail,
|
||||
&detail_size) == TRAINLOG_STATUS_OK);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM session_preparations WHERE source_proposal_id="
|
||||
"'aid_11111111-1111-4111-8111-111111111111'") == 1);
|
||||
free(detail);
|
||||
detail = NULL;
|
||||
detail_size = 0U;
|
||||
CHECK(trainlog_web_sessions_save_json(database,
|
||||
preparation_id,
|
||||
revision_id,
|
||||
|
|
@ -133,7 +168,7 @@ static bool creation_replay_revision_and_pagination(void) {
|
|||
strlen(UPDATE_BODY),
|
||||
&detail,
|
||||
&detail_size) == TRAINLOG_STATUS_CONFLICT);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 2);
|
||||
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 3);
|
||||
free(created);
|
||||
free(replayed);
|
||||
free(detail);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SessionsPage } from './SessionsPage'
|
||||
import { commandOccurrence, SessionsPage } from './SessionsPage'
|
||||
|
||||
function page(kind: string, items: unknown[]) {
|
||||
return { api_version: 1, kind, offset: 0, more: false, next_offset: items.length, items }
|
||||
|
|
@ -11,6 +11,19 @@ function response(value: unknown) {
|
|||
}
|
||||
|
||||
describe('Sessions page', () => {
|
||||
it('removes presentation-only fields from a preparation command', () => {
|
||||
const command = commandOccurrence({
|
||||
exercise_id: 'ex_one', exercise_name: 'Visible name', equipment_id: null,
|
||||
recording_mode: 'sets', tracking_mode: 'reps', load_mode: 'external', rest_seconds: 90,
|
||||
target_sets: 3, target_reps: 8, target_duration_seconds: null, target_weight_kg: 42,
|
||||
notes: null,
|
||||
})
|
||||
expect(command).not.toHaveProperty('exercise_name')
|
||||
expect(command).not.toHaveProperty('recording_mode')
|
||||
expect(command).not.toHaveProperty('tracking_mode')
|
||||
expect(command).toMatchObject({ exercise_id: 'ex_one', target_sets: 3, rest_seconds: 90 })
|
||||
})
|
||||
|
||||
it('keeps proposals distinct and opens a stable deep link', async () => {
|
||||
window.history.replaceState(null, '', '/seances')
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,42 @@ function emptyInput(): PreparationInput {
|
|||
editing_state: 'draft', occurrences: [] }
|
||||
}
|
||||
|
||||
function editableOccurrence(
|
||||
value: PreparationOccurrence,
|
||||
preserveIdentity: boolean,
|
||||
): PreparationOccurrence {
|
||||
return {
|
||||
...(preserveIdentity ? { entry_id: value.entry_id } : {}),
|
||||
exercise_id: value.exercise_id,
|
||||
exercise_name: value.exercise_name,
|
||||
equipment_id: value.equipment_id,
|
||||
recording_mode: value.recording_mode,
|
||||
tracking_mode: value.tracking_mode,
|
||||
load_mode: value.load_mode,
|
||||
rest_seconds: value.rest_seconds,
|
||||
target_sets: value.target_sets,
|
||||
target_reps: value.target_reps,
|
||||
target_duration_seconds: value.target_duration_seconds,
|
||||
target_weight_kg: value.target_weight_kg,
|
||||
notes: value.notes,
|
||||
}
|
||||
}
|
||||
|
||||
export function commandOccurrence(value: PreparationOccurrence): PreparationOccurrence {
|
||||
return {
|
||||
...(value.entry_id ? { entry_id: value.entry_id } : {}),
|
||||
exercise_id: value.exercise_id,
|
||||
equipment_id: value.equipment_id,
|
||||
load_mode: value.load_mode,
|
||||
rest_seconds: value.rest_seconds,
|
||||
target_sets: value.target_sets,
|
||||
target_reps: value.target_reps,
|
||||
target_duration_seconds: value.target_duration_seconds,
|
||||
target_weight_kg: value.target_weight_kg,
|
||||
notes: value.notes,
|
||||
}
|
||||
}
|
||||
|
||||
function Editor({ initial, onSaved, onCancel }: {
|
||||
initial?: SessionDetail
|
||||
onSaved: (identity: string) => void
|
||||
|
|
@ -38,9 +74,8 @@ function Editor({ initial, onSaved, onCancel }: {
|
|||
}) {
|
||||
const [input, setInput] = useState<PreparationInput>(() => initial ? {
|
||||
title: initial.title, session_type: initial.session_type, planned_for: initial.planned_for,
|
||||
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) => ({
|
||||
...value, entry_id: initial.kind === 'proposal' ? undefined : value.entry_id,
|
||||
})),
|
||||
notes: initial.notes, editing_state: 'draft', occurrences: initial.occurrences.map((value) =>
|
||||
editableOccurrence(value, initial.kind === 'preparation')),
|
||||
...(initial.kind === 'proposal' ? {
|
||||
source_proposal_id: initial.identity,
|
||||
source_payload_sha256: initial.source_fingerprint ?? undefined,
|
||||
|
|
@ -78,7 +113,8 @@ function Editor({ initial, onSaved, onCancel }: {
|
|||
const submit = async (ready: boolean) => {
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft' },
|
||||
const result = await savePreparation({ ...input, editing_state: ready ? 'ready' : 'draft',
|
||||
occurrences: input.occurrences.map(commandOccurrence) },
|
||||
initial?.kind === 'preparation' ? initial.identity : undefined,
|
||||
initial?.kind === 'preparation' ? initial.revision_id : undefined)
|
||||
if (ready) await prepareForAndroid(result.preparation_id, result.revision_id)
|
||||
|
|
|
|||
Loading…
Reference in a new issue